From d28a3552544b137962fcd8242c521617f592be49 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Feb 2026 17:17:35 +0100 Subject: [PATCH 001/198] feat: add `SocketInterceptor` --- package.json | 1 + pnpm-lock.yaml | 8 + src/interceptors/net/index.ts | 63 ++++ src/interceptors/net/mock-socket.ts | 48 +++ src/interceptors/net/object-recorder.test.ts | 289 ++++++++++++++++++ src/interceptors/net/object-recorder.ts | 271 ++++++++++++++++ src/interceptors/net/socket-controller.ts | 113 +++++++ .../net/utils/normalize-net-connect-args.ts | 88 ++++++ test/modules/net/example.test.ts | 71 +++++ 9 files changed, 952 insertions(+) create mode 100644 src/interceptors/net/index.ts create mode 100644 src/interceptors/net/mock-socket.ts create mode 100644 src/interceptors/net/object-recorder.test.ts create mode 100644 src/interceptors/net/object-recorder.ts create mode 100644 src/interceptors/net/socket-controller.ts create mode 100644 src/interceptors/net/utils/normalize-net-connect-args.ts create mode 100644 test/modules/net/example.test.ts diff --git a/package.json b/package.json index b47ce831b..7b9857fcb 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", + "es-toolkit": "^1.44.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36641f6c0..2d2f17c2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@open-draft/until': specifier: ^2.0.0 version: 2.1.0 + es-toolkit: + specifier: ^1.44.0 + version: 1.44.0 is-node-process: specifier: ^1.2.0 version: 1.2.0 @@ -1540,6 +1543,9 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -4622,6 +4628,8 @@ snapshots: es-module-lexer@2.0.0: {} + es-toolkit@1.44.0: {} + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts new file mode 100644 index 000000000..3cac39527 --- /dev/null +++ b/src/interceptors/net/index.ts @@ -0,0 +1,63 @@ +import net from 'node:net' +import { Interceptor } from '../../Interceptor' +import { + type NetworkConnectionOptions, + normalizeNetConnectArgs, +} from './utils/normalize-net-connect-args' +import { MockSocket } from './mock-socket' +import { + kServerSocket, + kSocketProxy, + SocketController, +} from './socket-controller' + +interface SocketEventMap { + connection: [ + { + socket: net.Socket + connectionOptions: NetworkConnectionOptions + controller: SocketController + }, + ] +} + +export class SocketInterceptor extends Interceptor { + static symbol = Symbol('socket-interceptor') + + constructor() { + super(SocketInterceptor.symbol) + } + + protected setup(): void { + const originalNetConnect = net.connect + + net.connect = (...args: [any, any]) => { + const [connectionOptions, connectionCallback] = + normalizeNetConnectArgs(args) + + const socket = new MockSocket(connectionOptions, connectionCallback) + const controller = new SocketController({ + socket, + createConnection() { + return originalNetConnect.apply(null, args) + }, + }) + + process.nextTick(() => { + this.emitter.emit('connection', { + socket: controller[kServerSocket], + connectionOptions, + controller, + }) + }) + + // Return the socket wrapped in the recorder proxy. + return controller[kSocketProxy] + } + net.createConnection = net.connect + + this.subscriptions.push(() => { + net.connect = originalNetConnect + }) + } +} diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts new file mode 100644 index 000000000..48266a1d4 --- /dev/null +++ b/src/interceptors/net/mock-socket.ts @@ -0,0 +1,48 @@ +import net from 'node:net' + +export class MockSocket extends net.Socket { + public connecting: boolean + + constructor(options: net.SocketConstructorOpts, onConnect?: () => void) { + super({ ...options, allowHalfOpen: true }) + this.connecting = true + + if (onConnect) { + this.on('connect', onConnect) + } + } + + public mockConnect() { + queueMicrotask(() => { + this.connecting = false + this.emit('connect') + }) + + return this + } + + // Override _writeGeneric to prevent "Socket is closed" errors + // when the socket tries to flush buffered data during connect + // @ts-ignore - overriding private method + public _writeGeneric( + writev: boolean, + data: any, + encoding?: any, + callback?: any + ) { + // If the socket is not properly initialized with a handle, + // just call the callback without trying to write + // @ts-ignore - accessing private property + if (!this._handle) { + if (typeof callback === 'function') { + process.nextTick(callback) + } + + return + } + + // Otherwise, call the parent implementation + // @ts-ignore - calling private method + return super._writeGeneric(writev, data, encoding, callback) + } +} diff --git a/src/interceptors/net/object-recorder.test.ts b/src/interceptors/net/object-recorder.test.ts new file mode 100644 index 000000000..19fce6fb3 --- /dev/null +++ b/src/interceptors/net/object-recorder.test.ts @@ -0,0 +1,289 @@ +import { it, expect } from 'vitest' +import { ObjectRecorder } from './object-recorder' + +it('records a setter', () => { + const target = { a: 1 } + const recorder = new ObjectRecorder(target) + recorder.start() + + recorder.proxy.a = 2 + + expect(target).toEqual({ a: 2 }) + expect(recorder.entries).toEqual([ + { + type: 'set', + path: [], + metadata: { property: 'a', descriptor: { value: 2 } }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { a: 1 } + recorder.replay(otherTarget) + expect(otherTarget.a).toBe(2) +}) + +it('records a nested setter', () => { + const target = { a: { b: 1 } } + const recorder = new ObjectRecorder(target) + recorder.start() + + recorder.proxy.a.b = 2 + + expect(target).toEqual({ a: { b: 2 } }) + expect(recorder.entries).toEqual([ + { + type: 'set', + path: ['a'], + metadata: { property: 'b', descriptor: { value: 2 } }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { a: { b: 1 } } + recorder.replay(otherTarget) + expect(otherTarget.a.b).toBe(2) +}) + +it('records a method call without any arguments', () => { + const target = { + state: '', + update() { + this.state = 'updated' + }, + } + const recorder = new ObjectRecorder(target) + recorder.start() + + recorder.proxy.update() + + expect(target.state).toBe('updated') + expect(recorder.entries).toEqual([ + { + type: 'apply', + path: [], + metadata: { method: 'update', args: [] }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { + state: '', + update() { + this.state = 'updated' + }, + } + recorder.replay(otherTarget) + expect(otherTarget.state).toBe('updated') +}) + +it('records array mutations', () => { + const target = { numbers: [1] } + const recorder = new ObjectRecorder(target) + recorder.start() + + recorder.proxy.numbers.push(2) + + expect(target).toEqual({ numbers: [1, 2] }) + expect(recorder.entries).toEqual([ + { + type: 'apply', + path: ['numbers'], + metadata: { + method: 'push', + args: [2], + }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { numbers: [1] } + recorder.replay(otherTarget) + expect(otherTarget.numbers).toEqual([1, 2]) +}) + +it('records a method call that deletes a property', () => { + const target: { a?: number; clear: () => void } = { + a: 1, + clear() { + delete this.a + }, + } + const recorder = new ObjectRecorder(target) + recorder.start() + + recorder.proxy.clear() + + expect(target).toEqual({ clear: expect.any(Function) }) + expect(recorder.entries).toEqual([ + { + type: 'apply', + path: [], + metadata: { + method: 'clear', + args: [], + }, + replay: expect.any(Function), + }, + ]) + + const otherTarget: { a?: number; clear: () => void } = { + a: 1, + clear() { + delete this.a + }, + } + recorder.replay(otherTarget) + expect(otherTarget).toEqual({ clear: expect.any(Function) }) +}) + +it('records a method call with arguments', () => { + const target = { + state: '', + update(value: string) { + this.state = value + }, + } + const recorder = new ObjectRecorder(target) + recorder.start() + + recorder.proxy.update('new state') + + expect(target.state).toBe('new state') + expect(recorder.entries).toEqual([ + { + type: 'apply', + path: [], + metadata: { method: 'update', args: ['new state'] }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { + state: '', + update(value: string) { + this.state = value + }, + } + recorder.replay(otherTarget) + expect(otherTarget.state).toBe('new state') +}) + +it('records a property deletion', () => { + const target: { a?: number; b: number } = { a: 1, b: 2 } + const recorder = new ObjectRecorder(target) + recorder.start() + + delete recorder.proxy.a + + expect(target).toEqual({ b: 2 }) + expect(recorder.entries).toEqual([ + { + type: 'delete', + path: [], + metadata: { property: 'a' }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { a: 1, b: 2 } + recorder.replay(otherTarget) + expect(otherTarget).toEqual({ b: 2 }) +}) + +it('records a nested property deletion', () => { + const target: { a: { b?: number }; c: number } = { a: { b: 1 }, c: 2 } + const recorder = new ObjectRecorder(target) + recorder.start() + + delete recorder.proxy.a.b + + expect(target).toEqual({ a: {}, c: 2 }) + expect(recorder.entries).toEqual([ + { + type: 'delete', + path: ['a'], + metadata: { property: 'b' }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { a: { b: 1 }, c: 2 } + recorder.replay(otherTarget) + expect(otherTarget).toEqual({ a: {}, c: 2 }) +}) + +it('supports running actions quietly', () => { + const target = { a: 1, b: 2 } + const recorder = new ObjectRecorder(target) + recorder.start() + + recorder.proxy.a = 2 + recorder.runQuietly(() => { + recorder.proxy.b = 3 + }) + + expect(target).toEqual({ a: 2, b: 3 }) + expect(recorder.entries).toEqual([ + { + type: 'set', + path: [], + metadata: { + property: 'a', + descriptor: { + value: 2, + }, + }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { a: 1, b: 2 } + recorder.replay(otherTarget) + expect(otherTarget, 'Does not replay quiet actions').toEqual({ + a: 2, + b: 2, + }) +}) + +it('supports custom action predicate', () => { + const target = { a: 1, _internal: 'a' } + const recorder = new ObjectRecorder(target, { + filter(entry) { + if ( + entry.type === 'set' && + entry.metadata.property.toString().startsWith('_') + ) { + return false + } + + return true + }, + }) + recorder.start() + + recorder.proxy.a = 2 + recorder.proxy._internal = 'b' + + expect(target).toEqual({ a: 2, _internal: 'b' }) + expect(recorder.entries).toEqual([ + { + type: 'set', + path: [], + metadata: { + property: 'a', + descriptor: { + value: 2, + }, + }, + replay: expect.any(Function), + }, + ]) + + const otherTarget = { a: 1, _internal: 'a' } + recorder.replay(otherTarget) + expect(otherTarget, 'Does not replay ignored actions').toEqual({ + a: 2, + _internal: 'a', + }) +}) diff --git a/src/interceptors/net/object-recorder.ts b/src/interceptors/net/object-recorder.ts new file mode 100644 index 000000000..dd1cad2ae --- /dev/null +++ b/src/interceptors/net/object-recorder.ts @@ -0,0 +1,271 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { invariant } from 'outvariant' +import { get } from 'es-toolkit/compat' + +const nestedInvocationContext = new AsyncLocalStorage() + +type ObjectRecordEntry = + | { + type: 'apply' + path: Array + metadata: { + method: string | symbol + args: Array + } + replay: (nextTarget: T) => void + } + | { + type: 'set' + path: Array + metadata: { + property: string | symbol + descriptor: PropertyDescriptor + } + replay: (nextTarget: T) => void + } + | { + type: 'delete' + path: Array + metadata: { + property: string | symbol + } + replay: (nextTarget: T) => void + } + +interface ObjectRecorderOptions { + filter: (entry: ObjectRecordEntry) => boolean +} + +export class ObjectRecorder { + static IDLE = 1 as const + static RECORDING = 2 as const + static PAUSED = 3 as const + static DISPOSED = 4 as const + + #entries: Array> + + public proxy: T + public readyState: 1 | 2 | 3 | 4 + + constructor( + protected readonly target: T, + protected readonly options?: ObjectRecorderOptions + ) { + this.#entries = [] + + this.readyState = ObjectRecorder.IDLE + this.proxy = target + } + + public get entries(): Array> { + return this.#entries + } + + public start(): void { + invariant( + this.readyState !== ObjectRecorder.DISPOSED, + 'Failed to start recording: recorder is disposed' + ) + + invariant( + this.readyState === ObjectRecorder.IDLE, + 'Failed to start recording: recording already in progress' + ) + + this.readyState = ObjectRecorder.RECORDING + + const wrapInProxy = ( + target: V, + parentPath: Array + ) => { + return new Proxy(target, { + get: (target, property, receiver) => { + const value = target[property as keyof V] + + if (typeof value === 'function') { + return new Proxy(value, { + apply: (fn, thisArg, args) => { + const defaultApply = () => { + return fn.apply(thisArg, args) + } + + const entry: ObjectRecordEntry = { + type: 'apply', + path: parentPath, + metadata: { + method: property, + args, + }, + replay(nextTarget) { + const nextTargetValue = Reflect.get(nextTarget, property) + + if (typeof nextTargetValue !== 'function') { + return + } + + Reflect.apply(nextTargetValue, nextTarget, args) + }, + } + + this.#addEntry(entry) + return nestedInvocationContext.run(true, defaultApply) + }, + }) + } + + if (value != null && typeof value === 'object') { + return wrapInProxy(value, parentPath.concat(property)) + } + + return Reflect.get(target, property, receiver) + }, + defineProperty: (target, property, descriptor) => { + const defaultDefineProperty = () => { + return Reflect.defineProperty(target, property, descriptor) + } + + // Prevent recording changes caused by method invocations. + // Replaying the method must be enough to reapply them, too. + if (nestedInvocationContext.getStore()) { + return defaultDefineProperty() + } + + const entry: ObjectRecordEntry = { + type: 'set', + path: parentPath, + metadata: { + property, + descriptor, + }, + replay(nextTarget) { + Reflect.defineProperty(nextTarget, property, descriptor) + }, + } + + this.#addEntry(entry) + return defaultDefineProperty() + }, + deleteProperty: (target, property) => { + const defaultDeleteProperty = () => { + return Reflect.deleteProperty(target, property) + } + + // Prevent recording changes caused by method invocations. + // Replaying the method must be enough to reapply them, too. + if (nestedInvocationContext.getStore()) { + return defaultDeleteProperty() + } + + const entry: ObjectRecordEntry = { + type: 'delete', + path: parentPath, + metadata: { + property, + }, + replay(nextTarget) { + Reflect.deleteProperty(nextTarget, property) + }, + } + + this.#addEntry(entry) + return defaultDeleteProperty() + }, + }) + } + + this.proxy = wrapInProxy(this.target, []) + } + + public replay(nextTarget: T): void { + for (const entry of this.#entries) { + entry.replay( + entry.path.length > 0 ? get(nextTarget, entry.path) : nextTarget + ) + } + } + + /** + * Pause the recording. + * Any changes applied while the recording is paused will not be recorded. + */ + public pause(): void { + invariant( + this.readyState !== ObjectRecorder.DISPOSED, + 'Failed to pause the recorder: recorder is disposed' + ) + + invariant( + this.readyState === ObjectRecorder.RECORDING, + 'Failed to pause the recorder: recorder is not running' + ) + + this.readyState = ObjectRecorder.PAUSED + } + + /** + * Resume the recording. + */ + public resume(): void { + invariant( + this.readyState !== ObjectRecorder.DISPOSED, + 'Failed to resume the recorder: recorder is disposed' + ) + + invariant( + this.readyState === ObjectRecorder.PAUSED, + 'Failed to resume the recorder: recorder is not paused' + ) + + this.readyState = ObjectRecorder.RECORDING + } + + /** + * Pause the recording and execute the given callback. + * Any mutations applied within the callback will not be recorded. + */ + public runQuietly(callback: () => Promise | void): void { + invariant( + this.readyState !== ObjectRecorder.DISPOSED, + 'Failed to run action quielty: recorder is disposed' + ) + + this.pause() + + try { + const result = callback() + + if (result instanceof Promise) { + result.finally(this.resume.bind(this)) + } else { + this.resume() + } + } catch { + this.resume() + } + } + + public dispose(): void { + this.readyState = ObjectRecorder.DISPOSED + this.#entries.length = 0 + } + + #addEntry(entry: ObjectRecordEntry): void { + invariant( + this.readyState !== ObjectRecorder.IDLE, + 'Failed to add entry to the recorder: recorder is idle' + ) + + invariant( + this.readyState !== ObjectRecorder.DISPOSED, + 'Failed to add entry to the recorder: recorder is disposed' + ) + + if (this.readyState === ObjectRecorder.PAUSED) { + return + } + + if (this.options?.filter(entry) ?? true) { + this.#entries.push(entry) + } + } +} diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts new file mode 100644 index 000000000..de33bd46e --- /dev/null +++ b/src/interceptors/net/socket-controller.ts @@ -0,0 +1,113 @@ +import net from 'node:net' +import { ObjectRecorder } from './object-recorder' +import { MockSocket } from './mock-socket' +import { + normalizeSocketWriteArgs, + type WriteArgs, +} from '../Socket/utils/normalizeSocketWriteArgs' + +export const kSocketProxy = Symbol('kSocketProxy') +export const kServerSocket = Symbol('kServerSocket') + +interface SocketControllerOptions { + socket: MockSocket + createConnection: () => net.Socket +} + +export class SocketController { + private [kSocketProxy]: net.Socket + private [kServerSocket]: MockSocket + + #recorder: ObjectRecorder + #clientSocket: MockSocket + + constructor(protected readonly options: SocketControllerOptions) { + this.#clientSocket = this.options.socket + this[kServerSocket] = new MockSocket({}) + + this.#recorder = new ObjectRecorder(this.options.socket, { + filter: (entry) => { + if (entry.type === 'apply') { + if ( + entry.metadata.method === 'write' || + entry.metadata.method === 'end' + ) { + const [chunk, encoding] = normalizeSocketWriteArgs( + entry.metadata.args as WriteArgs + ) + + if (chunk) { + // Translate client writes to the "data" event on the server socket. + this[kServerSocket].emit( + 'data', + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) + ) + } + } + } + + return true + }, + }) + this.#recorder.start() + + // When server writes, client receives data + const originalServerWrite = this[kServerSocket].write.bind( + this[kServerSocket] + ) + const originalServerEnd = this[kServerSocket].end.bind(this[kServerSocket]) + + this[kServerSocket].write = (...args: [any, any]) => { + if (args[0] != null) { + const [chunk, encoding] = normalizeSocketWriteArgs(args) + + this.#clientSocket.emit( + 'data', + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) + ) + } + + return originalServerWrite(...args) + } + + this[kServerSocket].end = (...args: [any]) => { + if (args[0] != null) { + const [chunk, encoding] = normalizeSocketWriteArgs(args) + + this.#clientSocket.emit( + 'data', + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) + ) + } + + return originalServerEnd(...args) + } + + this[kSocketProxy] = this.#recorder.proxy + } + + /** + * Mock the socket connection. + */ + public connect(): void { + this.options.socket.mockConnect() + this[kServerSocket].mockConnect() + } + + /** + * Establish this socket connection as-is. + */ + public passthrough(): void { + this.#recorder.dispose() + + const realSocket = this.options.createConnection() + this.#recorder.replay(realSocket) + } + + /** + * Abort the underlying socket connection with the given reason. + */ + public errorWith(reason?: Error): void { + this.options.socket.destroy(reason) + } +} 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..b5b778be7 --- /dev/null +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -0,0 +1,88 @@ +import net from 'node:net' +import url from 'node:url' + +export interface NetworkConnectionOptions { + secure?: boolean | null + port?: number | null + path: string | null + host?: string | null + protocol?: string | null + auth?: string | null + family?: number | null + session?: Buffer + localAddress?: string | null + localPort?: number | null +} + +export type NetConnectArgs = + | [options: net.NetConnectOpts, connectionListener?: () => void] + | [url: URL, connectionListener?: () => void] + | [port: number, host: string, connectionListener?: () => void] + | [path: string, connectionListener?: () => void] + +export type NormalizedNetConnectArgs = [ + options: NetworkConnectionOptions, + connectionListener?: () => void, +] + +/** + * Normalizes the arguments passed to `net.connect()`. + */ +export function normalizeNetConnectArgs( + args: NetConnectArgs +): NormalizedNetConnectArgs { + const callback = typeof args[1] === 'function' ? args[1] : args[2] + + if (typeof args[0] === 'string') { + return [{ path: args[0] }, callback] + } + + if (typeof args[0] === 'number' && typeof args[1] === 'string') { + return [{ port: args[0], path: '', host: args[1] }, callback] + } + + if (typeof args[0] === 'object') { + if ('href' in args[0]) { + const options = url.urlToHttpOptions(args[0]) + + return [ + { + protocol: args[0].protocol, + path: options.path || '', + port: +args[0].port, + host: options.hostname, + auth: options.auth, + }, + callback, + ] + } + + if ('port' in args[0]) { + return [ + { + path: '', + port: args[0].port, + host: args[0].host, + auth: Reflect.get(args[0], 'auth'), + family: args[0].family, + session: Reflect.get(args[0], 'session'), + localAddress: args[0].localAddress, + localPort: args[0].localPort, + }, + callback, + ] + } + + return [ + { + path: args[0].path || '', + family: Reflect.get(args[0], 'family'), + session: Reflect.get(args[0], 'session'), + auth: Reflect.get(args[0], 'auth'), + }, + callback, + ] + } + + throw new Error(`Invalid arguments passed to net.connect: ${args}`) +} diff --git a/test/modules/net/example.test.ts b/test/modules/net/example.test.ts new file mode 100644 index 000000000..364831b71 --- /dev/null +++ b/test/modules/net/example.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node +import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import net from 'node:net' +import { SocketInterceptor } from '../../../src/interceptors/net' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks the intercepted connection', async () => { + const serverDataListener = vi.fn() + + interceptor.on('connection', ({ socket, controller }) => { + controller.connect() + + socket.on('data', serverDataListener) + socket.write('hello from server') + }) + + const connectionListener = vi.fn() + const socket = net.connect(3000, '127.0.0.1', connectionListener) + + const errorListener = vi.fn() + socket.on('error', errorListener) + + const clientDataListener = vi.fn() + socket.on('data', clientDataListener) + + socket.on('connect', () => { + socket.write('hello from client') + }) + + await expect.poll(() => connectionListener).toHaveBeenCalled() + expect(errorListener).not.toHaveBeenCalled() + expect(serverDataListener).toHaveBeenCalledExactlyOnceWith( + Buffer.from('hello from client') + ) + expect(clientDataListener).toHaveBeenCalledExactlyOnceWith( + Buffer.from('hello from server') + ) +}) + +it('errors the intercepted socket before it connects', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ controller }) => { + controller.errorWith(reason) + }) + + const connectionListener = vi.fn() + const socket = net.connect(3000, '127.0.0.1', connectionListener) + + const errorListener = vi.fn() + const closeListener = vi.fn() + socket.on('error', errorListener) + socket.on('close', closeListener) + + await expect.poll(() => errorListener).toHaveBeenCalled() + expect.soft(errorListener).toHaveBeenCalledExactlyOnceWith(reason) + expect.soft(closeListener).toHaveBeenCalledExactlyOnceWith() + expect.soft(connectionListener).not.toHaveBeenCalled() +}) From 984eaab989e26c0d6082a057104a904cc4b14796 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Feb 2026 18:20:11 +0100 Subject: [PATCH 002/198] feat: `HttpRequestInterceptor` over `SocketInterceptor` --- _http_common.d.ts | 14 +- src/interceptors/http/http-parser.ts | 232 ++++++++++++++++++ src/interceptors/http/index.ts | 209 ++++++++++++++++ src/interceptors/net/socket-controller.ts | 29 +-- .../net/utils/connection-options-to-url.ts | 49 ++++ src/utils/bufferUtils.ts | 7 + .../http/response/http-empty-response.test.ts | 17 +- 7 files changed, 524 insertions(+), 33 deletions(-) create mode 100644 src/interceptors/http/http-parser.ts create mode 100644 src/interceptors/http/index.ts create mode 100644 src/interceptors/net/utils/connection-options-to-url.ts diff --git a/_http_common.d.ts b/_http_common.d.ts index a968379cc..e28a21f10 100644 --- a/_http_common.d.ts +++ b/_http_common.d.ts @@ -21,15 +21,15 @@ declare var HTTPParser: { export interface HTTPParser { new (): HTTPParser - [HTTPParser.kOnMessageBegin]: (() => void) | null - [HTTPParser.kOnHeaders]: HeadersCallback | null - [HTTPParser.kOnHeadersComplete]: ParserType extends 0 + [HTTPParser.kOnMessageBegin]?: (() => void) | null + [HTTPParser.kOnHeaders]?: HeadersCallback + [HTTPParser.kOnHeadersComplete]?: ParserType extends 0 ? RequestHeadersCompleteCallback | null : ResponseHeadersCompleteCallback | null - [HTTPParser.kOnBody]: ((chunk: Buffer) => void) | null - [HTTPParser.kOnMessageComplete]: (() => void) | null - [HTTPParser.kOnExecute]: (() => void) | null - [HTTPParser.kOnTimeout]: (() => void) | null + [HTTPParser.kOnBody]?: ((chunk: Buffer) => void) | null + [HTTPParser.kOnMessageComplete]?: (() => void) | null + [HTTPParser.kOnExecute]?: (() => void) | null + [HTTPParser.kOnTimeout]?: (() => void) | null initialize(type: ParserType, asyncResource: object): void execute(buffer: Buffer): void diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts new file mode 100644 index 000000000..f65b5082a --- /dev/null +++ b/src/interceptors/http/http-parser.ts @@ -0,0 +1,232 @@ +import { + HTTPParser, + type HeadersCallback, + type RequestHeadersCompleteCallback, + type ResponseHeadersCompleteCallback, +} from '_http_common' +import net from 'node:net' +import { Readable } from 'node:stream' +import { invariant } from 'outvariant' +import { FetchResponse } from '../../utils/fetchUtils' + +type HttpParserKind = typeof HTTPParser.REQUEST | typeof HTTPParser.RESPONSE + +interface ParserHooks { + onMessageBegin?: () => void + onHeaders?: HeadersCallback + onHeadersComplete?: ParserKind extends typeof HTTPParser.REQUEST + ? RequestHeadersCompleteCallback + : ResponseHeadersCompleteCallback + onBody?: (chunk: Buffer) => void + onMessageComplete?: () => void + onExecute?: () => void + onTimeout?: () => void +} + +export class HttpParser { + static REQUEST = HTTPParser.REQUEST + static RESPONSE = HTTPParser.RESPONSE + + #parser: HTTPParser + + constructor(kind: ParserKind, hooks: ParserHooks) { + this.#parser = new HTTPParser() + this.#parser.initialize(kind, {}) + + this.#parser[HTTPParser.kOnMessageBegin] = hooks.onMessageBegin + this.#parser[HTTPParser.kOnHeaders] = hooks.onHeaders + this.#parser[HTTPParser.kOnHeadersComplete] = hooks.onHeadersComplete + this.#parser[HTTPParser.kOnBody] = hooks.onBody + this.#parser[HTTPParser.kOnMessageComplete] = hooks.onMessageComplete + this.#parser[HTTPParser.kOnExecute] = hooks.onExecute + this.#parser[HTTPParser.kOnTimeout] = hooks.onTimeout + } + + public execute(data: Buffer): void { + this.#parser.execute(data) + } + + /** + * @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) { + Reflect.set(socket, 'parser', null) + } + } +} + +interface HttpRequestParserOptions { + connectionOptions: { + method?: string + url: URL + } + onRequest: (request: Request) => void +} + +export class HttpRequestParser extends HttpParser { + #rawHeadersBuffer: Array + #requestBodyStream?: Readable + + constructor(options: HttpRequestParserOptions) { + super(HttpParser.REQUEST, { + onHeaders: (rawHeaders) => { + this.#rawHeadersBuffer.push(...rawHeaders) + }, + onHeadersComplete: ( + _, + __, + rawHeaders = [], + ___, + path, + ____, + _____, + ______, + shouldKeepAlive + ) => { + const method = options.connectionOptions.method?.toUpperCase() || 'GET' + const url = new URL(path || '', options.connectionOptions.url) + const headers = FetchResponse.parseRawHeaders([ + ...this.#rawHeadersBuffer, + ...rawHeaders, + ]) + + // 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 = '' + } + + const canHaveBody = method !== 'HEAD' && method !== 'GET' + 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, + }) + + 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.#rawHeadersBuffer = [] + } + + public free(socket?: net.Socket): void { + super.free(socket) + this.#rawHeadersBuffer.length = 0 + this.#requestBodyStream = undefined + } +} + +export class HttpResponseParser extends HttpParser { + #responseRawHeadersBuffer: Array + #responseBodyStream?: Readable | null + + constructor(options: { onResponse: (response: Response) => void }) { + super(HttpParser.RESPONSE, { + onHeaders: (rawHeaders) => { + this.#responseRawHeadersBuffer.push(...rawHeaders) + }, + onHeadersComplete: ( + versionMajor, + versionMinor, + rawHeaders, + method, + url, + status, + statusText + ) => { + const headers = FetchResponse.parseRawHeaders([ + ...this.#responseRawHeadersBuffer, + ...(rawHeaders || []), + ]) + + const response = new FetchResponse( + FetchResponse.isResponseWithBody(status) + ? (Readable.toWeb( + (this.#responseBodyStream = new Readable({ read() {} })) + ) as any) + : null, + { + url, + status, + statusText, + headers, + } + ) + + options.onResponse(response) + }, + onBody: (chunk) => { + invariant( + this.#responseBodyStream, + 'Failed to read from a response stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub.' + ) + + this.#responseBodyStream.push(chunk) + }, + onMessageComplete: () => { + this.#responseBodyStream?.push(null) + }, + }) + + this.#responseRawHeadersBuffer = [] + } + + public free(socket?: net.Socket): void { + super.free(socket) + this.#responseRawHeadersBuffer = [] + this.#responseBodyStream = null + } +} diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts new file mode 100644 index 000000000..9179ff000 --- /dev/null +++ b/src/interceptors/http/index.ts @@ -0,0 +1,209 @@ +import net from 'node:net' +import { invariant } from 'outvariant' +import { Interceptor } from '../../Interceptor' +import { type HttpRequestEventMap } from '../../glossary' +import { RequestController } from '../../RequestController' +import { + getRawFetchHeaders, + recordRawFetchHeaders, + restoreHeadersPrototype, +} from '../ClientRequest/utils/recordRawHeaders' +import { SocketInterceptor } from '../net' +import { connectionOptionsToUrl } from '../net/utils/connection-options-to-url' +import { toBuffer } from '../../utils/bufferUtils' +import { createRequestId } from '../../createRequestId' +import { HttpRequestParser, HttpResponseParser } from './http-parser' +import { emitAsync } from '../../utils/emitAsync' +import { handleRequest } from '../../utils/handleRequest' +import { isResponseError } from '../../utils/responseUtils' +import { kClientSocket } from '../net/socket-controller' + +export class HttpRequestInterceptor extends Interceptor { + static symbol = Symbol('client-request-interceptor') + + constructor() { + super(HttpRequestInterceptor.symbol) + } + + protected setup(): void { + const socketInterceptor = new SocketInterceptor() + socketInterceptor.apply() + this.subscriptions.push(() => socketInterceptor.dispose()) + + recordRawFetchHeaders() + this.subscriptions.push(() => restoreHeadersPrototype()) + + socketInterceptor.on( + 'connection', + ({ connectionOptions, socket, controller: socketController }) => { + socket.once('data', (chunk) => { + const firstFrame = chunk.toString() + const httpMethod = firstFrame.split(' ')[0] + + invariant( + httpMethod != null, + 'Failed to handle an HTTP request: expected a valid HTTP method but got "%s"', + httpMethod + ) + + const baseUrl = connectionOptionsToUrl(connectionOptions) + const requestParser = new HttpRequestParser({ + connectionOptions: { + method: httpMethod, + url: baseUrl, + }, + onRequest: async (request) => { + const requestId = createRequestId() + + const requestController = new RequestController(request, { + respondWith: async (response) => { + await this.respondWith({ + socket: socketController[kClientSocket], + request, + response, + }) + }, + errorWith: (reason) => { + if (reason instanceof Error) { + socketController.errorWith(reason) + } + }, + passthrough: () => { + const realSocket = 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. + realSocket._httpMessage = socket._httpMessage + // @ts-expect-error Node.js internals. + realSocket.parser = socket.parser + // @ts-expect-error Node.js internals. + realSocket.parser.socket = passthroughSocket + + if (this.emitter.listenerCount('response') > 0) { + const responseParser = new HttpResponseParser({ + onResponse: async (response) => { + await emitAsync(this.emitter, 'response', { + requestId, + request, + response, + isMockedResponse: false, + }) + }, + }) + + realSocket + .on('data', (chunk) => 'RESPONSE PARSER') + .on('close', () => responseParser.free()) + } + }, + }) + + await handleRequest({ + request, + requestId, + controller: requestController, + emitter: this.emitter, + }) + }, + }) + + // Forward the first frame to the parser. + requestParser.execute(toBuffer(chunk)) + + // Forward subsequent socket writes to the parser. + socket.on('data', (chunk) => { + if (chunk) { + requestParser.execute(chunk) + } + }) + + /** @todo Free the parser once the socket is destroyed */ + }) + } + ) + } + + private async respondWith(args: { + socket: net.Socket + request: Request + response: Response + }): Promise { + const { socket, request, response } = args + + if (socket.destroyed) { + return + } + + if (isResponseError(response)) { + socket.destroy(new TypeError('Network error')) + return + } + + const { STATUS_CODES } = await import('node:http') + + const statusText = + response.statusText || STATUS_CODES[response.status] || '' + const statusLine = `HTTP/1.1 ${response.status} ${statusText}\r\n` + + const rawResponseHeaders = getRawFetchHeaders(response.headers) + + let headersString = '' + for (const [name, value] of rawResponseHeaders) { + headersString += `${name}: ${value}\r\n` + } + + headersString += '\r\n' + + /** + * @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 + */ + // @ts-expect-error Node.js internals + socket.parser.socket = socket + + // Push the status line and headers to the readable stream + socket.push(Buffer.from(statusLine + headersString)) + + if (response.body) { + try { + const reader = response.body.getReader() + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + socket.push(value) + } + } catch (error) { + if (error instanceof Error) { + /** + * Destroy the socket if the response stream errored. + * @see https://github.com/mswjs/interceptors/issues/738 + */ + socket.destroy() + return + } + } + } else { + socket.push(null) + } + + // Close the connection if it wasn't marked as keep-alive. + if (request.headers.get('connection') !== 'keep-alive') { + socket.push(null) + } + } +} diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index de33bd46e..4761f9dd0 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -5,8 +5,10 @@ import { normalizeSocketWriteArgs, type WriteArgs, } from '../Socket/utils/normalizeSocketWriteArgs' +import { toBuffer } from '../../utils/bufferUtils' export const kSocketProxy = Symbol('kSocketProxy') +export const kClientSocket = Symbol('kClientSocket') export const kServerSocket = Symbol('kServerSocket') interface SocketControllerOptions { @@ -16,13 +18,13 @@ interface SocketControllerOptions { export class SocketController { private [kSocketProxy]: net.Socket + private [kClientSocket]: MockSocket private [kServerSocket]: MockSocket #recorder: ObjectRecorder - #clientSocket: MockSocket constructor(protected readonly options: SocketControllerOptions) { - this.#clientSocket = this.options.socket + this[kClientSocket] = this.options.socket this[kServerSocket] = new MockSocket({}) this.#recorder = new ObjectRecorder(this.options.socket, { @@ -38,10 +40,7 @@ export class SocketController { if (chunk) { // Translate client writes to the "data" event on the server socket. - this[kServerSocket].emit( - 'data', - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) - ) + this[kServerSocket].emit('data', toBuffer(chunk, encoding)) } } } @@ -61,10 +60,7 @@ export class SocketController { if (args[0] != null) { const [chunk, encoding] = normalizeSocketWriteArgs(args) - this.#clientSocket.emit( - 'data', - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) - ) + this[kClientSocket].emit('data', toBuffer(chunk, encoding)) } return originalServerWrite(...args) @@ -74,10 +70,7 @@ export class SocketController { if (args[0] != null) { const [chunk, encoding] = normalizeSocketWriteArgs(args) - this.#clientSocket.emit( - 'data', - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) - ) + this[kClientSocket].emit('data', toBuffer(chunk, encoding)) } return originalServerEnd(...args) @@ -90,24 +83,26 @@ export class SocketController { * Mock the socket connection. */ public connect(): void { - this.options.socket.mockConnect() + this[kClientSocket].mockConnect() this[kServerSocket].mockConnect() } /** * Establish this socket connection as-is. */ - public passthrough(): void { + public passthrough(): net.Socket { this.#recorder.dispose() const realSocket = this.options.createConnection() this.#recorder.replay(realSocket) + + return realSocket } /** * Abort the underlying socket connection with the given reason. */ public errorWith(reason?: Error): void { - this.options.socket.destroy(reason) + this[kClientSocket].destroy(reason) } } diff --git a/src/interceptors/net/utils/connection-options-to-url.ts b/src/interceptors/net/utils/connection-options-to-url.ts new file mode 100644 index 000000000..5fdfbbb1f --- /dev/null +++ b/src/interceptors/net/utils/connection-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 connectionOptionsToUrl(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/utils/bufferUtils.ts b/src/utils/bufferUtils.ts index b4b1c3784..45d726664 100644 --- a/src/utils/bufferUtils.ts +++ b/src/utils/bufferUtils.ts @@ -20,3 +20,10 @@ export function toArrayBuffer(array: Uint8Array): ArrayBuffer { array.byteOffset + array.byteLength ) } + +export function toBuffer( + data: string | Buffer, + encoding?: BufferEncoding +): Buffer { + return Buffer.isBuffer(data) ? data : Buffer.from(data, encoding) +} diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts index 427ae1aa9..a8c5f7373 100644 --- a/test/modules/http/response/http-empty-response.test.ts +++ b/test/modules/http/response/http-empty-response.test.ts @@ -4,9 +4,9 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -17,19 +17,18 @@ afterAll(() => { }) it('supports responding with an empty mocked response', async () => { - interceptor.once('request', ({ controller }) => { - // Responding with an empty response must - // translate to 200 OK with an empty body. + interceptor.once('request', ({ request, controller }) => { + // Responding with an empty response must translate to 200 OK with an empty body. controller.respondWith(new Response()) }) const request = http.get('http://localhost') const { res, text } = await waitForClientRequest(request) - expect(res.statusCode).toBe(200) + expect.soft(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.headers).toEqual({}) + expect.soft(res.rawHeaders).toEqual([]) + await expect(text()).resolves.toBe('') }) From 4edc893c6405c2621e9fa5943b1a5bec31d78a7b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 13:43:17 +0100 Subject: [PATCH 003/198] feat(wip): tcpwrap-based interception --- src/interceptors/http/index.ts | 59 ++++-- src/interceptors/net/index.ts | 40 ++-- src/interceptors/net/mocker-socket.ts | 195 ++++++++++++++++++ src/interceptors/net/object-recorder.test.ts | 129 ++++++++++++ src/interceptors/net/object-recorder.ts | 88 ++++++-- src/interceptors/net/socket-controller.ts | 18 +- .../http/response/http-response-delay.test.ts | 14 +- .../http/response/http-response-error.test.ts | 4 +- .../http-response-readable-stream.test.ts | 12 +- test/modules/net/example.test.ts | 15 +- 10 files changed, 504 insertions(+), 70 deletions(-) create mode 100644 src/interceptors/net/mocker-socket.ts diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 9179ff000..9b9a09012 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -69,21 +69,47 @@ export class HttpRequestInterceptor extends Interceptor { } }, passthrough: () => { + console.log('PASSTHROUGH CALLED!') + const clientSocket = socketController[kClientSocket] const realSocket = socketController.passthrough() + console.log('Real socket created:', { + connecting: realSocket.connecting, + readable: realSocket.readable, + writable: realSocket.writable, + }) + + // @ts-expect-error Node.js internals. + const httpMessage = clientSocket._httpMessage + // @ts-expect-error Node.js internals. + const parser = clientSocket.parser - /** - * @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. - realSocket._httpMessage = socket._httpMessage + realSocket._httpMessage = httpMessage // @ts-expect-error Node.js internals. - realSocket.parser = socket.parser + realSocket.parser = parser // @ts-expect-error Node.js internals. - realSocket.parser.socket = passthroughSocket + parser.socket = realSocket + + if (httpMessage) { + // @ts-expect-error Node.js internals. + httpMessage.socket = realSocket + } + + realSocket.on('connect', () => { + console.log('REAL SOCKET CONNECTED!') + }) + + realSocket.on('data', (chunk) => { + console.log( + 'REAL SOCKET RECEIVED DATA:', + chunk.length, + 'bytes' + ) + }) + + realSocket.on('error', (error) => { + console.log('REAL SOCKET ERROR:', error.message) + }) if (this.emitter.listenerCount('response') > 0) { const responseParser = new HttpResponseParser({ @@ -98,7 +124,7 @@ export class HttpRequestInterceptor extends Interceptor { }) realSocket - .on('data', (chunk) => 'RESPONSE PARSER') + .on('data', (chunk) => responseParser.execute(chunk)) .on('close', () => responseParser.free()) } }, @@ -119,7 +145,7 @@ export class HttpRequestInterceptor extends Interceptor { // Forward subsequent socket writes to the parser. socket.on('data', (chunk) => { if (chunk) { - requestParser.execute(chunk) + requestParser.execute(toBuffer(chunk)) } }) @@ -134,7 +160,7 @@ export class HttpRequestInterceptor extends Interceptor { request: Request response: Response }): Promise { - const { socket, request, response } = args + const { socket, response } = args if (socket.destroyed) { return @@ -197,13 +223,8 @@ export class HttpRequestInterceptor extends Interceptor { return } } - } else { - socket.push(null) } - // Close the connection if it wasn't marked as keep-alive. - if (request.headers.get('connection') !== 'keep-alive') { - socket.push(null) - } + socket.push(null) } } diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 3cac39527..ff91b2523 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -10,6 +10,7 @@ import { kSocketProxy, SocketController, } from './socket-controller' +import { NewMockSocket } from './mocker-socket' interface SocketEventMap { connection: [ @@ -29,35 +30,50 @@ export class SocketInterceptor extends Interceptor { } protected setup(): void { - const originalNetConnect = net.connect + const realNetConnect = net.connect + /** + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L236 + */ net.connect = (...args: [any, any]) => { const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) - const socket = new MockSocket(connectionOptions, connectionCallback) - const controller = new SocketController({ - socket, - createConnection() { - return originalNetConnect.apply(null, args) - }, - }) + // const socket = new MockSocket(connectionOptions, connectionCallback) + // const controller = new SocketController({ + // socket, + // createConnection() { + // return realNetConnect.apply(null, args) + // }, + // }) + + const socket = new NewMockSocket(args) process.nextTick(() => { this.emitter.emit('connection', { - socket: controller[kServerSocket], + socket, + // socket: controller[kServerSocket], connectionOptions, - controller, + // controller, }) }) + if (connectionOptions.timeout) { + socket.setTimeout(connectionOptions.timeout) + } + + return socket.connect(connectionOptions, connectionCallback) + // Return the socket wrapped in the recorder proxy. - return controller[kSocketProxy] + // return controller[kSocketProxy] } + + const realNetCreateConnection = net.createConnection net.createConnection = net.connect this.subscriptions.push(() => { - net.connect = originalNetConnect + net.connect = realNetConnect + net.createConnection = realNetCreateConnection }) } } diff --git a/src/interceptors/net/mocker-socket.ts b/src/interceptors/net/mocker-socket.ts new file mode 100644 index 000000000..c8898b5a3 --- /dev/null +++ b/src/interceptors/net/mocker-socket.ts @@ -0,0 +1,195 @@ +import net from 'node:net' + +type ErrorStatus = 0 | 1 + +declare module 'node:net' { + interface Socket { + _handle: { + open: (fd: unknown) => ErrorStatus + connect: (request: TcpWrap, address: string, port: number) => void + listen: (backlog: number) => ErrorStatus + onconnection?: () => void + getpeername?: () => ErrorStatus + getsockname?: () => ErrorStatus + reading: boolean + onread: () => {} + readStart: () => void + readStop: () => void + bytesRead: number + bytesWritten: number + ref?: () => void + unref?: () => void + fchmod: (mode: number) => void + setBlocking: (blocking: boolean) => ErrorStatus + setNoDelay?: (noDelay: boolean) => void + setKeepAlive?: (keepAlive: boolean, initialDelay: number) => void + shutdown: (reqest: unknown /* ShutdownWrap */) => ErrorStatus + close: () => void + } + } +} + +interface TcpWrap { + oncomplete: ( + status: ErrorStatus, + owner: any, + request: TcpWrap, + readable?: boolean, + writable?: boolean + ) => void +} + +export class NewMockSocket extends net.Socket { + public connecting: boolean + + constructor(options: net.SocketConstructorOpts) { + super(options) + this.connecting = false + } + + /** + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1281 + */ + public connect(...args: SocketConnectArgs): this { + // this.connecting = true + // this.#connectionOptions = args + + // if (connectionCallback != null) { + // this.on('connect', connectionCallback) + // } + + // process.nextTick(() => { + // this.connecting = false + // this.emit('connect') + // }) + // + const [options, connectionCallback] = normalizeSocketConnectArgs(args) + + // options.lookup = (hostname, options, callback) => { + // console.log('DNS LOOKUP!', hostname, options, callback) + + // this.emit('lookup', null, 'ip', 'addressType', 'host') + // this.emit('connectionAttempt', 'address', 'port', 'addressType') + + // process.nextTick(() => { + // this.connecting = false + // this.emit('connect') + // }) + // } + + this.on('connectionAttempt', () => { + // Patch the TCPWrap handle set only after the connection attempt. + this._handle.connect = (tcpWrap, address, port) => { + console.log('HANDLE CONNECT!', tcpWrap, address, port) + + /** + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1649 + */ + tcpWrap.oncomplete(0, this._handle, tcpWrap, true, true) + } + + this._handle.readStart = () => { + console.log('READ!') + } + }) + + return super.connect(...args) + } + + _write( + chunk: any, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + console.log('WRITE!', chunk) + callback(null) + } + + /** + * Establish this socket connection as-is. + */ + public passthrough(): void { + // super.connect(this.#connectionOptions) + /** @todo Replay any writes or changes onto the passthrough socket */ + } +} + +type SocketConnectArgs = + | [options: net.NetConnectOpts, connectionListener?: () => void] + | [port: number, host: string, connectionListener?: () => void] + | [port: number, connectionListener?: () => void] + | [path: string, connectionListener?: () => void] + | [] + +type NormalizdeSocketConnectArgs = [ + options: { + host?: string + port?: number + path?: string + }, + connectionListener: (() => void) | null, +] + +function normalizeSocketConnectArgs( + args: SocketConnectArgs +): NormalizdeSocketConnectArgs { + if (args.length === 0) { + return [{}, null] + } + + let result: NormalizdeSocketConnectArgs = [{}, null] + let options: NormalizdeSocketConnectArgs[0] = {} + const [arg0] = args + + if (typeof arg0 === 'object' && arg0 !== null) { + options = arg0 + } else if (typeof arg0 === 'string' && isNaN(Number(arg0))) { + options.path = arg0 + } else { + options.port = Number(arg0) + + if (args.length > 1 && typeof args[1] === 'string') { + options.host = args[1] + } + } + + const callback = args[args.length - 1] + if (typeof callback === 'function') { + result = [options, callback] + } else { + result = [options, null] + } + + return result +} + +const kRealConnect = Symbol('kRealConnect') +const kConnectArgs = Symbol('kConnectArgs') + +class NewSocketController { + constructor(private readonly socket: net.Socket) { + Reflect.set(socket, kRealConnect, socket.connect.bind(socket)) + + socket.connect = function mockConnect(...args) { + Reflect.set(this, 'connecting', true) + + process.nextTick(() => { + Reflect.set(this, 'connecting', false) + this.emit('connect') + }) + + Reflect.set(socket, kConnectArgs, args) + return this + } + } + + public passthrough(): net.Socket { + const connect = Reflect.get(this.socket, kRealConnect) + const connectArgs = Reflect.get(this.socket, kConnectArgs) + connect(connectArgs) + } + + public errorWith(reason?: Error): void { + this.socket.destroy(reason) + } +} diff --git a/src/interceptors/net/object-recorder.test.ts b/src/interceptors/net/object-recorder.test.ts index 19fce6fb3..80bd872c1 100644 --- a/src/interceptors/net/object-recorder.test.ts +++ b/src/interceptors/net/object-recorder.test.ts @@ -287,3 +287,132 @@ it('supports custom action predicate', () => { _internal: 'a', }) }) + +it('restores original target when disposed', () => { + const target = { a: 1, b: 2 } + const recorder = new ObjectRecorder(target) + recorder.start() + + const proxiedObject = recorder.proxy + expect(proxiedObject).not.toBe(target) + + recorder.proxy.a = 10 + expect(target.a).toBe(10) + + recorder.dispose() + + expect(recorder.proxy).toBe(target) + expect(recorder.entries).toHaveLength(0) +}) + +it('restores nested proxies when disposed', () => { + const target = { a: { b: { c: 1 } } } + const recorder = new ObjectRecorder(target) + recorder.start() + + const proxiedA = recorder.proxy.a + const proxiedB = recorder.proxy.a.b + + expect(proxiedA).not.toBe(target.a) + expect(proxiedB).not.toBe(target.a.b) + + recorder.proxy.a.b.c = 10 + expect(target.a.b.c).toBe(10) + + recorder.dispose() + + expect(recorder.proxy).toBe(target) + expect(recorder.proxy.a).toBe(target.a) + expect(recorder.proxy.a.b).toBe(target.a.b) +}) + +it('allows mutations after dispose without recording', () => { + const target = { a: 1 } + const recorder = new ObjectRecorder(target) + recorder.start() + + recorder.proxy.a = 2 + expect(recorder.entries).toHaveLength(1) + + recorder.dispose() + + recorder.proxy.a = 3 + expect(target.a).toBe(3) + expect(recorder.entries).toHaveLength(0) +}) + +it('restores nested arrays when disposed', () => { + const target = { items: [1, 2, 3], nested: { arr: [4, 5] } } + const recorder = new ObjectRecorder(target) + recorder.start() + + const proxiedItems = recorder.proxy.items + const proxiedNestedArr = recorder.proxy.nested.arr + + expect(proxiedItems).not.toBe(target.items) + expect(proxiedNestedArr).not.toBe(target.nested.arr) + + recorder.proxy.items.push(4) + recorder.proxy.nested.arr.push(6) + + recorder.dispose() + + expect(recorder.proxy.items).toBe(target.items) + expect(recorder.proxy.nested.arr).toBe(target.nested.arr) + expect(target.items).toEqual([1, 2, 3, 4]) + expect(target.nested.arr).toEqual([4, 5, 6]) +}) + +it('restores deeply nested object proxies when disposed', () => { + const target = { + level1: { + level2: { + level3: { + value: 'deep', + }, + }, + }, + } + const recorder = new ObjectRecorder(target) + recorder.start() + + const proxiedLevel1 = recorder.proxy.level1 + const proxiedLevel2 = recorder.proxy.level1.level2 + const proxiedLevel3 = recorder.proxy.level1.level2.level3 + + expect(proxiedLevel1).not.toBe(target.level1) + expect(proxiedLevel2).not.toBe(target.level1.level2) + expect(proxiedLevel3).not.toBe(target.level1.level2.level3) + + recorder.dispose() + + expect(recorder.proxy.level1).toBe(target.level1) + expect(recorder.proxy.level1.level2).toBe(target.level1.level2) + expect(recorder.proxy.level1.level2.level3).toBe(target.level1.level2.level3) +}) + +it('handles dispose with mixed property types', () => { + const target = { + primitive: 42, + object: { nested: true }, + array: [1, 2, 3], + func() { + return 'result' + }, + } + const recorder = new ObjectRecorder(target) + recorder.start() + + const proxiedObject = recorder.proxy.object + const proxiedArray = recorder.proxy.array + + expect(proxiedObject).not.toBe(target.object) + expect(proxiedArray).not.toBe(target.array) + + recorder.dispose() + + expect(recorder.proxy).toBe(target) + expect(recorder.proxy.object).toBe(target.object) + expect(recorder.proxy.array).toBe(target.array) + expect(recorder.proxy.func()).toBe('result') +}) diff --git a/src/interceptors/net/object-recorder.ts b/src/interceptors/net/object-recorder.ts index dd1cad2ae..455cf5db3 100644 --- a/src/interceptors/net/object-recorder.ts +++ b/src/interceptors/net/object-recorder.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { invariant } from 'outvariant' -import { get } from 'es-toolkit/compat' +import { get, set } from 'es-toolkit/compat' const nestedInvocationContext = new AsyncLocalStorage() @@ -43,6 +43,8 @@ export class ObjectRecorder { static DISPOSED = 4 as const #entries: Array> + #proxyToOriginal: WeakMap + #parentToProxiedProperties: WeakMap> public proxy: T public readyState: 1 | 2 | 3 | 4 @@ -52,6 +54,8 @@ export class ObjectRecorder { protected readonly options?: ObjectRecorderOptions ) { this.#entries = [] + this.#proxyToOriginal = new WeakMap() + this.#parentToProxiedProperties = new WeakMap() this.readyState = ObjectRecorder.IDLE this.proxy = target @@ -77,8 +81,8 @@ export class ObjectRecorder { const wrapInProxy = ( target: V, parentPath: Array - ) => { - return new Proxy(target, { + ): V => { + const proxy = new Proxy(target, { get: (target, property, receiver) => { const value = target[property as keyof V] @@ -114,7 +118,19 @@ export class ObjectRecorder { } if (value != null && typeof value === 'object') { - return wrapInProxy(value, parentPath.concat(property)) + let proxiedPropertiesMap = + this.#parentToProxiedProperties.get(target) + if (!proxiedPropertiesMap) { + proxiedPropertiesMap = new Map() + this.#parentToProxiedProperties.set(target, proxiedPropertiesMap) + } + + let proxiedValue = proxiedPropertiesMap.get(property) + if (!proxiedValue) { + proxiedValue = wrapInProxy(value, parentPath.concat(property)) + proxiedPropertiesMap.set(property, proxiedValue) + } + return proxiedValue } return Reflect.get(target, property, receiver) @@ -124,8 +140,6 @@ export class ObjectRecorder { return Reflect.defineProperty(target, property, descriptor) } - // Prevent recording changes caused by method invocations. - // Replaying the method must be enough to reapply them, too. if (nestedInvocationContext.getStore()) { return defaultDefineProperty() } @@ -150,8 +164,6 @@ export class ObjectRecorder { return Reflect.deleteProperty(target, property) } - // Prevent recording changes caused by method invocations. - // Replaying the method must be enough to reapply them, too. if (nestedInvocationContext.getStore()) { return defaultDeleteProperty() } @@ -171,6 +183,10 @@ export class ObjectRecorder { return defaultDeleteProperty() }, }) + + this.#proxyToOriginal.set(proxy, target) + + return proxy } this.proxy = wrapInProxy(this.target, []) @@ -184,10 +200,6 @@ export class ObjectRecorder { } } - /** - * Pause the recording. - * Any changes applied while the recording is paused will not be recorded. - */ public pause(): void { invariant( this.readyState !== ObjectRecorder.DISPOSED, @@ -202,9 +214,6 @@ export class ObjectRecorder { this.readyState = ObjectRecorder.PAUSED } - /** - * Resume the recording. - */ public resume(): void { invariant( this.readyState !== ObjectRecorder.DISPOSED, @@ -219,10 +228,6 @@ export class ObjectRecorder { this.readyState = ObjectRecorder.RECORDING } - /** - * Pause the recording and execute the given callback. - * Any mutations applied within the callback will not be recorded. - */ public runQuietly(callback: () => Promise | void): void { invariant( this.readyState !== ObjectRecorder.DISPOSED, @@ -247,6 +252,44 @@ export class ObjectRecorder { public dispose(): void { this.readyState = ObjectRecorder.DISPOSED this.#entries.length = 0 + + const restoreOriginals = ( + obj: any, + path: Array = [] + ): void => { + if (obj == null || typeof obj !== 'object') { + return + } + + const proxiedPropertiesMap = this.#parentToProxiedProperties.get(obj) + if (proxiedPropertiesMap) { + for (const [property, proxiedValue] of proxiedPropertiesMap.entries()) { + const original = this.#proxyToOriginal.get(proxiedValue) + if (original !== undefined) { + obj[property] = original + restoreOriginals(original, path.concat(property)) + } + } + this.#parentToProxiedProperties.delete(obj) + } + + for (const key of Object.keys(obj)) { + const value = obj[key] + if (value != null && typeof value === 'object') { + const original = this.#proxyToOriginal.get(value) + if (original !== undefined) { + obj[key] = original + restoreOriginals(original, path.concat(key)) + } else { + restoreOriginals(value, path.concat(key)) + } + } + } + } + + restoreOriginals(this.target) + + this.proxy = this.target } #addEntry(entry: ObjectRecordEntry): void { @@ -257,7 +300,12 @@ export class ObjectRecorder { invariant( this.readyState !== ObjectRecorder.DISPOSED, - 'Failed to add entry to the recorder: recorder is disposed' + 'Failed to add entry to the recorder: recorder is disposed. Entry: %j', + { + type: entry.type, + path: entry.path, + metadata: entry.metadata, + } ) if (this.readyState === ObjectRecorder.PAUSED) { diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 4761f9dd0..394450f3c 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -29,6 +29,20 @@ export class SocketController { this.#recorder = new ObjectRecorder(this.options.socket, { filter: (entry) => { + if ( + entry.type === 'apply' && + entry.path.some((segment) => segment.toString().startsWith('_')) + ) { + return false + } + + if ( + entry.type === 'set' && + entry.metadata.property.toString().startsWith('_') + ) { + return false + } + if (entry.type === 'apply') { if ( entry.metadata.method === 'write' || @@ -91,11 +105,13 @@ export class SocketController { * Establish this socket connection as-is. */ public passthrough(): net.Socket { - this.#recorder.dispose() + this.#recorder.pause() const realSocket = this.options.createConnection() this.#recorder.replay(realSocket) + this.#recorder.dispose() + return realSocket } diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 339c3d7c7..2b45639eb 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -2,9 +2,9 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'http' import { HttpServer } from '@open-draft/test-server/http' import { sleep, waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { @@ -22,7 +22,7 @@ afterAll(async () => { await httpServer.close() }) -it('supports custom delay before responding with a mock', async () => { +it.skip('supports custom delay before responding with a mock', async () => { interceptor.once('request', async ({ controller }) => { await sleep(750) controller.respondWith(new Response('mocked response')) @@ -33,8 +33,8 @@ it('supports custom delay before responding with a mock', async () => { const { res, text } = await waitForClientRequest(request) const requestEnd = Date.now() - expect(res.statusCode).toBe(200) - expect(await text()).toBe('mocked response') + expect.soft(res.statusCode).toBe(200) + await expect(text()).resolves.toBe('mocked response') expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700) }) @@ -50,7 +50,7 @@ it('supports custom delay before receiving the original response', async () => { const { res, text } = await waitForClientRequest(request) const requestEnd = Date.now() - expect(res.statusCode).toBe(200) - expect(await text()).toBe('original response') + expect.soft(res.statusCode).toBe(200) + await expect(text()).resolves.toBe('original response') expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700) }) diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index 7f657ee3d..ac871dac8 100644 --- a/test/modules/http/response/http-response-error.test.ts +++ b/test/modules/http/response/http-response-error.test.ts @@ -1,9 +1,9 @@ // @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -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 7b20f018b..0a3d5dcc6 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,17 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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() @@ -43,7 +41,7 @@ it('supports ReadableStream as a mocked response', async () => { const request = http.get('http://example.com/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 () => { @@ -191,7 +189,7 @@ it('handles delayed response stream errors as IncomingMessage errors', async () ) }) -it('treats unhandled exceptions during the response stream as request errors', async () => { +it.only('treats unhandled exceptions during the response stream as request errors', async () => { const requestErrorListener = vi.fn() const responseErrorListener = vi.fn() diff --git a/test/modules/net/example.test.ts b/test/modules/net/example.test.ts index 364831b71..8fe626fca 100644 --- a/test/modules/net/example.test.ts +++ b/test/modules/net/example.test.ts @@ -17,11 +17,14 @@ afterAll(() => { interceptor.dispose() }) -it('mocks the intercepted connection', async () => { +it.only('mocks the intercepted connection', async () => { const serverDataListener = vi.fn() interceptor.on('connection', ({ socket, controller }) => { - controller.connect() + // controller.connect() + socket.on('data', (chunk) => + console.log('✅ SERVER RECEIVED:', chunk.toString()) + ) socket.on('data', serverDataListener) socket.write('hello from server') @@ -33,6 +36,14 @@ it('mocks the intercepted connection', async () => { const errorListener = vi.fn() socket.on('error', errorListener) + // DEBUG // + socket.on('lookup', () => console.log('LOOKUP!')) + socket.on('error', console.error) + socket.on('connect', () => console.log('✅ CONNECT!')) + socket.on('data', (chunk) => + console.log('✅ CLIENT RECEIVED:', chunk.toString()) + ) + const clientDataListener = vi.fn() socket.on('data', clientDataListener) From 74f9102cee044190c26a58959d9cbf9b72d8cba1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 14:16:21 +0100 Subject: [PATCH 004/198] feat(wip): implement server socket proxy --- net-architecture.md | 28 +++ net-outline.md | 76 +++++++ src/interceptors/net/index.ts | 24 +-- src/interceptors/net/mocker-socket.ts | 197 +++++++----------- src/interceptors/net/socket-controller.ts | 9 +- .../net/utils/normalize-net-connect-args.ts | 1 + test/modules/net/example.test.ts | 22 +- 7 files changed, 200 insertions(+), 157 deletions(-) create mode 100644 net-architecture.md create mode 100644 net-outline.md diff --git a/net-architecture.md b/net-architecture.md new file mode 100644 index 000000000..d561cef19 --- /dev/null +++ b/net-architecture.md @@ -0,0 +1,28 @@ +## Net-based Interception + +Your task is to implement the network packet interception on the `net.Socket` level in Node.js. The underlying `SocketInterceptor` is already implemented at `src/interceptors/net/index.ts`. Below, are the outline of the necessary pieces. + +## `SocketInterceptor` + +- Responsible for patching `net.createConnection` and `net.connect`. Once patched, any created connection receive a mock socket instance. +- The mock socket instance pretends to successfully establish the connection with whatever connection options provided. This is so connecting to the non-existing hosts works in the mock-first scenario. +- The mock socket is wrapped in a `controller`. Controller is used by a higher-level interceptors to control the underlying socket. Since the data sent over socket is ambiguous, the controller only allows for `.connect()` (to mock the connection), `.passthrough()` (establish the connection as-is), and `.errorWith()` (abort the socket connection). +- Only for the user, a special socket reference is created and exposed as the `socket` argument in the `connection` listener. That special socket is meant to represent the intercepted socket instance _from the server's perspective_. This means that `socket.write()` on the actual socket (e.g. the one created by `net.connect` and then used via `http.ClientRequest`) are translated to the "data" events being emitted on the special server-side `socket`. The server-side socket is used ONLY for this purpose: to observe what the client writes _and_ write data _to_ the client from the `connection` listener as if it has been sent from the "server". + +## `HttpRequestInterceptor` + +- `src/interceptors/http/index.ts` +- This is a higher-level interceptor that relies on `SocketInterceptor` and routes all the intercepted socket packets through the HTTP request parser (we're using Node's parser). +- If the request parser tells us that the sent packet is an HTTP request message header, the interceptor then proceeds with handling that request. It emits the `request` event for the consumer, and uses the established `RequestController` to control the request flow (`.respondWith`, `.errorWith`, etc). These APIs are already created and functional, they need no change. +- The missing parts in the `HttpRequestInterceptor` is establishing the passthrough connection properly. For that, the socket controller accepted the `createConnection` option that constructs the bypassed socket. That works. But the socket hangs forever. + +## Requirements + +- Feel free to improve the existing classes but stay strictily within each class' responsibilities. Those must not leak. +- The overall architecture must compose: MockSocket -> SocketController -> Higher-level interceptors. +- Introduce absolutely no workarounds, zero. +- You will need to reference Node.js internals, particularly `net` and `http` modules to implement this functionality properly. +- Feel free to rely on Node.js internals knowledge and be clever, but not hacky. +- Feel free to `@ts-ignore` property access to Node.js internals, such as `socket.parser`. +- Do not comment your code. +- You can verify your changes via `pnpm test:node test/modules/http/response/http-response-delay.test.ts`. All the test cases must pass there. diff --git a/net-outline.md b/net-outline.md new file mode 100644 index 000000000..8a21dcd73 --- /dev/null +++ b/net-outline.md @@ -0,0 +1,76 @@ +## High-level architecture overview + +> The `net.Socket` constructor doesn't have all the context of `net.connect()` to be a viable layer of interception. All the network requests in Node.js go through `net.connect()` (HTTP, SMTP, etc). + +``` +http.request + Agent.createConnection() + net.Socket +``` + +``` +net.connect() + net.Socket +``` + +## Pieces + +- `SocketInterceptor`. A regular interceptor, patches `net.connect()` and emits the "connection" event to the consumers. +- `MockSocket` returned from the patched `net.connect()`. Always emulates a successful connection, allows spying on the data sent by the client (`socket.write()`) and pushing data to it from the interceptor. +- `SocketController` that, similar to `RequestController`, lets the user decide what to do with the intercepted socket connection. Unlike requests, there's no single `respondWith()` as the nature of data sent via the socket is ambiguous and is for the higher-level interceptors to determine and appropriately handle. There are still, however, methods to error the intercepted connection (`SocketController.errorWith()`) and perform it as-is (`SocketController.passthrough()`). +- A special `net.Socket` representation exposed in the "connection" listener. The `MockSocket` placeholder is _client-side_. To work with the intercepted socket from the server's perspective in the "connection" listener, another, special socket instance has to be provided. It acts as a mirror and allows the user to write data _to_ the client socket via `socket.write()` and listen to the data _sent_ from the client via `socket.on('data')`. This won't be possible with the client placeholder as `socket.on('data')` would represent the data received from the _server_ (and must still be emitted when the interceptor sends mock data to the socket). + +This pretty much covers the mock-first scenarios. But when it comes to passthrough, it gets tricky. The passthrough socket will usually be created after some time the connection has been intercepted. During that time, the intercepted socket might have been acted upon (e.g. written to, changed via methods). The passthrough socket would have to be put _in the exact same state_ as the intercepted socket at the moment of calling `SocketController.passthrough()`. + +This is where I think about employing an _object recorder_. It will use `Proxy` to record any changes done with the intercepted socket instance and then allow us to replay those changes on the passthrough socket. + +## Problems + +### Problem 1: Passthrough socket state + +Node.js sockets are quite intricate. Even with deduped method/property recording via `AsyncLocalStorage`, it still arrives at the state where a change on the intercepted socket is attempted to be recorded when the recorder should've stopped altogether after `passthrough()`. + +### Problem 2: The placeholder `MockSocket` + +Even when the passthrough connection is established, the consumers, like `http.Agent`, have already received and stored the placeholder `MockSocket` as their socket. This means that for passthrough scenarios, the `MockSocket` instance would have to become a _proxy socket_, forwarding whatever data or changes from the passthrough socket. + +What makes it more complex is that the consumers might still _act_ on the placeholder socket even after passthrough. Those acts would also have to be forwarded to the passthrough socket. It seems that the object recorder over the placeholder socket mustn't stop after passthrough, after all. Instead, it should replay the actions on the passthrough socket immediately if passthrough has been established. + +```ts +this.#recorder = new ObjectRecorder(socket, { + filter(entry) { + if (this.#passthroughSocket) { + entry.replay(this.#passthroughSocket) + return false + } + } +}) + +// ...later on. +public passthrough(): net.Socket { + this.#passthroughSocket = this.options.createConnection() + this.#recorder.pause() + this.#recorder.replay(this.#passthroughSocket) + this.#recorder.resume() +} +``` + +> Above, I'm using the `filter()` capabilities of the object recorder to immediately replay the recorded action on the passthrough socket, if it exists. + +Considering that the recorder should continue recording and replaying events, it looks like I need to implement some sort of _entry buffering_. While the previously recorded changes are being replayed on the passthrough socket, the consumers might produce _new changes_ to the placeholder socket. With the approach above, those changes will be lost. So the recorder have to be put in a buffering state until the replay is done, and then replay any buffered events immediately after that. + +> Note: I would love to forego the object recording altogether in favor of something more elegant. I just don't know what that something might be. The actual connection cannot be established as it would error. + +## Alternative to object recording + +Alternatively, instead of having two separate sockets (placeholder and passthrough), only a _single_ passthrough socket can be used. With this approach, all that I have to do is this: + +- Record and silence any errors occurring on the socket. Those are crucial to be replayed if the user decides to passthrough. +- Buffer the data sent from the client instead of sending it to the server. Any writes are still translated to the special `socket` for the "connection" listener. + +Then, if passthrough is established, the socket would have to: + +- Replay the errors, if any. This is for passing through to non-existing hosts. +- If no errors were emitted, replay all the writes that occurred so the original socket will receive them. + +> The danger here are the write callbacks: `socket.write(chunk, encoding, callback)`. Those callbacks would have to be called for the mock-first scenario as consumer logic can and will depend on those callbacks. But calling them _again_ in passthrough will be a mistake. diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index ff91b2523..001b64786 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -4,7 +4,6 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { MockSocket } from './mock-socket' import { kServerSocket, kSocketProxy, @@ -17,7 +16,6 @@ interface SocketEventMap { { socket: net.Socket connectionOptions: NetworkConnectionOptions - controller: SocketController }, ] } @@ -39,33 +37,21 @@ export class SocketInterceptor extends Interceptor { const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) - // const socket = new MockSocket(connectionOptions, connectionCallback) - // const controller = new SocketController({ - // socket, - // createConnection() { - // return realNetConnect.apply(null, args) - // }, - // }) - - const socket = new NewMockSocket(args) + const clientSocket = new NewMockSocket(connectionOptions) + const serverSocket = clientSocket.createServerSocket() process.nextTick(() => { this.emitter.emit('connection', { - socket, - // socket: controller[kServerSocket], + socket: serverSocket, connectionOptions, - // controller, }) }) if (connectionOptions.timeout) { - socket.setTimeout(connectionOptions.timeout) + clientSocket.setTimeout(connectionOptions.timeout) } - return socket.connect(connectionOptions, connectionCallback) - - // Return the socket wrapped in the recorder proxy. - // return controller[kSocketProxy] + return clientSocket.connect(connectionOptions, connectionCallback) } const realNetCreateConnection = net.createConnection diff --git a/src/interceptors/net/mocker-socket.ts b/src/interceptors/net/mocker-socket.ts index c8898b5a3..127649cc4 100644 --- a/src/interceptors/net/mocker-socket.ts +++ b/src/interceptors/net/mocker-socket.ts @@ -1,4 +1,5 @@ import net from 'node:net' +import { toBuffer } from '../../utils/bufferUtils' type ErrorStatus = 0 | 1 @@ -39,69 +40,37 @@ interface TcpWrap { ) => void } -export class NewMockSocket extends net.Socket { - public connecting: boolean - - constructor(options: net.SocketConstructorOpts) { - super(options) - this.connecting = false - } +const kListenerWrap = Symbol('kListenerWrap') +export class NewMockSocket extends net.Socket { /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1281 */ - public connect(...args: SocketConnectArgs): this { - // this.connecting = true - // this.#connectionOptions = args - - // if (connectionCallback != null) { - // this.on('connect', connectionCallback) - // } - - // process.nextTick(() => { - // this.connecting = false - // this.emit('connect') - // }) - // - const [options, connectionCallback] = normalizeSocketConnectArgs(args) - - // options.lookup = (hostname, options, callback) => { - // console.log('DNS LOOKUP!', hostname, options, callback) - - // this.emit('lookup', null, 'ip', 'addressType', 'host') - // this.emit('connectionAttempt', 'address', 'port', 'addressType') - - // process.nextTick(() => { - // this.connecting = false - // this.emit('connect') - // }) - // } - + public connect(...args: [any, any]): this { this.on('connectionAttempt', () => { // Patch the TCPWrap handle set only after the connection attempt. this._handle.connect = (tcpWrap, address, port) => { - console.log('HANDLE CONNECT!', tcpWrap, address, port) - /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1649 */ tcpWrap.oncomplete(0, this._handle, tcpWrap, true, true) } - - this._handle.readStart = () => { - console.log('READ!') - } }) return super.connect(...args) } + _read(size: number): void {} + _write( chunk: any, encoding: BufferEncoding, callback: (error?: Error | null) => void ): void { - console.log('WRITE!', chunk) + // Emit an internal event to translate client writes to server socket "data" events. + // This might not be super elegant, but it doesn't require us to create new emitters. + this.emit('internal:write', chunk, encoding) + callback(null) } @@ -109,87 +78,73 @@ export class NewMockSocket extends net.Socket { * Establish this socket connection as-is. */ public passthrough(): void { - // super.connect(this.#connectionOptions) - /** @todo Replay any writes or changes onto the passthrough socket */ + throw new Error('Passthough not implemented') } -} - -type SocketConnectArgs = - | [options: net.NetConnectOpts, connectionListener?: () => void] - | [port: number, host: string, connectionListener?: () => void] - | [port: number, connectionListener?: () => void] - | [path: string, connectionListener?: () => void] - | [] - -type NormalizdeSocketConnectArgs = [ - options: { - host?: string - port?: number - path?: string - }, - connectionListener: (() => void) | null, -] - -function normalizeSocketConnectArgs( - args: SocketConnectArgs -): NormalizdeSocketConnectArgs { - if (args.length === 0) { - return [{}, null] - } - - let result: NormalizdeSocketConnectArgs = [{}, null] - let options: NormalizdeSocketConnectArgs[0] = {} - const [arg0] = args - if (typeof arg0 === 'object' && arg0 !== null) { - options = arg0 - } else if (typeof arg0 === 'string' && isNaN(Number(arg0))) { - options.path = arg0 - } else { - options.port = Number(arg0) - - if (args.length > 1 && typeof args[1] === 'string') { - options.host = args[1] - } - } - - const callback = args[args.length - 1] - if (typeof callback === 'function') { - result = [options, callback] - } else { - result = [options, null] - } - - return result -} - -const kRealConnect = Symbol('kRealConnect') -const kConnectArgs = Symbol('kConnectArgs') - -class NewSocketController { - constructor(private readonly socket: net.Socket) { - Reflect.set(socket, kRealConnect, socket.connect.bind(socket)) - - socket.connect = function mockConnect(...args) { - Reflect.set(this, 'connecting', true) - - process.nextTick(() => { - Reflect.set(this, 'connecting', false) - this.emit('connect') - }) - - Reflect.set(socket, kConnectArgs, args) - return this - } - } - - public passthrough(): net.Socket { - const connect = Reflect.get(this.socket, kRealConnect) - const connectArgs = Reflect.get(this.socket, kConnectArgs) - connect(connectArgs) - } - - public errorWith(reason?: Error): void { - this.socket.destroy(reason) + public createServerSocket(): net.Socket { + return new Proxy(this, { + get: (target, property, receiver) => { + const getRealValue = () => { + return Reflect.get(target, property, receiver) + } + + if (property === 'on' || property === 'addListener') { + const realAddListener = getRealValue() as net.Socket['addListener'] + + return ( + event: string, + listener: (...args: Array) => void + ) => { + if (event === 'data') { + const listenerWrap = (chunk: any, encoding?: BufferEncoding) => { + listener(toBuffer(chunk, encoding)) + } + + Object.defineProperty(listener, kListenerWrap, { + enumerable: false, + writable: false, + value: listenerWrap, + }) + + this.on('internal:write', listenerWrap) + + return target + } + + return realAddListener.call(target, event, listener) + } + } + + if (property === 'off' || property === 'removeListener') { + const realRemoveListener = + getRealValue() as net.Socket['removeListener'] + + return (event: string, listener: any) => { + if (event === 'data') { + const listenerWrap = listener[kListenerWrap] + + if (listenerWrap) { + return realRemoveListener.call(target, event, listenerWrap) + } + } + + return realRemoveListener.call(target, event, listener) + } + } + + // Push data to the client socket when "server.write()" is called. + if (property === 'write') { + return ( + chunk: any, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ) => { + this.push(toBuffer(chunk, encoding), encoding) + } + } + + return getRealValue() + }, + }) } } diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 394450f3c..0c27acae8 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -1,6 +1,7 @@ import net from 'node:net' import { ObjectRecorder } from './object-recorder' import { MockSocket } from './mock-socket' +import { NewMockSocket } from './mocker-socket' import { normalizeSocketWriteArgs, type WriteArgs, @@ -12,20 +13,20 @@ export const kClientSocket = Symbol('kClientSocket') export const kServerSocket = Symbol('kServerSocket') interface SocketControllerOptions { - socket: MockSocket + socket: NewMockSocket createConnection: () => net.Socket } export class SocketController { private [kSocketProxy]: net.Socket - private [kClientSocket]: MockSocket - private [kServerSocket]: MockSocket + private [kClientSocket]: NewMockSocket + private [kServerSocket]: NewMockSocket #recorder: ObjectRecorder constructor(protected readonly options: SocketControllerOptions) { this[kClientSocket] = this.options.socket - this[kServerSocket] = new MockSocket({}) + this[kServerSocket] = this.options.socket this.#recorder = new ObjectRecorder(this.options.socket, { filter: (entry) => { diff --git a/src/interceptors/net/utils/normalize-net-connect-args.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts index b5b778be7..016f4979e 100644 --- a/src/interceptors/net/utils/normalize-net-connect-args.ts +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -12,6 +12,7 @@ export interface NetworkConnectionOptions { session?: Buffer localAddress?: string | null localPort?: number | null + timeout?: number } export type NetConnectArgs = diff --git a/test/modules/net/example.test.ts b/test/modules/net/example.test.ts index 8fe626fca..0d724c4c3 100644 --- a/test/modules/net/example.test.ts +++ b/test/modules/net/example.test.ts @@ -20,11 +20,15 @@ afterAll(() => { it.only('mocks the intercepted connection', async () => { const serverDataListener = vi.fn() - interceptor.on('connection', ({ socket, controller }) => { - // controller.connect() - socket.on('data', (chunk) => - console.log('✅ SERVER RECEIVED:', chunk.toString()) - ) + interceptor.on('connection', ({ socket }) => { + /** + * Expose a `connection` reference instead of `controller`. + * It controls the intercepted connection: + * - connection.claim() + * - connection.errorWith() + * - connection.retry() + * - connection.passthrough() + */ socket.on('data', serverDataListener) socket.write('hello from server') @@ -36,14 +40,6 @@ it.only('mocks the intercepted connection', async () => { const errorListener = vi.fn() socket.on('error', errorListener) - // DEBUG // - socket.on('lookup', () => console.log('LOOKUP!')) - socket.on('error', console.error) - socket.on('connect', () => console.log('✅ CONNECT!')) - socket.on('data', (chunk) => - console.log('✅ CLIENT RECEIVED:', chunk.toString()) - ) - const clientDataListener = vi.fn() socket.on('data', clientDataListener) From 51b0a370e6634e9c341a3a9969ed6248f2055151 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 14:42:06 +0100 Subject: [PATCH 005/198] feat: implement `ConnectionController` --- src/interceptors/net/connection-controller.ts | 104 ++++++++++++++++++ src/interceptors/net/index.ts | 4 + src/interceptors/net/mocker-socket.ts | 61 +--------- test/modules/net/example.test.ts | 26 ++--- 4 files changed, 124 insertions(+), 71 deletions(-) create mode 100644 src/interceptors/net/connection-controller.ts diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts new file mode 100644 index 000000000..e076c4698 --- /dev/null +++ b/src/interceptors/net/connection-controller.ts @@ -0,0 +1,104 @@ +import net from 'node:net' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { NewMockSocket } from './mocker-socket' + +// Internally, Node.js represents the result of various operations +// by the number they return: 0 (error), 1 (success). +type OperationStatus = 0 | 1 + +declare module 'node:net' { + interface Socket { + _handle: TcpHandle + } +} + +interface TcpHandle { + open: (fd: unknown) => OperationStatus + connect: (request: TcpWrap, address: string, port: number) => void + listen: (backlog: number) => OperationStatus + onconnection?: () => void + getpeername?: () => OperationStatus + getsockname?: () => OperationStatus + reading: boolean + onread: () => {} + readStart: () => void + readStop: () => void + bytesRead: number + bytesWritten: number + ref?: () => void + unref?: () => void + fchmod: (mode: number) => void + setBlocking: (blocking: boolean) => OperationStatus + setNoDelay?: (noDelay: boolean) => void + setKeepAlive?: (keepAlive: boolean, initialDelay: number) => void + shutdown: (reqest: unknown /* ShutdownWrap */) => OperationStatus + close: () => void +} + +interface TcpWrap { + oncomplete: ( + status: OperationStatus, + owner: TcpHandle, + request: TcpWrap, + readable?: boolean, + writable?: boolean + ) => void +} + +export class ConnectionController { + #pendingRequest: DeferredPromise + + constructor(private readonly socket: NewMockSocket) { + this.#pendingRequest = new DeferredPromise() + + socket.prependListener('connectionAttempt', (ip, port, family) => { + /** + * @todo @fixme Also patch "socket._handle.connect6" for IPv6 connections. + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 + */ + if (family === 6) { + throw new Error( + 'IPv6 connections not implemented (implement "socket._handle.connect6' + ) + } + + socket._handle.connect = (request) => { + this.#pendingRequest.resolve(request) + } + }) + } + + /** + * Wait for the first connection attempt and claim this socket connection. + * This will transition the socket into a connected state as if the + * connection with the remote address was successful. + */ + public claim(): void { + /** + * The controller exposes the socket to the user *before* the connection attempt + * is made. That is so the user can handle the socket before connection happens. + */ + this.#pendingRequest.then((request) => { + /** + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 + */ + request.oncomplete(0, this.socket._handle, request, true, true) + }) + } + + public retry(): void { + throw new Error('Not Implemented') + } + + public close(): void { + throw new Error('Not Implemented') + } + + public errorWith(reason?: Error): void { + this.socket.destroy(reason) + } + + public passthrough(): net.Socket { + throw new Error('Not Implemented') + } +} diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 001b64786..4f1aa9b6e 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -10,11 +10,13 @@ import { SocketController, } from './socket-controller' import { NewMockSocket } from './mocker-socket' +import { ConnectionController } from './connection-controller' interface SocketEventMap { connection: [ { socket: net.Socket + controller: ConnectionController connectionOptions: NetworkConnectionOptions }, ] @@ -39,10 +41,12 @@ export class SocketInterceptor extends Interceptor { const clientSocket = new NewMockSocket(connectionOptions) const serverSocket = clientSocket.createServerSocket() + const controller = new ConnectionController(clientSocket) process.nextTick(() => { this.emitter.emit('connection', { socket: serverSocket, + controller, connectionOptions, }) }) diff --git a/src/interceptors/net/mocker-socket.ts b/src/interceptors/net/mocker-socket.ts index 127649cc4..7f852e5c7 100644 --- a/src/interceptors/net/mocker-socket.ts +++ b/src/interceptors/net/mocker-socket.ts @@ -1,65 +1,9 @@ import net from 'node:net' import { toBuffer } from '../../utils/bufferUtils' -type ErrorStatus = 0 | 1 - -declare module 'node:net' { - interface Socket { - _handle: { - open: (fd: unknown) => ErrorStatus - connect: (request: TcpWrap, address: string, port: number) => void - listen: (backlog: number) => ErrorStatus - onconnection?: () => void - getpeername?: () => ErrorStatus - getsockname?: () => ErrorStatus - reading: boolean - onread: () => {} - readStart: () => void - readStop: () => void - bytesRead: number - bytesWritten: number - ref?: () => void - unref?: () => void - fchmod: (mode: number) => void - setBlocking: (blocking: boolean) => ErrorStatus - setNoDelay?: (noDelay: boolean) => void - setKeepAlive?: (keepAlive: boolean, initialDelay: number) => void - shutdown: (reqest: unknown /* ShutdownWrap */) => ErrorStatus - close: () => void - } - } -} - -interface TcpWrap { - oncomplete: ( - status: ErrorStatus, - owner: any, - request: TcpWrap, - readable?: boolean, - writable?: boolean - ) => void -} - const kListenerWrap = Symbol('kListenerWrap') export class NewMockSocket extends net.Socket { - /** - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1281 - */ - public connect(...args: [any, any]): this { - this.on('connectionAttempt', () => { - // Patch the TCPWrap handle set only after the connection attempt. - this._handle.connect = (tcpWrap, address, port) => { - /** - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1649 - */ - tcpWrap.oncomplete(0, this._handle, tcpWrap, true, true) - } - }) - - return super.connect(...args) - } - _read(size: number): void {} _write( @@ -71,6 +15,11 @@ export class NewMockSocket extends net.Socket { // This might not be super elegant, but it doesn't require us to create new emitters. this.emit('internal:write', chunk, encoding) + /** + * @todo Check if the socket still buffers the write with this custom "_write". + * Ideally, we can rely on the buffered writes so the socket flushes them on passthrough for us. + */ + callback(null) } diff --git a/test/modules/net/example.test.ts b/test/modules/net/example.test.ts index 0d724c4c3..f2b71c3f9 100644 --- a/test/modules/net/example.test.ts +++ b/test/modules/net/example.test.ts @@ -17,21 +17,14 @@ afterAll(() => { interceptor.dispose() }) -it.only('mocks the intercepted connection', async () => { +it('mocks the intercepted connection', async () => { const serverDataListener = vi.fn() - interceptor.on('connection', ({ socket }) => { - /** - * Expose a `connection` reference instead of `controller`. - * It controls the intercepted connection: - * - connection.claim() - * - connection.errorWith() - * - connection.retry() - * - connection.passthrough() - */ + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() - socket.on('data', serverDataListener) socket.write('hello from server') + socket.on('data', serverDataListener) }) const connectionListener = vi.fn() @@ -63,16 +56,19 @@ it('errors the intercepted socket before it connects', async () => { controller.errorWith(reason) }) - const connectionListener = vi.fn() - const socket = net.connect(3000, '127.0.0.1', connectionListener) + const connectionCallback = vi.fn() + const socket = net.connect(3000, '127.0.0.1', connectionCallback) + const connectListener = vi.fn() const errorListener = vi.fn() const closeListener = vi.fn() + socket.on('connect', connectListener) socket.on('error', errorListener) socket.on('close', closeListener) await expect.poll(() => errorListener).toHaveBeenCalled() expect.soft(errorListener).toHaveBeenCalledExactlyOnceWith(reason) - expect.soft(closeListener).toHaveBeenCalledExactlyOnceWith() - expect.soft(connectionListener).not.toHaveBeenCalled() + expect.soft(closeListener).toHaveBeenCalledExactlyOnceWith(true) + expect.soft(connectListener).not.toHaveBeenCalled() + expect.soft(connectionCallback).not.toHaveBeenCalled() }) From d90a1272fd196350bed0327072295282459fed47 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 15:04:05 +0100 Subject: [PATCH 006/198] feat(wip): implement `ConnectionController.passthrough()` --- src/interceptors/net/connection-controller.ts | 30 ++++++++++++- src/interceptors/net/index.ts | 9 +++- src/interceptors/net/mocker-socket.ts | 7 ---- test/modules/net/example.test.ts | 42 +++++++++++++++++++ 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index e076c4698..4687de32f 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -48,7 +48,10 @@ interface TcpWrap { export class ConnectionController { #pendingRequest: DeferredPromise - constructor(private readonly socket: NewMockSocket) { + constructor( + private readonly socket: NewMockSocket, + private readonly createConnection: () => net.Socket + ) { this.#pendingRequest = new DeferredPromise() socket.prependListener('connectionAttempt', (ip, port, family) => { @@ -98,7 +101,30 @@ export class ConnectionController { this.socket.destroy(reason) } + /** + * Bypass this socket connection and perform it as-is. + */ public passthrough(): net.Socket { - throw new Error('Not Implemented') + const realSocket = this.createConnection() + realSocket.pipe(this.socket) + + realSocket.prependListener('connectionAttempt', () => { + this.socket._handle.unref?.() + this.socket._handle = realSocket._handle + }) + + realSocket.emit = new Proxy(realSocket.emit, { + apply: (target, thisArg, args: [string, Function]) => { + this.socket.emit(...args) + return Reflect.apply(target, thisArg, args) + }, + }) + + /** + * @todo @fixme Forwarding events is not enough. + * Real socket has to, effectively, replace the client socket in every sense. + */ + + return realSocket } } diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 4f1aa9b6e..2c6a61be2 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -39,9 +39,16 @@ export class SocketInterceptor extends Interceptor { const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) + const createConnection = () => { + return realNetConnect(...args) + } + const clientSocket = new NewMockSocket(connectionOptions) const serverSocket = clientSocket.createServerSocket() - const controller = new ConnectionController(clientSocket) + const controller = new ConnectionController( + clientSocket, + createConnection + ) process.nextTick(() => { this.emitter.emit('connection', { diff --git a/src/interceptors/net/mocker-socket.ts b/src/interceptors/net/mocker-socket.ts index 7f852e5c7..71a8532cc 100644 --- a/src/interceptors/net/mocker-socket.ts +++ b/src/interceptors/net/mocker-socket.ts @@ -23,13 +23,6 @@ export class NewMockSocket extends net.Socket { callback(null) } - /** - * Establish this socket connection as-is. - */ - public passthrough(): void { - throw new Error('Passthough not implemented') - } - public createServerSocket(): net.Socket { return new Proxy(this, { get: (target, property, receiver) => { diff --git a/test/modules/net/example.test.ts b/test/modules/net/example.test.ts index f2b71c3f9..1b4b0dba8 100644 --- a/test/modules/net/example.test.ts +++ b/test/modules/net/example.test.ts @@ -72,3 +72,45 @@ it('errors the intercepted socket before it connects', async () => { expect.soft(connectListener).not.toHaveBeenCalled() expect.soft(connectionCallback).not.toHaveBeenCalled() }) + +it('supports bypassing the intercepted connection to a non-existing host', async () => { + const realSocketConnectListener = vi.fn() + const realSocketErrorListener = vi.fn() + const realSocketCloseListener = vi.fn() + + interceptor.on('connection', ({ socket, controller }) => { + const realSocket = controller.passthrough() + realSocket.on('connect', realSocketConnectListener) + realSocket.on('error', realSocketErrorListener) + realSocket.on('close', realSocketCloseListener) + }) + + const connectionCallback = vi.fn() + const socket = net.connect(3000, '127.0.0.1', connectionCallback) + + const clientErrorListener = vi.fn() + socket.on('error', clientErrorListener) + + const clientDataListener = vi.fn() + socket.on('data', clientDataListener) + + const clientConnectListener = vi.fn() + socket.on('connect', clientConnectListener) + + await expect + .poll(() => realSocketErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + code: 'ECONNREFUSED', + port: 3000, + address: '127.0.0.1', + message: 'connect ECONNREFUSED 127.0.0.1:3000', + }) + ) + expect(realSocketCloseListener).toHaveBeenCalledExactlyOnceWith(true) + expect(realSocketConnectListener).not.toHaveBeenCalled() + + expect(clientErrorListener).toHaveBeenCalled() + expect(connectionCallback).not.toHaveBeenCalled() + expect(clientConnectListener).not.toHaveBeenCalled() +}) From b9745171c1fffa9c9a7584641dcf526f3e3272e3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 15:08:34 +0100 Subject: [PATCH 007/198] chore: remove unused classes --- src/interceptors/net/connection-controller.ts | 13 +- src/interceptors/net/index.ts | 18 +-- src/interceptors/net/mock-socket.ts | 118 +++++++++++------ src/interceptors/net/mocker-socket.ts | 92 ------------- src/interceptors/net/socket-controller.ts | 125 ------------------ 5 files changed, 94 insertions(+), 272 deletions(-) delete mode 100644 src/interceptors/net/mocker-socket.ts delete mode 100644 src/interceptors/net/socket-controller.ts diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index 4687de32f..50aa61220 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -1,6 +1,6 @@ import net from 'node:net' import { DeferredPromise } from '@open-draft/deferred-promise' -import { NewMockSocket } from './mocker-socket' +import { MockSocket } from './mock-socket' // Internally, Node.js represents the result of various operations // by the number they return: 0 (error), 1 (success). @@ -49,7 +49,7 @@ export class ConnectionController { #pendingRequest: DeferredPromise constructor( - private readonly socket: NewMockSocket, + private readonly socket: MockSocket, private readonly createConnection: () => net.Socket ) { this.#pendingRequest = new DeferredPromise() @@ -77,10 +77,8 @@ export class ConnectionController { * connection with the remote address was successful. */ public claim(): void { - /** - * The controller exposes the socket to the user *before* the connection attempt - * is made. That is so the user can handle the socket before connection happens. - */ + // The user can interact with the connection controller *before* the connection attempt + // is made. That is so they could handle the socket before the connection. this.#pendingRequest.then((request) => { /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 @@ -97,6 +95,9 @@ export class ConnectionController { throw new Error('Not Implemented') } + /** + * Abort this socket connection with an optional error. + */ public errorWith(reason?: Error): void { this.socket.destroy(reason) } diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 2c6a61be2..5f4e77100 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -4,12 +4,7 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { - kServerSocket, - kSocketProxy, - SocketController, -} from './socket-controller' -import { NewMockSocket } from './mocker-socket' +import { MockSocket } from './mock-socket' import { ConnectionController } from './connection-controller' interface SocketEventMap { @@ -33,21 +28,20 @@ export class SocketInterceptor extends Interceptor { const realNetConnect = net.connect /** + * Luckily, "net.connect()" is rather short and we can replicate it as-is. * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L236 */ net.connect = (...args: [any, any]) => { const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) - const createConnection = () => { - return realNetConnect(...args) - } - - const clientSocket = new NewMockSocket(connectionOptions) + const clientSocket = new MockSocket(connectionOptions) const serverSocket = clientSocket.createServerSocket() const controller = new ConnectionController( clientSocket, - createConnection + function createConnection() { + return realNetConnect(...args) + } ) process.nextTick(() => { diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 48266a1d4..565177e33 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,48 +1,92 @@ import net from 'node:net' +import { toBuffer } from '../../utils/bufferUtils' + +const kListenerWrap = Symbol('kListenerWrap') export class MockSocket extends net.Socket { - public connecting: boolean + _read(size: number): void {} + + _write( + chunk: any, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ): void { + // Emit an internal event to translate client writes to server socket "data" events. + // This might not be super elegant, but it doesn't require us to create new emitters. + this.emit('internal:write', chunk, encoding) - constructor(options: net.SocketConstructorOpts, onConnect?: () => void) { - super({ ...options, allowHalfOpen: true }) - this.connecting = true + /** + * @todo Check if the socket still buffers the write with this custom "_write". + * Ideally, we can rely on the buffered writes so the socket flushes them on passthrough for us. + */ - if (onConnect) { - this.on('connect', onConnect) - } + callback(null) } - public mockConnect() { - queueMicrotask(() => { - this.connecting = false - this.emit('connect') - }) + public createServerSocket(): net.Socket { + return new Proxy(this, { + get: (target, property, receiver) => { + const getRealValue = () => { + return Reflect.get(target, property, receiver) + } - return this - } + if (property === 'on' || property === 'addListener') { + const realAddListener = getRealValue() as net.Socket['addListener'] + + return ( + event: string, + listener: (...args: Array) => void + ) => { + if (event === 'data') { + const listenerWrap = (chunk: any, encoding?: BufferEncoding) => { + listener(toBuffer(chunk, encoding)) + } + + Object.defineProperty(listener, kListenerWrap, { + enumerable: false, + writable: false, + value: listenerWrap, + }) + + this.on('internal:write', listenerWrap) + + return target + } - // Override _writeGeneric to prevent "Socket is closed" errors - // when the socket tries to flush buffered data during connect - // @ts-ignore - overriding private method - public _writeGeneric( - writev: boolean, - data: any, - encoding?: any, - callback?: any - ) { - // If the socket is not properly initialized with a handle, - // just call the callback without trying to write - // @ts-ignore - accessing private property - if (!this._handle) { - if (typeof callback === 'function') { - process.nextTick(callback) - } - - return - } - - // Otherwise, call the parent implementation - // @ts-ignore - calling private method - return super._writeGeneric(writev, data, encoding, callback) + return realAddListener.call(target, event, listener) + } + } + + if (property === 'off' || property === 'removeListener') { + const realRemoveListener = + getRealValue() as net.Socket['removeListener'] + + return (event: string, listener: any) => { + if (event === 'data') { + const listenerWrap = listener[kListenerWrap] + + if (listenerWrap) { + return realRemoveListener.call(target, event, listenerWrap) + } + } + + return realRemoveListener.call(target, event, listener) + } + } + + // Push data to the client socket when "server.write()" is called. + if (property === 'write') { + return ( + chunk: any, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ) => { + this.push(toBuffer(chunk, encoding), encoding) + } + } + + return getRealValue() + }, + }) } } diff --git a/src/interceptors/net/mocker-socket.ts b/src/interceptors/net/mocker-socket.ts deleted file mode 100644 index 71a8532cc..000000000 --- a/src/interceptors/net/mocker-socket.ts +++ /dev/null @@ -1,92 +0,0 @@ -import net from 'node:net' -import { toBuffer } from '../../utils/bufferUtils' - -const kListenerWrap = Symbol('kListenerWrap') - -export class NewMockSocket extends net.Socket { - _read(size: number): void {} - - _write( - chunk: any, - encoding: BufferEncoding, - callback: (error?: Error | null) => void - ): void { - // Emit an internal event to translate client writes to server socket "data" events. - // This might not be super elegant, but it doesn't require us to create new emitters. - this.emit('internal:write', chunk, encoding) - - /** - * @todo Check if the socket still buffers the write with this custom "_write". - * Ideally, we can rely on the buffered writes so the socket flushes them on passthrough for us. - */ - - callback(null) - } - - public createServerSocket(): net.Socket { - return new Proxy(this, { - get: (target, property, receiver) => { - const getRealValue = () => { - return Reflect.get(target, property, receiver) - } - - if (property === 'on' || property === 'addListener') { - const realAddListener = getRealValue() as net.Socket['addListener'] - - return ( - event: string, - listener: (...args: Array) => void - ) => { - if (event === 'data') { - const listenerWrap = (chunk: any, encoding?: BufferEncoding) => { - listener(toBuffer(chunk, encoding)) - } - - Object.defineProperty(listener, kListenerWrap, { - enumerable: false, - writable: false, - value: listenerWrap, - }) - - this.on('internal:write', listenerWrap) - - return target - } - - return realAddListener.call(target, event, listener) - } - } - - if (property === 'off' || property === 'removeListener') { - const realRemoveListener = - getRealValue() as net.Socket['removeListener'] - - return (event: string, listener: any) => { - if (event === 'data') { - const listenerWrap = listener[kListenerWrap] - - if (listenerWrap) { - return realRemoveListener.call(target, event, listenerWrap) - } - } - - return realRemoveListener.call(target, event, listener) - } - } - - // Push data to the client socket when "server.write()" is called. - if (property === 'write') { - return ( - chunk: any, - encoding: BufferEncoding, - callback: (error?: Error | null) => void - ) => { - this.push(toBuffer(chunk, encoding), encoding) - } - } - - return getRealValue() - }, - }) - } -} diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts deleted file mode 100644 index 0c27acae8..000000000 --- a/src/interceptors/net/socket-controller.ts +++ /dev/null @@ -1,125 +0,0 @@ -import net from 'node:net' -import { ObjectRecorder } from './object-recorder' -import { MockSocket } from './mock-socket' -import { NewMockSocket } from './mocker-socket' -import { - normalizeSocketWriteArgs, - type WriteArgs, -} from '../Socket/utils/normalizeSocketWriteArgs' -import { toBuffer } from '../../utils/bufferUtils' - -export const kSocketProxy = Symbol('kSocketProxy') -export const kClientSocket = Symbol('kClientSocket') -export const kServerSocket = Symbol('kServerSocket') - -interface SocketControllerOptions { - socket: NewMockSocket - createConnection: () => net.Socket -} - -export class SocketController { - private [kSocketProxy]: net.Socket - private [kClientSocket]: NewMockSocket - private [kServerSocket]: NewMockSocket - - #recorder: ObjectRecorder - - constructor(protected readonly options: SocketControllerOptions) { - this[kClientSocket] = this.options.socket - this[kServerSocket] = this.options.socket - - this.#recorder = new ObjectRecorder(this.options.socket, { - filter: (entry) => { - if ( - entry.type === 'apply' && - entry.path.some((segment) => segment.toString().startsWith('_')) - ) { - return false - } - - if ( - entry.type === 'set' && - entry.metadata.property.toString().startsWith('_') - ) { - return false - } - - if (entry.type === 'apply') { - if ( - entry.metadata.method === 'write' || - entry.metadata.method === 'end' - ) { - const [chunk, encoding] = normalizeSocketWriteArgs( - entry.metadata.args as WriteArgs - ) - - if (chunk) { - // Translate client writes to the "data" event on the server socket. - this[kServerSocket].emit('data', toBuffer(chunk, encoding)) - } - } - } - - return true - }, - }) - this.#recorder.start() - - // When server writes, client receives data - const originalServerWrite = this[kServerSocket].write.bind( - this[kServerSocket] - ) - const originalServerEnd = this[kServerSocket].end.bind(this[kServerSocket]) - - this[kServerSocket].write = (...args: [any, any]) => { - if (args[0] != null) { - const [chunk, encoding] = normalizeSocketWriteArgs(args) - - this[kClientSocket].emit('data', toBuffer(chunk, encoding)) - } - - return originalServerWrite(...args) - } - - this[kServerSocket].end = (...args: [any]) => { - if (args[0] != null) { - const [chunk, encoding] = normalizeSocketWriteArgs(args) - - this[kClientSocket].emit('data', toBuffer(chunk, encoding)) - } - - return originalServerEnd(...args) - } - - this[kSocketProxy] = this.#recorder.proxy - } - - /** - * Mock the socket connection. - */ - public connect(): void { - this[kClientSocket].mockConnect() - this[kServerSocket].mockConnect() - } - - /** - * Establish this socket connection as-is. - */ - public passthrough(): net.Socket { - this.#recorder.pause() - - const realSocket = this.options.createConnection() - this.#recorder.replay(realSocket) - - this.#recorder.dispose() - - return realSocket - } - - /** - * Abort the underlying socket connection with the given reason. - */ - public errorWith(reason?: Error): void { - this[kClientSocket].destroy(reason) - } -} From 4419f46cb1e22b58b813c6d51443304ce51843b9 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 15:10:46 +0100 Subject: [PATCH 008/198] chore: document `MockSocket` --- src/interceptors/net/mock-socket.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 565177e33..e4789683b 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -23,6 +23,12 @@ export class MockSocket extends net.Socket { callback(null) } + /** + * Create a proxy `net.Socket` instance that represents the intercepted socket server-side. + * This is the reference exposed as `socket` in the connection listener. This proxy allows + * the user to interact with `socket` from the server's perspective (e.g. `socket.write()` + * on the server translates to the `socket.push()` on the client). + */ public createServerSocket(): net.Socket { return new Proxy(this, { get: (target, property, receiver) => { @@ -33,10 +39,7 @@ export class MockSocket extends net.Socket { if (property === 'on' || property === 'addListener') { const realAddListener = getRealValue() as net.Socket['addListener'] - return ( - event: string, - listener: (...args: Array) => void - ) => { + return (event: any, listener: (...args: Array) => void) => { if (event === 'data') { const listenerWrap = (chunk: any, encoding?: BufferEncoding) => { listener(toBuffer(chunk, encoding)) From 5c8446fbeebfc9975de58c5345c8886fd8fc251a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 15:57:10 +0100 Subject: [PATCH 009/198] fix: flush client socket pending data to real socket --- src/interceptors/http/index.ts | 52 +++---------------- src/interceptors/net/connection-controller.ts | 45 ++++++++++------ src/interceptors/net/mock-socket.ts | 26 +++++++--- test/modules/http/intercept/http.get.test.ts | 13 ++--- .../http-response-readable-stream.test.ts | 2 +- 5 files changed, 62 insertions(+), 76 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 9b9a09012..d47f2adcc 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -16,7 +16,7 @@ import { HttpRequestParser, HttpResponseParser } from './http-parser' import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' -import { kClientSocket } from '../net/socket-controller' +import { kClientSocket } from '../net/connection-controller' export class HttpRequestInterceptor extends Interceptor { static symbol = Symbol('client-request-interceptor') @@ -35,7 +35,7 @@ export class HttpRequestInterceptor extends Interceptor { socketInterceptor.on( 'connection', - ({ connectionOptions, socket, controller: socketController }) => { + ({ connectionOptions, socket, controller: connectionController }) => { socket.once('data', (chunk) => { const firstFrame = chunk.toString() const httpMethod = firstFrame.split(' ')[0] @@ -57,59 +57,21 @@ export class HttpRequestInterceptor extends Interceptor { const requestController = new RequestController(request, { respondWith: async (response) => { + connectionController.claim() + await this.respondWith({ - socket: socketController[kClientSocket], + socket: connectionController[kClientSocket], request, response, }) }, errorWith: (reason) => { if (reason instanceof Error) { - socketController.errorWith(reason) + connectionController.errorWith(reason) } }, passthrough: () => { - console.log('PASSTHROUGH CALLED!') - const clientSocket = socketController[kClientSocket] - const realSocket = socketController.passthrough() - console.log('Real socket created:', { - connecting: realSocket.connecting, - readable: realSocket.readable, - writable: realSocket.writable, - }) - - // @ts-expect-error Node.js internals. - const httpMessage = clientSocket._httpMessage - // @ts-expect-error Node.js internals. - const parser = clientSocket.parser - - // @ts-expect-error Node.js internals. - realSocket._httpMessage = httpMessage - // @ts-expect-error Node.js internals. - realSocket.parser = parser - // @ts-expect-error Node.js internals. - parser.socket = realSocket - - if (httpMessage) { - // @ts-expect-error Node.js internals. - httpMessage.socket = realSocket - } - - realSocket.on('connect', () => { - console.log('REAL SOCKET CONNECTED!') - }) - - realSocket.on('data', (chunk) => { - console.log( - 'REAL SOCKET RECEIVED DATA:', - chunk.length, - 'bytes' - ) - }) - - realSocket.on('error', (error) => { - console.log('REAL SOCKET ERROR:', error.message) - }) + const realSocket = connectionController.passthrough() if (this.emitter.listenerCount('response') > 0) { const responseParser = new HttpResponseParser({ diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index 50aa61220..0fd3213f4 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -8,6 +8,12 @@ type OperationStatus = 0 | 1 declare module 'node:net' { interface Socket { + _writeGeneric: ( + writev: boolean, + data: any, + encoding: BufferEncoding, + callback?: (error?: Error | null) => void + ) => void _handle: TcpHandle } } @@ -45,13 +51,18 @@ interface TcpWrap { ) => void } +export const kClientSocket = Symbol('kClientSymbol') + export class ConnectionController { #pendingRequest: DeferredPromise + private [kClientSocket]: MockSocket + constructor( - private readonly socket: MockSocket, + socket: MockSocket, private readonly createConnection: () => net.Socket ) { + this[kClientSocket] = socket this.#pendingRequest = new DeferredPromise() socket.prependListener('connectionAttempt', (ip, port, family) => { @@ -83,7 +94,7 @@ export class ConnectionController { /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 */ - request.oncomplete(0, this.socket._handle, request, true, true) + request.oncomplete(0, this[kClientSocket]._handle, request, true, true) }) } @@ -99,27 +110,31 @@ export class ConnectionController { * Abort this socket connection with an optional error. */ public errorWith(reason?: Error): void { - this.socket.destroy(reason) + this[kClientSocket].destroy(reason) } /** * Bypass this socket connection and perform it as-is. */ public passthrough(): net.Socket { + const clientSocket = this[kClientSocket] const realSocket = this.createConnection() - realSocket.pipe(this.socket) - realSocket.prependListener('connectionAttempt', () => { - this.socket._handle.unref?.() - this.socket._handle = realSocket._handle - }) - - realSocket.emit = new Proxy(realSocket.emit, { - apply: (target, thisArg, args: [string, Function]) => { - this.socket.emit(...args) - return Reflect.apply(target, thisArg, args) - }, - }) + if (clientSocket._pendingData) { + realSocket.write(clientSocket._pendingData) + } + + realSocket + .prependListener('connectionAttempt', () => { + clientSocket._handle.unref?.() + clientSocket._handle = realSocket._handle + }) + .on('connect', () => { + clientSocket.connecting = realSocket.connecting + }) + .on('data', (data) => { + clientSocket.push(data) + }) /** * @todo @fixme Forwarding events is not enough. diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index e4789683b..21c0b967a 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -4,6 +4,19 @@ import { toBuffer } from '../../utils/bufferUtils' const kListenerWrap = Symbol('kListenerWrap') export class MockSocket extends net.Socket { + public connecting: boolean + + constructor(options: net.SocketConstructorOpts) { + super(options) + + /** + * @note Start the socket in the connecting state. + * This will make Node.js buffer any writes to it automatically. + * See the "_write" implementation below for more details. + */ + this.connecting = true + } + _read(size: number): void {} _write( @@ -11,16 +24,15 @@ export class MockSocket extends net.Socket { encoding: BufferEncoding, callback: (error?: Error | null) => void ): void { - // Emit an internal event to translate client writes to server socket "data" events. - // This might not be super elegant, but it doesn't require us to create new emitters. - this.emit('internal:write', chunk, encoding) - /** - * @todo Check if the socket still buffers the write with this custom "_write". - * Ideally, we can rely on the buffered writes so the socket flushes them on passthrough for us. + * Call "_writeGeneric" because it buffers any writes while the connection is pending. + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L994 */ + super._writeGeneric(false, chunk, encoding, callback) - callback(null) + // Emit an internal event to translate client writes to server socket "data" events. + // This might not be super elegant, but it doesn't require us to create new emitters. + this.emit('internal:write', chunk, encoding) } /** diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 609c4b90c..fb8598ef6 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,7 +1,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { RequestController } from '../../../../src/RequestController' import { HttpRequestEventMap } from '../../../../src/glossary' @@ -14,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 () => { @@ -38,6 +38,7 @@ it('intercepts an http.get request', async () => { 'x-custom-header': 'yes', }, }) + const { text } = await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -54,9 +55,7 @@ it('intercepts an http.get request', async () => { expect(controller).toBeInstanceOf(RequestController) 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 () => { @@ -83,7 +82,5 @@ it('intercepts an http.get request given RequestOptions without a protocol', asy expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) - - // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('user-body') }) diff --git a/test/modules/http/response/http-response-readable-stream.test.ts b/test/modules/http/response/http-response-readable-stream.test.ts index 0a3d5dcc6..d4c32ec13 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -189,7 +189,7 @@ it('handles delayed response stream errors as IncomingMessage errors', async () ) }) -it.only('treats unhandled exceptions during the response stream as request errors', async () => { +it('treats unhandled exceptions during the response stream as request errors', async () => { const requestErrorListener = vi.fn() const responseErrorListener = vi.fn() From 1fc56b5f9e75505c3ebc46c7d1f5695732bc7c99 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 17:53:15 +0100 Subject: [PATCH 010/198] fix: implement "_writeGeneric" and handle mock/passthrough properly --- src/RequestController.ts | 6 +- src/interceptors/http/index.ts | 22 +++++-- src/interceptors/net/connection-controller.ts | 12 +++- src/interceptors/net/mock-socket.ts | 66 +++++++++++++++---- .../http-response-readable-stream.test.ts | 4 +- 5 files changed, 82 insertions(+), 28 deletions(-) diff --git a/src/RequestController.ts b/src/RequestController.ts index 57486d848..3bb82fd52 100644 --- a/src/RequestController.ts +++ b/src/RequestController.ts @@ -3,9 +3,9 @@ import { invariant } from 'outvariant' import { InterceptorError } from './InterceptorError' export interface RequestControllerSource { - passthrough(): void - respondWith(response: Response): void - errorWith(reason?: unknown): void + passthrough(): void | Promise + respondWith(response: Response): void | Promise + errorWith(reason?: unknown): void | Promise } export class RequestController { diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index d47f2adcc..fc88d0707 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -56,13 +56,15 @@ export class HttpRequestInterceptor extends Interceptor { const requestId = createRequestId() const requestController = new RequestController(request, { - respondWith: async (response) => { + respondWith: (response) => { connectionController.claim() - await this.respondWith({ - socket: connectionController[kClientSocket], - request, - response, + socket.once('connect', async () => { + await this.respondWith({ + socket: connectionController[kClientSocket], + request, + response, + }) }) }, errorWith: (reason) => { @@ -111,7 +113,7 @@ export class HttpRequestInterceptor extends Interceptor { } }) - /** @todo Free the parser once the socket is destroyed */ + socket.on('close', () => requestParser.free()) }) } ) @@ -133,6 +135,11 @@ export class HttpRequestInterceptor extends Interceptor { return } + invariant( + !socket.connecting, + 'Failed to mock a response: socket has not connected' + ) + const { STATUS_CODES } = await import('node:http') const statusText = @@ -187,6 +194,9 @@ export class HttpRequestInterceptor extends Interceptor { } } + /** + * @todo Keep-Alive requests shouldn't end the stream here. + */ socket.push(null) } } diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index 0fd3213f4..9a92727e1 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -1,6 +1,6 @@ import net from 'node:net' import { DeferredPromise } from '@open-draft/deferred-promise' -import { MockSocket } from './mock-socket' +import { kMockState, MockSocket } from './mock-socket' // Internally, Node.js represents the result of various operations // by the number they return: 0 (error), 1 (success). @@ -8,12 +8,14 @@ type OperationStatus = 0 | 1 declare module 'node:net' { interface Socket { - _writeGeneric: ( + _pendingData: string | Buffer | null + _pendingEncoding: BufferEncoding | null + _writeGeneric( writev: boolean, data: any, encoding: BufferEncoding, callback?: (error?: Error | null) => void - ) => void + ): void _handle: TcpHandle } } @@ -88,6 +90,8 @@ export class ConnectionController { * connection with the remote address was successful. */ public claim(): void { + this[kClientSocket][kMockState] = 1 + // The user can interact with the connection controller *before* the connection attempt // is made. That is so they could handle the socket before the connection. this.#pendingRequest.then((request) => { @@ -118,6 +122,8 @@ export class ConnectionController { */ public passthrough(): net.Socket { const clientSocket = this[kClientSocket] + clientSocket[kMockState] = 2 + const realSocket = this.createConnection() if (clientSocket._pendingData) { diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 21c0b967a..1415902b0 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -2,37 +2,75 @@ import net from 'node:net' import { toBuffer } from '../../utils/bufferUtils' const kListenerWrap = Symbol('kListenerWrap') +export const kMockState = Symbol('kMockState') export class MockSocket extends net.Socket { + private [kMockState]: 0 | 1 | 2 + public connecting: boolean constructor(options: net.SocketConstructorOpts) { super(options) + this[kMockState] = 0 + /** * @note Start the socket in the connecting state. * This will make Node.js buffer any writes to it automatically. - * See the "_write" implementation below for more details. */ this.connecting = true } _read(size: number): void {} - _write( - chunk: any, + /** + * Override "_writeGeneric" to benefit from built-in chunk buffering in Node.js. + * That's also the baseline method for both "write" and "writev". + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L994 + */ + _writeGeneric( + writev: boolean, + data: Array | any, encoding: BufferEncoding, - callback: (error?: Error | null) => void + callback?: ((error?: Error | null) => void) | undefined ): void { - /** - * Call "_writeGeneric" because it buffers any writes while the connection is pending. - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L994 - */ - super._writeGeneric(false, chunk, encoding, callback) - - // Emit an internal event to translate client writes to server socket "data" events. - // This might not be super elegant, but it doesn't require us to create new emitters. - this.emit('internal:write', chunk, encoding) + const emitWrite = () => { + if (Array.isArray(data)) { + for (const entry of data) { + this.emit('internal:write', entry.chunk, entry.encoding) + } + } else { + this.emit('internal:write', data, encoding) + } + } + + // While connecting, the socket is in ambiguous state. + // Buffer the writes using Node's existing buffering logic. + if (this.connecting) { + super._writeGeneric(writev, data, encoding, callback) + emitWrite() + return + } + + if (this[kMockState] === 1) { + /** + * Handle "_writeGeneric" calls scheduled after the "connect" event. + * These are writes performed while connecting, and for the mocked socket + * they must be ignored. There's nowhere to flush them. Calling "_writeGeneric" + * past this point will result in "Error: write EBADF". + * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 + */ + if (this._pendingData) { + this._pendingData = null + this._pendingEncoding = null + return + } + + emitWrite() + return + } + + super._writeGeneric(writev, data, encoding, callback) } /** @@ -89,7 +127,7 @@ export class MockSocket extends net.Socket { } } - // Push data to the client socket when "server.write()" is called. + // Push data to the client socket when server "socket.write()" is called. if (property === 'write') { return ( chunk: any, 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 d4c32ec13..c480f9f2c 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -25,7 +25,7 @@ afterAll(async () => { interceptor.dispose() }) -it('supports ReadableStream as a mocked response', async () => { +it.only('supports ReadableStream as a mocked response', async () => { const encoder = new TextEncoder() interceptor.once('request', ({ controller }) => { const stream = new ReadableStream({ @@ -39,7 +39,7 @@ 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) await expect(text()).resolves.toBe('hello world') }) From 12b06b49b9351c6e70132a21f73668914514e22d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 19:16:29 +0100 Subject: [PATCH 011/198] fix: support streaming --- src/interceptors/http/index.ts | 19 +- src/interceptors/net/connection-controller.ts | 3 + .../http-response-readable-stream.test.ts | 389 ++++++++++++++---- 3 files changed, 325 insertions(+), 86 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index fc88d0707..49b7c5e05 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -166,7 +166,8 @@ export class HttpRequestInterceptor extends Interceptor { // @ts-expect-error Node.js internals socket.parser.socket = socket - // Push the status line and headers to the readable stream + // Flush the mocked response headers. + // This will trigger the "response" event in "ClientRequest". socket.push(Buffer.from(statusLine + headersString)) if (response.body) { @@ -180,6 +181,22 @@ export class HttpRequestInterceptor extends Interceptor { break } + /** + * Validate that the chunk is a valid type before pushing to the socket. + * If it's not a Buffer, string, or TypedArray, socket.push() will emit + * an async error event that bypasses our try/catch. We need to catch + * this case and handle it synchronously. + */ + if ( + value != null && + typeof value !== 'string' && + !Buffer.isBuffer(value) && + !(value instanceof Uint8Array) && + !ArrayBuffer.isView(value) + ) { + throw new Error('Invalid chunk type') + } + socket.push(value) } } catch (error) { diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index 9a92727e1..6709a00b8 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -141,6 +141,9 @@ export class ConnectionController { .on('data', (data) => { clientSocket.push(data) }) + .on('end', () => { + clientSocket.push(null) + }) /** * @todo @fixme Forwarding events is not enough. 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 c480f9f2c..756d50480 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -2,19 +2,67 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { performance } from 'node:perf_hooks' import http from 'node:http' -import https from 'node:https' +import { Readable } from 'node:stream' +import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { sleep, waitForClientRequest } from '../../../helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> -const encoder = new TextEncoder() +const httpServer = new HttpServer((app) => { + app.get('/stream/immediate-error', (req, res) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('first-chunk')) + controller.error(new Error('Response stream error')) + }, + }) + + res.writeHead(200).flushHeaders() + Readable.fromWeb(stream) + .on('error', (error) => res.destroy(error)) + .pipe(res) + }) + + app.get('/stream/delayed-error', (req, res) => { + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('first-chunk')) + await setTimeout(100) + controller.error(new Error('Response stream error')) + }, + }) + + res.writeHead(200).flushHeaders() + Readable.fromWeb(stream) + .on('error', (error) => res.destroy(error)) + .pipe(res) + }) + + app.get('/stream/exception', (req, res) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('first-chunk')) + // Intentionally invalid input. + controller.enqueue({}) + }, + }) + res.writeHead(200).flushHeaders() + Readable.fromWeb(stream) + .on('error', (error) => res.destroy(error)) + .pipe(res) + }) +}) + +const encoder = new TextEncoder() const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() + await httpServer.listen() }) afterEach(() => { @@ -23,11 +71,12 @@ afterEach(() => { afterAll(async () => { interceptor.dispose() + await httpServer.close() }) -it.only('supports ReadableStream as a mocked response', 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')) @@ -44,18 +93,18 @@ it.only('supports ReadableStream as a mocked response', async () => { await expect(text()).resolves.toBe('hello world') }) -it('supports delays when enqueuing chunks', async () => { - interceptor.once('request', ({ controller }) => { +it('supports delays between the mock response stream chunks', async () => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ async start(controller) { controller.enqueue(encoder.encode('first')) - await sleep(200) + await sleep(150) controller.enqueue(encoder.encode('second')) - await sleep(200) + await sleep(150) controller.enqueue(encoder.encode('third')) - await sleep(200) + await sleep(150) controller.close() }, @@ -70,133 +119,293 @@ it('supports delays when enqueuing chunks', async () => { ) }) - const responseChunksPromise = new DeferredPromise() + const request = http.get('http://localhost/stream') + const responseChunks: ResponseChunks = [] - const request = https.get('https://api.example.com/stream', (response) => { - const chunks: ResponseChunks = [] + const responseErrorListener = vi.fn() + const requestCloseListener = vi.fn() - response - .on('data', (data) => { - chunks.push({ - buffer: Buffer.from(data), + request + .on('response', (response) => { + response.on('data', (data) => { + responseChunks.push({ + buffer: data, timestamp: performance.now(), }) }) - .on('end', () => { - responseChunksPromise.resolve(chunks) - }) - .on('error', responseChunksPromise.reject) - }) + }) + .on('error', responseErrorListener) + .on('close', requestCloseListener) - request.on('error', responseChunksPromise.reject) + await expect.poll(() => requestCloseListener).toHaveBeenCalled() - const responseChunks = await responseChunksPromise - const textChunks = responseChunks.map((chunk) => { - return chunk.buffer.toString('utf8') - }) - expect(textChunks).toEqual(['first', 'second', 'third']) + expect + .soft(responseChunks.map((chunk) => chunk.buffer)) + .toEqual([ + Buffer.from('first'), + Buffer.from('second'), + Buffer.from('third'), + ]) // Ensure that the chunks were sent over time, // respecting the delay set in the mocked stream. const chunkTimings = responseChunks.map((chunk) => chunk.timestamp) - expect(chunkTimings[1] - chunkTimings[0]).toBeGreaterThanOrEqual(150) - expect(chunkTimings[2] - chunkTimings[1]).toBeGreaterThanOrEqual(150) + expect(chunkTimings[1] - chunkTimings[0]).toBeGreaterThanOrEqual(140) + expect(chunkTimings[2] - chunkTimings[1]).toBeGreaterThanOrEqual(140) }) -it('handles immediate response stream errors as request errors', async () => { - const requestErrorListener = vi.fn() - const responseErrorListener = vi.fn() - +it('handles immediate mock response stream errors as request errors', async () => { const streamError = new Error('stream error') - interceptor.once('request', ({ controller }) => { + + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - async start(controller) { - controller.enqueue(new TextEncoder().encode('original')) + start(controller) { + controller.enqueue(encoder.encode('first-chunk')) controller.error(streamError) }, }) controller.respondWith(new Response(stream)) }) - const request = http.get('http://localhost/resource') - request.on('error', requestErrorListener) + const request = http.get('http://localhost/stream') - await vi.waitFor(() => { - return new Promise((resolve, reject) => { - request.on('error', () => resolve()) - request.on('response', () => { - reject('Must not emit response') - }) + const socketErrorListener = vi.fn() + const socketCloseListener = vi.fn() + const requestResponseListener = vi.fn() + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + + request + .on('socket', (socket) => { + socket.on('error', socketErrorListener).on('close', socketCloseListener) }) + .on('response', requestResponseListener) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + const responseDataListener = vi.fn() + const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('data', responseDataListener).on('error', responseErrorListener) }) + await expect + .poll(() => responseErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'ECONNRESET' }) + ) + expect.soft(responseDataListener).not.toHaveBeenCalled() + expect.soft(request.destroyed).toBe(true) - expect.soft(requestErrorListener).toHaveBeenCalledOnce() - expect.soft(requestErrorListener).toHaveBeenCalledWith( + expect.soft(requestResponseListener).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ - code: 'ECONNRESET', - message: 'socket hang up', + statusCode: 200, + statusMessage: 'OK', }) ) - expect.soft(responseErrorListener).not.toHaveBeenCalled() + expect.soft(requestErrorListener).not.toHaveBeenCalled() + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) }) -it('handles delayed response stream errors as IncomingMessage errors', async () => { +it('handles immediate bypassed response stream errors as response errors', async () => { + const request = http.get(httpServer.http.url('/stream/immediate-error')) + + const socketErrorListener = vi.fn() + const socketCloseListener = vi.fn() + const requestResponseListener = vi.fn() const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + + request + .on('socket', (socket) => { + socket.on('error', socketErrorListener).on('close', socketCloseListener) + }) + .on('response', requestResponseListener) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + const responseDataListener = vi.fn() const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('data', responseDataListener).on('error', responseErrorListener) + }) + await expect + .poll(() => responseErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'ECONNRESET' }) + ) + expect.soft(responseDataListener).not.toHaveBeenCalled() + + expect.soft(request.destroyed).toBe(true) + expect.soft(requestResponseListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + statusCode: 200, + statusMessage: 'OK', + }) + ) + expect.soft(requestErrorListener).not.toHaveBeenCalled() + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) +}) + +// +// +// +// + +it('handles delayed mock response stream errors as request errors', async () => { const streamError = new Error('stream error') - interceptor.once('request', ({ controller }) => { + + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ async start(controller) { - controller.enqueue(new TextEncoder().encode('original')) + controller.enqueue(encoder.encode('first-chunk')) /** * @note Pause is important here so that Node.js flushes the response headers * before the stream errors. If the error happens immediately, Node.js will * optimize for that and translate it into the request error. */ - await sleep(250) + await setTimeout(100) controller.error(streamError) }, }) controller.respondWith(new Response(stream)) }) - const request = http.get('http://localhost/resource') - request.on('error', requestErrorListener) - - const response = await vi.waitFor(() => { - return new Promise((resolve, reject) => { - request.on('error', () => reject('Must not emit request error')) - request.on('response', (response) => { - response.on('close', () => resolve(response)) - response.on('error', responseErrorListener) - }) + const request = http.get('http://localhost/stream') + + const socketErrorListener = vi.fn() + const socketCloseListener = vi.fn() + const requestResponseListener = vi.fn() + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + + request + .on('socket', (socket) => { + socket.on('error', socketErrorListener).on('close', socketCloseListener) }) + .on('response', requestResponseListener) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + const responseDataListener = vi.fn() + const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('data', responseDataListener).on('error', responseErrorListener) }) - expect.soft(response.statusCode).toBe(200) - expect.soft(response.statusMessage).toBe('OK') + await expect + .poll(() => responseErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'ECONNRESET' }) + ) + expect + .soft(responseDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('first-chunk')) expect.soft(request.destroyed).toBe(true) + expect.soft(requestResponseListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + statusCode: 200, + statusMessage: 'OK', + }) + ) expect.soft(requestErrorListener).not.toHaveBeenCalled() - expect.soft(responseErrorListener).toHaveBeenCalledOnce() - expect.soft(responseErrorListener).toHaveBeenCalledWith( + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) +}) + +it('handles delayed bypassed response stream errors as response errors', async () => { + const request = http.get(httpServer.http.url('/stream/delayed-error')) + + const socketErrorListener = vi.fn() + const socketCloseListener = vi.fn() + const requestResponseListener = vi.fn() + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + + request + .on('socket', (socket) => { + socket.on('error', socketErrorListener).on('close', socketCloseListener) + }) + .on('response', requestResponseListener) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + const responseDataListener = vi.fn() + const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('data', responseDataListener).on('error', responseErrorListener) + }) + + await expect + .poll(() => responseErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'ECONNRESET' }) + ) + expect + .soft(responseDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('first-chunk')) + + expect.soft(request.destroyed).toBe(true) + expect.soft(requestResponseListener).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ - code: 'ECONNRESET', - message: 'aborted', + statusCode: 200, + statusMessage: 'OK', }) ) + expect.soft(requestErrorListener).not.toHaveBeenCalled() + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) }) -it('treats unhandled exceptions during the response stream as request errors', async () => { +it('treats unhandled exceptions during bypass response stream as request errors', async () => { + const request = http.get(httpServer.http.url('/stream/exception')) + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + const responseDataListener = vi.fn() const responseErrorListener = vi.fn() - interceptor.once('request', ({ controller }) => { + request + .on('response', (response) => { + response + .on('data', responseDataListener) + .on('error', responseErrorListener) + }) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + await expect.poll(() => requestCloseListener).toHaveBeenCalledOnce() + expect.soft(request.destroyed).toBe(true) + expect.soft(requestErrorListener).not.toHaveBeenCalled() + + expect + .soft(responseDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('first-chunk')) + expect.soft(responseErrorListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + code: 'ECONNRESET', + message: 'aborted', + }) + ) +}) + +it('treats unhandled exceptions during mock response stream as request errors', async () => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - async start(controller) { - await sleep(200) + start(controller) { + controller.enqueue(encoder.encode('first-chunk')) // Intentionally invalid input. controller.enqueue({}) }, @@ -205,24 +414,34 @@ it('treats unhandled exceptions during the response stream as request errors', a }) const request = http.get('http://localhost/resource') - request.on('error', requestErrorListener) - await vi.waitFor(() => { - return new Promise((resolve, reject) => { - request.on('error', () => resolve()) - request.on('response', (response) => { - reject('Must not emit response') - }) + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + const responseDataListener = vi.fn() + const responseErrorListener = vi.fn() + + request + .on('response', (response) => { + response + .on('data', responseDataListener) + .on('error', responseErrorListener) }) - }) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + request.on('error', (error) => console.trace(error)) + await expect.poll(() => requestCloseListener).toHaveBeenCalledOnce() expect.soft(request.destroyed).toBe(true) - expect.soft(requestErrorListener).toHaveBeenCalledOnce() - expect.soft(requestErrorListener).toHaveBeenCalledWith( + expect.soft(requestErrorListener).not.toHaveBeenCalled() + + expect + .soft(responseDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('first-chunk')) + expect.soft(responseErrorListener).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ code: 'ECONNRESET', - message: 'socket hang up', + message: 'aborted', }) ) - expect.soft(responseErrorListener).not.toHaveBeenCalled() }) From b21f61d8d257a2a9ab69515e5a09a7f54e7621bc Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 21:22:49 +0100 Subject: [PATCH 012/198] fix: support chunked response streams --- package.json | 2 + pnpm-lock.yaml | 114 ++++++++++++++++++ src/interceptors/http/index.ts | 54 +++++++-- src/interceptors/net/index.ts | 10 ++ src/interceptors/net/mock-socket.ts | 16 ++- src/utils/logger.ts | 10 ++ .../http-response-readable-stream.test.ts | 6 +- .../http-response-transfer-encoding.test.ts | 19 +-- 8 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 src/utils/logger.ts diff --git a/package.json b/package.json index 7b9857fcb..38849c564 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,8 @@ "es-toolkit": "^1.44.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "strict-event-emitter": "^0.5.1" }, "resolutions": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d2f17c2c..51cad5ea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: outvariant: specifier: ^1.4.3 version: 1.4.3 + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 strict-event-emitter: specifier: ^0.5.1 version: 0.5.1 @@ -564,6 +570,9 @@ packages: '@oxc-project/types@0.103.0': resolution: {integrity: sha512-bkiYX5kaXWwUessFRSoXFkGIQTmc6dLGdxuRTrC+h8PSnIdZyuXHHlLAeTmOue5Br/a0/a7dHH0Gca6eXn9MKg==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.51.0': resolution: {integrity: sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA==} engines: {node: '>=18'} @@ -1619,6 +1628,9 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1824,6 +1836,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hexoid@2.0.0: resolution: {integrity: sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==} engines: {node: '>=8'} @@ -2276,6 +2291,10 @@ packages: on-exit-leak-free@0.2.0: resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2379,6 +2398,13 @@ packages: pino-abstract-transport@0.5.0: resolution: {integrity: sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ==} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + pino-pretty@7.6.1: resolution: {integrity: sha512-H7N6ZYkiyrfwBGW9CSjx0uyO9Q2Lyt73881+OTYk8v3TiTdgN92QHrWlEq/LeWw5XtDP64jeSk3mnc6T+xX9/w==} hasBin: true @@ -2386,6 +2412,13 @@ packages: pino-std-serializers@4.0.0: resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pino@7.11.0: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true @@ -2414,6 +2447,9 @@ packages: process-warning@1.0.0: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -2480,6 +2516,10 @@ packages: resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==} engines: {node: '>= 12.13.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + registry-auth-token@5.1.0: resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==} engines: {node: '>=14'} @@ -2588,6 +2628,9 @@ packages: secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -2659,6 +2702,9 @@ packages: sonic-boom@2.8.0: resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2741,6 +2787,10 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + superagent@10.1.1: resolution: {integrity: sha512-9pIwrHrOj3uAnqg9gDlW7EA2xv+N5au/dSM0kM22HTqmUu8jBxNT+8uA7tA3UoCnmiqzpSbu8rasIUZvbyamMQ==} engines: {node: '>=14.18.0'} @@ -2800,6 +2850,10 @@ packages: thread-stream@0.15.2: resolution: {integrity: sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==} + thread-stream@4.0.0: + resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} + engines: {node: '>=20'} + through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} @@ -3671,6 +3725,8 @@ snapshots: '@oxc-project/types@0.103.0': {} + '@pinojs/redact@0.4.0': {} + '@playwright/test@1.51.0': dependencies: playwright: 1.51.0 @@ -4751,6 +4807,8 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 + fast-copy@4.0.2: {} + fast-deep-equal@3.1.3: {} fast-redact@3.5.0: {} @@ -4971,6 +5029,8 @@ snapshots: dependencies: function-bind: 1.1.2 + help-me@5.0.0: {} + hexoid@2.0.0: {} homedir-polyfill@1.0.3: @@ -5350,6 +5410,8 @@ snapshots: on-exit-leak-free@0.2.0: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -5440,6 +5502,26 @@ snapshots: duplexify: 4.1.3 split2: 4.2.0 + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.2 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + pino-pretty@7.6.1: dependencies: args: 5.0.3 @@ -5458,6 +5540,22 @@ snapshots: pino-std-serializers@4.0.0: {} + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.0.0 + pino@7.11.0: dependencies: atomic-sleep: 1.0.0 @@ -5492,6 +5590,8 @@ snapshots: process-warning@1.0.0: {} + process-warning@5.0.0: {} + proto-list@1.2.4: {} proxy-addr@2.0.7: @@ -5567,6 +5667,8 @@ snapshots: real-require@0.1.0: {} + real-require@0.2.0: {} + registry-auth-token@5.1.0: dependencies: '@pnpm/npm-conf': 2.3.1 @@ -5691,6 +5793,8 @@ snapshots: secure-json-parse@2.7.0: {} + secure-json-parse@4.1.0: {} + semver@7.6.3: {} semver@7.7.3: {} @@ -5803,6 +5907,10 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5873,6 +5981,8 @@ snapshots: strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + superagent@10.1.1: dependencies: component-emitter: 1.3.1 @@ -5946,6 +6056,10 @@ snapshots: dependencies: real-require: 0.1.0 + thread-stream@4.0.0: + dependencies: + real-require: 0.2.0 + through2@2.0.5: dependencies: readable-stream: 2.3.8 diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 49b7c5e05..80c43ef9f 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -16,8 +16,11 @@ import { HttpRequestParser, HttpResponseParser } from './http-parser' import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' +import { logger } from '../../utils/logger' import { kClientSocket } from '../net/connection-controller' +const log = logger.child({ module: 'HttpRequestInterceptor' }) + export class HttpRequestInterceptor extends Interceptor { static symbol = Symbol('client-request-interceptor') @@ -47,6 +50,12 @@ export class HttpRequestInterceptor extends Interceptor { ) const baseUrl = connectionOptionsToUrl(connectionOptions) + + log.debug( + { firstFrame, httpMethod, baseUrl }, + 'handling first frame...' + ) + const requestParser = new HttpRequestParser({ connectionOptions: { method: httpMethod, @@ -55,8 +64,22 @@ export class HttpRequestInterceptor extends Interceptor { onRequest: async (request) => { const requestId = createRequestId() + logger.debug( + { method: request.method, url: request.url }, + 'received a parsed HTTP request!' + ) + const requestController = new RequestController(request, { respondWith: (response) => { + logger.debug( + { + status: response.status, + statusText: response.statusText, + hasBody: response.body != null, + }, + 'respondWith()' + ) + connectionController.claim() socket.once('connect', async () => { @@ -147,6 +170,8 @@ export class HttpRequestInterceptor extends Interceptor { const statusLine = `HTTP/1.1 ${response.status} ${statusText}\r\n` const rawResponseHeaders = getRawFetchHeaders(response.headers) + const isChunkedEncoding = + response.headers.get('transfer-encoding') === 'chunked' let headersString = '' for (const [name, value] of rawResponseHeaders) { @@ -155,20 +180,12 @@ export class HttpRequestInterceptor extends Interceptor { headersString += '\r\n' - /** - * @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 - */ - // @ts-expect-error Node.js internals - socket.parser.socket = socket + const httpMessageHeaders = statusLine + headersString + log.debug({ httpMessageHeaders }, 'writing response headers...') // Flush the mocked response headers. // This will trigger the "response" event in "ClientRequest". - socket.push(Buffer.from(statusLine + headersString)) + socket.push(Buffer.from(httpMessageHeaders)) if (response.body) { try { @@ -197,7 +214,14 @@ export class HttpRequestInterceptor extends Interceptor { throw new Error('Invalid chunk type') } - socket.push(value) + if (isChunkedEncoding) { + const chunkSize = value.byteLength.toString(16) + socket.push(Buffer.from(`${chunkSize}\r\n`)) + socket.push(value) + socket.push(Buffer.from('\r\n')) + } else { + socket.push(value) + } } } catch (error) { if (error instanceof Error) { @@ -209,6 +233,12 @@ export class HttpRequestInterceptor extends Interceptor { return } } + + log.debug('response stream handling done!') + } + + if (isChunkedEncoding) { + socket.push(Buffer.from('0\r\n\r\n')) } /** diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 5f4e77100..17e749f6c 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -6,6 +6,7 @@ import { } from './utils/normalize-net-connect-args' import { MockSocket } from './mock-socket' import { ConnectionController } from './connection-controller' +import { logger } from '../../utils/logger' interface SocketEventMap { connection: [ @@ -27,6 +28,8 @@ export class SocketInterceptor extends Interceptor { protected setup(): void { const realNetConnect = net.connect + const log = logger.child({ module: 'net.connect' }) + /** * Luckily, "net.connect()" is rather short and we can replicate it as-is. * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L236 @@ -35,6 +38,9 @@ export class SocketInterceptor extends Interceptor { const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) + log.debug('connect()') + log.debug({ connectionOptions, connectionCallback }) + const clientSocket = new MockSocket(connectionOptions) const serverSocket = clientSocket.createServerSocket() const controller = new ConnectionController( @@ -50,12 +56,16 @@ export class SocketInterceptor extends Interceptor { controller, connectionOptions, }) + + log.debug('emitted "connection" event!') }) if (connectionOptions.timeout) { + log.debug('set custom connection timeout:', connectionOptions.timeout) clientSocket.setTimeout(connectionOptions.timeout) } + log.debug('connecting the socket...') return clientSocket.connect(connectionOptions, connectionCallback) } diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 1415902b0..3868b4197 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,9 +1,12 @@ import net from 'node:net' import { toBuffer } from '../../utils/bufferUtils' +import { logger } from '../../utils/logger' const kListenerWrap = Symbol('kListenerWrap') export const kMockState = Symbol('kMockState') +const log = logger.child({ module: 'MockSocket' }) + export class MockSocket extends net.Socket { private [kMockState]: 0 | 1 | 2 @@ -19,9 +22,13 @@ export class MockSocket extends net.Socket { * This will make Node.js buffer any writes to it automatically. */ this.connecting = true + + log.debug('constructed new instance') } - _read(size: number): void {} + _read(size: number): void { + log.debug('read', size) + } /** * Override "_writeGeneric" to benefit from built-in chunk buffering in Node.js. @@ -34,6 +41,11 @@ export class MockSocket extends net.Socket { encoding: BufferEncoding, callback?: ((error?: Error | null) => void) | undefined ): void { + log.debug( + { connecting: this.connecting, data, encoding, callback }, + 'write' + ) + const emitWrite = () => { if (Array.isArray(data)) { for (const entry of data) { @@ -61,6 +73,8 @@ export class MockSocket extends net.Socket { * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 */ if (this._pendingData) { + log.debug(this._pendingData, 'mocked connection, clearing write buffer') + this._pendingData = null this._pendingEncoding = null return diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 000000000..5b647da20 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,10 @@ +import baseLogger from 'pino' + +export const logger = baseLogger({ + transport: { + target: 'pino-pretty', + options: { + colorize: true, + }, + }, +}) 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 756d50480..72efd62d6 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -21,7 +21,7 @@ const httpServer = new HttpServer((app) => { }) res.writeHead(200).flushHeaders() - Readable.fromWeb(stream) + Readable.fromWeb(stream as any) .on('error', (error) => res.destroy(error)) .pipe(res) }) @@ -36,7 +36,7 @@ const httpServer = new HttpServer((app) => { }) res.writeHead(200).flushHeaders() - Readable.fromWeb(stream) + Readable.fromWeb(stream as any) .on('error', (error) => res.destroy(error)) .pipe(res) }) @@ -51,7 +51,7 @@ const httpServer = new HttpServer((app) => { }) res.writeHead(200).flushHeaders() - Readable.fromWeb(stream) + Readable.fromWeb(stream as any) .on('error', (error) => res.destroy(error)) .pipe(res) }) 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..a0f41c193 100644 --- a/test/modules/http/response/http-response-transfer-encoding.test.ts +++ b/test/modules/http/response/http-response-transfer-encoding.test.ts @@ -2,9 +2,9 @@ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -21,8 +21,11 @@ afterAll(() => { it('responds with a mocked "transfer-encoding: chunked" response', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith( - new Response('mock', { - headers: { 'Transfer-Encoding': 'chunked' }, + new Response('hello world', { + headers: { + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + }, }) ) }) @@ -30,8 +33,8 @@ it('responds with a mocked "transfer-encoding: chunked" response', async () => { const request = http.get('http://localhost') const { res, text } = await waitForClientRequest(request) - expect(res.statusCode).toBe(200) - expect(res.headers).toHaveProperty('transfer-encoding', 'chunked') - expect(res.rawHeaders).toContain('Transfer-Encoding') - expect(await text()).toBe('mock') + expect.soft(res.statusCode).toBe(200) + expect.soft(res.headers).toHaveProperty('transfer-encoding', 'chunked') + expect.soft(res.rawHeaders).toContain('Transfer-Encoding') + await expect(text()).resolves.toBe('hello world') }) From 57874e2c06676190b558fbb1544719b768ce35a5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 21:32:39 +0100 Subject: [PATCH 013/198] chore: use `debug` for logging --- package.json | 4 +- pnpm-lock.yaml | 173 +++++++--------------------- src/interceptors/http/index.ts | 34 +++--- src/interceptors/net/index.ts | 16 +-- src/interceptors/net/mock-socket.ts | 15 +-- src/utils/logger.ts | 13 +-- 6 files changed, 74 insertions(+), 181 deletions(-) diff --git a/package.json b/package.json index 38849c564..857077779 100644 --- a/package.json +++ b/package.json @@ -159,11 +159,11 @@ "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", + "@types/debug": "^4.1.12", + "debug": "^4.4.3", "es-toolkit": "^1.44.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", - "pino": "^10.3.1", - "pino-pretty": "^13.1.3", "strict-event-emitter": "^0.5.1" }, "resolutions": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51cad5ea5..33c659355 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@open-draft/until': specifier: ^2.0.0 version: 2.1.0 + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + debug: + specifier: ^4.4.3 + version: 4.4.3 es-toolkit: specifier: ^1.44.0 version: 1.44.0 @@ -29,12 +35,6 @@ importers: outvariant: specifier: ^1.4.3 version: 1.4.3 - pino: - specifier: ^10.3.1 - version: 10.3.1 - pino-pretty: - specifier: ^13.1.3 - version: 13.1.3 strict-event-emitter: specifier: ^0.5.1 version: 0.5.1 @@ -83,7 +83,7 @@ importers: version: 8.18.0 axios: specifier: ^1.8.2 - version: 1.8.2 + version: 1.8.2(debug@4.4.3) body-parser: specifier: ^1.19.0 version: 1.20.3 @@ -113,7 +113,7 @@ importers: version: 7.5.0(express@4.21.2) follow-redirects: specifier: ^1.15.1 - version: 1.15.9 + version: 1.15.9(debug@4.4.3) got: specifier: ^14.4.6 version: 14.4.6 @@ -155,10 +155,10 @@ importers: version: 7.4.0 vitest: specifier: ^3.0.8 - version: 3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) + version: 3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) vitest-environment-miniflare: specifier: ^2.14.1 - version: 2.14.4(vitest@3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)) + version: 2.14.4(vitest@3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)) wait-for-expect: specifier: ^3.0.2 version: 3.0.2 @@ -570,9 +570,6 @@ packages: '@oxc-project/types@0.103.0': resolution: {integrity: sha512-bkiYX5kaXWwUessFRSoXFkGIQTmc6dLGdxuRTrC+h8PSnIdZyuXHHlLAeTmOue5Br/a0/a7dHH0Gca6eXn9MKg==} - '@pinojs/redact@0.4.0': - resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@playwright/test@1.51.0': resolution: {integrity: sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA==} engines: {node: '>=18'} @@ -811,6 +808,9 @@ packages: '@types/cors@2.8.17': resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -859,6 +859,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mustache@4.2.5': resolution: {integrity: sha512-PLwiVvTBg59tGFL/8VpcGvqOu3L4OuveNvPi0EYbWchRdEVP++yRUXJPFl+CApKEq13017/4Nf7aQ5lTtHUNsA==} @@ -1398,8 +1401,8 @@ packages: supports-color: optional: true - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -1628,9 +1631,6 @@ packages: resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} engines: {node: '>=4'} - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1836,9 +1836,6 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - help-me@5.0.0: - resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hexoid@2.0.0: resolution: {integrity: sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==} engines: {node: '>=8'} @@ -2291,10 +2288,6 @@ packages: on-exit-leak-free@0.2.0: resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} - on-exit-leak-free@2.1.2: - resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} - engines: {node: '>=14.0.0'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2398,13 +2391,6 @@ packages: pino-abstract-transport@0.5.0: resolution: {integrity: sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ==} - pino-abstract-transport@3.0.0: - resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - - pino-pretty@13.1.3: - resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} - hasBin: true - pino-pretty@7.6.1: resolution: {integrity: sha512-H7N6ZYkiyrfwBGW9CSjx0uyO9Q2Lyt73881+OTYk8v3TiTdgN92QHrWlEq/LeWw5XtDP64jeSk3mnc6T+xX9/w==} hasBin: true @@ -2412,13 +2398,6 @@ packages: pino-std-serializers@4.0.0: resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} - pino-std-serializers@7.1.0: - resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} - - pino@10.3.1: - resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} - hasBin: true - pino@7.11.0: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true @@ -2447,9 +2426,6 @@ packages: process-warning@1.0.0: resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} - proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -2516,10 +2492,6 @@ packages: resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==} engines: {node: '>= 12.13.0'} - real-require@0.2.0: - resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} - engines: {node: '>= 12.13.0'} - registry-auth-token@5.1.0: resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==} engines: {node: '>=14'} @@ -2628,9 +2600,6 @@ packages: secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -2702,9 +2671,6 @@ packages: sonic-boom@2.8.0: resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} - sonic-boom@4.2.1: - resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2787,10 +2753,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} - superagent@10.1.1: resolution: {integrity: sha512-9pIwrHrOj3uAnqg9gDlW7EA2xv+N5au/dSM0kM22HTqmUu8jBxNT+8uA7tA3UoCnmiqzpSbu8rasIUZvbyamMQ==} engines: {node: '>=14.18.0'} @@ -2850,10 +2812,6 @@ packages: thread-stream@0.15.2: resolution: {integrity: sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==} - thread-stream@4.0.0: - resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} - engines: {node: '>=20'} - through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} @@ -3725,8 +3683,6 @@ snapshots: '@oxc-project/types@0.103.0': {} - '@pinojs/redact@0.4.0': {} - '@playwright/test@1.51.0': dependencies: playwright: 1.51.0 @@ -3894,6 +3850,10 @@ snapshots: dependencies: '@types/node': 18.19.67 + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -3957,6 +3917,8 @@ snapshots: '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/mustache@4.2.5': {} '@types/node-fetch@2.6.12': @@ -4226,9 +4188,9 @@ snapshots: dependencies: possible-typed-array-names: 1.0.0 - axios@1.8.2: + axios@1.8.2(debug@4.4.3): dependencies: - follow-redirects: 1.15.9 + follow-redirects: 1.15.9(debug@4.4.3) form-data: 4.0.1 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -4548,7 +4510,7 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.0: + debug@4.4.3: dependencies: ms: 2.1.3 @@ -4807,8 +4769,6 @@ snapshots: iconv-lite: 0.4.24 tmp: 0.0.33 - fast-copy@4.0.2: {} - fast-deep-equal@3.1.3: {} fast-redact@3.5.0: {} @@ -4866,7 +4826,9 @@ snapshots: micromatch: 4.0.8 resolve-dir: 1.0.1 - follow-redirects@1.15.9: {} + follow-redirects@1.15.9(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 for-each@0.3.3: dependencies: @@ -5029,8 +4991,6 @@ snapshots: dependencies: function-bind: 1.1.2 - help-me@5.0.0: {} - hexoid@2.0.0: {} homedir-polyfill@1.0.3: @@ -5058,7 +5018,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5070,7 +5030,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5410,8 +5370,6 @@ snapshots: on-exit-leak-free@0.2.0: {} - on-exit-leak-free@2.1.2: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -5502,26 +5460,6 @@ snapshots: duplexify: 4.1.3 split2: 4.2.0 - pino-abstract-transport@3.0.0: - dependencies: - split2: 4.2.0 - - pino-pretty@13.1.3: - dependencies: - colorette: 2.0.20 - dateformat: 4.6.3 - fast-copy: 4.0.2 - fast-safe-stringify: 2.1.1 - help-me: 5.0.0 - joycon: 3.1.1 - minimist: 1.2.8 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pump: 3.0.2 - secure-json-parse: 4.1.0 - sonic-boom: 4.2.1 - strip-json-comments: 5.0.3 - pino-pretty@7.6.1: dependencies: args: 5.0.3 @@ -5540,22 +5478,6 @@ snapshots: pino-std-serializers@4.0.0: {} - pino-std-serializers@7.1.0: {} - - pino@10.3.1: - dependencies: - '@pinojs/redact': 0.4.0 - atomic-sleep: 1.0.0 - on-exit-leak-free: 2.1.2 - pino-abstract-transport: 3.0.0 - pino-std-serializers: 7.1.0 - process-warning: 5.0.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.5.0 - sonic-boom: 4.2.1 - thread-stream: 4.0.0 - pino@7.11.0: dependencies: atomic-sleep: 1.0.0 @@ -5590,8 +5512,6 @@ snapshots: process-warning@1.0.0: {} - process-warning@5.0.0: {} - proto-list@1.2.4: {} proxy-addr@2.0.7: @@ -5667,8 +5587,6 @@ snapshots: real-require@0.1.0: {} - real-require@0.2.0: {} - registry-auth-token@5.1.0: dependencies: '@pnpm/npm-conf': 2.3.1 @@ -5793,8 +5711,6 @@ snapshots: secure-json-parse@2.7.0: {} - secure-json-parse@4.1.0: {} - semver@7.6.3: {} semver@7.7.3: {} @@ -5907,10 +5823,6 @@ snapshots: dependencies: atomic-sleep: 1.0.0 - sonic-boom@4.2.1: - dependencies: - atomic-sleep: 1.0.0 - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5981,13 +5893,11 @@ snapshots: strip-json-comments@3.1.1: {} - strip-json-comments@5.0.3: {} - superagent@10.1.1: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.3.7 + debug: 4.4.3 fast-safe-stringify: 2.1.1 form-data: 4.0.1 formidable: 3.5.2 @@ -6001,7 +5911,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.0 + debug: 4.4.3 fast-safe-stringify: 2.1.1 form-data: 4.0.1 formidable: 3.5.2 @@ -6056,10 +5966,6 @@ snapshots: dependencies: real-require: 0.1.0 - thread-stream@4.0.0: - dependencies: - real-require: 0.2.0 - through2@2.0.5: dependencies: readable-stream: 2.3.8 @@ -6211,7 +6117,7 @@ snapshots: vite-node@3.0.8(@types/node@22.13.9)(terser@5.36.0): dependencies: cac: 6.7.14 - debug: 4.4.0 + debug: 4.4.3 es-module-lexer: 1.6.0 pathe: 2.0.3 vite: 5.4.11(@types/node@22.13.9)(terser@5.36.0) @@ -6236,19 +6142,19 @@ snapshots: fsevents: 2.3.3 terser: 5.36.0 - vitest-environment-miniflare@2.14.4(vitest@3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)): + vitest-environment-miniflare@2.14.4(vitest@3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)): dependencies: '@miniflare/queues': 2.14.4 '@miniflare/runner-vm': 2.14.4 '@miniflare/shared': 2.14.4 '@miniflare/shared-test-environment': 2.14.4 undici: 5.28.4 - vitest: 3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) + vitest: 3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) transitivePeerDependencies: - bufferutil - utf-8-validate - vitest@3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0): + vitest@3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0): dependencies: '@vitest/expect': 3.0.8 '@vitest/mocker': 3.0.8(vite@5.4.11(@types/node@22.13.9)(terser@5.36.0)) @@ -6258,7 +6164,7 @@ snapshots: '@vitest/spy': 3.0.8 '@vitest/utils': 3.0.8 chai: 5.2.0 - debug: 4.4.0 + debug: 4.4.3 expect-type: 1.2.0 magic-string: 0.30.17 pathe: 2.0.3 @@ -6271,6 +6177,7 @@ snapshots: vite-node: 3.0.8(@types/node@22.13.9)(terser@5.36.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/debug': 4.1.12 '@types/node': 22.13.9 happy-dom: 17.3.0 jsdom: 26.1.0 diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 80c43ef9f..0d63180b5 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -16,10 +16,10 @@ import { HttpRequestParser, HttpResponseParser } from './http-parser' import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' -import { logger } from '../../utils/logger' +import { createLogger } from '../../utils/logger' import { kClientSocket } from '../net/connection-controller' -const log = logger.child({ module: 'HttpRequestInterceptor' }) +const log = createLogger('HttpRequestInterceptor') export class HttpRequestInterceptor extends Interceptor { static symbol = Symbol('client-request-interceptor') @@ -51,10 +51,7 @@ export class HttpRequestInterceptor extends Interceptor { const baseUrl = connectionOptionsToUrl(connectionOptions) - log.debug( - { firstFrame, httpMethod, baseUrl }, - 'handling first frame...' - ) + log('handling first frame...', { firstFrame, httpMethod, baseUrl }) const requestParser = new HttpRequestParser({ connectionOptions: { @@ -64,21 +61,18 @@ export class HttpRequestInterceptor extends Interceptor { onRequest: async (request) => { const requestId = createRequestId() - logger.debug( - { method: request.method, url: request.url }, - 'received a parsed HTTP request!' - ) + log('received a parsed HTTP request!', { + method: request.method, + url: request.url, + }) const requestController = new RequestController(request, { respondWith: (response) => { - logger.debug( - { - status: response.status, - statusText: response.statusText, - hasBody: response.body != null, - }, - 'respondWith()' - ) + log('respondWith() %o', { + status: response.status, + statusText: response.statusText, + hasBody: response.body != null, + }) connectionController.claim() @@ -181,7 +175,7 @@ export class HttpRequestInterceptor extends Interceptor { headersString += '\r\n' const httpMessageHeaders = statusLine + headersString - log.debug({ httpMessageHeaders }, 'writing response headers...') + log('writing http response message headers...\n', httpMessageHeaders) // Flush the mocked response headers. // This will trigger the "response" event in "ClientRequest". @@ -234,7 +228,7 @@ export class HttpRequestInterceptor extends Interceptor { } } - log.debug('response stream handling done!') + log('response stream handling done!') } if (isChunkedEncoding) { diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 17e749f6c..009e8fc73 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -6,7 +6,7 @@ import { } from './utils/normalize-net-connect-args' import { MockSocket } from './mock-socket' import { ConnectionController } from './connection-controller' -import { logger } from '../../utils/logger' +import { createLogger } from '../../utils/logger' interface SocketEventMap { connection: [ @@ -18,6 +18,8 @@ interface SocketEventMap { ] } +const log = createLogger('SocketInterceptor') + export class SocketInterceptor extends Interceptor { static symbol = Symbol('socket-interceptor') @@ -28,8 +30,6 @@ export class SocketInterceptor extends Interceptor { protected setup(): void { const realNetConnect = net.connect - const log = logger.child({ module: 'net.connect' }) - /** * Luckily, "net.connect()" is rather short and we can replicate it as-is. * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L236 @@ -38,8 +38,8 @@ export class SocketInterceptor extends Interceptor { const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) - log.debug('connect()') - log.debug({ connectionOptions, connectionCallback }) + log('connect()') + log({ connectionOptions, connectionCallback }) const clientSocket = new MockSocket(connectionOptions) const serverSocket = clientSocket.createServerSocket() @@ -57,15 +57,15 @@ export class SocketInterceptor extends Interceptor { connectionOptions, }) - log.debug('emitted "connection" event!') + log('emitted "connection" event!') }) if (connectionOptions.timeout) { - log.debug('set custom connection timeout:', connectionOptions.timeout) + log('set custom connection timeout:', connectionOptions.timeout) clientSocket.setTimeout(connectionOptions.timeout) } - log.debug('connecting the socket...') + log('connecting the socket...') return clientSocket.connect(connectionOptions, connectionCallback) } diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 3868b4197..faf10866f 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,11 +1,11 @@ import net from 'node:net' import { toBuffer } from '../../utils/bufferUtils' -import { logger } from '../../utils/logger' +import { createLogger } from '../../utils/logger' const kListenerWrap = Symbol('kListenerWrap') export const kMockState = Symbol('kMockState') -const log = logger.child({ module: 'MockSocket' }) +const log = createLogger('MockSocket') export class MockSocket extends net.Socket { private [kMockState]: 0 | 1 | 2 @@ -23,11 +23,11 @@ export class MockSocket extends net.Socket { */ this.connecting = true - log.debug('constructed new instance') + log('constructed new instance') } _read(size: number): void { - log.debug('read', size) + log('read', size) } /** @@ -41,10 +41,7 @@ export class MockSocket extends net.Socket { encoding: BufferEncoding, callback?: ((error?: Error | null) => void) | undefined ): void { - log.debug( - { connecting: this.connecting, data, encoding, callback }, - 'write' - ) + log({ connecting: this.connecting, data, encoding, callback }, 'write') const emitWrite = () => { if (Array.isArray(data)) { @@ -73,7 +70,7 @@ export class MockSocket extends net.Socket { * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 */ if (this._pendingData) { - log.debug(this._pendingData, 'mocked connection, clearing write buffer') + log(this._pendingData, 'mocked connection, clearing write buffer') this._pendingData = null this._pendingEncoding = null diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 5b647da20..e0ec28187 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,10 +1,5 @@ -import baseLogger from 'pino' +import debug from 'debug' -export const logger = baseLogger({ - transport: { - target: 'pino-pretty', - options: { - colorize: true, - }, - }, -}) +export function createLogger(namespace: string) { + return debug(`interceptors:${namespace}`) +} From eabc273058ea626d105eb8452a1d347973c44268 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 19 Feb 2026 23:46:15 +0100 Subject: [PATCH 014/198] fix: handle an array of `clientSocket._pendingData` --- src/interceptors/net/connection-controller.ts | 15 +++++++++++++-- test/modules/http/intercept/http.request.test.ts | 10 +++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index 6709a00b8..eb54cadf4 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -1,6 +1,7 @@ import net from 'node:net' import { DeferredPromise } from '@open-draft/deferred-promise' import { kMockState, MockSocket } from './mock-socket' +import { chunk } from 'node_modules/es-toolkit/dist/compat/compat.mjs' // Internally, Node.js represents the result of various operations // by the number they return: 0 (error), 1 (success). @@ -8,7 +9,11 @@ type OperationStatus = 0 | 1 declare module 'node:net' { interface Socket { - _pendingData: string | Buffer | null + _pendingData: + | string + | Buffer + | Array<{ chunk: string | Buffer; encoding?: BufferEncoding }> + | null _pendingEncoding: BufferEncoding | null _writeGeneric( writev: boolean, @@ -127,7 +132,13 @@ export class ConnectionController { const realSocket = this.createConnection() if (clientSocket._pendingData) { - realSocket.write(clientSocket._pendingData) + if (Array.isArray(clientSocket._pendingData)) { + for (const entry of clientSocket._pendingData) { + realSocket.write(entry.chunk, entry.encoding) + } + } else { + realSocket.write(clientSocket._pendingData) + } } realSocket diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 8c75a41aa..24684843b 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -5,7 +5,7 @@ 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' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -19,7 +19,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 () => { @@ -117,7 +117,7 @@ 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) @@ -147,7 +147,7 @@ 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) @@ -177,7 +177,7 @@ 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) From bd53015f463c292fbed95d96796b770d90388774 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 20 Feb 2026 12:38:34 +0100 Subject: [PATCH 015/198] chore: add `readyState` enums as static on `MockSocket` --- src/interceptors/net/connection-controller.ts | 5 ++--- src/interceptors/net/mock-socket.ts | 6 +++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index eb54cadf4..1d66a21be 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -1,7 +1,6 @@ import net from 'node:net' import { DeferredPromise } from '@open-draft/deferred-promise' import { kMockState, MockSocket } from './mock-socket' -import { chunk } from 'node_modules/es-toolkit/dist/compat/compat.mjs' // Internally, Node.js represents the result of various operations // by the number they return: 0 (error), 1 (success). @@ -95,7 +94,7 @@ export class ConnectionController { * connection with the remote address was successful. */ public claim(): void { - this[kClientSocket][kMockState] = 1 + this[kClientSocket][kMockState] = MockSocket.MOCKED // The user can interact with the connection controller *before* the connection attempt // is made. That is so they could handle the socket before the connection. @@ -127,7 +126,7 @@ export class ConnectionController { */ public passthrough(): net.Socket { const clientSocket = this[kClientSocket] - clientSocket[kMockState] = 2 + clientSocket[kMockState] = MockSocket.PASSTHROUGH const realSocket = this.createConnection() diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index faf10866f..8ba7f16a6 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -8,6 +8,10 @@ export const kMockState = Symbol('kMockState') const log = createLogger('MockSocket') export class MockSocket extends net.Socket { + static PENDING = 0 as const + static MOCKED = 1 as const + static PASSTHROUGH = 2 as const + private [kMockState]: 0 | 1 | 2 public connecting: boolean @@ -61,7 +65,7 @@ export class MockSocket extends net.Socket { return } - if (this[kMockState] === 1) { + if (this[kMockState] === MockSocket.MOCKED) { /** * Handle "_writeGeneric" calls scheduled after the "connect" event. * These are writes performed while connecting, and for the mocked socket From fc0d1476e4e9a142d061f1cfca1a0be74aadb74d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 20 Feb 2026 13:18:41 +0100 Subject: [PATCH 016/198] feat(wipe): support `tls.connect` --- src/interceptors/net/index.ts | 36 +++++++++++++++ .../net/utils/normalize-net-connect-args.ts | 17 ++++--- .../net/utils/normalize-tls-connect-args.ts | 46 +++++++++++++++++++ .../intercept/http-client-request.test.ts | 31 ++++++++----- 4 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 src/interceptors/net/utils/normalize-tls-connect-args.ts diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 009e8fc73..d62949164 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 { type NetworkConnectionOptions, @@ -7,6 +8,7 @@ import { import { MockSocket } from './mock-socket' import { ConnectionController } from './connection-controller' import { createLogger } from '../../utils/logger' +import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' interface SocketEventMap { connection: [ @@ -72,9 +74,43 @@ export class SocketInterceptor extends Interceptor { const realNetCreateConnection = net.createConnection net.createConnection = net.connect + const realTlsConnect = tls.connect + tls.connect = (...args: [any, any]) => { + const [tlsConnectionOptions, secureConnectionCallback] = + normalizeTlsConnectArgs(args) + + const clientSocket = new MockSocket(tlsConnectionOptions) + const serverSocket = clientSocket.createServerSocket() + const controller = new ConnectionController( + clientSocket, + function createTlsConnection() { + return realTlsConnect(...args) + } + ) + + process.nextTick(() => { + this.emitter.emit('connection', { + socket: serverSocket, + controller, + connectionOptions: tlsConnectionOptions, + }) + }) + + if (tlsConnectionOptions.socket) { + throw new Error('Custom sockets in TLS connections are not supported') + } + + return clientSocket.connect( + tlsConnectionOptions, + secureConnectionCallback + ) + } + this.subscriptions.push(() => { net.connect = realNetConnect net.createConnection = realNetCreateConnection + + tls.connect = realTlsConnect }) } } diff --git a/src/interceptors/net/utils/normalize-net-connect-args.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts index 016f4979e..fbadd5199 100644 --- a/src/interceptors/net/utils/normalize-net-connect-args.ts +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -16,14 +16,15 @@ export interface NetworkConnectionOptions { } export type NetConnectArgs = - | [options: net.NetConnectOpts, connectionListener?: () => void] - | [url: URL, connectionListener?: () => void] - | [port: number, host: string, connectionListener?: () => void] - | [path: string, connectionListener?: () => void] + | [] + | [options: net.NetConnectOpts, callback?: () => void] + | [url: URL, callback?: () => void] + | [port: number, host?: string, callback?: () => void] + | [path: string, callback?: () => void] export type NormalizedNetConnectArgs = [ options: NetworkConnectionOptions, - connectionListener?: () => void, + callback: (() => void) | null, ] /** @@ -32,7 +33,11 @@ export type NormalizedNetConnectArgs = [ export function normalizeNetConnectArgs( args: NetConnectArgs ): NormalizedNetConnectArgs { - const callback = typeof args[1] === 'function' ? args[1] : args[2] + if (args.length === 0) { + return [{ path: '' }, null] + } + + const callback = typeof args[1] === 'function' ? args[1] : args[2] || null if (typeof args[0] === 'string') { return [{ path: args[0] }, callback] diff --git a/src/interceptors/net/utils/normalize-tls-connect-args.ts b/src/interceptors/net/utils/normalize-tls-connect-args.ts new file mode 100644 index 000000000..ca3182c11 --- /dev/null +++ b/src/interceptors/net/utils/normalize-tls-connect-args.ts @@ -0,0 +1,46 @@ +import tls from 'node:tls' +import { + type NetConnectArgs, + normalizeNetConnectArgs, +} from './normalize-net-connect-args' + +type TlsConnectArgs = + | [] + | [options: tls.ConnectionOptions, callback?: () => void] + | [url: URL, callback?: () => void] + | [port: number, options?: tls.ConnectionOptions, callback?: () => void] + | [ + port: number, + host?: string, + options?: tls.ConnectionOptions, + callback?: () => void, + ] + +type NormalizedTlsConnectionArgs = [ + options: tls.ConnectionOptions, + callback?: (() => void) | null, +] + +export function normalizeTlsConnectArgs( + args: TlsConnectArgs +): NormalizedTlsConnectionArgs { + /** + * @note Despite incorrect type definitions, "tls.connect()" has all the + * options of "net.connect()" and then those specific to TLS connections, + * like "session" or "socket". + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1615 + */ + const netConnectArgs = normalizeNetConnectArgs(args as NetConnectArgs) + const options = netConnectArgs[0] as tls.ConnectionOptions + const callback = netConnectArgs[1] + + if (args[0] !== null && typeof args[0] === 'object') { + Object.assign(options, args[0]) + } else if (args[1] !== null && typeof args[1] === 'object') { + Object.assign(options, args[1]) + } else if (args[2] !== null && typeof args[2] === 'object') { + Object.assign(options, args[2]) + } + + return callback ? [options, callback] : [options] +} diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index 5b395601f..f801cfd4e 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -1,18 +1,18 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { httpsAgent, HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { RequestController } from '../../../../src/RequestController' import { HttpRequestEventMap } from '../../../../src/glossary' const httpServer = new HttpServer((app) => { app.get('/user', (_req, res) => { - res.status(200).send('user-body') + res.status(200).send('original-body') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' @@ -66,7 +66,7 @@ it('intercepts an HTTP ClientRequest request with request options', async () => expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('original-body') }) it('intercepts an HTTP ClientRequest request with URL string', async () => { @@ -96,7 +96,7 @@ it('intercepts an HTTP ClientRequest request with URL string', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('original-body') }) it('intercepts an HTTP ClientRequest request with URL instance', async () => { @@ -126,7 +126,7 @@ it('intercepts an HTTP ClientRequest request with URL instance', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with URL string', async () => { @@ -135,7 +135,10 @@ 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 Invalid Node.js types. + agent: httpsAgent, + }) req.setHeader('x-custom-header', 'yes') req.end() @@ -156,7 +159,7 @@ it('intercepts an HTTPS ClientRequest request with URL string', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with URL instance', async () => { @@ -165,7 +168,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 Invalid Node.js types. + agent: httpsAgent, + }) req.setHeader('x-custom-header', 'yes') req.end() @@ -186,7 +192,7 @@ it('intercepts an HTTPS ClientRequest request with URL instance', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with request options', async () => { @@ -203,6 +209,7 @@ it('intercepts an HTTPS ClientRequest request with request options', async () => headers: { 'x-custom-header': 'yes', }, + agent: httpsAgent, }) req.end() @@ -223,7 +230,7 @@ it('intercepts an HTTPS ClientRequest request with request options', async () => expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('original-body') }) it('restores the original ClientRequest class after disposal', async () => { From 59ef8ba9e6525d4472e06a595e14806b5f935321 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 20 Feb 2026 17:26:28 +0100 Subject: [PATCH 017/198] fix(wip): support tls connections --- .../utils/getIncomingMessageBody.test.ts | 2 +- .../utils/getIncomingMessageBody.ts | 2 +- .../utils/normalizeClientRequestArgs.test.ts | 7 ++- src/interceptors/net/connection-controller.ts | 63 +++++++++++-------- src/interceptors/net/index.ts | 39 ++++++++++-- src/interceptors/net/mock-socket.ts | 12 +++- src/interceptors/net/utils/flush-writes.ts | 18 ++++++ .../net/utils/normalize-tls-connect-args.ts | 2 +- src/utils/getUrlByRequestOptions.test.ts | 9 +-- src/utils/getUrlByRequestOptions.ts | 6 +- test/features/events/request.test.ts | 2 +- .../compliance/http-abort-controller.test.ts | 6 +- .../http/compliance/http-custom-agent.test.ts | 4 +- .../http/compliance/http-errors.test.ts | 57 +++++++++-------- .../compliance/http-event-connect.test.ts | 24 ++++--- test/modules/http/intercept/http.get.test.ts | 2 +- .../http/intercept/http.request.test.ts | 2 +- test/modules/http/intercept/https.get.test.ts | 6 +- .../http/intercept/https.request.test.ts | 14 ++--- test/modules/http/response/http-https.test.ts | 4 +- .../http/response/http-response-delay.test.ts | 2 +- 21 files changed, 180 insertions(+), 103 deletions(-) create mode 100644 src/interceptors/net/utils/flush-writes.ts diff --git a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts index 76d232971..5cbcd1dd8 100644 --- a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts +++ b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts @@ -1,5 +1,5 @@ import { it, expect } from 'vitest' -import { IncomingMessage } from 'http' +import { IncomingMessage } from 'node:http' import { Socket } from 'net' import * as zlib from 'zlib' import { getIncomingMessageBody } from './getIncomingMessageBody' diff --git a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts index 563e55259..9a8f14f56 100644 --- a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts +++ b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts @@ -1,4 +1,4 @@ -import { IncomingMessage } from 'http' +import { IncomingMessage } from 'node:http' import { PassThrough } from 'stream' import * as zlib from 'zlib' import { Logger } from '@open-draft/logger' diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts index ab0e217ee..8cdc7ec16 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts @@ -1,7 +1,10 @@ import { it, expect } from 'vitest' import { parse } from 'url' -import { globalAgent as httpGlobalAgent, RequestOptions } from 'http' -import { Agent as HttpsAgent, globalAgent as httpsGlobalAgent } from 'https' +import { globalAgent as httpGlobalAgent, RequestOptions } from 'node:http' +import { + Agent as HttpsAgent, + globalAgent as httpsGlobalAgent, +} from 'node:https' import { getUrlByRequestOptions } from '../../../utils/getUrlByRequestOptions' import { normalizeClientRequestArgs } from './normalizeClientRequestArgs' diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index 1d66a21be..70d93f1ad 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -1,6 +1,8 @@ import net from 'node:net' import { DeferredPromise } from '@open-draft/deferred-promise' +import { invariant } from 'outvariant' import { kMockState, MockSocket } from './mock-socket' +import { unwrapPendingData } from './utils/flush-writes' // Internally, Node.js represents the result of various operations // by the number they return: 0 (error), 1 (success). @@ -11,7 +13,11 @@ declare module 'node:net' { _pendingData: | string | Buffer - | Array<{ chunk: string | Buffer; encoding?: BufferEncoding }> + | Array<{ + chunk: string | Buffer + encoding?: BufferEncoding + callback?: (error?: Error | null) => void + }> | null _pendingEncoding: BufferEncoding | null _writeGeneric( @@ -71,21 +77,19 @@ export class ConnectionController { this[kClientSocket] = socket this.#pendingRequest = new DeferredPromise() - socket.prependListener('connectionAttempt', (ip, port, family) => { - /** - * @todo @fixme Also patch "socket._handle.connect6" for IPv6 connections. - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 - */ - if (family === 6) { - throw new Error( - 'IPv6 connections not implemented (implement "socket._handle.connect6' - ) - } - - socket._handle.connect = (request) => { - this.#pendingRequest.resolve(request) - } - }) + invariant( + socket._handle, + 'Failed to create a socket connection controller: socket._handle is missing' + ) + + socket._handle.connect = (request) => { + this.#pendingRequest.resolve(request) + } + + /** + * @todo @fixme Also patch "socket._handle.connect6" for IPv6 connections. + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 + */ } /** @@ -131,25 +135,34 @@ export class ConnectionController { const realSocket = this.createConnection() if (clientSocket._pendingData) { - if (Array.isArray(clientSocket._pendingData)) { - for (const entry of clientSocket._pendingData) { - realSocket.write(entry.chunk, entry.encoding) - } - } else { - realSocket.write(clientSocket._pendingData) - } + unwrapPendingData(clientSocket._pendingData, (chunk, encoding) => { + realSocket.write(chunk, encoding) + }) } + clientSocket + .on('drain', () => realSocket.resume()) + .on('close', () => realSocket.destroy()) + clientSocket._write = (...args) => realSocket.write(...args) + clientSocket._final = (callback) => realSocket.end(callback) + realSocket - .prependListener('connectionAttempt', () => { + .once('connectionAttempt', () => { clientSocket._handle.unref?.() clientSocket._handle = realSocket._handle }) .on('connect', () => { clientSocket.connecting = realSocket.connecting + clientSocket.emit('connect') + // clientSocket.emit('ready') }) .on('data', (data) => { - clientSocket.push(data) + if (!clientSocket.push(data)) { + realSocket.pause() + } + }) + .on('error', (error) => { + clientSocket.emit('error', error) }) .on('end', () => { clientSocket.push(null) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index d62949164..74c73f87f 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -5,10 +5,11 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { MockSocket } from './mock-socket' +import { kMockState, MockSocket } from './mock-socket' import { ConnectionController } from './connection-controller' import { createLogger } from '../../utils/logger' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' +import { unwrapPendingData } from './utils/flush-writes' interface SocketEventMap { connection: [ @@ -44,7 +45,6 @@ export class SocketInterceptor extends Interceptor { log({ connectionOptions, connectionCallback }) const clientSocket = new MockSocket(connectionOptions) - const serverSocket = clientSocket.createServerSocket() const controller = new ConnectionController( clientSocket, function createConnection() { @@ -54,7 +54,7 @@ export class SocketInterceptor extends Interceptor { process.nextTick(() => { this.emitter.emit('connection', { - socket: serverSocket, + socket: clientSocket.createServerSocket(), controller, connectionOptions, }) @@ -100,10 +100,41 @@ export class SocketInterceptor extends Interceptor { throw new Error('Custom sockets in TLS connections are not supported') } - return clientSocket.connect( + /** + * @note Enable unauthorized requests by default, unless explicitly disabled. + * It's either this or asking the user to always provide a custom Agent that + * allows otherwise unauthorized requests (e.g. self-signed SSL, non-existing hosts). + * + * @todo @fixme Reconsider this. + */ + tlsConnectionOptions.rejectUnauthorized ??= false + + const tlsSocket = realTlsConnect( tlsConnectionOptions, secureConnectionCallback ) + + tlsSocket._writeGeneric = new Proxy(tlsSocket._writeGeneric, { + apply(target, thisArg, args) { + const bufferedWrites = args[1] + + if (clientSocket[kMockState] !== MockSocket.PASSTHROUGH) { + unwrapPendingData(bufferedWrites, (chunk, encoding) => { + /** + * @note Emit the internal write event, which triggers the "data" event on the server socket. + * This allows the user to listen to outgoing TLS connections before the handshake runs. + * Normally, TLSSocket buffers the writes until the "secure" event is emitted and it doesn't + * forward those writes to the "clientSocket" for the client -> server proxy to trigger. + */ + clientSocket.emit('internal:write', chunk, encoding) + }) + } + + return Reflect.apply(target, thisArg, args) + }, + }) + + return tlsSocket } this.subscriptions.push(() => { diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 8ba7f16a6..68a8386d8 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -17,7 +17,16 @@ export class MockSocket extends net.Socket { public connecting: boolean constructor(options: net.SocketConstructorOpts) { - super(options) + super({ + ...options, + /** + * @note Providing a file descriptor triggers Node.js to create an appropriate handle for this socket. + * Initiate the handle earlier so we can mock its methods in the connection controller without waiting + * for the "connectionAttempt". + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/net.js#L424 + */ + fd: options.fd || 1, + }) this[kMockState] = 0 @@ -150,6 +159,7 @@ export class MockSocket extends net.Socket { callback: (error?: Error | null) => void ) => { this.push(toBuffer(chunk, encoding), encoding) + callback() } } diff --git a/src/interceptors/net/utils/flush-writes.ts b/src/interceptors/net/utils/flush-writes.ts new file mode 100644 index 000000000..03aee5d30 --- /dev/null +++ b/src/interceptors/net/utils/flush-writes.ts @@ -0,0 +1,18 @@ +import net from 'node:net' + +export function unwrapPendingData( + data: net.Socket['_pendingData'], + callback: ( + chunk: string | Buffer | null, + encoding?: BufferEncoding, + callback?: (error?: Error | null) => void + ) => void +) { + if (Array.isArray(data)) { + for (const entry of data) { + callback(entry.chunk, entry.encoding, entry.callback) + } + } else { + callback(data) + } +} diff --git a/src/interceptors/net/utils/normalize-tls-connect-args.ts b/src/interceptors/net/utils/normalize-tls-connect-args.ts index ca3182c11..5f6ff7f39 100644 --- a/src/interceptors/net/utils/normalize-tls-connect-args.ts +++ b/src/interceptors/net/utils/normalize-tls-connect-args.ts @@ -18,7 +18,7 @@ type TlsConnectArgs = type NormalizedTlsConnectionArgs = [ options: tls.ConnectionOptions, - callback?: (() => void) | null, + callback?: () => void, ] export function normalizeTlsConnectArgs( diff --git a/src/utils/getUrlByRequestOptions.test.ts b/src/utils/getUrlByRequestOptions.test.ts index cc9423609..e2da49a12 100644 --- a/src/utils/getUrlByRequestOptions.test.ts +++ b/src/utils/getUrlByRequestOptions.test.ts @@ -1,6 +1,6 @@ import { it, expect } from 'vitest' -import { Agent as HttpAgent } from 'http' -import { RequestOptions, Agent as HttpsAgent } from 'https' +import { Agent as HttpAgent } from 'node:http' +import { RequestOptions, Agent as HttpsAgent } from 'node:https' import { getUrlByRequestOptions } from './getUrlByRequestOptions' it('returns a URL based on the basic RequestOptions', () => { @@ -130,9 +130,7 @@ it('use "hostname" if both "hostname" and "host" are specified', () => { path: '/resource', } - expect(getUrlByRequestOptions(options).href).toBe( - 'https://hostname/resource' - ) + expect(getUrlByRequestOptions(options).href).toBe('https://hostname/resource') }) it('parses "host" in IPv6', () => { @@ -149,7 +147,6 @@ it('parses "host" in IPv6', () => { path: '/resource', }).href ).toBe('http://[::1]/resource') - }) it('parses "host" and "port" in IPv6', () => { diff --git a/src/utils/getUrlByRequestOptions.ts b/src/utils/getUrlByRequestOptions.ts index 0b8a5ef76..0ff6ddac8 100644 --- a/src/utils/getUrlByRequestOptions.ts +++ b/src/utils/getUrlByRequestOptions.ts @@ -1,5 +1,5 @@ -import { Agent } from 'http' -import { RequestOptions, Agent as HttpsAgent } from 'https' +import { Agent } from 'node:http' +import { RequestOptions, Agent as HttpsAgent } from 'node:https' import { Logger } from '@open-draft/logger' const logger = new Logger('utils getUrlByRequestOptions') @@ -94,7 +94,7 @@ function getHostname(options: ResolvedRequestOptions): string | undefined { if (host) { if (isRawIPv6Address(host)) { - host = `[${host}]` + host = `[${host}]` } // Check the presence of the port, and if it's present, diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 6ccf717b9..7a1604977 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestEventMap } from '../../../src' import { diff --git a/test/modules/http/compliance/http-abort-controller.test.ts b/test/modules/http/compliance/http-abort-controller.test.ts index 630404d00..780b5720b 100644 --- a/test/modules/http/compliance/http-abort-controller.test.ts +++ b/test/modules/http/compliance/http-abort-controller.test.ts @@ -1,11 +1,11 @@ // @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { sleep, waitForClientRequest } from '../../../helpers' -import { setTimeout } from 'node:timers/promises' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { @@ -14,7 +14,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..ab31b93c7 100644 --- a/test/modules/http/compliance/http-custom-agent.test.ts +++ b/test/modules/http/compliance/http-custom-agent.test.ts @@ -3,10 +3,10 @@ import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../../test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => res.send('hello world')) diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index 6a9f70442..9b7f167a5 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 - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +// @vitest-environment node +import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { sleep, waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interface NotFoundError extends NodeJS.ErrnoException { hostname: string @@ -22,6 +20,10 @@ beforeAll(() => { interceptor.apply() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(() => { interceptor.dispose() }) @@ -29,7 +31,7 @@ afterAll(() => { it('suppresses ECONNREFUSED error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { await sleep(250) - controller.respondWith(new Response('Mocked')) + controller.respondWith(new Response('mocked')) }) // Connecting to a non-existing host will @@ -41,7 +43,7 @@ it('suppresses ECONNREFUSED error given a mocked response', async () => { const { res, text } = await waitForClientRequest(request) expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + await expect(text()).resolves.toBe('mocked') expect(errorListener).not.toHaveBeenCalled() }) @@ -53,11 +55,12 @@ it('forwards ECONNREFUSED error given a bypassed request', async () => { // result in the "ECONNREFUSED" error in Node.js. // In this case, nothing is handling a response for this // request, so the connection error must be forwarded. - const request = http.get('http://localhost:9876') - request.on('error', (error: ConnectionError) => { - errorPromise.resolve(error) - }) - request.on('response', responseListener) + const request = http.get('http://localhost/non-existing') + request + .on('error', (error: ConnectionError) => { + errorPromise.resolve(error) + }) + .on('response', responseListener) const requestError = await errorPromise @@ -66,14 +69,14 @@ it('forwards ECONNREFUSED error given a bypassed request', async () => { * because Node.js v20 will aggregate connection errors * into a single "AggregateError" instance that doesn't have those. */ - expect(requestError.code).toBe('ECONNREFUSED') - expect(responseListener).not.toHaveBeenCalled() + expect.soft(requestError.code).toBe('ECONNREFUSED') + expect.soft(responseListener).not.toHaveBeenCalled() }) it('suppresses ENOTFOUND error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { await sleep(250) - controller.respondWith(new Response('Mocked')) + controller.respondWith(new Response('mocked')) }) const request = http.get('http://non-existing-url.com') @@ -83,7 +86,7 @@ it('suppresses ENOTFOUND error given a mocked response', async () => { const { res, text } = await waitForClientRequest(request) expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + await expect(text()).resolves.toBe('mocked') expect(errorListener).not.toHaveBeenCalled() }) @@ -106,7 +109,7 @@ it('forwards ENOTFOUND error for a bypassed request', async () => { it('suppresses EHOSTUNREACH error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { await sleep(250) - controller.respondWith(new Response('Mocked')) + controller.respondWith(new Response('mocked')) }) // Connecting to an IPv6 address that's out of the network's @@ -118,7 +121,7 @@ it('suppresses EHOSTUNREACH error given a mocked response', async () => { const { res, text } = await waitForClientRequest(request) expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + await expect(text()).resolves.toBe('mocked') }) it('forwards EHOSTUNREACH error for a bypassed request', async () => { @@ -147,7 +150,10 @@ it('allows throwing connection errors in the request listener', async () => { errno?: number syscall?: string - constructor(public address: string, public port: number) { + constructor( + public address: string, + public port: number + ) { super() this.code = 'ECONNREFUSED' this.errno = -61 @@ -172,8 +178,9 @@ it('allows throwing connection errors in the request listener', async () => { const requestError = await errorPromise - expect(requestError.message).toBe('connect ECONNREFUSED ::1 4444') - expect(requestError.code).toBe('ECONNREFUSED') - expect(requestError.address).toBe('::1') - expect(requestError.port).toBe(4444) + expect(requestError).toMatchObject({ + code: 'ECONNREFUSED', + address: '::1', + port: 4444, + }) }) diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts index ca745e1cc..50528324a 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 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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() @@ -30,7 +28,7 @@ afterAll(async () => { await httpServer.close() }) -it('emits the "connect" event for a mocked request', async () => { +it('emits the "connect" event for a mocked HTTP request', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith(new Response('hello world')) }) @@ -43,19 +41,19 @@ it('emits the "connect" event for a mocked request', async () => { await waitForClientRequest(request) - expect(connectListener).toHaveBeenCalledTimes(1) + expect(connectListener).toHaveBeenCalledOnce() }) -it('emits the "connect" event for a bypassed request', async () => { - const connectListener = vi.fn() +it('emits the "connect" event for a bypassed HTTP request', async () => { const request = http.get(httpServer.http.url('/')) + + const socketConnectListener = vi.fn() request.on('socket', (socket) => { - socket.on('connect', connectListener) + socket.on('connect', socketConnectListener) }) await waitForClientRequest(request) - - expect(connectListener).toHaveBeenCalledTimes(1) + expect(socketConnectListener).toHaveBeenCalledOnce() }) it('emits the "secureConnect" event for a mocked HTTPS request', async () => { @@ -77,7 +75,7 @@ it('emits the "secureConnect" event for a mocked HTTPS request', async () => { expect(connectListener).toHaveBeenCalledTimes(2) }) -it('emits the "secureConnect" event for a mocked HTTPS request', async () => { +it('emits the "secureConnect" event for a bypassed HTTPS request', async () => { const connectListener = vi.fn<(input: string) => void>() const request = https.get(httpServer.https.url('/'), { rejectUnauthorized: false, diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index fb8598ef6..d96bff578 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,5 +1,5 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 24684843b..795bda40f 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -1,5 +1,5 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from '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' diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 3c420d024..8161b03e6 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,10 +1,10 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import https from 'https' +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' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -13,7 +13,7 @@ const httpServer = new HttpServer((app) => { }) const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index e602d99c2..fc55e6a42 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -1,11 +1,11 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import https from 'https' +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' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (req, res) => { @@ -21,7 +21,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 () => { @@ -108,7 +108,7 @@ it('intercepts a POST request', async () => { 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') + await expect(request.text()).resolves.toBe('post-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -134,7 +134,7 @@ it('intercepts a PUT request', async () => { 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') + await expect(request.text()).resolves.toBe('put-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -160,7 +160,7 @@ it('intercepts a PATCH request', async () => { 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') + await expect(request.text()).resolves.toBe('patch-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -185,7 +185,7 @@ it('intercepts a DELETE request', async () => { 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('') + await expect(request.text()).resolves.toBe('') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 074cb3f01..91f3ba47c 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,6 +1,6 @@ import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' -import https from 'https' +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' diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 2b45639eb..4de58e8e2 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -1,5 +1,5 @@ import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { sleep, waitForClientRequest } from '../../../helpers' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' From 05fc369c11c98f9e9884e721f820a8dc680cc0fd Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 20 Feb 2026 17:38:10 +0100 Subject: [PATCH 018/198] chore: implement `MockSocket.wrapTlsSocket` --- src/interceptors/net/index.ts | 28 ++---------------------- src/interceptors/net/mock-socket.ts | 33 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 74c73f87f..fb18d856c 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -5,11 +5,10 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { kMockState, MockSocket } from './mock-socket' +import { MockSocket } from './mock-socket' import { ConnectionController } from './connection-controller' import { createLogger } from '../../utils/logger' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' -import { unwrapPendingData } from './utils/flush-writes' interface SocketEventMap { connection: [ @@ -96,10 +95,6 @@ export class SocketInterceptor extends Interceptor { }) }) - if (tlsConnectionOptions.socket) { - throw new Error('Custom sockets in TLS connections are not supported') - } - /** * @note Enable unauthorized requests by default, unless explicitly disabled. * It's either this or asking the user to always provide a custom Agent that @@ -113,26 +108,7 @@ export class SocketInterceptor extends Interceptor { tlsConnectionOptions, secureConnectionCallback ) - - tlsSocket._writeGeneric = new Proxy(tlsSocket._writeGeneric, { - apply(target, thisArg, args) { - const bufferedWrites = args[1] - - if (clientSocket[kMockState] !== MockSocket.PASSTHROUGH) { - unwrapPendingData(bufferedWrites, (chunk, encoding) => { - /** - * @note Emit the internal write event, which triggers the "data" event on the server socket. - * This allows the user to listen to outgoing TLS connections before the handshake runs. - * Normally, TLSSocket buffers the writes until the "secure" event is emitted and it doesn't - * forward those writes to the "clientSocket" for the client -> server proxy to trigger. - */ - clientSocket.emit('internal:write', chunk, encoding) - }) - } - - return Reflect.apply(target, thisArg, args) - }, - }) + clientSocket.wrapTlsSocket(tlsSocket) return tlsSocket } diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 68a8386d8..e9cc4f92f 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,9 +1,13 @@ import net from 'node:net' +import tls from 'node:tls' +import { invariant } from 'outvariant' import { toBuffer } from '../../utils/bufferUtils' import { createLogger } from '../../utils/logger' +import { unwrapPendingData } from './utils/flush-writes' const kListenerWrap = Symbol('kListenerWrap') export const kMockState = Symbol('kMockState') +const kTlsSocketWrapped = Symbol('kTlsSocketWrapped') const log = createLogger('MockSocket') @@ -167,4 +171,33 @@ export class MockSocket extends net.Socket { }, }) } + + public wrapTlsSocket(tlsSocket: tls.TLSSocket): void { + invariant( + Reflect.get(tlsSocket, kTlsSocketWrapped) == null, + 'Failed to wrap a TLSSocket: already wrapped. This is likely an issue with MSW. Please report it on GitHub.' + ) + + const realTlsSocketWriteGeneric = tlsSocket._writeGeneric + + tlsSocket._writeGeneric = (...args) => { + const pendingData = args[1] + + if (this[kMockState] !== MockSocket.PASSTHROUGH) { + unwrapPendingData(pendingData, (chunk, encoding) => { + /** + * @note Emit the internal write event, which triggers the "data" event on the server socket. + * This allows the user to listen to outgoing TLS connections before the handshake runs. + * Normally, TLSSocket buffers the writes until the "secure" event is emitted and it doesn't + * forward those writes to the "clientSocket" for the client -> server proxy to trigger. + */ + this.emit('internal:write', chunk, encoding) + }) + } + + return realTlsSocketWriteGeneric.apply(tlsSocket, args) + } + + Reflect.set(tlsSocket, kTlsSocketWrapped, true) + } } From 3701b141d3eaf125544be72edadea63fc12d5917 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 20 Feb 2026 17:41:57 +0100 Subject: [PATCH 019/198] test: rewrite the agent patching test suite --- .../http-client-request-agent.test.ts | 48 +++++++------------ 1 file changed, 16 insertions(+), 32 deletions(-) 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..bb9f8f17b 100644 --- a/test/modules/http/intercept/http-client-request-agent.test.ts +++ b/test/modules/http/intercept/http-client-request-agent.test.ts @@ -1,20 +1,16 @@ /** - * @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. + * @note Historically, we used to intercept "ClientRequest" via a custom agent. + * With the socket-based interception, that's no longer the case. I've rewritten + * this test suite to ensure we are *not* patching the agents anymore. */ import { beforeAll, afterEach, afterAll, it, expect } from 'vitest' +import net from 'node:net' 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' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -28,36 +24,24 @@ afterAll(() => { interceptor.dispose() }) -it('reuses the same mock socket for an HTTP ClientRequest and its agent', async () => { - const socketPromise = new DeferredPromise() +it('does not patch the agent for the HTTP request', async () => { + const socketPromise = new DeferredPromise() const request = http .get('http://localhost/does-not-matter') - .on('socket', (socket) => socketPromise.resolve(socket as MockHttpSocket)) + .on('socket', (socket) => socketPromise.resolve(socket as net.Socket)) .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) + expect(Reflect.get(request, 'agent')).toBeInstanceOf(http.Agent) + await expect(socketPromise).resolves.toBeInstanceOf(net.Socket) }) -it('reuses the same mock socket for an HTTPS ClientRequest and its agent', async () => { - const socketPromise = new DeferredPromise() +it('does not patch the agent for the HTTPS request', async () => { + const socketPromise = new DeferredPromise() const request = https .get('https://localhost/does-not-matter') - .on('socket', (socket) => socketPromise.resolve(socket as MockHttpSocket)) + .on('socket', (socket) => socketPromise.resolve(socket as net.Socket)) .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) + expect(Reflect.get(request, 'agent')).toBeInstanceOf(https.Agent) + await expect(socketPromise).resolves.toBeInstanceOf(net.Socket) }) From 426aadb8e6013537e0144a5e4ad22de65b87857b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 20 Feb 2026 19:30:36 +0100 Subject: [PATCH 020/198] fix(wip): attempt to fix mocked https (breaks passthrough though) --- src/interceptors/http/index.ts | 20 +++-- src/interceptors/net/connection-controller.ts | 76 ++++++++++++++++--- src/interceptors/net/index.ts | 12 +++ src/interceptors/net/mock-socket.ts | 24 +++--- src/interceptors/net/utils/flush-writes.ts | 4 +- test/modules/http/compliance/https.test.ts | 62 ++++++++------- 6 files changed, 141 insertions(+), 57 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 0d63180b5..cf310df1f 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -53,6 +53,8 @@ export class HttpRequestInterceptor extends Interceptor { log('handling first frame...', { firstFrame, httpMethod, baseUrl }) + console.log('HTTP INTERCEPTOR!', firstFrame) + const requestParser = new HttpRequestParser({ connectionOptions: { method: httpMethod, @@ -124,13 +126,13 @@ export class HttpRequestInterceptor extends Interceptor { requestParser.execute(toBuffer(chunk)) // Forward subsequent socket writes to the parser. - socket.on('data', (chunk) => { - if (chunk) { - requestParser.execute(toBuffer(chunk)) - } - }) - - socket.on('close', () => requestParser.free()) + socket + .on('data', (chunk) => { + if (chunk) { + requestParser.execute(toBuffer(chunk)) + } + }) + .on('close', () => requestParser.free()) }) } ) @@ -181,6 +183,8 @@ export class HttpRequestInterceptor extends Interceptor { // This will trigger the "response" event in "ClientRequest". socket.push(Buffer.from(httpMessageHeaders)) + console.log('pushing mocked response...', httpMessageHeaders) + if (response.body) { try { const reader = response.body.getReader() @@ -231,6 +235,8 @@ export class HttpRequestInterceptor extends Interceptor { log('response stream handling done!') } + console.log('response stream done') + if (isChunkedEncoding) { socket.push(Buffer.from('0\r\n\r\n')) } diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index 70d93f1ad..f16241a62 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -1,7 +1,7 @@ import net from 'node:net' import { DeferredPromise } from '@open-draft/deferred-promise' import { invariant } from 'outvariant' -import { kMockState, MockSocket } from './mock-socket' +import { kMockState, kTlsSocket, MockSocket } from './mock-socket' import { unwrapPendingData } from './utils/flush-writes' // Internally, Node.js represents the result of various operations @@ -30,15 +30,27 @@ declare module 'node:net' { } } +declare module 'node:tls' { + interface TLSSocket { + _handle: TcpHandle & { + start: () => void + onhandshakedone: () => void + onnewsession: (sessionId: unknown, session: Buffer) => void + verifyError: () => void + } + } +} + interface TcpHandle { open: (fd: unknown) => OperationStatus connect: (request: TcpWrap, address: string, port: number) => void + connect6: (request: TcpWrap, address: string, port: number) => void listen: (backlog: number) => OperationStatus onconnection?: () => void getpeername?: () => OperationStatus getsockname?: () => OperationStatus reading: boolean - onread: () => {} + onread: () => void readStart: () => void readStop: () => void bytesRead: number @@ -82,14 +94,14 @@ export class ConnectionController { 'Failed to create a socket connection controller: socket._handle is missing' ) + socket._handle.readStart = () => console.log('\n\nYES\n\n') + socket._handle.connect = (request) => { this.#pendingRequest.resolve(request) } - - /** - * @todo @fixme Also patch "socket._handle.connect6" for IPv6 connections. - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 - */ + socket._handle.connect6 = (request) => { + this.#pendingRequest.resolve(request) + } } /** @@ -98,7 +110,53 @@ export class ConnectionController { * connection with the remote address was successful. */ public claim(): void { - this[kClientSocket][kMockState] = MockSocket.MOCKED + const clientSocket = this[kClientSocket] + clientSocket[kMockState] = MockSocket.MOCKED + + console.log('CLAIM!') + + const tlsSocket = clientSocket[kTlsSocket] + + if (tlsSocket) { + // Update the client socket reference so that connection controller interacts + // with the top-most socket, which is the TLSSocket. This way, mocked response + // gets pushed to the TLS socket correctly. Pushing it to TCP does nothing. + /** + * @fixme This should be removed after MockTlsSocket is implemented. + * [kClientSocket] should point to MockTlsSocket from the start, no nesting. + */ + this[kClientSocket] = tlsSocket + + this.#pendingRequest = new DeferredPromise() + + /** + * Mock this to prevent the "Error: Worker exited unexpectedly" error. + * This will trigger when "secure" is emitted. + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 + */ + tlsSocket._handle.verifyError = () => void 0 + + tlsSocket._handle.start = () => { + process.nextTick(() => { + /** + * Mock successful SSL handshake completion. + * This will emit "secureConnect" and "secure" on the TLS socket, and trigger "tlsSocket._finishInit". + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L878 + */ + tlsSocket._handle.onhandshakedone() + tlsSocket._handle.onnewsession(1, Buffer.alloc(0)) + }) + } + + clientSocket.connecting = false + + process.nextTick(() => { + clientSocket.emit('connect') + tlsSocket.emit('ready') + }) + + return + } // The user can interact with the connection controller *before* the connection attempt // is made. That is so they could handle the socket before the connection. @@ -106,7 +164,7 @@ export class ConnectionController { /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 */ - request.oncomplete(0, this[kClientSocket]._handle, request, true, true) + request.oncomplete(0, clientSocket._handle, request, true, true) }) } diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index fb18d856c..b85c62518 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -89,12 +89,24 @@ export class SocketInterceptor extends Interceptor { process.nextTick(() => { this.emitter.emit('connection', { + /** + * @fixme This is incorrect. The TLSSocket has to be exposed here so the user can access its properties, + * like "encrypted", "secure", etc. I think I need to create a MockTlsSocket class after all. + * TLS handling is just DRAMATICALLY different from TCP. + */ socket: serverSocket, controller, connectionOptions: tlsConnectionOptions, }) }) + /** + * If "socket" is unset, "TLSSocket" will rely on "net.Socket.connect" to create one. + * This will cause the unpatched socket to be created, failing connections to non-existing hosts. + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L560 + */ + tlsConnectionOptions.socket = clientSocket + /** * @note Enable unauthorized requests by default, unless explicitly disabled. * It's either this or asking the user to always provide a custom Agent that diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index e9cc4f92f..59fbc01b9 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -6,9 +6,11 @@ import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' const kListenerWrap = Symbol('kListenerWrap') -export const kMockState = Symbol('kMockState') const kTlsSocketWrapped = Symbol('kTlsSocketWrapped') +export const kMockState = Symbol('kMockState') +export const kTlsSocket = Symbol('kTlsSocket') + const log = createLogger('MockSocket') export class MockSocket extends net.Socket { @@ -17,6 +19,7 @@ export class MockSocket extends net.Socket { static PASSTHROUGH = 2 as const private [kMockState]: 0 | 1 | 2 + private [kTlsSocket]?: tls.TLSSocket public connecting: boolean @@ -36,7 +39,7 @@ export class MockSocket extends net.Socket { /** * @note Start the socket in the connecting state. - * This will make Node.js buffer any writes to it automatically. + * This will make Node.js buffer any writes to this socket automatically. */ this.connecting = true @@ -61,13 +64,9 @@ export class MockSocket extends net.Socket { log({ connecting: this.connecting, data, encoding, callback }, 'write') const emitWrite = () => { - if (Array.isArray(data)) { - for (const entry of data) { - this.emit('internal:write', entry.chunk, entry.encoding) - } - } else { - this.emit('internal:write', data, encoding) - } + unwrapPendingData(data, (chunk, encoding) => { + this.emit('internal:write', chunk, encoding) + }) } // While connecting, the socket is in ambiguous state. @@ -178,12 +177,15 @@ export class MockSocket extends net.Socket { 'Failed to wrap a TLSSocket: already wrapped. This is likely an issue with MSW. Please report it on GitHub.' ) - const realTlsSocketWriteGeneric = tlsSocket._writeGeneric + this[kTlsSocket] = tlsSocket + const realTlsSocketWriteGeneric = tlsSocket._writeGeneric tlsSocket._writeGeneric = (...args) => { const pendingData = args[1] - if (this[kMockState] !== MockSocket.PASSTHROUGH) { + if (this[kMockState] === MockSocket.PENDING) { + console.log('>> tlsSocket._writeGeneric FORWARD:', pendingData) + unwrapPendingData(pendingData, (chunk, encoding) => { /** * @note Emit the internal write event, which triggers the "data" event on the server socket. diff --git a/src/interceptors/net/utils/flush-writes.ts b/src/interceptors/net/utils/flush-writes.ts index 03aee5d30..b9ae20dfc 100644 --- a/src/interceptors/net/utils/flush-writes.ts +++ b/src/interceptors/net/utils/flush-writes.ts @@ -1,9 +1,9 @@ import net from 'node:net' export function unwrapPendingData( - data: net.Socket['_pendingData'], + data: NonNullable, callback: ( - chunk: string | Buffer | null, + chunk: string | Buffer, encoding?: BufferEncoding, callback?: (error?: Error | null) => void ) => void diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 2d392c454..27913ddb9 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -1,36 +1,40 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { - res.send('hello') + res.send('original') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() await httpServer.listen() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(async () => { interceptor.dispose() await httpServer.close() }) -it('emits correct events for a mocked HTTPS request', async () => { - interceptor.once('request', ({ controller }) => { +it.only('emits correct events for a mocked HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { controller.respondWith(new Response()) }) - const request = https.get('https://example.com') + const request = https.get('https://localhost/api', (response) => { + console.log('> RESPONSE') + }) const socketListener = vi.fn() const socketReadyListener = vi.fn() @@ -51,15 +55,14 @@ it('emits correct events for a mocked HTTPS request', async () => { await waitForClientRequest(request) - // Must emit the correct events for a TLS connection. - expect(socketListener).toHaveBeenCalledOnce() - expect(socketReadyListener).toHaveBeenCalledOnce() - expect(socketSecureListener).toHaveBeenCalledOnce() - expect(socketSecureConnectListener).toHaveBeenCalledOnce() - expect(socketSessionListener).toHaveBeenCalledTimes(2) - expect(socketSessionListener).toHaveBeenNthCalledWith(1, expect.any(Buffer)) - expect(socketSessionListener).toHaveBeenNthCalledWith(2, expect.any(Buffer)) - expect(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketListener).toHaveBeenCalledOnce() + expect.soft(socketReadyListener).toHaveBeenCalledOnce() + expect.soft(socketSecureListener).toHaveBeenCalledOnce() + expect.soft(socketSecureConnectListener).toHaveBeenCalledOnce() + expect + .soft(socketSessionListener) + .toHaveBeenCalledExactlyOnceWith(expect.any(Buffer)) + expect.soft(socketErrorListener).not.toHaveBeenCalled() }) it('emits correct events for a passthrough HTTPS request', async () => { @@ -86,13 +89,16 @@ it('emits correct events for a passthrough HTTPS request', async () => { await waitForClientRequest(request) - // Must emit the correct events for a TLS connection. - expect(socketListener).toHaveBeenCalledOnce() - expect(socketReadyListener).toHaveBeenCalledOnce() - expect(socketSecureListener).toHaveBeenCalledOnce() - expect(socketSecureConnectListener).toHaveBeenCalledOnce() - expect(socketSessionListener).toHaveBeenCalledTimes(2) - expect(socketSessionListener).toHaveBeenNthCalledWith(1, expect.any(Buffer)) - expect(socketSessionListener).toHaveBeenNthCalledWith(2, expect.any(Buffer)) - expect(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketListener).toHaveBeenCalledOnce() + expect.soft(socketReadyListener).toHaveBeenCalledOnce() + expect.soft(socketSecureListener).toHaveBeenCalledOnce() + expect.soft(socketSecureConnectListener).toHaveBeenCalledOnce() + expect.soft(socketSessionListener).toHaveBeenCalledTimes(2) + expect + .soft(socketSessionListener) + .toHaveBeenNthCalledWith(1, expect.any(Buffer)) + expect + .soft(socketSessionListener) + .toHaveBeenNthCalledWith(2, expect.any(Buffer)) + expect.soft(socketErrorListener).not.toHaveBeenCalled() }) From bdf21b4505700f44bdf157c8e76ee55d2c49c060 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 20 Feb 2026 20:13:50 +0100 Subject: [PATCH 021/198] docs: write down some findings about tls --- discoveries/https.mdx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 discoveries/https.mdx diff --git a/discoveries/https.mdx b/discoveries/https.mdx new file mode 100644 index 000000000..dc96f327e --- /dev/null +++ b/discoveries/https.mdx @@ -0,0 +1,25 @@ +# HTTPS/TLS + +## `tls.TLSSocket` + +### Handle + +Regular `net.Socket` connections finalize in the `afterConnect` callback [attached](https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/net.js#L1142) to the `TCPWrap` request. A TLS sockets\, while extending `net.Socket` and still relying on `afterConnect` being called to transition the socket into the connected state, _do not_ rely on `_handle.oncomplete`. **It is never called**. + +A TLS socket wraps `net.Socket._handle` in a `TLSWrap`, which becomes responsible for the connection. It calls the `afterConnect` callback _internally_, in the C++ code, and there's no public method to trigger it. + +> TLS wrap has its own methods, like `onhandshakedone` and `verifyError`. + +### Connection event sequence + +> For brevity: "wrap" means the TCP socket TLS extends, "socket" means the TLS socket. + +1. `tls.connect()`. +2. `new TLSSocket(options.socket)`. +3. `TLSSocket.prototype._wrapHandle` (wraps TCPWrap in TLSWrap). +4. Calls `socket._handle.start()` (or schedules it until wrap emits "connect"). +5. Wrap emits "connect". +6. Socket emits "connect". +7. Socket validates the connection (handshake, certificate validation). +7. Socket emits "secureConnect". +7. Socket emits "secure". From 06fe0400e17b9407ef44b564b2e594a2fe7e1a41 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 22 Feb 2026 18:57:15 +0100 Subject: [PATCH 022/198] feat(wip): implement `MockTlsSocketController` --- src/interceptors/http/index.ts | 4 +- src/interceptors/net/connection-controller.ts | 42 ++- src/interceptors/net/index.ts | 75 ++--- src/interceptors/net/mock-socket.ts | 298 +++++++++++++----- test/modules/http/compliance/https.test.ts | 34 +- 5 files changed, 286 insertions(+), 167 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index cf310df1f..97f8dd904 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -17,7 +17,7 @@ import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' -import { kClientSocket } from '../net/connection-controller' +import { kRawSocket } from '../net/connection-controller' const log = createLogger('HttpRequestInterceptor') @@ -80,7 +80,7 @@ export class HttpRequestInterceptor extends Interceptor { socket.once('connect', async () => { await this.respondWith({ - socket: connectionController[kClientSocket], + socket: connectionController[kRawSocket], request, response, }) diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index f16241a62..79440ceb8 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -1,6 +1,5 @@ import net from 'node:net' import { DeferredPromise } from '@open-draft/deferred-promise' -import { invariant } from 'outvariant' import { kMockState, kTlsSocket, MockSocket } from './mock-socket' import { unwrapPendingData } from './utils/flush-writes' @@ -41,7 +40,7 @@ declare module 'node:tls' { } } -interface TcpHandle { +export interface TcpHandle { open: (fd: unknown) => OperationStatus connect: (request: TcpWrap, address: string, port: number) => void connect6: (request: TcpWrap, address: string, port: number) => void @@ -63,9 +62,11 @@ interface TcpHandle { setKeepAlive?: (keepAlive: boolean, initialDelay: number) => void shutdown: (reqest: unknown /* ShutdownWrap */) => OperationStatus close: () => void + + _parent?: TcpHandle } -interface TcpWrap { +export interface TcpWrap { oncomplete: ( status: OperationStatus, owner: TcpHandle, @@ -75,33 +76,28 @@ interface TcpWrap { ) => void } -export const kClientSocket = Symbol('kClientSymbol') +export const kRawSocket = Symbol('kRawSocket') export class ConnectionController { #pendingRequest: DeferredPromise - private [kClientSocket]: MockSocket + private [kRawSocket]: MockSocket constructor( socket: MockSocket, private readonly createConnection: () => net.Socket ) { - this[kClientSocket] = socket + this[kRawSocket] = socket this.#pendingRequest = new DeferredPromise() - invariant( - socket._handle, - 'Failed to create a socket connection controller: socket._handle is missing' - ) - - socket._handle.readStart = () => console.log('\n\nYES\n\n') - - socket._handle.connect = (request) => { - this.#pendingRequest.resolve(request) - } - socket._handle.connect6 = (request) => { - this.#pendingRequest.resolve(request) - } + socket.prependListener('connectionAttempt', () => { + socket._handle.connect = (request) => { + this.#pendingRequest.resolve(request) + } + socket._handle.connect6 = (request) => { + this.#pendingRequest.resolve(request) + } + }) } /** @@ -110,7 +106,7 @@ export class ConnectionController { * connection with the remote address was successful. */ public claim(): void { - const clientSocket = this[kClientSocket] + const clientSocket = this[kRawSocket] clientSocket[kMockState] = MockSocket.MOCKED console.log('CLAIM!') @@ -125,7 +121,7 @@ export class ConnectionController { * @fixme This should be removed after MockTlsSocket is implemented. * [kClientSocket] should point to MockTlsSocket from the start, no nesting. */ - this[kClientSocket] = tlsSocket + this[kRawSocket] = tlsSocket this.#pendingRequest = new DeferredPromise() @@ -180,14 +176,14 @@ export class ConnectionController { * Abort this socket connection with an optional error. */ public errorWith(reason?: Error): void { - this[kClientSocket].destroy(reason) + this[kRawSocket].destroy(reason) } /** * Bypass this socket connection and perform it as-is. */ public passthrough(): net.Socket { - const clientSocket = this[kClientSocket] + const clientSocket = this[kRawSocket] clientSocket[kMockState] = MockSocket.PASSTHROUGH const realSocket = this.createConnection() diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index b85c62518..73e36b9a2 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -5,7 +5,11 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { MockSocket } from './mock-socket' +import { + MockSocket, + MockTlsSocketController, + toServerSocket, +} from './mock-socket' import { ConnectionController } from './connection-controller' import { createLogger } from '../../utils/logger' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' @@ -40,12 +44,12 @@ export class SocketInterceptor extends Interceptor { const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) - log('connect()') + log('net.connect()') log({ connectionOptions, connectionCallback }) - const clientSocket = new MockSocket(connectionOptions) + const socket = new MockSocket(connectionOptions) const controller = new ConnectionController( - clientSocket, + socket, function createConnection() { return realNetConnect(...args) } @@ -53,7 +57,7 @@ export class SocketInterceptor extends Interceptor { process.nextTick(() => { this.emitter.emit('connection', { - socket: clientSocket.createServerSocket(), + socket: toServerSocket(socket), controller, connectionOptions, }) @@ -63,65 +67,52 @@ export class SocketInterceptor extends Interceptor { if (connectionOptions.timeout) { log('set custom connection timeout:', connectionOptions.timeout) - clientSocket.setTimeout(connectionOptions.timeout) + socket.setTimeout(connectionOptions.timeout) } log('connecting the socket...') - return clientSocket.connect(connectionOptions, connectionCallback) + return socket.connect(connectionOptions, connectionCallback) } const realNetCreateConnection = net.createConnection net.createConnection = net.connect + /** + * TLS. + */ + const realTlsConnect = tls.connect tls.connect = (...args: [any, any]) => { const [tlsConnectionOptions, secureConnectionCallback] = normalizeTlsConnectArgs(args) - const clientSocket = new MockSocket(tlsConnectionOptions) - const serverSocket = clientSocket.createServerSocket() - const controller = new ConnectionController( - clientSocket, - function createTlsConnection() { - return realTlsConnect(...args) - } + tlsConnectionOptions.rejectUnauthorized = false + + const tlsSocket = realTlsConnect( + { + ...tlsConnectionOptions, + /** + * Suppress unauthorized connection errors to allow mocking connections to non-existing hosts. + * This prevents the "Error: Hostname/IP does not match certificate's altnames: Cert does not contain a DNS name" error. + * @note Passthrough scenarios will respect the original "rejectUnauthorized" option. + */ + rejectUnauthorized: false, + }, + secureConnectionCallback ) + const controller = new MockTlsSocketController(tlsSocket, () => { + return realTlsConnect(...args) + }) + process.nextTick(() => { this.emitter.emit('connection', { - /** - * @fixme This is incorrect. The TLSSocket has to be exposed here so the user can access its properties, - * like "encrypted", "secure", etc. I think I need to create a MockTlsSocket class after all. - * TLS handling is just DRAMATICALLY different from TCP. - */ - socket: serverSocket, + socket: controller.serverSocket, controller, connectionOptions: tlsConnectionOptions, }) }) - /** - * If "socket" is unset, "TLSSocket" will rely on "net.Socket.connect" to create one. - * This will cause the unpatched socket to be created, failing connections to non-existing hosts. - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L560 - */ - tlsConnectionOptions.socket = clientSocket - - /** - * @note Enable unauthorized requests by default, unless explicitly disabled. - * It's either this or asking the user to always provide a custom Agent that - * allows otherwise unauthorized requests (e.g. self-signed SSL, non-existing hosts). - * - * @todo @fixme Reconsider this. - */ - tlsConnectionOptions.rejectUnauthorized ??= false - - const tlsSocket = realTlsConnect( - tlsConnectionOptions, - secureConnectionCallback - ) - clientSocket.wrapTlsSocket(tlsSocket) - return tlsSocket } diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 59fbc01b9..1c101c53a 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,12 +1,13 @@ import net from 'node:net' import tls from 'node:tls' -import { invariant } from 'outvariant' import { toBuffer } from '../../utils/bufferUtils' import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' +import { invariant } from 'outvariant' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { kRawSocket, TcpHandle, TcpWrap } from './connection-controller' const kListenerWrap = Symbol('kListenerWrap') -const kTlsSocketWrapped = Symbol('kTlsSocketWrapped') export const kMockState = Symbol('kMockState') export const kTlsSocket = Symbol('kTlsSocket') @@ -24,16 +25,7 @@ export class MockSocket extends net.Socket { public connecting: boolean constructor(options: net.SocketConstructorOpts) { - super({ - ...options, - /** - * @note Providing a file descriptor triggers Node.js to create an appropriate handle for this socket. - * Initiate the handle earlier so we can mock its methods in the connection controller without waiting - * for the "connectionAttempt". - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/net.js#L424 - */ - fd: options.fd || 1, - }) + super(options) this[kMockState] = 0 @@ -99,107 +91,241 @@ export class MockSocket extends net.Socket { super._writeGeneric(writev, data, encoding, callback) } +} - /** - * Create a proxy `net.Socket` instance that represents the intercepted socket server-side. - * This is the reference exposed as `socket` in the connection listener. This proxy allows - * the user to interact with `socket` from the server's perspective (e.g. `socket.write()` - * on the server translates to the `socket.push()` on the client). - */ - public createServerSocket(): net.Socket { - return new Proxy(this, { - get: (target, property, receiver) => { - const getRealValue = () => { - return Reflect.get(target, property, receiver) - } - - if (property === 'on' || property === 'addListener') { - const realAddListener = getRealValue() as net.Socket['addListener'] +/** + * Create a proxy `net.Socket` instance that represents the intercepted socket server-side. + * This is the reference exposed as `socket` in the connection listener. This proxy allows + * the user to interact with `socket` from the server's perspective (e.g. `socket.write()` + * on the server translates to the `socket.push()` on the client). + */ +export function toServerSocket(socket: T): T { + return new Proxy(socket, { + get: (target, property, receiver) => { + const getRealValue = () => { + return Reflect.get(target, property, receiver) + } - return (event: any, listener: (...args: Array) => void) => { - if (event === 'data') { - const listenerWrap = (chunk: any, encoding?: BufferEncoding) => { - listener(toBuffer(chunk, encoding)) - } + if (property === 'on' || property === 'addListener') { + const realAddListener = getRealValue() as net.Socket['addListener'] - Object.defineProperty(listener, kListenerWrap, { - enumerable: false, - writable: false, - value: listenerWrap, - }) + return (event: any, listener: (...args: Array) => void) => { + if (event === 'data') { + const listenerWrap = (chunk: any, encoding?: BufferEncoding) => { + listener(toBuffer(chunk, encoding)) + } - this.on('internal:write', listenerWrap) + Object.defineProperty(listener, kListenerWrap, { + enumerable: false, + writable: false, + value: listenerWrap, + }) - return target - } + socket.on('internal:write', listenerWrap) - return realAddListener.call(target, event, listener) + return target } + + return realAddListener.call(target, event, listener) } + } - if (property === 'off' || property === 'removeListener') { - const realRemoveListener = - getRealValue() as net.Socket['removeListener'] + if (property === 'off' || property === 'removeListener') { + const realRemoveListener = + getRealValue() as net.Socket['removeListener'] - return (event: string, listener: any) => { - if (event === 'data') { - const listenerWrap = listener[kListenerWrap] + return (event: string, listener: any) => { + if (event === 'data') { + const listenerWrap = listener[kListenerWrap] - if (listenerWrap) { - return realRemoveListener.call(target, event, listenerWrap) - } + if (listenerWrap) { + return realRemoveListener.call(target, event, listenerWrap) } - - return realRemoveListener.call(target, event, listener) } + + return realRemoveListener.call(target, event, listener) } + } - // Push data to the client socket when server "socket.write()" is called. - if (property === 'write') { - return ( - chunk: any, - encoding: BufferEncoding, - callback: (error?: Error | null) => void - ) => { - this.push(toBuffer(chunk, encoding), encoding) - callback() - } + // Push data to the client socket when server "socket.write()" is called. + if (property === 'write') { + return ( + chunk: any, + encoding: BufferEncoding, + callback: (error?: Error | null) => void + ) => { + socket.push(toBuffer(chunk, encoding), encoding) + callback() } + } - return getRealValue() - }, - }) + return getRealValue() + }, + }) +} + +abstract class SocketController { + static PENDING = 0 as const + static MOCKED = 1 as const + static PASSTHROUGH = 2 as const + + protected readyState: + | typeof SocketController.PENDING + | typeof SocketController.MOCKED + | typeof SocketController.PASSTHROUGH + + constructor() { + this.readyState = SocketController.PENDING } - public wrapTlsSocket(tlsSocket: tls.TLSSocket): void { - invariant( - Reflect.get(tlsSocket, kTlsSocketWrapped) == null, - 'Failed to wrap a TLSSocket: already wrapped. This is likely an issue with MSW. Please report it on GitHub.' - ) + public abstract claim(): void + public abstract passthrough(): T +} + +export class MockTlsSocketController extends SocketController { + public serverSocket: tls.TLSSocket + private [kRawSocket]: tls.TLSSocket + + #pendingConnection: DeferredPromise<[TcpWrap, TcpHandle]> + + constructor( + private readonly socket: tls.TLSSocket, + private readonly createConnection: () => tls.TLSSocket + ) { + super() + + this.#pendingConnection = new DeferredPromise() - this[kTlsSocket] = tlsSocket + // Implement the read method to prevent the "Error: read ENOTCONN" errors on non-existing hosts. + this.socket._read = () => {} + + this.socket.prependOnceListener('connectionAttempt', () => { + console.log('>> CONNECTION ATTEMPT') + + const tlsHandle = this.socket._handle + const tcpHandle = tlsHandle._parent + + if (tcpHandle == null) { + return + } - const realTlsSocketWriteGeneric = tlsSocket._writeGeneric - tlsSocket._writeGeneric = (...args) => { - const pendingData = args[1] + tcpHandle.connect = tcpHandle.connect6 = (request) => { + this.#pendingConnection.resolve([request, tcpHandle]) + } + }) - if (this[kMockState] === MockSocket.PENDING) { - console.log('>> tlsSocket._writeGeneric FORWARD:', pendingData) + const realWriteGeneric = this.socket._writeGeneric - unwrapPendingData(pendingData, (chunk, encoding) => { - /** - * @note Emit the internal write event, which triggers the "data" event on the server socket. - * This allows the user to listen to outgoing TLS connections before the handshake runs. - * Normally, TLSSocket buffers the writes until the "secure" event is emitted and it doesn't - * forward those writes to the "clientSocket" for the client -> server proxy to trigger. - */ - this.emit('internal:write', chunk, encoding) + this.socket._writeGeneric = (...args) => { + if (this.readyState === SocketController.PENDING) { + unwrapPendingData(args[1], (chunk, encoding) => { + this.socket.emit('internal:write', chunk, encoding) }) } - return realTlsSocketWriteGeneric.apply(tlsSocket, args) + return realWriteGeneric.apply(this.socket, args) } - Reflect.set(tlsSocket, kTlsSocketWrapped, true) + this[kRawSocket] = socket + this.serverSocket = toServerSocket(this.socket) + } + + public claim(): void { + invariant( + this.readyState !== SocketController.MOCKED, + 'Failed to claim a TLSSocket: already claimed' + ) + + this.readyState = SocketController.MOCKED + + console.log('CLAIM!') + + this.#pendingConnection.then(([request, handle]) => { + /** + * Mock this to prevent the "Error: Worker exited unexpectedly" error. + * This will trigger when "secure" is emitted. + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 + */ + this.socket._handle.verifyError = () => void 0 + + this.socket._handle.start = () => { + /** + * Mock successful SSL handshake completion. + * This will emit "secureConnect" and "secure" on the TLS socket and trigger "tlsSocket._finishInit". + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L878 + */ + this.socket._handle.onhandshakedone() + this.socket._handle.onnewsession(1, Buffer.alloc(0)) + } + + /** + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 + */ + request.oncomplete(0, handle, request, true, true) + }) + } + + public passthrough(): tls.TLSSocket { + invariant( + this.readyState !== SocketController.PASSTHROUGH, + 'Failed to passthrough a TLSSocket: already passthrough' + ) + + this.readyState = SocketController.PASSTHROUGH + + console.log('PASSTHROUGH!') + + const realSocket = this.createConnection() + + this.socket.on('drain', () => realSocket.resume()) + + this.socket.write = realSocket.write.bind(realSocket) + this.socket.read = realSocket.read.bind(realSocket) + + realSocket + .once('connect', () => { + this.socket._handle = realSocket._handle + + /** + * @note Remove the internal "connect" listener added when the mock socket was created. + * If preserved, that connect will prevent the mock socket from transitioning into the + * connected state. + * + * This prevents the following error: + * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo &) at ../src/crypto/crypto_tls.cc:589 + # Assertion failed: !wrap->started_ + */ + this.socket.removeListener('connect', this.socket._start) + + this.socket.connecting = false + this.socket.emit('connect') + this.socket.emit('ready') + }) + .on('secure', () => { + this.socket.emit('secure') + }) + .on('session', (...args) => { + this.socket.emit('session', ...args) + }) + .on('secureConnect', () => { + if (this.socket._pendingData) { + unwrapPendingData(this.socket._pendingData, (chunk, encoding) => { + realSocket.write(chunk, encoding) + }) + } + }) + .on('data', (data) => { + if (!this.socket.push(data)) { + realSocket.pause() + } + }) + .on('error', (error) => { + this.socket.destroy(error) + }) + .on('end', () => { + this.socket.push(null) + }) + + return realSocket } } diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 27913ddb9..9fb7a43c9 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -27,16 +27,15 @@ afterAll(async () => { await httpServer.close() }) -it.only('emits correct events for a mocked HTTPS request', async () => { +it('emits correct events for a mocked HTTPS request', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith(new Response()) }) - const request = https.get('https://localhost/api', (response) => { - console.log('> RESPONSE') - }) + const request = https.get('https://localhost/api') const socketListener = vi.fn() + const socketConnectListener = vi.fn() const socketReadyListener = vi.fn() const socketSecureListener = vi.fn() const socketSecureConnectListener = vi.fn() @@ -46,17 +45,20 @@ it.only('emits correct events for a mocked HTTPS request', async () => { request.on('socket', (socket) => { socketListener(socket) - socket.on('ready', socketReadyListener) - socket.on('secure', socketSecureListener) - socket.on('secureConnect', socketSecureConnectListener) - socket.on('session', socketSessionListener) - socket.on('error', socketErrorListener) + socket + .on('connect', socketConnectListener) + .on('ready', socketReadyListener) + .on('secure', socketSecureListener) + .on('secureConnect', socketSecureConnectListener) + .on('session', socketSessionListener) + .on('error', socketErrorListener) }) await waitForClientRequest(request) expect.soft(socketListener).toHaveBeenCalledOnce() expect.soft(socketReadyListener).toHaveBeenCalledOnce() + expect.soft(socketConnectListener).toHaveBeenCalledOnce() expect.soft(socketSecureListener).toHaveBeenCalledOnce() expect.soft(socketSecureConnectListener).toHaveBeenCalledOnce() expect @@ -71,6 +73,7 @@ it('emits correct events for a passthrough HTTPS request', async () => { }) const socketListener = vi.fn() + const socketConnectListener = vi.fn() const socketReadyListener = vi.fn() const socketSecureListener = vi.fn() const socketSecureConnectListener = vi.fn() @@ -80,16 +83,19 @@ it('emits correct events for a passthrough HTTPS request', async () => { request.on('socket', (socket) => { socketListener(socket) - socket.on('ready', socketReadyListener) - socket.on('secure', socketSecureListener) - socket.on('secureConnect', socketSecureConnectListener) - socket.on('session', socketSessionListener) - socket.on('error', socketErrorListener) + socket + .on('connect', socketConnectListener) + .on('ready', socketReadyListener) + .on('secure', socketSecureListener) + .on('secureConnect', socketSecureConnectListener) + .on('session', socketSessionListener) + .on('error', socketErrorListener) }) await waitForClientRequest(request) expect.soft(socketListener).toHaveBeenCalledOnce() + expect.soft(socketConnectListener).toHaveBeenCalledOnce() expect.soft(socketReadyListener).toHaveBeenCalledOnce() expect.soft(socketSecureListener).toHaveBeenCalledOnce() expect.soft(socketSecureConnectListener).toHaveBeenCalledOnce() From 4f80cd2df5938e66eca1a668dca8e13334c4cc8b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 22 Feb 2026 19:08:15 +0100 Subject: [PATCH 023/198] chore: implement tls controller by extending tcp controller --- src/interceptors/http/index.ts | 6 - src/interceptors/net/connection-controller.ts | 1 + src/interceptors/net/index.ts | 10 +- src/interceptors/net/mock-socket.ts | 116 ++++++++++-------- 4 files changed, 70 insertions(+), 63 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 97f8dd904..f6ce6a53f 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -53,8 +53,6 @@ export class HttpRequestInterceptor extends Interceptor { log('handling first frame...', { firstFrame, httpMethod, baseUrl }) - console.log('HTTP INTERCEPTOR!', firstFrame) - const requestParser = new HttpRequestParser({ connectionOptions: { method: httpMethod, @@ -183,8 +181,6 @@ export class HttpRequestInterceptor extends Interceptor { // This will trigger the "response" event in "ClientRequest". socket.push(Buffer.from(httpMessageHeaders)) - console.log('pushing mocked response...', httpMessageHeaders) - if (response.body) { try { const reader = response.body.getReader() @@ -235,8 +231,6 @@ export class HttpRequestInterceptor extends Interceptor { log('response stream handling done!') } - console.log('response stream done') - if (isChunkedEncoding) { socket.push(Buffer.from('0\r\n\r\n')) } diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts index 79440ceb8..38702f892 100644 --- a/src/interceptors/net/connection-controller.ts +++ b/src/interceptors/net/connection-controller.ts @@ -26,6 +26,7 @@ declare module 'node:net' { callback?: (error?: Error | null) => void ): void _handle: TcpHandle + _start: () => void } } diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 73e36b9a2..4f59e9785 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -5,11 +5,7 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { - MockSocket, - MockTlsSocketController, - toServerSocket, -} from './mock-socket' +import { MockSocket, TlsSocketController, toServerSocket } from './mock-socket' import { ConnectionController } from './connection-controller' import { createLogger } from '../../utils/logger' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' @@ -17,7 +13,7 @@ import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' interface SocketEventMap { connection: [ { - socket: net.Socket + socket: net.Socket | tls.TLSSocket controller: ConnectionController connectionOptions: NetworkConnectionOptions }, @@ -101,7 +97,7 @@ export class SocketInterceptor extends Interceptor { secureConnectionCallback ) - const controller = new MockTlsSocketController(tlsSocket, () => { + const controller = new TlsSocketController(tlsSocket, () => { return realTlsConnect(...args) }) diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 1c101c53a..71ece5f1d 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -6,6 +6,7 @@ import { unwrapPendingData } from './utils/flush-writes' import { invariant } from 'outvariant' import { DeferredPromise } from '@open-draft/deferred-promise' import { kRawSocket, TcpHandle, TcpWrap } from './connection-controller' +import EventEmitter from 'node:events' const kListenerWrap = Symbol('kListenerWrap') @@ -164,7 +165,7 @@ export function toServerSocket(socket: T): T { }) } -abstract class SocketController { +abstract class SocketController extends EventEmitter { static PENDING = 0 as const static MOCKED = 1 as const static PASSTHROUGH = 2 as const @@ -175,6 +176,7 @@ abstract class SocketController { | typeof SocketController.PASSTHROUGH constructor() { + super() this.readyState = SocketController.PENDING } @@ -182,26 +184,20 @@ abstract class SocketController { public abstract passthrough(): T } -export class MockTlsSocketController extends SocketController { - public serverSocket: tls.TLSSocket - private [kRawSocket]: tls.TLSSocket - - #pendingConnection: DeferredPromise<[TcpWrap, TcpHandle]> +export class TcpSocketController extends SocketController { + public serverSocket: net.Socket + private [kRawSocket]: net.Socket constructor( - private readonly socket: tls.TLSSocket, - private readonly createConnection: () => tls.TLSSocket + protected readonly socket: net.Socket, + protected readonly createConnection: () => net.Socket ) { super() - this.#pendingConnection = new DeferredPromise() - // Implement the read method to prevent the "Error: read ENOTCONN" errors on non-existing hosts. - this.socket._read = () => {} + this.socket._read = () => void 0 this.socket.prependOnceListener('connectionAttempt', () => { - console.log('>> CONNECTION ATTEMPT') - const tlsHandle = this.socket._handle const tcpHandle = tlsHandle._parent @@ -210,7 +206,8 @@ export class MockTlsSocketController extends SocketController { } tcpHandle.connect = tcpHandle.connect6 = (request) => { - this.#pendingConnection.resolve([request, tcpHandle]) + this.emit('internal:connect', request, tcpHandle) + // this.#pendingConnection.resolve([request, tcpHandle]) } }) @@ -233,31 +230,12 @@ export class MockTlsSocketController extends SocketController { public claim(): void { invariant( this.readyState !== SocketController.MOCKED, - 'Failed to claim a TLSSocket: already claimed' + 'Failed to claim a TLS socket: already claimed' ) this.readyState = SocketController.MOCKED - console.log('CLAIM!') - - this.#pendingConnection.then(([request, handle]) => { - /** - * Mock this to prevent the "Error: Worker exited unexpectedly" error. - * This will trigger when "secure" is emitted. - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 - */ - this.socket._handle.verifyError = () => void 0 - - this.socket._handle.start = () => { - /** - * Mock successful SSL handshake completion. - * This will emit "secureConnect" and "secure" on the TLS socket and trigger "tlsSocket._finishInit". - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L878 - */ - this.socket._handle.onhandshakedone() - this.socket._handle.onnewsession(1, Buffer.alloc(0)) - } - + this.on('internal:connect', (request: TcpWrap, handle: TcpHandle) => { /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 */ @@ -265,16 +243,14 @@ export class MockTlsSocketController extends SocketController { }) } - public passthrough(): tls.TLSSocket { + public passthrough(): net.Socket { invariant( this.readyState !== SocketController.PASSTHROUGH, - 'Failed to passthrough a TLSSocket: already passthrough' + 'Failed to passthrough a TLS socket: already passthrough' ) this.readyState = SocketController.PASSTHROUGH - console.log('PASSTHROUGH!') - const realSocket = this.createConnection() this.socket.on('drain', () => realSocket.resume()) @@ -301,6 +277,57 @@ export class MockTlsSocketController extends SocketController { this.socket.emit('connect') this.socket.emit('ready') }) + .on('data', (data) => { + if (!this.socket.push(data)) { + realSocket.pause() + } + }) + .on('error', (error) => { + this.socket.destroy(error) + }) + .on('end', () => { + this.socket.push(null) + }) + + return realSocket + } +} + +export class TlsSocketController extends TcpSocketController { + constructor( + protected readonly socket: tls.TLSSocket, + protected readonly createConnection: () => tls.TLSSocket + ) { + super(socket, createConnection) + } + + public claim(): void { + this.prependListener('internal:connect', () => { + /** + * Mock this to prevent the "Error: Worker exited unexpectedly" error. + * This will trigger when "secure" is emitted. + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 + */ + this.socket._handle.verifyError = () => void 0 + + this.socket._handle.start = () => { + /** + * Mock a successful SSL handshake. + * This will emit "secureConnect" and "secure" on the TLS socket and trigger "tlsSocket._finishInit". + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L878 + */ + this.socket._handle.onhandshakedone() + this.socket._handle.onnewsession(1, Buffer.alloc(0)) + } + }) + + super.claim() + } + + public passthrough(): tls.TLSSocket { + const realSocket = super.passthrough() as tls.TLSSocket + + realSocket .on('secure', () => { this.socket.emit('secure') }) @@ -314,17 +341,6 @@ export class MockTlsSocketController extends SocketController { }) } }) - .on('data', (data) => { - if (!this.socket.push(data)) { - realSocket.pause() - } - }) - .on('error', (error) => { - this.socket.destroy(error) - }) - .on('end', () => { - this.socket.push(null) - }) return realSocket } From 4c505d5d39421c15041992bb81351495aafacfd7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 22 Feb 2026 19:25:44 +0100 Subject: [PATCH 024/198] fix: proper tcp and tls handling --- src/interceptors/net/index.ts | 28 +++--- src/interceptors/net/mock-socket.ts | 93 +++++++++++-------- test/modules/http/response/http-https.test.ts | 4 +- 3 files changed, 66 insertions(+), 59 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 4f59e9785..f3079b06c 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -5,8 +5,11 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { MockSocket, TlsSocketController, toServerSocket } from './mock-socket' -import { ConnectionController } from './connection-controller' +import { + SocketController, + TcpSocketController, + TlsSocketController, +} from './mock-socket' import { createLogger } from '../../utils/logger' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' @@ -14,7 +17,7 @@ interface SocketEventMap { connection: [ { socket: net.Socket | tls.TLSSocket - controller: ConnectionController + controller: SocketController connectionOptions: NetworkConnectionOptions }, ] @@ -32,10 +35,6 @@ export class SocketInterceptor extends Interceptor { protected setup(): void { const realNetConnect = net.connect - /** - * Luckily, "net.connect()" is rather short and we can replicate it as-is. - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L236 - */ net.connect = (...args: [any, any]) => { const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) @@ -43,17 +42,14 @@ export class SocketInterceptor extends Interceptor { log('net.connect()') log({ connectionOptions, connectionCallback }) - const socket = new MockSocket(connectionOptions) - const controller = new ConnectionController( - socket, - function createConnection() { - return realNetConnect(...args) - } - ) + const socket = realNetConnect(...args) + const controller = new TcpSocketController(socket, () => { + return realNetConnect(...args) + }) process.nextTick(() => { this.emitter.emit('connection', { - socket: toServerSocket(socket), + socket: controller.serverSocket, controller, connectionOptions, }) @@ -67,7 +63,7 @@ export class SocketInterceptor extends Interceptor { } log('connecting the socket...') - return socket.connect(connectionOptions, connectionCallback) + return socket } const realNetCreateConnection = net.createConnection diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 71ece5f1d..d621e381c 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,12 +1,11 @@ import net from 'node:net' import tls from 'node:tls' +import EventEmitter from 'node:events' +import { invariant } from 'outvariant' import { toBuffer } from '../../utils/bufferUtils' import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' -import { invariant } from 'outvariant' -import { DeferredPromise } from '@open-draft/deferred-promise' import { kRawSocket, TcpHandle, TcpWrap } from './connection-controller' -import EventEmitter from 'node:events' const kListenerWrap = Symbol('kListenerWrap') @@ -100,7 +99,7 @@ export class MockSocket extends net.Socket { * the user to interact with `socket` from the server's perspective (e.g. `socket.write()` * on the server translates to the `socket.push()` on the client). */ -export function toServerSocket(socket: T): T { +function toServerSocket(socket: T): T { return new Proxy(socket, { get: (target, property, receiver) => { const getRealValue = () => { @@ -165,7 +164,7 @@ export function toServerSocket(socket: T): T { }) } -abstract class SocketController extends EventEmitter { +export abstract class SocketController extends EventEmitter { static PENDING = 0 as const static MOCKED = 1 as const static PASSTHROUGH = 2 as const @@ -177,14 +176,30 @@ abstract class SocketController extends EventEmitter { constructor() { super() + this.readyState = SocketController.PENDING } - public abstract claim(): void - public abstract passthrough(): T + public claim(): void { + invariant( + this.readyState !== SocketController.MOCKED, + 'Failed to claim a TLS socket: already claimed' + ) + + this.readyState = SocketController.MOCKED + } + + public passthrough() { + invariant( + this.readyState !== SocketController.PASSTHROUGH, + 'Failed to passthrough a TLS socket: already passthrough' + ) + + this.readyState = SocketController.PASSTHROUGH + } } -export class TcpSocketController extends SocketController { +export class TcpSocketController extends SocketController { public serverSocket: net.Socket private [kRawSocket]: net.Socket @@ -207,7 +222,6 @@ export class TcpSocketController extends SocketController { tcpHandle.connect = tcpHandle.connect6 = (request) => { this.emit('internal:connect', request, tcpHandle) - // this.#pendingConnection.resolve([request, tcpHandle]) } }) @@ -228,12 +242,7 @@ export class TcpSocketController extends SocketController { } public claim(): void { - invariant( - this.readyState !== SocketController.MOCKED, - 'Failed to claim a TLS socket: already claimed' - ) - - this.readyState = SocketController.MOCKED + super.claim() this.on('internal:connect', (request: TcpWrap, handle: TcpHandle) => { /** @@ -244,12 +253,7 @@ export class TcpSocketController extends SocketController { } public passthrough(): net.Socket { - invariant( - this.readyState !== SocketController.PASSTHROUGH, - 'Failed to passthrough a TLS socket: already passthrough' - ) - - this.readyState = SocketController.PASSTHROUGH + super.passthrough() const realSocket = this.createConnection() @@ -258,21 +262,16 @@ export class TcpSocketController extends SocketController { this.socket.write = realSocket.write.bind(realSocket) this.socket.read = realSocket.read.bind(realSocket) + if (this.socket._pendingData) { + unwrapPendingData(this.socket._pendingData, (chunk, encoding) => { + realSocket.write(chunk, encoding) + }) + } + realSocket .once('connect', () => { this.socket._handle = realSocket._handle - /** - * @note Remove the internal "connect" listener added when the mock socket was created. - * If preserved, that connect will prevent the mock socket from transitioning into the - * connected state. - * - * This prevents the following error: - * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo &) at ../src/crypto/crypto_tls.cc:589 - # Assertion failed: !wrap->started_ - */ - this.socket.removeListener('connect', this.socket._start) - this.socket.connecting = false this.socket.emit('connect') this.socket.emit('ready') @@ -302,6 +301,8 @@ export class TlsSocketController extends TcpSocketController { } public claim(): void { + super.claim() + this.prependListener('internal:connect', () => { /** * Mock this to prevent the "Error: Worker exited unexpectedly" error. @@ -320,27 +321,37 @@ export class TlsSocketController extends TcpSocketController { this.socket._handle.onnewsession(1, Buffer.alloc(0)) } }) - - super.claim() } public passthrough(): tls.TLSSocket { + /** + * @note Clear the buffered writes to prevent flushing them to the real socket. + * That flush is required for TCP, but TLS flushes the buffer automatically, somehow. + */ + this.socket._pendingData = null + this.socket._pendingEncoding = null + const realSocket = super.passthrough() as tls.TLSSocket realSocket + .prependOnceListener('connect', () => { + /** + * @note Remove the internal "connect" listener added when the mock socket was created. + * If preserved, that connect will prevent the mock socket from transitioning into the + * connected state. + * + * This prevents the following error: + * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo &) at ../src/crypto/crypto_tls.cc:589 + # Assertion failed: !wrap->started_ + */ + this.socket.removeListener('connect', this.socket._start) + }) .on('secure', () => { this.socket.emit('secure') }) .on('session', (...args) => { this.socket.emit('session', ...args) }) - .on('secureConnect', () => { - if (this.socket._pendingData) { - unwrapPendingData(this.socket._pendingData, (chunk, encoding) => { - realSocket.write(chunk, encoding) - }) - } - }) return realSocket } diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 91f3ba47c..2f778b4ae 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -2,7 +2,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -14,7 +14,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) From 330cd37a1c423096a6276be4e213425fd9012967 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 22 Feb 2026 20:54:20 +0100 Subject: [PATCH 025/198] fix(wip): mocked tcp/tls scenarios --- src/interceptors/net/index.ts | 9 +- src/interceptors/net/mock-socket.ts | 167 +++++------------- test/modules/http/response/http-https.test.ts | 21 +-- 3 files changed, 61 insertions(+), 136 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index f3079b06c..13f617bdd 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -42,7 +42,7 @@ export class SocketInterceptor extends Interceptor { log('net.connect()') log({ connectionOptions, connectionCallback }) - const socket = realNetConnect(...args) + const socket = new net.Socket() const controller = new TcpSocketController(socket, () => { return realNetConnect(...args) }) @@ -57,13 +57,8 @@ export class SocketInterceptor extends Interceptor { log('emitted "connection" event!') }) - if (connectionOptions.timeout) { - log('set custom connection timeout:', connectionOptions.timeout) - socket.setTimeout(connectionOptions.timeout) - } - log('connecting the socket...') - return socket + return socket.connect(connectionOptions, connectionCallback) } const realNetCreateConnection = net.createConnection diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index d621e381c..b6c17e45c 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -6,6 +6,8 @@ import { toBuffer } from '../../utils/bufferUtils' import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' import { kRawSocket, TcpHandle, TcpWrap } from './connection-controller' +import { SocketInterceptor } from '.' +import { DeferredPromise } from '@open-draft/deferred-promise' const kListenerWrap = Symbol('kListenerWrap') @@ -14,85 +16,6 @@ export const kTlsSocket = Symbol('kTlsSocket') const log = createLogger('MockSocket') -export class MockSocket extends net.Socket { - static PENDING = 0 as const - static MOCKED = 1 as const - static PASSTHROUGH = 2 as const - - private [kMockState]: 0 | 1 | 2 - private [kTlsSocket]?: tls.TLSSocket - - public connecting: boolean - - constructor(options: net.SocketConstructorOpts) { - super(options) - - this[kMockState] = 0 - - /** - * @note Start the socket in the connecting state. - * This will make Node.js buffer any writes to this socket automatically. - */ - this.connecting = true - - log('constructed new instance') - } - - _read(size: number): void { - log('read', size) - } - - /** - * Override "_writeGeneric" to benefit from built-in chunk buffering in Node.js. - * That's also the baseline method for both "write" and "writev". - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L994 - */ - _writeGeneric( - writev: boolean, - data: Array | any, - encoding: BufferEncoding, - callback?: ((error?: Error | null) => void) | undefined - ): void { - log({ connecting: this.connecting, data, encoding, callback }, 'write') - - const emitWrite = () => { - unwrapPendingData(data, (chunk, encoding) => { - this.emit('internal:write', chunk, encoding) - }) - } - - // While connecting, the socket is in ambiguous state. - // Buffer the writes using Node's existing buffering logic. - if (this.connecting) { - super._writeGeneric(writev, data, encoding, callback) - emitWrite() - return - } - - if (this[kMockState] === MockSocket.MOCKED) { - /** - * Handle "_writeGeneric" calls scheduled after the "connect" event. - * These are writes performed while connecting, and for the mocked socket - * they must be ignored. There's nowhere to flush them. Calling "_writeGeneric" - * past this point will result in "Error: write EBADF". - * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 - */ - if (this._pendingData) { - log(this._pendingData, 'mocked connection, clearing write buffer') - - this._pendingData = null - this._pendingEncoding = null - return - } - - emitWrite() - return - } - - super._writeGeneric(writev, data, encoding, callback) - } -} - /** * Create a proxy `net.Socket` instance that represents the intercepted socket server-side. * This is the reference exposed as `socket` in the connection listener. This proxy allows @@ -164,7 +87,7 @@ function toServerSocket(socket: T): T { }) } -export abstract class SocketController extends EventEmitter { +export abstract class SocketController { static PENDING = 0 as const static MOCKED = 1 as const static PASSTHROUGH = 2 as const @@ -175,8 +98,6 @@ export abstract class SocketController extends EventEmitter { | typeof SocketController.PASSTHROUGH constructor() { - super() - this.readyState = SocketController.PENDING } @@ -203,37 +124,57 @@ export class TcpSocketController extends SocketController { public serverSocket: net.Socket private [kRawSocket]: net.Socket + protected pendingConnection: DeferredPromise<[TcpWrap, TcpHandle]> + constructor( protected readonly socket: net.Socket, protected readonly createConnection: () => net.Socket ) { super() + this.pendingConnection = new DeferredPromise() + // Implement the read method to prevent the "Error: read ENOTCONN" errors on non-existing hosts. this.socket._read = () => void 0 this.socket.prependOnceListener('connectionAttempt', () => { - const tlsHandle = this.socket._handle - const tcpHandle = tlsHandle._parent + const handle = this.socket._handle - if (tcpHandle == null) { - return - } - - tcpHandle.connect = tcpHandle.connect6 = (request) => { - this.emit('internal:connect', request, tcpHandle) + handle.connect = handle.connect6 = (request) => { + this.pendingConnection.resolve([request, handle]) } }) const realWriteGeneric = this.socket._writeGeneric this.socket._writeGeneric = (...args) => { - if (this.readyState === SocketController.PENDING) { + const emitWrite = () => { unwrapPendingData(args[1], (chunk, encoding) => { this.socket.emit('internal:write', chunk, encoding) }) } + if (this.readyState === SocketController.PENDING) { + emitWrite() + return realWriteGeneric.apply(this.socket, args) + } + + if (this.readyState === SocketController.MOCKED) { + /** + * Handle "_writeGeneric" calls scheduled after the "connect" event. + * These are writes performed while connecting, and for the mocked socket + * they must be ignored. There's nowhere to flush them. Calling "_writeGeneric" + * past this point will result in "Error: write EBADF". + * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 + */ + if (this.socket._pendingData) { + return + } + + emitWrite() + return + } + return realWriteGeneric.apply(this.socket, args) } @@ -244,7 +185,7 @@ export class TcpSocketController extends SocketController { public claim(): void { super.claim() - this.on('internal:connect', (request: TcpWrap, handle: TcpHandle) => { + this.pendingConnection.then(([request, handle]) => { /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 */ @@ -262,12 +203,6 @@ export class TcpSocketController extends SocketController { this.socket.write = realSocket.write.bind(realSocket) this.socket.read = realSocket.read.bind(realSocket) - if (this.socket._pendingData) { - unwrapPendingData(this.socket._pendingData, (chunk, encoding) => { - realSocket.write(chunk, encoding) - }) - } - realSocket .once('connect', () => { this.socket._handle = realSocket._handle @@ -301,9 +236,9 @@ export class TlsSocketController extends TcpSocketController { } public claim(): void { - super.claim() - - this.prependListener('internal:connect', () => { + // Add this callback before "super.claim()" so it executes first. + // TLSWrap methods have to be patched before TCPWrap fires "oncomplete". + this.pendingConnection.then(() => { /** * Mock this to prevent the "Error: Worker exited unexpectedly" error. * This will trigger when "secure" is emitted. @@ -321,31 +256,25 @@ export class TlsSocketController extends TcpSocketController { this.socket._handle.onnewsession(1, Buffer.alloc(0)) } }) + + super.claim() } public passthrough(): tls.TLSSocket { + const realSocket = super.passthrough() as tls.TLSSocket + /** - * @note Clear the buffered writes to prevent flushing them to the real socket. - * That flush is required for TCP, but TLS flushes the buffer automatically, somehow. + * @note Remove the internal "connect" listener added when the mock socket was created. + * If preserved, that connect will prevent the mock socket from transitioning into the + * connected state. + * + * This prevents the following error: + * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo &) at ../src/crypto/crypto_tls.cc:589 + # Assertion failed: !wrap->started_ */ - this.socket._pendingData = null - this.socket._pendingEncoding = null - - const realSocket = super.passthrough() as tls.TLSSocket + this.socket.removeListener('connect', this.socket._start) realSocket - .prependOnceListener('connect', () => { - /** - * @note Remove the internal "connect" listener added when the mock socket was created. - * If preserved, that connect will prevent the mock socket from transitioning into the - * connected state. - * - * This prevents the following error: - * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo &) at ../src/crypto/crypto_tls.cc:589 - # Assertion failed: !wrap->started_ - */ - this.socket.removeListener('connect', this.socket._start) - }) .on('secure', () => { this.socket.emit('secure') }) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 2f778b4ae..d4a8ccb0d 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import https from 'node:https' @@ -57,10 +58,10 @@ 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 () => { +it.only('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) @@ -71,7 +72,7 @@ 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 () => { @@ -82,7 +83,7 @@ it('bypasses an unhandled request issued by "http.get"', async () => { 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 () => { @@ -95,7 +96,7 @@ it('bypasses an unhandled request issued by "https.get"', async () => { 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 () => { @@ -106,7 +107,7 @@ it('responds to a handled request issued by "http.request"', async () => { 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 () => { @@ -122,7 +123,7 @@ 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 () => { @@ -134,7 +135,7 @@ it('bypasses an unhandled request issued by "http.request"', async () => { 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 () => { @@ -148,7 +149,7 @@ it('bypasses an unhandled request issued by "https.request"', async () => { 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 () => { @@ -168,5 +169,5 @@ it('bypasses any request after the interceptor was restored', async () => { statusCode: 200, statusMessage: 'OK', }) - expect(await text()).toEqual('/') + await expect(text()).resolves.toEqual('/') }) From 8164d4a63ffc35420e4b7dab140f69ef3cc3c7d6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 22 Feb 2026 21:26:24 +0100 Subject: [PATCH 026/198] fix(tcp): use 127.0.0.1 to bypass dns lookup for mocked connections --- src/interceptors/net/index.ts | 10 ++++++++-- src/interceptors/net/mock-socket.ts | 6 +++--- test/modules/http/compliance/https.test.ts | 2 +- test/modules/http/response/http-https.test.ts | 10 +++++----- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 13f617bdd..dbf0c2a71 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -73,11 +73,17 @@ export class SocketInterceptor extends Interceptor { const [tlsConnectionOptions, secureConnectionCallback] = normalizeTlsConnectArgs(args) - tlsConnectionOptions.rejectUnauthorized = false - const tlsSocket = realTlsConnect( { ...tlsConnectionOptions, + /** + * Use a fake IP address to bypass DNS lookup. + * This ensures that "connectionAttempt" event fires even for non-existent hosts. + * Node.js skips DNS resolution when the host is an IP address, going directly to + * "internalConnect()" which emits "connectionAttempt". + * @see https://github.com/nodejs/node/blob/5babc8d5c91914ce0fb708e647c144570c671c50/lib/net.js + */ + host: '127.0.0.1', /** * Suppress unauthorized connection errors to allow mocking connections to non-existing hosts. * This prevents the "Error: Hostname/IP does not match certificate's altnames: Cert does not contain a DNS name" error. diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index b6c17e45c..422874e2b 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,13 +1,11 @@ import net from 'node:net' import tls from 'node:tls' -import EventEmitter from 'node:events' import { invariant } from 'outvariant' +import { DeferredPromise } from '@open-draft/deferred-promise' import { toBuffer } from '../../utils/bufferUtils' import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' import { kRawSocket, TcpHandle, TcpWrap } from './connection-controller' -import { SocketInterceptor } from '.' -import { DeferredPromise } from '@open-draft/deferred-promise' const kListenerWrap = Symbol('kListenerWrap') @@ -168,6 +166,8 @@ export class TcpSocketController extends SocketController { * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 */ if (this.socket._pendingData) { + this.socket._pendingData = null + this.socket._pendingEncoding = null return } diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 9fb7a43c9..792dd749f 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -32,7 +32,7 @@ it('emits correct events for a mocked HTTPS request', async () => { controller.respondWith(new Response()) }) - const request = https.get('https://localhost/api') + const request = https.get('https://any.localhost/api') const socketListener = vi.fn() const socketConnectListener = vi.fn() diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index d4a8ccb0d..d1cc7c0e1 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -48,7 +48,7 @@ afterAll(async () => { }) it('responds to a handled request issued by "http.get"', async () => { - const req = http.get('http://any.thing/non-existing') + const req = http.get('http://any.localhost/non-existing') const { res, text } = await waitForClientRequest(req) expect(res).toMatchObject>({ @@ -61,8 +61,8 @@ it('responds to a handled request issued by "http.get"', async () => { await expect(text()).resolves.toEqual('mocked') }) -it.only('responds to a handled request issued by "https.get"', async () => { - const req = https.get('https://any.thing/non-existing') +it('responds to a handled request issued by "https.get"', async () => { + const req = https.get('https://any.localhost/non-existing') const { res, text } = await waitForClientRequest(req) expect(res).toMatchObject>({ @@ -100,7 +100,7 @@ it('bypasses an unhandled request issued by "https.get"', async () => { }) it('responds to a handled request issued by "http.request"', async () => { - const req = http.request('http://any.thing/non-existing') + const req = http.request('http://any.localhost/non-existing') req.end() const { res, text } = await waitForClientRequest(req) @@ -111,7 +111,7 @@ it('responds to a handled request issued by "http.request"', async () => { }) it('responds to a handled request issued by "https.request"', async () => { - const req = https.request('https://any.thing/non-existing') + const req = https.request('https://any.localhost/non-existing') req.end() const { res, text } = await waitForClientRequest(req) From eaf845cfbce16a93832ac6e9463434dcddca38ef Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 12:01:26 +0100 Subject: [PATCH 027/198] fix: "response" event for mock/passthrough --- src/interceptors/http/index.ts | 26 +- src/interceptors/net/connection-controller.ts | 233 ------------------ src/interceptors/net/index.ts | 12 +- src/interceptors/net/mock-socket.ts | 127 +++++++++- .../http-await-response-event.test.ts | 45 ++-- 5 files changed, 172 insertions(+), 271 deletions(-) delete mode 100644 src/interceptors/net/connection-controller.ts diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index f6ce6a53f..14e726420 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -17,7 +17,8 @@ import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' -import { kRawSocket } from '../net/connection-controller' +import { kRawSocket } from '../net/mock-socket' +import { isModuleNamespaceObject } from 'node:util/types' const log = createLogger('HttpRequestInterceptor') @@ -83,6 +84,19 @@ export class HttpRequestInterceptor extends Interceptor { response, }) }) + + if (this.emitter.listenerCount('response') > 0) { + const responseClone = response.clone() + + process.nextTick(async () => { + await emitAsync(this.emitter, 'response', { + requestId, + request, + response: responseClone, + isMockedResponse: true, + }) + }) + } }, errorWith: (reason) => { if (reason instanceof Error) { @@ -93,6 +107,14 @@ export class HttpRequestInterceptor extends Interceptor { const realSocket = connectionController.passthrough() if (this.emitter.listenerCount('response') > 0) { + const mockSocket = connectionController[kRawSocket] + + // Pause the mock socket to prevent the passthrough 'data' listener + // from pushing data to it. The passthrough checks isPaused() and skips + // pushing when paused, allowing realSocket to continue emitting data + // for our response parser without backpressure issues. + mockSocket.pause() + const responseParser = new HttpResponseParser({ onResponse: async (response) => { await emitAsync(this.emitter, 'response', { @@ -101,6 +123,8 @@ export class HttpRequestInterceptor extends Interceptor { response, isMockedResponse: false, }) + + mockSocket.resume() }, }) diff --git a/src/interceptors/net/connection-controller.ts b/src/interceptors/net/connection-controller.ts deleted file mode 100644 index 38702f892..000000000 --- a/src/interceptors/net/connection-controller.ts +++ /dev/null @@ -1,233 +0,0 @@ -import net from 'node:net' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { kMockState, kTlsSocket, MockSocket } from './mock-socket' -import { unwrapPendingData } from './utils/flush-writes' - -// Internally, Node.js represents the result of various operations -// by the number they return: 0 (error), 1 (success). -type OperationStatus = 0 | 1 - -declare module 'node:net' { - interface Socket { - _pendingData: - | string - | Buffer - | Array<{ - chunk: string | Buffer - encoding?: BufferEncoding - callback?: (error?: Error | null) => void - }> - | null - _pendingEncoding: BufferEncoding | null - _writeGeneric( - writev: boolean, - data: any, - encoding: BufferEncoding, - callback?: (error?: Error | null) => void - ): void - _handle: TcpHandle - _start: () => void - } -} - -declare module 'node:tls' { - interface TLSSocket { - _handle: TcpHandle & { - start: () => void - onhandshakedone: () => void - onnewsession: (sessionId: unknown, session: Buffer) => void - verifyError: () => void - } - } -} - -export interface TcpHandle { - open: (fd: unknown) => OperationStatus - connect: (request: TcpWrap, address: string, port: number) => void - connect6: (request: TcpWrap, address: string, port: number) => void - listen: (backlog: number) => OperationStatus - onconnection?: () => void - getpeername?: () => OperationStatus - getsockname?: () => OperationStatus - reading: boolean - onread: () => void - readStart: () => void - readStop: () => void - bytesRead: number - bytesWritten: number - ref?: () => void - unref?: () => void - fchmod: (mode: number) => void - setBlocking: (blocking: boolean) => OperationStatus - setNoDelay?: (noDelay: boolean) => void - setKeepAlive?: (keepAlive: boolean, initialDelay: number) => void - shutdown: (reqest: unknown /* ShutdownWrap */) => OperationStatus - close: () => void - - _parent?: TcpHandle -} - -export interface TcpWrap { - oncomplete: ( - status: OperationStatus, - owner: TcpHandle, - request: TcpWrap, - readable?: boolean, - writable?: boolean - ) => void -} - -export const kRawSocket = Symbol('kRawSocket') - -export class ConnectionController { - #pendingRequest: DeferredPromise - - private [kRawSocket]: MockSocket - - constructor( - socket: MockSocket, - private readonly createConnection: () => net.Socket - ) { - this[kRawSocket] = socket - this.#pendingRequest = new DeferredPromise() - - socket.prependListener('connectionAttempt', () => { - socket._handle.connect = (request) => { - this.#pendingRequest.resolve(request) - } - socket._handle.connect6 = (request) => { - this.#pendingRequest.resolve(request) - } - }) - } - - /** - * Wait for the first connection attempt and claim this socket connection. - * This will transition the socket into a connected state as if the - * connection with the remote address was successful. - */ - public claim(): void { - const clientSocket = this[kRawSocket] - clientSocket[kMockState] = MockSocket.MOCKED - - console.log('CLAIM!') - - const tlsSocket = clientSocket[kTlsSocket] - - if (tlsSocket) { - // Update the client socket reference so that connection controller interacts - // with the top-most socket, which is the TLSSocket. This way, mocked response - // gets pushed to the TLS socket correctly. Pushing it to TCP does nothing. - /** - * @fixme This should be removed after MockTlsSocket is implemented. - * [kClientSocket] should point to MockTlsSocket from the start, no nesting. - */ - this[kRawSocket] = tlsSocket - - this.#pendingRequest = new DeferredPromise() - - /** - * Mock this to prevent the "Error: Worker exited unexpectedly" error. - * This will trigger when "secure" is emitted. - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 - */ - tlsSocket._handle.verifyError = () => void 0 - - tlsSocket._handle.start = () => { - process.nextTick(() => { - /** - * Mock successful SSL handshake completion. - * This will emit "secureConnect" and "secure" on the TLS socket, and trigger "tlsSocket._finishInit". - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L878 - */ - tlsSocket._handle.onhandshakedone() - tlsSocket._handle.onnewsession(1, Buffer.alloc(0)) - }) - } - - clientSocket.connecting = false - - process.nextTick(() => { - clientSocket.emit('connect') - tlsSocket.emit('ready') - }) - - return - } - - // The user can interact with the connection controller *before* the connection attempt - // is made. That is so they could handle the socket before the connection. - this.#pendingRequest.then((request) => { - /** - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 - */ - request.oncomplete(0, clientSocket._handle, request, true, true) - }) - } - - public retry(): void { - throw new Error('Not Implemented') - } - - public close(): void { - throw new Error('Not Implemented') - } - - /** - * Abort this socket connection with an optional error. - */ - public errorWith(reason?: Error): void { - this[kRawSocket].destroy(reason) - } - - /** - * Bypass this socket connection and perform it as-is. - */ - public passthrough(): net.Socket { - const clientSocket = this[kRawSocket] - clientSocket[kMockState] = MockSocket.PASSTHROUGH - - const realSocket = this.createConnection() - - if (clientSocket._pendingData) { - unwrapPendingData(clientSocket._pendingData, (chunk, encoding) => { - realSocket.write(chunk, encoding) - }) - } - - clientSocket - .on('drain', () => realSocket.resume()) - .on('close', () => realSocket.destroy()) - clientSocket._write = (...args) => realSocket.write(...args) - clientSocket._final = (callback) => realSocket.end(callback) - - realSocket - .once('connectionAttempt', () => { - clientSocket._handle.unref?.() - clientSocket._handle = realSocket._handle - }) - .on('connect', () => { - clientSocket.connecting = realSocket.connecting - clientSocket.emit('connect') - // clientSocket.emit('ready') - }) - .on('data', (data) => { - if (!clientSocket.push(data)) { - realSocket.pause() - } - }) - .on('error', (error) => { - clientSocket.emit('error', error) - }) - .on('end', () => { - clientSocket.push(null) - }) - - /** - * @todo @fixme Forwarding events is not enough. - * Real socket has to, effectively, replace the client socket in every sense. - */ - - return realSocket - } -} diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index dbf0c2a71..2ebc74c5b 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -5,19 +5,15 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { - SocketController, - TcpSocketController, - TlsSocketController, -} from './mock-socket' -import { createLogger } from '../../utils/logger' +import { TcpSocketController, TlsSocketController } from './mock-socket' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' +import { createLogger } from '../../utils/logger' interface SocketEventMap { connection: [ { socket: net.Socket | tls.TLSSocket - controller: SocketController + controller: TcpSocketController | TlsSocketController connectionOptions: NetworkConnectionOptions }, ] @@ -82,6 +78,8 @@ export class SocketInterceptor extends Interceptor { * Node.js skips DNS resolution when the host is an IP address, going directly to * "internalConnect()" which emits "connectionAttempt". * @see https://github.com/nodejs/node/blob/5babc8d5c91914ce0fb708e647c144570c671c50/lib/net.js + * + * @todo This will produce invalid "lookup" event on the socket, failing compliance. */ host: '127.0.0.1', /** diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 422874e2b..93feeee72 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -5,15 +5,90 @@ import { DeferredPromise } from '@open-draft/deferred-promise' import { toBuffer } from '../../utils/bufferUtils' import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' -import { kRawSocket, TcpHandle, TcpWrap } from './connection-controller' const kListenerWrap = Symbol('kListenerWrap') +export const kRawSocket = Symbol('kRawSocket') + export const kMockState = Symbol('kMockState') export const kTlsSocket = Symbol('kTlsSocket') const log = createLogger('MockSocket') +// Internally, Node.js represents the result of various operations +// by the number they return: 0 (error), 1 (success). +type OperationStatus = 0 | 1 + +declare module 'node:net' { + interface Socket { + _pendingData: + | string + | Buffer + | Array<{ + chunk: string | Buffer + encoding?: BufferEncoding + callback?: (error?: Error | null) => void + }> + | null + _pendingEncoding: BufferEncoding | null + _writeGeneric( + writev: boolean, + data: any, + encoding: BufferEncoding, + callback?: (error?: Error | null) => void + ): void + _handle: TcpHandle + _start: () => void + } +} + +declare module 'node:tls' { + interface TLSSocket { + _handle: TcpHandle & { + start: () => void + onhandshakedone: () => void + onnewsession: (sessionId: unknown, session: Buffer) => void + verifyError: () => void + } + } +} + +export interface TcpHandle { + open: (fd: unknown) => OperationStatus + connect: (request: TcpWrap, address: string, port: number) => void + connect6: (request: TcpWrap, address: string, port: number) => void + listen: (backlog: number) => OperationStatus + onconnection?: () => void + getpeername?: () => OperationStatus + getsockname?: () => OperationStatus + reading: boolean + onread: () => void + readStart: () => void + readStop: () => void + bytesRead: number + bytesWritten: number + ref?: () => void + unref?: () => void + fchmod: (mode: number) => void + setBlocking: (blocking: boolean) => OperationStatus + setNoDelay?: (noDelay: boolean) => void + setKeepAlive?: (keepAlive: boolean, initialDelay: number) => void + shutdown: (reqest: unknown /* ShutdownWrap */) => OperationStatus + close: () => void + + _parent?: TcpHandle +} + +export interface TcpWrap { + oncomplete: ( + status: OperationStatus, + owner: TcpHandle, + request: TcpWrap, + readable?: boolean, + writable?: boolean + ) => void +} + /** * Create a proxy `net.Socket` instance that represents the intercepted socket server-side. * This is the reference exposed as `socket` in the connection listener. This proxy allows @@ -95,7 +170,10 @@ export abstract class SocketController { | typeof SocketController.MOCKED | typeof SocketController.PASSTHROUGH - constructor() { + private [kRawSocket]: net.Socket + + constructor(socket: net.Socket) { + this[kRawSocket] = socket this.readyState = SocketController.PENDING } @@ -108,7 +186,9 @@ export abstract class SocketController { this.readyState = SocketController.MOCKED } - public passthrough() { + public abstract errorWith(reason?: Error): void + + public passthrough(): void { invariant( this.readyState !== SocketController.PASSTHROUGH, 'Failed to passthrough a TLS socket: already passthrough' @@ -120,7 +200,6 @@ export abstract class SocketController { export class TcpSocketController extends SocketController { public serverSocket: net.Socket - private [kRawSocket]: net.Socket protected pendingConnection: DeferredPromise<[TcpWrap, TcpHandle]> @@ -128,7 +207,7 @@ export class TcpSocketController extends SocketController { protected readonly socket: net.Socket, protected readonly createConnection: () => net.Socket ) { - super() + super(socket) this.pendingConnection = new DeferredPromise() @@ -178,7 +257,6 @@ export class TcpSocketController extends SocketController { return realWriteGeneric.apply(this.socket, args) } - this[kRawSocket] = socket this.serverSocket = toServerSocket(this.socket) } @@ -193,16 +271,41 @@ export class TcpSocketController extends SocketController { }) } + public errorWith(reason?: Error): void { + this.socket.destroy(reason) + } + public passthrough(): net.Socket { super.passthrough() const realSocket = this.createConnection() - this.socket.on('drain', () => realSocket.resume()) - this.socket.write = realSocket.write.bind(realSocket) this.socket.read = realSocket.read.bind(realSocket) + // Buffer to hold data chunks while the mock socket is paused. + // This allows async response event listeners to complete before + // data flows to the mock socket and triggers ClientRequest events. + const pausedBuffer: Array = [] + this.socket.resume = new Proxy(this.socket.resume, { + apply: (target, thisArg, argArray) => { + const result = Reflect.apply(target, thisArg, argArray) + + while (pausedBuffer.length > 0) { + const bufferedData = pausedBuffer.shift()! + + if (!this.socket.push(bufferedData)) { + realSocket.pause() + break + } + } + + return result + }, + }) + + this.socket.on('drain', () => realSocket.resume()) + realSocket .once('connect', () => { this.socket._handle = realSocket._handle @@ -212,6 +315,14 @@ export class TcpSocketController extends SocketController { this.socket.emit('ready') }) .on('data', (data) => { + // If the mock socket is paused, buffer the data instead of pushing it. + // This allows other listeners on realSocket to continue receiving data + // (e.g., response parsers) without being affected by backpressure. + if (this.socket.isPaused()) { + pausedBuffer.push(data) + return + } + if (!this.socket.push(data)) { realSocket.pause() } 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..2bfb6efb8 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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() @@ -27,49 +28,49 @@ afterAll(async () => { }) it('awaits asynchronous response event listener for a mocked response', async () => { - const markStep = vi.fn<(input: number) => void>() + const tag = vi.fn<(tag: string) => void>() interceptor.on('request', ({ controller }) => { controller.respondWith(new Response('hello world')) }) interceptor.on('response', async ({ response }) => { - markStep(2) + tag('response') await response.text() - markStep(3) + tag('after-response') }) - markStep(1) + tag('before-request') const request = http.get('http://localhost/') const { text } = await waitForClientRequest(request) - markStep(4) + tag('after-request') - expect(await text()).toBe('hello world') + await expect(text()).resolves.toBe('hello world') - expect(markStep).toHaveBeenNthCalledWith(1, 1) - expect(markStep).toHaveBeenNthCalledWith(2, 2) - expect(markStep).toHaveBeenNthCalledWith(3, 3) - expect(markStep).toHaveBeenNthCalledWith(4, 4) + expect.soft(tag).toHaveBeenNthCalledWith(1, 'before-request') + expect.soft(tag).toHaveBeenNthCalledWith(2, 'response') + expect.soft(tag).toHaveBeenNthCalledWith(3, 'after-response') + expect.soft(tag).toHaveBeenNthCalledWith(4, 'after-request') }) it('awaits asynchronous response event listener for the original response', async () => { - const markStep = vi.fn<(input: number) => void>() + const tag = vi.fn<(tag: string) => void>() interceptor.on('response', async ({ response }) => { - markStep(2) + tag('response') await response.text() - markStep(3) + tag('after-response') }) - markStep(1) + tag('before-request') const request = http.get(httpServer.http.url('/resource')) const { text } = await waitForClientRequest(request) - markStep(4) + tag('after-request') - expect(await text()).toBe('original response') + await expect(text()).resolves.toBe('original response') - expect(markStep).toHaveBeenNthCalledWith(1, 1) - expect(markStep).toHaveBeenNthCalledWith(2, 2) - expect(markStep).toHaveBeenNthCalledWith(3, 3) - expect(markStep).toHaveBeenNthCalledWith(4, 4) + expect.soft(tag).toHaveBeenNthCalledWith(1, 'before-request') + expect.soft(tag).toHaveBeenNthCalledWith(2, 'response') + expect.soft(tag).toHaveBeenNthCalledWith(3, 'after-response') + expect.soft(tag).toHaveBeenNthCalledWith(4, 'after-request') }) From 70f65cc9062f1a679fd07f5afda39e0b06649f5c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 12:11:55 +0100 Subject: [PATCH 028/198] fix: do not emit "response" for error responses --- src/interceptors/http/index.ts | 18 +++++++++++++++--- src/interceptors/net/mock-socket.ts | 3 --- .../response/http-response-patching.test.ts | 4 ++-- .../http-response-readable-stream.test.ts | 1 - 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 14e726420..806809f1f 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -18,7 +18,6 @@ import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' import { kRawSocket } from '../net/mock-socket' -import { isModuleNamespaceObject } from 'node:util/types' const log = createLogger('HttpRequestInterceptor') @@ -85,7 +84,15 @@ export class HttpRequestInterceptor extends Interceptor { }) }) - if (this.emitter.listenerCount('response') > 0) { + if ( + this.emitter.listenerCount('response') > 0 && + /** + * @note The "response" event is designed to observe responses. + * While a mocked "Response.error()" is, technically, a response, + * it must not emit the "response" event as it's treated as a request error. + */ + !isResponseError(response) + ) { const responseClone = response.clone() process.nextTick(async () => { @@ -106,7 +113,7 @@ export class HttpRequestInterceptor extends Interceptor { passthrough: () => { const realSocket = connectionController.passthrough() - if (this.emitter.listenerCount('response') > 0) { + if (this.emitter.listenerCount('response')) { const mockSocket = connectionController[kRawSocket] // Pause the mock socket to prevent the passthrough 'data' listener @@ -117,6 +124,11 @@ export class HttpRequestInterceptor extends Interceptor { const responseParser = new HttpResponseParser({ onResponse: async (response) => { + if (isResponseError(response)) { + mockSocket.resume() + return + } + await emitAsync(this.emitter, 'response', { requestId, request, diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 93feeee72..823f28477 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -280,9 +280,6 @@ export class TcpSocketController extends SocketController { const realSocket = this.createConnection() - this.socket.write = realSocket.write.bind(realSocket) - this.socket.read = realSocket.read.bind(realSocket) - // Buffer to hold data chunks while the mock socket is paused. // This allows async response event listeners to complete before // data flows to the mock socket and triggers ClientRequest events. diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 613cfd332..2498dbfab 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,7 +1,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { sleep, waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -10,7 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() async function getResponse(request: Request): Promise { const url = new URL(request.url) diff --git a/test/modules/http/response/http-response-readable-stream.test.ts b/test/modules/http/response/http-response-readable-stream.test.ts index 72efd62d6..5d7043196 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -4,7 +4,6 @@ import { performance } from 'node:perf_hooks' import http from 'node:http' import { Readable } from 'node:stream' import { setTimeout } from 'node:timers/promises' -import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { sleep, waitForClientRequest } from '../../../helpers' From 84242244d791b287ef61e98a5afe3542de24c80d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 12:27:12 +0100 Subject: [PATCH 029/198] fix: reset socket controller when "free" is emitted --- src/interceptors/http/index.ts | 10 ++++--- src/interceptors/net/mock-socket.ts | 11 ++++++++ .../intercept/http-client-request.test.ts | 27 +++++++++---------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 806809f1f..28bba11f0 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -40,8 +40,8 @@ export class HttpRequestInterceptor extends Interceptor { 'connection', ({ connectionOptions, socket, controller: connectionController }) => { socket.once('data', (chunk) => { - const firstFrame = chunk.toString() - const httpMethod = firstFrame.split(' ')[0] + const httpMessage = chunk.toString() + const httpMethod = httpMessage.split(' ')[0] invariant( httpMethod != null, @@ -51,7 +51,11 @@ export class HttpRequestInterceptor extends Interceptor { const baseUrl = connectionOptionsToUrl(connectionOptions) - log('handling first frame...', { firstFrame, httpMethod, baseUrl }) + log('handling http message...', { + httpMessage, + httpMethod, + baseUrl, + }) const requestParser = new HttpRequestParser({ connectionOptions: { diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 823f28477..3fb5f068d 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -257,6 +257,17 @@ export class TcpSocketController extends SocketController { return realWriteGeneric.apply(this.socket, args) } + /** + * @note A single socket can be reused for connections to the same host. + * When one connection ends, the Agent frees the socket, then uses it + * to write the next request's HTTP message immediately. Use the "free" + * event to transition the controller into the pending state so "_writeGeneric" + * would behave correctly. + */ + socket.on('free', () => { + this.readyState = SocketController.PENDING + }) + this.serverSocket = toServerSocket(this.socket) } diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index f801cfd4e..72a7d9004 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -15,9 +15,8 @@ const httpServer = new HttpServer((app) => { const interceptor = new HttpRequestInterceptor() beforeAll(async () => { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterEach(() => { @@ -50,12 +49,12 @@ it('intercepts an HTTP ClientRequest request with request options', async () => req.end() const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url.href) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -80,12 +79,12 @@ it('intercepts an HTTP ClientRequest request with URL string', async () => { req.end() const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -110,12 +109,12 @@ it('intercepts an HTTP ClientRequest request with URL instance', async () => { req.end() const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url.href) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -143,12 +142,12 @@ it('intercepts an HTTPS ClientRequest request with URL string', async () => { req.end() const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -176,12 +175,12 @@ it('intercepts an HTTPS ClientRequest request with URL instance', async () => { req.end() const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url.href) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -214,12 +213,12 @@ it('intercepts an HTTPS ClientRequest request with request options', async () => req.end() const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url.href) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) From b84348d42ea17ceabe38d5b47c2f868f3540a181 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 12:29:57 +0100 Subject: [PATCH 030/198] test: switch more tests to use new interceptor --- test/modules/http/compliance/events.test.ts | 8 +++----- .../compliance/http-head-response-body.test.ts | 8 +++----- .../http-max-header-fields-count.test.ts | 14 +++++++------- .../http/compliance/http-modify-request.test.ts | 8 +++----- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index 1f82d2e8e..71e79e8bf 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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-head-response-body.test.ts b/test/modules/http/compliance/http-head-response-body.test.ts index 4ea5e3e7f..930ea2d3b 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 http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-max-header-fields-count.test.ts b/test/modules/http/compliance/http-max-header-fields-count.test.ts index fb2bc42b0..1d3a93d45 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 { waitForClientRequest } from '../../../helpers' import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -46,7 +46,7 @@ it('supports requests with more than default maximum header fields count', async const requestHeaders = await requestHeadersPromise expect(Array.from(requestHeaders)).toEqual([ - ['connection', 'close'], + ['connection', 'keep-alive'], ['host', 'localhost'], ...headersPairs, ]) @@ -81,7 +81,7 @@ it('supports multiple parallel "slow" requests', async () => { const requestHeaders = await requestHeadersPromise expect(Array.from(requestHeaders)).toEqual([ - ['connection', 'close'], + ['connection', 'keep-alive'], ['host', 'localhost'], ...headersPairs, ]) @@ -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..b04674211 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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() From 950d6fdd9408e2fec5ca4dc2dbfd8d107d7f80c1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 12:39:26 +0100 Subject: [PATCH 031/198] chore: delete object recorder implementation --- src/interceptors/net/object-recorder.test.ts | 418 ------------------- src/interceptors/net/object-recorder.ts | 319 -------------- 2 files changed, 737 deletions(-) delete mode 100644 src/interceptors/net/object-recorder.test.ts delete mode 100644 src/interceptors/net/object-recorder.ts diff --git a/src/interceptors/net/object-recorder.test.ts b/src/interceptors/net/object-recorder.test.ts deleted file mode 100644 index 80bd872c1..000000000 --- a/src/interceptors/net/object-recorder.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { it, expect } from 'vitest' -import { ObjectRecorder } from './object-recorder' - -it('records a setter', () => { - const target = { a: 1 } - const recorder = new ObjectRecorder(target) - recorder.start() - - recorder.proxy.a = 2 - - expect(target).toEqual({ a: 2 }) - expect(recorder.entries).toEqual([ - { - type: 'set', - path: [], - metadata: { property: 'a', descriptor: { value: 2 } }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { a: 1 } - recorder.replay(otherTarget) - expect(otherTarget.a).toBe(2) -}) - -it('records a nested setter', () => { - const target = { a: { b: 1 } } - const recorder = new ObjectRecorder(target) - recorder.start() - - recorder.proxy.a.b = 2 - - expect(target).toEqual({ a: { b: 2 } }) - expect(recorder.entries).toEqual([ - { - type: 'set', - path: ['a'], - metadata: { property: 'b', descriptor: { value: 2 } }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { a: { b: 1 } } - recorder.replay(otherTarget) - expect(otherTarget.a.b).toBe(2) -}) - -it('records a method call without any arguments', () => { - const target = { - state: '', - update() { - this.state = 'updated' - }, - } - const recorder = new ObjectRecorder(target) - recorder.start() - - recorder.proxy.update() - - expect(target.state).toBe('updated') - expect(recorder.entries).toEqual([ - { - type: 'apply', - path: [], - metadata: { method: 'update', args: [] }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { - state: '', - update() { - this.state = 'updated' - }, - } - recorder.replay(otherTarget) - expect(otherTarget.state).toBe('updated') -}) - -it('records array mutations', () => { - const target = { numbers: [1] } - const recorder = new ObjectRecorder(target) - recorder.start() - - recorder.proxy.numbers.push(2) - - expect(target).toEqual({ numbers: [1, 2] }) - expect(recorder.entries).toEqual([ - { - type: 'apply', - path: ['numbers'], - metadata: { - method: 'push', - args: [2], - }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { numbers: [1] } - recorder.replay(otherTarget) - expect(otherTarget.numbers).toEqual([1, 2]) -}) - -it('records a method call that deletes a property', () => { - const target: { a?: number; clear: () => void } = { - a: 1, - clear() { - delete this.a - }, - } - const recorder = new ObjectRecorder(target) - recorder.start() - - recorder.proxy.clear() - - expect(target).toEqual({ clear: expect.any(Function) }) - expect(recorder.entries).toEqual([ - { - type: 'apply', - path: [], - metadata: { - method: 'clear', - args: [], - }, - replay: expect.any(Function), - }, - ]) - - const otherTarget: { a?: number; clear: () => void } = { - a: 1, - clear() { - delete this.a - }, - } - recorder.replay(otherTarget) - expect(otherTarget).toEqual({ clear: expect.any(Function) }) -}) - -it('records a method call with arguments', () => { - const target = { - state: '', - update(value: string) { - this.state = value - }, - } - const recorder = new ObjectRecorder(target) - recorder.start() - - recorder.proxy.update('new state') - - expect(target.state).toBe('new state') - expect(recorder.entries).toEqual([ - { - type: 'apply', - path: [], - metadata: { method: 'update', args: ['new state'] }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { - state: '', - update(value: string) { - this.state = value - }, - } - recorder.replay(otherTarget) - expect(otherTarget.state).toBe('new state') -}) - -it('records a property deletion', () => { - const target: { a?: number; b: number } = { a: 1, b: 2 } - const recorder = new ObjectRecorder(target) - recorder.start() - - delete recorder.proxy.a - - expect(target).toEqual({ b: 2 }) - expect(recorder.entries).toEqual([ - { - type: 'delete', - path: [], - metadata: { property: 'a' }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { a: 1, b: 2 } - recorder.replay(otherTarget) - expect(otherTarget).toEqual({ b: 2 }) -}) - -it('records a nested property deletion', () => { - const target: { a: { b?: number }; c: number } = { a: { b: 1 }, c: 2 } - const recorder = new ObjectRecorder(target) - recorder.start() - - delete recorder.proxy.a.b - - expect(target).toEqual({ a: {}, c: 2 }) - expect(recorder.entries).toEqual([ - { - type: 'delete', - path: ['a'], - metadata: { property: 'b' }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { a: { b: 1 }, c: 2 } - recorder.replay(otherTarget) - expect(otherTarget).toEqual({ a: {}, c: 2 }) -}) - -it('supports running actions quietly', () => { - const target = { a: 1, b: 2 } - const recorder = new ObjectRecorder(target) - recorder.start() - - recorder.proxy.a = 2 - recorder.runQuietly(() => { - recorder.proxy.b = 3 - }) - - expect(target).toEqual({ a: 2, b: 3 }) - expect(recorder.entries).toEqual([ - { - type: 'set', - path: [], - metadata: { - property: 'a', - descriptor: { - value: 2, - }, - }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { a: 1, b: 2 } - recorder.replay(otherTarget) - expect(otherTarget, 'Does not replay quiet actions').toEqual({ - a: 2, - b: 2, - }) -}) - -it('supports custom action predicate', () => { - const target = { a: 1, _internal: 'a' } - const recorder = new ObjectRecorder(target, { - filter(entry) { - if ( - entry.type === 'set' && - entry.metadata.property.toString().startsWith('_') - ) { - return false - } - - return true - }, - }) - recorder.start() - - recorder.proxy.a = 2 - recorder.proxy._internal = 'b' - - expect(target).toEqual({ a: 2, _internal: 'b' }) - expect(recorder.entries).toEqual([ - { - type: 'set', - path: [], - metadata: { - property: 'a', - descriptor: { - value: 2, - }, - }, - replay: expect.any(Function), - }, - ]) - - const otherTarget = { a: 1, _internal: 'a' } - recorder.replay(otherTarget) - expect(otherTarget, 'Does not replay ignored actions').toEqual({ - a: 2, - _internal: 'a', - }) -}) - -it('restores original target when disposed', () => { - const target = { a: 1, b: 2 } - const recorder = new ObjectRecorder(target) - recorder.start() - - const proxiedObject = recorder.proxy - expect(proxiedObject).not.toBe(target) - - recorder.proxy.a = 10 - expect(target.a).toBe(10) - - recorder.dispose() - - expect(recorder.proxy).toBe(target) - expect(recorder.entries).toHaveLength(0) -}) - -it('restores nested proxies when disposed', () => { - const target = { a: { b: { c: 1 } } } - const recorder = new ObjectRecorder(target) - recorder.start() - - const proxiedA = recorder.proxy.a - const proxiedB = recorder.proxy.a.b - - expect(proxiedA).not.toBe(target.a) - expect(proxiedB).not.toBe(target.a.b) - - recorder.proxy.a.b.c = 10 - expect(target.a.b.c).toBe(10) - - recorder.dispose() - - expect(recorder.proxy).toBe(target) - expect(recorder.proxy.a).toBe(target.a) - expect(recorder.proxy.a.b).toBe(target.a.b) -}) - -it('allows mutations after dispose without recording', () => { - const target = { a: 1 } - const recorder = new ObjectRecorder(target) - recorder.start() - - recorder.proxy.a = 2 - expect(recorder.entries).toHaveLength(1) - - recorder.dispose() - - recorder.proxy.a = 3 - expect(target.a).toBe(3) - expect(recorder.entries).toHaveLength(0) -}) - -it('restores nested arrays when disposed', () => { - const target = { items: [1, 2, 3], nested: { arr: [4, 5] } } - const recorder = new ObjectRecorder(target) - recorder.start() - - const proxiedItems = recorder.proxy.items - const proxiedNestedArr = recorder.proxy.nested.arr - - expect(proxiedItems).not.toBe(target.items) - expect(proxiedNestedArr).not.toBe(target.nested.arr) - - recorder.proxy.items.push(4) - recorder.proxy.nested.arr.push(6) - - recorder.dispose() - - expect(recorder.proxy.items).toBe(target.items) - expect(recorder.proxy.nested.arr).toBe(target.nested.arr) - expect(target.items).toEqual([1, 2, 3, 4]) - expect(target.nested.arr).toEqual([4, 5, 6]) -}) - -it('restores deeply nested object proxies when disposed', () => { - const target = { - level1: { - level2: { - level3: { - value: 'deep', - }, - }, - }, - } - const recorder = new ObjectRecorder(target) - recorder.start() - - const proxiedLevel1 = recorder.proxy.level1 - const proxiedLevel2 = recorder.proxy.level1.level2 - const proxiedLevel3 = recorder.proxy.level1.level2.level3 - - expect(proxiedLevel1).not.toBe(target.level1) - expect(proxiedLevel2).not.toBe(target.level1.level2) - expect(proxiedLevel3).not.toBe(target.level1.level2.level3) - - recorder.dispose() - - expect(recorder.proxy.level1).toBe(target.level1) - expect(recorder.proxy.level1.level2).toBe(target.level1.level2) - expect(recorder.proxy.level1.level2.level3).toBe(target.level1.level2.level3) -}) - -it('handles dispose with mixed property types', () => { - const target = { - primitive: 42, - object: { nested: true }, - array: [1, 2, 3], - func() { - return 'result' - }, - } - const recorder = new ObjectRecorder(target) - recorder.start() - - const proxiedObject = recorder.proxy.object - const proxiedArray = recorder.proxy.array - - expect(proxiedObject).not.toBe(target.object) - expect(proxiedArray).not.toBe(target.array) - - recorder.dispose() - - expect(recorder.proxy).toBe(target) - expect(recorder.proxy.object).toBe(target.object) - expect(recorder.proxy.array).toBe(target.array) - expect(recorder.proxy.func()).toBe('result') -}) diff --git a/src/interceptors/net/object-recorder.ts b/src/interceptors/net/object-recorder.ts deleted file mode 100644 index 455cf5db3..000000000 --- a/src/interceptors/net/object-recorder.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { AsyncLocalStorage } from 'node:async_hooks' -import { invariant } from 'outvariant' -import { get, set } from 'es-toolkit/compat' - -const nestedInvocationContext = new AsyncLocalStorage() - -type ObjectRecordEntry = - | { - type: 'apply' - path: Array - metadata: { - method: string | symbol - args: Array - } - replay: (nextTarget: T) => void - } - | { - type: 'set' - path: Array - metadata: { - property: string | symbol - descriptor: PropertyDescriptor - } - replay: (nextTarget: T) => void - } - | { - type: 'delete' - path: Array - metadata: { - property: string | symbol - } - replay: (nextTarget: T) => void - } - -interface ObjectRecorderOptions { - filter: (entry: ObjectRecordEntry) => boolean -} - -export class ObjectRecorder { - static IDLE = 1 as const - static RECORDING = 2 as const - static PAUSED = 3 as const - static DISPOSED = 4 as const - - #entries: Array> - #proxyToOriginal: WeakMap - #parentToProxiedProperties: WeakMap> - - public proxy: T - public readyState: 1 | 2 | 3 | 4 - - constructor( - protected readonly target: T, - protected readonly options?: ObjectRecorderOptions - ) { - this.#entries = [] - this.#proxyToOriginal = new WeakMap() - this.#parentToProxiedProperties = new WeakMap() - - this.readyState = ObjectRecorder.IDLE - this.proxy = target - } - - public get entries(): Array> { - return this.#entries - } - - public start(): void { - invariant( - this.readyState !== ObjectRecorder.DISPOSED, - 'Failed to start recording: recorder is disposed' - ) - - invariant( - this.readyState === ObjectRecorder.IDLE, - 'Failed to start recording: recording already in progress' - ) - - this.readyState = ObjectRecorder.RECORDING - - const wrapInProxy = ( - target: V, - parentPath: Array - ): V => { - const proxy = new Proxy(target, { - get: (target, property, receiver) => { - const value = target[property as keyof V] - - if (typeof value === 'function') { - return new Proxy(value, { - apply: (fn, thisArg, args) => { - const defaultApply = () => { - return fn.apply(thisArg, args) - } - - const entry: ObjectRecordEntry = { - type: 'apply', - path: parentPath, - metadata: { - method: property, - args, - }, - replay(nextTarget) { - const nextTargetValue = Reflect.get(nextTarget, property) - - if (typeof nextTargetValue !== 'function') { - return - } - - Reflect.apply(nextTargetValue, nextTarget, args) - }, - } - - this.#addEntry(entry) - return nestedInvocationContext.run(true, defaultApply) - }, - }) - } - - if (value != null && typeof value === 'object') { - let proxiedPropertiesMap = - this.#parentToProxiedProperties.get(target) - if (!proxiedPropertiesMap) { - proxiedPropertiesMap = new Map() - this.#parentToProxiedProperties.set(target, proxiedPropertiesMap) - } - - let proxiedValue = proxiedPropertiesMap.get(property) - if (!proxiedValue) { - proxiedValue = wrapInProxy(value, parentPath.concat(property)) - proxiedPropertiesMap.set(property, proxiedValue) - } - return proxiedValue - } - - return Reflect.get(target, property, receiver) - }, - defineProperty: (target, property, descriptor) => { - const defaultDefineProperty = () => { - return Reflect.defineProperty(target, property, descriptor) - } - - if (nestedInvocationContext.getStore()) { - return defaultDefineProperty() - } - - const entry: ObjectRecordEntry = { - type: 'set', - path: parentPath, - metadata: { - property, - descriptor, - }, - replay(nextTarget) { - Reflect.defineProperty(nextTarget, property, descriptor) - }, - } - - this.#addEntry(entry) - return defaultDefineProperty() - }, - deleteProperty: (target, property) => { - const defaultDeleteProperty = () => { - return Reflect.deleteProperty(target, property) - } - - if (nestedInvocationContext.getStore()) { - return defaultDeleteProperty() - } - - const entry: ObjectRecordEntry = { - type: 'delete', - path: parentPath, - metadata: { - property, - }, - replay(nextTarget) { - Reflect.deleteProperty(nextTarget, property) - }, - } - - this.#addEntry(entry) - return defaultDeleteProperty() - }, - }) - - this.#proxyToOriginal.set(proxy, target) - - return proxy - } - - this.proxy = wrapInProxy(this.target, []) - } - - public replay(nextTarget: T): void { - for (const entry of this.#entries) { - entry.replay( - entry.path.length > 0 ? get(nextTarget, entry.path) : nextTarget - ) - } - } - - public pause(): void { - invariant( - this.readyState !== ObjectRecorder.DISPOSED, - 'Failed to pause the recorder: recorder is disposed' - ) - - invariant( - this.readyState === ObjectRecorder.RECORDING, - 'Failed to pause the recorder: recorder is not running' - ) - - this.readyState = ObjectRecorder.PAUSED - } - - public resume(): void { - invariant( - this.readyState !== ObjectRecorder.DISPOSED, - 'Failed to resume the recorder: recorder is disposed' - ) - - invariant( - this.readyState === ObjectRecorder.PAUSED, - 'Failed to resume the recorder: recorder is not paused' - ) - - this.readyState = ObjectRecorder.RECORDING - } - - public runQuietly(callback: () => Promise | void): void { - invariant( - this.readyState !== ObjectRecorder.DISPOSED, - 'Failed to run action quielty: recorder is disposed' - ) - - this.pause() - - try { - const result = callback() - - if (result instanceof Promise) { - result.finally(this.resume.bind(this)) - } else { - this.resume() - } - } catch { - this.resume() - } - } - - public dispose(): void { - this.readyState = ObjectRecorder.DISPOSED - this.#entries.length = 0 - - const restoreOriginals = ( - obj: any, - path: Array = [] - ): void => { - if (obj == null || typeof obj !== 'object') { - return - } - - const proxiedPropertiesMap = this.#parentToProxiedProperties.get(obj) - if (proxiedPropertiesMap) { - for (const [property, proxiedValue] of proxiedPropertiesMap.entries()) { - const original = this.#proxyToOriginal.get(proxiedValue) - if (original !== undefined) { - obj[property] = original - restoreOriginals(original, path.concat(property)) - } - } - this.#parentToProxiedProperties.delete(obj) - } - - for (const key of Object.keys(obj)) { - const value = obj[key] - if (value != null && typeof value === 'object') { - const original = this.#proxyToOriginal.get(value) - if (original !== undefined) { - obj[key] = original - restoreOriginals(original, path.concat(key)) - } else { - restoreOriginals(value, path.concat(key)) - } - } - } - } - - restoreOriginals(this.target) - - this.proxy = this.target - } - - #addEntry(entry: ObjectRecordEntry): void { - invariant( - this.readyState !== ObjectRecorder.IDLE, - 'Failed to add entry to the recorder: recorder is idle' - ) - - invariant( - this.readyState !== ObjectRecorder.DISPOSED, - 'Failed to add entry to the recorder: recorder is disposed. Entry: %j', - { - type: entry.type, - path: entry.path, - metadata: entry.metadata, - } - ) - - if (this.readyState === ObjectRecorder.PAUSED) { - return - } - - if (this.options?.filter(entry) ?? true) { - this.#entries.push(entry) - } - } -} From cbbe954c7bc0cbfdb08ad168c1f46c0eb3292fe4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 12:39:38 +0100 Subject: [PATCH 032/198] chore: use `Reflect.set` for readonly `socket.connecting` --- src/interceptors/net/mock-socket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 3fb5f068d..2e606d0b2 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -318,7 +318,7 @@ export class TcpSocketController extends SocketController { .once('connect', () => { this.socket._handle = realSocket._handle - this.socket.connecting = false + Reflect.set(this.socket, 'connecting', false) this.socket.emit('connect') this.socket.emit('ready') }) From 5470871133f0240b3e9e1b4a2749eeecf20230d5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 12:40:20 +0100 Subject: [PATCH 033/198] chore: rename `mock-socket` to `socket-controller` --- src/interceptors/http/index.ts | 14 +++++++------- src/interceptors/net/index.ts | 2 +- .../net/{mock-socket.ts => socket-controller.ts} | 0 3 files changed, 8 insertions(+), 8 deletions(-) rename src/interceptors/net/{mock-socket.ts => socket-controller.ts} (100%) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 28bba11f0..5d6b12694 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -17,7 +17,7 @@ import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' -import { kRawSocket } from '../net/mock-socket' +import { kRawSocket } from '../net/socket-controller' const log = createLogger('HttpRequestInterceptor') @@ -38,7 +38,7 @@ export class HttpRequestInterceptor extends Interceptor { socketInterceptor.on( 'connection', - ({ connectionOptions, socket, controller: connectionController }) => { + ({ connectionOptions, socket, controller: socketController }) => { socket.once('data', (chunk) => { const httpMessage = chunk.toString() const httpMethod = httpMessage.split(' ')[0] @@ -78,11 +78,11 @@ export class HttpRequestInterceptor extends Interceptor { hasBody: response.body != null, }) - connectionController.claim() + socketController.claim() socket.once('connect', async () => { await this.respondWith({ - socket: connectionController[kRawSocket], + socket: socketController[kRawSocket], request, response, }) @@ -111,14 +111,14 @@ export class HttpRequestInterceptor extends Interceptor { }, errorWith: (reason) => { if (reason instanceof Error) { - connectionController.errorWith(reason) + socketController.errorWith(reason) } }, passthrough: () => { - const realSocket = connectionController.passthrough() + const realSocket = socketController.passthrough() if (this.emitter.listenerCount('response')) { - const mockSocket = connectionController[kRawSocket] + const mockSocket = socketController[kRawSocket] // Pause the mock socket to prevent the passthrough 'data' listener // from pushing data to it. The passthrough checks isPaused() and skips diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 2ebc74c5b..34ced9596 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -5,7 +5,7 @@ import { type NetworkConnectionOptions, normalizeNetConnectArgs, } from './utils/normalize-net-connect-args' -import { TcpSocketController, TlsSocketController } from './mock-socket' +import { TcpSocketController, TlsSocketController } from './socket-controller' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' import { createLogger } from '../../utils/logger' diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/socket-controller.ts similarity index 100% rename from src/interceptors/net/mock-socket.ts rename to src/interceptors/net/socket-controller.ts From 038e3c5fe36c312d649e95eb2be5b77ff9547988 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 17:48:40 +0100 Subject: [PATCH 034/198] fix: allow modifying `_pendingData` for http request headers --- src/interceptors/http/index.ts | 36 +++++++++++++++++- src/interceptors/net/socket-controller.ts | 37 +++++++++++++++++-- .../compliance/http-modify-request.test.ts | 30 ++++++++++++--- .../http/response/http-response-delay.test.ts | 2 +- 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 5d6b12694..2df51e461 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -115,7 +115,41 @@ export class HttpRequestInterceptor extends Interceptor { } }, passthrough: () => { - const realSocket = socketController.passthrough() + const transformRequestMessage = ( + httpMessage: string | Buffer, + encoding?: BufferEncoding + ): string => { + const rawHeaders = getRawFetchHeaders(request.headers) + const nextHeaders = rawHeaders + .map(([name, value]) => `${name}: ${value}`) + .join('\r\n') + + const parts = httpMessage.toString(encoding).split('\r\n') + parts.splice(1, parts.indexOf('') - 1, nextHeaders) + + return parts.join('\r\n') + } + + const realSocket = socketController.passthrough( + /** + * @todo Would be great NOT to run this if request headers weren't modified. + */ + (pendingData, encoding, callback) => { + if (Array.isArray(pendingData)) { + pendingData[0].chunk = transformRequestMessage( + pendingData[0].chunk, + pendingData[0].encoding + ) + } else { + pendingData = transformRequestMessage( + pendingData, + encoding + ) + } + + callback(pendingData) + } + ) if (this.emitter.listenerCount('response')) { const mockSocket = socketController[kRawSocket] diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 2e606d0b2..cadcb89eb 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -30,10 +30,10 @@ declare module 'node:net' { callback?: (error?: Error | null) => void }> | null - _pendingEncoding: BufferEncoding | null + _pendingEncoding: BufferEncoding | '' _writeGeneric( writev: boolean, - data: any, + data: NonNullable, encoding: BufferEncoding, callback?: (error?: Error | null) => void ): void @@ -246,7 +246,7 @@ export class TcpSocketController extends SocketController { */ if (this.socket._pendingData) { this.socket._pendingData = null - this.socket._pendingEncoding = null + this.socket._pendingEncoding = '' return } @@ -286,9 +286,38 @@ export class TcpSocketController extends SocketController { this.socket.destroy(reason) } - public passthrough(): net.Socket { + public passthrough( + flushPendingData?: ( + data: NonNullable, + encoding: BufferEncoding | undefined, + callback: (data: NonNullable) => void + ) => void + ): net.Socket { super.passthrough() + /** + * @note Modify the pending data to be flushed to the passthrough socket. + * In HTTP, this allows sending different request headers (e.g. modified in the listener). + */ + if (typeof flushPendingData === 'function') { + const realSocketWriteGeneric = this.socket._writeGeneric + + this.socket._writeGeneric = (writev, data, encoding, callback) => { + /** + * @note The scheduled write on "connect" will set "_pendingData" to null. + * @see https://github.com/nodejs/node/blob/6b5178f77b5d1f5d2adef8a1a092febe171cab80/lib/net.js#L1011 + */ + if (this.socket._pendingData) { + flushPendingData(data, encoding, (nextData) => { + realSocketWriteGeneric(writev, nextData, encoding, callback) + }) + return + } + + realSocketWriteGeneric(writev, data, encoding, callback) + } + } + const realSocket = this.createConnection() // Buffer to hold data chunks while the mock socket is paused. diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index b04674211..99ff30361 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -7,15 +7,21 @@ import { waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { app.use('/user', (req, res) => { - res.set('x-appended-header', req.headers['x-appended-header']).end() + const header = req.headers['x-appended-header'] + + if (header) { + res.set('x-appended-header', header) + } + + res.end() }) }) const interceptor = new HttpRequestInterceptor() beforeAll(async () => { - await server.listen() interceptor.apply() + await server.listen() }) afterEach(() => { @@ -23,11 +29,11 @@ afterEach(() => { }) afterAll(async () => { - await server.close() interceptor.dispose() + await server.close() }) -it('allows modifying the outgoing request headers', async () => { +it('allows modifying the outgoing headers for a request without a body', async () => { interceptor.on('request', ({ request }) => { request.headers.set('x-appended-header', 'modified') }) @@ -38,7 +44,21 @@ it('allows modifying the outgoing request headers', async () => { expect(res.headers['x-appended-header']).toBe('modified') }) -it('allows modifying the outgoing request headers in a request with body', async () => { +it('allows modifying the outgoing headers for a request with a body', async () => { + interceptor.on('request', ({ request }) => { + request.headers.set('x-appended-header', 'modified') + }) + + const request = http.request(server.http.url('/user')) + request.write('hello') + request.end(' world') + + const { res } = await waitForClientRequest(request) + + expect(res.headers['x-appended-header']).toBe('modified') +}) + +it('allows modifying the outgoing request headers in a request with a body', async () => { interceptor.on('request', ({ request }) => { request.headers.set('x-appended-header', 'modified') }) diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 4de58e8e2..6e52a8632 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -22,7 +22,7 @@ afterAll(async () => { await httpServer.close() }) -it.skip('supports custom delay before responding with a mock', async () => { +it('supports custom delay before responding with a mock', async () => { interceptor.once('request', async ({ controller }) => { await sleep(750) controller.respondWith(new Response('mocked response')) From ce82b6fbee3143bd5425613f1c4d6bdcdf502702 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 18:12:26 +0100 Subject: [PATCH 035/198] test: migrate test --- test/modules/http/compliance/http-rate-limit.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index e7a7ae63b..2ff695365 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' 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' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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) From bbd83f68b3617e94730e488a77d419e0dddaeb1c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 21:01:45 +0100 Subject: [PATCH 036/198] test: add socket reuse http compliance test --- .../http/compliance/http-socket-reuse.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 test/modules/http/compliance/http-socket-reuse.test.ts diff --git a/test/modules/http/compliance/http-socket-reuse.test.ts b/test/modules/http/compliance/http-socket-reuse.test.ts new file mode 100644 index 000000000..cc241cc1a --- /dev/null +++ b/test/modules/http/compliance/http-socket-reuse.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import https from 'node:https' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { waitForClientRequest } from '../../../helpers' + +const httpServer = new HttpServer((app) => { + app.get('/get', (req, res) => { + res.status(200).send('original') + }) +}) + +const interceptor = new HttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() + vi.restoreAllMocks() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('allows for ClientRequest to reuse the same socket', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.url.endsWith('/mock')) { + controller.respondWith(new Response(null, { status: 301 })) + } + }) + + { + const request = https.get(httpServer.https.url('/get'), { + rejectUnauthorized: false, + }) + const { res, text } = await waitForClientRequest(request) + + expect.soft(res.statusCode).toBe(200) + await expect.soft(text()).resolves.toBe('original') + } + + { + /** + * @note Performing a request to the same host with the same options + * will trigger https.Agent to reuse the socket created for the first request. + */ + const request = https.get(httpServer.https.url('/mock'), { + rejectUnauthorized: false, + }) + const { res, text } = await waitForClientRequest(request) + + expect.soft(res.statusCode).toBe(301) + await expect.soft(text()).resolves.toBe('') + } +}) From f8d69f263dbd6878b004ed788f9f342374e473bd Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 21:37:35 +0100 Subject: [PATCH 037/198] fix: support reusing the same socket --- src/interceptors/http/index.ts | 16 +- src/interceptors/net/socket-controller.ts | 199 +++++++++++------- .../http/compliance/http-socket-reuse.test.ts | 56 ++++- 3 files changed, 194 insertions(+), 77 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 2df51e461..881e06c19 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -71,7 +71,7 @@ export class HttpRequestInterceptor extends Interceptor { }) const requestController = new RequestController(request, { - respondWith: (response) => { + respondWith: async (response) => { log('respondWith() %o', { status: response.status, statusText: response.statusText, @@ -80,13 +80,23 @@ export class HttpRequestInterceptor extends Interceptor { socketController.claim() - socket.once('connect', async () => { + const respond = async () => { await this.respondWith({ socket: socketController[kRawSocket], request, response, }) - }) + } + + if (socket.connecting) { + socket.once('connect', respond) + } else { + /** + * @note Reused sockets stay connected between requests and will not + * emit "connect" anymore. If that's the case, respond immediately. + */ + await respond() + } if ( this.emitter.listenerCount('response') > 0 && diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index cadcb89eb..ba6c16a45 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -203,26 +203,54 @@ export class TcpSocketController extends SocketController { protected pendingConnection: DeferredPromise<[TcpWrap, TcpHandle]> + #realWriteGeneric: net.Socket['_writeGeneric'] + #passthroughSocket: net.Socket | null = null + #passthroughPausedBuffer: Array = [] + constructor( protected readonly socket: net.Socket, protected readonly createConnection: () => net.Socket ) { super(socket) - this.pendingConnection = new DeferredPromise() - // Implement the read method to prevent the "Error: read ENOTCONN" errors on non-existing hosts. this.socket._read = () => void 0 - this.socket.prependOnceListener('connectionAttempt', () => { - const handle = this.socket._handle + // Store the unpatched write method once so we have access to it between socket state resets. + this.#realWriteGeneric = this.socket._writeGeneric + + /** + * @note A single socket can be reused for connections to the same host. + * When one connection ends, the Agent frees the socket, then uses it + * to write the next request's HTTP message immediately. Use the "free" + * event to transition the controller into the pending state so "_writeGeneric" + * would behave correctly. + */ + socket.on('free', () => this.#reset()) + + this.serverSocket = toServerSocket(this.socket) + + this.pendingConnection = new DeferredPromise() + this.#reset() + } + + #reset(): void { + this.readyState = SocketController.PENDING + this.pendingConnection = new DeferredPromise() + const wrapHandle = (handle: TcpHandle) => { handle.connect = handle.connect6 = (request) => { this.pendingConnection.resolve([request, handle]) } - }) + } - const realWriteGeneric = this.socket._writeGeneric + if (this.socket._handle) { + wrapHandle(this.socket._handle) + } else { + this.socket.prependOnceListener('connectionAttempt', () => { + wrapHandle(this.socket._handle) + }) + } this.socket._writeGeneric = (...args) => { const emitWrite = () => { @@ -233,7 +261,7 @@ export class TcpSocketController extends SocketController { if (this.readyState === SocketController.PENDING) { emitWrite() - return realWriteGeneric.apply(this.socket, args) + return this.#realWriteGeneric.apply(this.socket, args) } if (this.readyState === SocketController.MOCKED) { @@ -254,35 +282,64 @@ export class TcpSocketController extends SocketController { return } - return realWriteGeneric.apply(this.socket, args) + return this.#realWriteGeneric.apply(this.socket, args) } + } - /** - * @note A single socket can be reused for connections to the same host. - * When one connection ends, the Agent frees the socket, then uses it - * to write the next request's HTTP message immediately. Use the "free" - * event to transition the controller into the pending state so "_writeGeneric" - * would behave correctly. - */ - socket.on('free', () => { - this.readyState = SocketController.PENDING - }) + #onRealSocketConnect = () => { + if (!this.#passthroughSocket) { + return + } - this.serverSocket = toServerSocket(this.socket) + this.socket._handle = this.#passthroughSocket._handle + + Reflect.set(this.socket, 'connecting', false) + this.socket.emit('connect') + this.socket.emit('ready') + } + + #onRealSocketData = (data: Buffer) => { + if (this.socket.isPaused()) { + this.#passthroughPausedBuffer.push(data) + return + } + + if (!this.socket.push(data)) { + this.#passthroughSocket?.pause() + } + } + + #onRealSocketError = (error: Error) => { + this.socket.destroy(error) + } + + #onRealSocketEnd = () => { + this.socket.push(null) + } + + #onMockSocketDrain = () => { + this.#passthroughSocket?.resume() } public claim(): void { super.claim() - this.pendingConnection.then(([request, handle]) => { - /** - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 - */ - request.oncomplete(0, handle, request, true, true) - }) + if (this.socket.connecting) { + this.pendingConnection.then(([request, handle]) => { + /** + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 + */ + request.oncomplete(0, handle, request, true, true) + }) + return + } } public errorWith(reason?: Error): void { + if (this.socket.destroyed) { + return + } + this.socket.destroy(reason) } @@ -300,6 +357,7 @@ export class TcpSocketController extends SocketController { * In HTTP, this allows sending different request headers (e.g. modified in the listener). */ if (typeof flushPendingData === 'function') { + // Intentionally grab the latest write method to preserve whatever patches it has. const realSocketWriteGeneric = this.socket._writeGeneric this.socket._writeGeneric = (writev, data, encoding, callback) => { @@ -318,21 +376,29 @@ export class TcpSocketController extends SocketController { } } - const realSocket = this.createConnection() + // If keepalive, reuse the existing real socket. + const realSocket = + this.#passthroughSocket && !this.#passthroughSocket.destroyed + ? this.#passthroughSocket + : this.createConnection() + + if (realSocket !== this.#passthroughSocket) { + this.#passthroughSocket = realSocket + } // Buffer to hold data chunks while the mock socket is paused. // This allows async response event listeners to complete before // data flows to the mock socket and triggers ClientRequest events. - const pausedBuffer: Array = [] + this.#passthroughPausedBuffer = [] this.socket.resume = new Proxy(this.socket.resume, { apply: (target, thisArg, argArray) => { const result = Reflect.apply(target, thisArg, argArray) - while (pausedBuffer.length > 0) { - const bufferedData = pausedBuffer.shift()! + while (this.#passthroughPausedBuffer.length > 0) { + const bufferedData = this.#passthroughPausedBuffer.shift()! if (!this.socket.push(bufferedData)) { - realSocket.pause() + this.#passthroughSocket?.pause() break } } @@ -341,35 +407,22 @@ export class TcpSocketController extends SocketController { }, }) - this.socket.on('drain', () => realSocket.resume()) - realSocket - .once('connect', () => { - this.socket._handle = realSocket._handle - Reflect.set(this.socket, 'connecting', false) - this.socket.emit('connect') - this.socket.emit('ready') - }) - .on('data', (data) => { - // If the mock socket is paused, buffer the data instead of pushing it. - // This allows other listeners on realSocket to continue receiving data - // (e.g., response parsers) without being affected by backpressure. - if (this.socket.isPaused()) { - pausedBuffer.push(data) - return - } + this.socket.removeListener('drain', this.#onMockSocketDrain) + this.socket.on('drain', this.#onMockSocketDrain) - if (!this.socket.push(data)) { - realSocket.pause() - } - }) - .on('error', (error) => { - this.socket.destroy(error) - }) - .on('end', () => { - this.socket.push(null) - }) + realSocket + .removeListener('connect', this.#onRealSocketConnect) + .removeListener('data', this.#onRealSocketData) + .removeListener('error', this.#onRealSocketError) + .removeListener('end', this.#onRealSocketEnd) + + realSocket + .once('connect', this.#onRealSocketConnect) + .on('data', this.#onRealSocketData) + .on('error', this.#onRealSocketError) + .on('end', this.#onRealSocketEnd) return realSocket } @@ -386,24 +439,26 @@ export class TlsSocketController extends TcpSocketController { public claim(): void { // Add this callback before "super.claim()" so it executes first. // TLSWrap methods have to be patched before TCPWrap fires "oncomplete". - this.pendingConnection.then(() => { - /** - * Mock this to prevent the "Error: Worker exited unexpectedly" error. - * This will trigger when "secure" is emitted. - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 - */ - this.socket._handle.verifyError = () => void 0 - - this.socket._handle.start = () => { + if (this.socket.connecting) { + this.pendingConnection.then(() => { /** - * Mock a successful SSL handshake. - * This will emit "secureConnect" and "secure" on the TLS socket and trigger "tlsSocket._finishInit". - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L878 + * Mock this to prevent the "Error: Worker exited unexpectedly" error. + * This will trigger when "secure" is emitted. + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 */ - this.socket._handle.onhandshakedone() - this.socket._handle.onnewsession(1, Buffer.alloc(0)) - } - }) + this.socket._handle.verifyError = () => void 0 + + this.socket._handle.start = () => { + /** + * Mock a successful SSL handshake. + * This will emit "secureConnect" and "secure" on the TLS socket and trigger "tlsSocket._finishInit". + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L878 + */ + this.socket._handle.onhandshakedone() + this.socket._handle.onnewsession(1, Buffer.alloc(0)) + } + }) + } super.claim() } diff --git a/test/modules/http/compliance/http-socket-reuse.test.ts b/test/modules/http/compliance/http-socket-reuse.test.ts index cc241cc1a..ba8dde8cf 100644 --- a/test/modules/http/compliance/http-socket-reuse.test.ts +++ b/test/modules/http/compliance/http-socket-reuse.test.ts @@ -20,7 +20,11 @@ beforeAll(async () => { afterEach(() => { interceptor.removeAllListeners() - vi.restoreAllMocks() + + // Free open sockets between tests to scope reusing of the sockets to each test. + Object.values(https.globalAgent.freeSockets).forEach((sockets) => { + sockets?.forEach((socket) => socket.destroy()) + }) }) afterAll(async () => { @@ -28,7 +32,7 @@ afterAll(async () => { await httpServer.close() }) -it('allows for ClientRequest to reuse the same socket', async () => { +it('allows reusing the same socket for mixed mocked/bypassed requests', async () => { interceptor.on('request', ({ request, controller }) => { if (request.url.endsWith('/mock')) { controller.respondWith(new Response(null, { status: 301 })) @@ -59,3 +63,51 @@ it('allows for ClientRequest to reuse the same socket', async () => { await expect.soft(text()).resolves.toBe('') } }) + +it('allows reusing the same socket for multiple mocked requests', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) + }) + + { + const request = https.get(httpServer.https.url('/mock'), { + rejectUnauthorized: false, + }) + const { res, text } = await waitForClientRequest(request) + + expect.soft(res.statusCode).toBe(200) + await expect.soft(text()).resolves.toBe('mocked') + } + + { + const request = https.get(httpServer.https.url('/mock'), { + rejectUnauthorized: false, + }) + const { res, text } = await waitForClientRequest(request) + + expect.soft(res.statusCode).toBe(200) + await expect.soft(text()).resolves.toBe('mocked') + } +}) + +it('allows reusing the same socket for multiple bypassed requests', async () => { + { + const request = https.get(httpServer.https.url('/get'), { + rejectUnauthorized: false, + }) + const { res, text } = await waitForClientRequest(request) + + expect.soft(res.statusCode).toBe(200) + await expect.soft(text()).resolves.toBe('original') + } + + { + const request = https.get(httpServer.https.url('/get'), { + rejectUnauthorized: false, + }) + const { res, text } = await waitForClientRequest(request) + + expect.soft(res.statusCode).toBe(200) + await expect.soft(text()).resolves.toBe('original') + } +}) From 212f39d5e2c71d9bbfc241d08cbb93524f2094ea Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 21:37:58 +0100 Subject: [PATCH 038/198] test: rename the test file --- .../http/compliance/http-req-callback.test.ts | 101 ------------------ .../http/compliance/http-res-callback.test.ts | 74 +++++++++++++ 2 files changed, 74 insertions(+), 101 deletions(-) delete mode 100644 test/modules/http/compliance/http-req-callback.test.ts create mode 100644 test/modules/http/compliance/http-res-callback.test.ts diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts deleted file mode 100644 index ebf86d255..000000000 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -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) => { - res.status(200).send('/') - }) -}) - -const interceptor = new ClientRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - if ([httpServer.https.url('/get')].includes(request.url)) { - return - } - - controller.respondWith( - new Response('mocked-body', { - status: 403, - statusText: 'Forbidden', - }) - ) -}) - -beforeAll(async () => { - interceptor.apply() - await httpServer.listen() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('calls a custom callback once when the request is bypassed', async () => { - let text: string = '' - - const responseReceived = new DeferredPromise() - const responseCallback = vi.fn<(response: IncomingMessage) => void>( - (response) => { - response.on('data', (chunk) => (text += chunk)) - response.on('end', () => responseReceived.resolve()) - response.on('error', (error) => responseReceived.reject(error)) - } - ) - - https.get( - httpServer.https.url('/get'), - { - rejectUnauthorized: false, - }, - responseCallback - ) - - await responseReceived - - // Check that the request was bypassed. - expect(text).toEqual('/') - - // Custom callback to "https.get" must be called once. - expect(responseCallback).toBeCalledTimes(1) -}) - -it('calls a custom callback once when the response is mocked', async () => { - let text: string = '' - - const responseReceived = new DeferredPromise() - const responseCallback = vi.fn<(response: IncomingMessage) => void>( - (response) => { - response.on('data', (chunk) => (text += chunk)) - response.on('end', () => responseReceived.resolve()) - response.on('error', (error) => responseReceived.reject(error)) - } - ) - - https.get( - httpServer.https.url('/arbitrary'), - { - rejectUnauthorized: false, - }, - responseCallback - ) - - await responseReceived - - // Check that the response was mocked. - expect(text).toEqual('mocked-body') - - // Custom callback to `https.get` must be called once. - expect(responseCallback).toBeCalledTimes(1) -}) diff --git a/test/modules/http/compliance/http-res-callback.test.ts b/test/modules/http/compliance/http-res-callback.test.ts new file mode 100644 index 000000000..ac44337a9 --- /dev/null +++ b/test/modules/http/compliance/http-res-callback.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment node +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import https from 'node:https' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { waitForClientRequest } from '../../../helpers' + +const httpServer = new HttpServer((app) => { + app.get('/get', (req, res) => { + res.status(200).send('original') + }) +}) + +const interceptor = new HttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() + vi.restoreAllMocks() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('calls a custom callback once when the request is bypassed', async () => { + const responseCallback = vi.fn() + + const request = https.get( + httpServer.https.url('/get'), + { + rejectUnauthorized: false, + }, + responseCallback + ) + + const { text } = await waitForClientRequest(request) + + await expect.soft(text()).resolves.toBe('original') + expect.soft(responseCallback).toHaveBeenCalledOnce() +}) + +it('calls a custom callback once when the response is mocked', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('mocked', { + status: 403, + statusText: 'Forbidden', + }) + ) + }) + + const responseCallback = vi.fn() + + const request = https.get( + httpServer.https.url('/arbitrary'), + { + rejectUnauthorized: false, + }, + responseCallback + ) + + const { text, res } = await waitForClientRequest(request) + + await expect.soft(text()).resolves.toBe('mocked') + expect.soft(res.statusCode).toBe(403) + expect.soft(res.statusMessage).toBe('Forbidden') + expect.soft(responseCallback).toHaveBeenCalledOnce() +}) From 8a643ea9993a4663646d1b97976e6792feedd90c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 21:44:25 +0100 Subject: [PATCH 039/198] chore: implement #push --- src/interceptors/net/socket-controller.ts | 26 ++++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index ba6c16a45..ee3611925 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -253,14 +253,8 @@ export class TcpSocketController extends SocketController { } this.socket._writeGeneric = (...args) => { - const emitWrite = () => { - unwrapPendingData(args[1], (chunk, encoding) => { - this.socket.emit('internal:write', chunk, encoding) - }) - } - if (this.readyState === SocketController.PENDING) { - emitWrite() + this.#push(args[1]) return this.#realWriteGeneric.apply(this.socket, args) } @@ -278,7 +272,7 @@ export class TcpSocketController extends SocketController { return } - emitWrite() + this.#push(args[1]) return } @@ -286,6 +280,22 @@ export class TcpSocketController extends SocketController { } } + /** + * Push the given data to the server socket. + * This has no effect on the public-facing socket and is used + * only for the interceptors to subscribe to "socket.on('data')" + * before the data is actually written anywhere. + */ + #push = (data: net.Socket['_pendingData']) => { + if (data == null) { + return + } + + unwrapPendingData(data, (chunk, encoding) => { + this.socket.emit('internal:write', chunk, encoding) + }) + } + #onRealSocketConnect = () => { if (!this.#passthroughSocket) { return From 859c266a7e94fe25d2fa9e85e0df35a3e5c61500 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 21:46:21 +0100 Subject: [PATCH 040/198] chore: clear internal refs on socket close --- src/interceptors/net/socket-controller.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index ee3611925..91d59cb0a 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -226,7 +226,12 @@ export class TcpSocketController extends SocketController { * event to transition the controller into the pending state so "_writeGeneric" * would behave correctly. */ - socket.on('free', () => this.#reset()) + socket + .on('free', () => this.#reset()) + .on('close', () => { + this.#passthroughSocket = null + this.#passthroughPausedBuffer = [] + }) this.serverSocket = toServerSocket(this.socket) @@ -341,15 +346,10 @@ export class TcpSocketController extends SocketController { */ request.oncomplete(0, handle, request, true, true) }) - return } } public errorWith(reason?: Error): void { - if (this.socket.destroyed) { - return - } - this.socket.destroy(reason) } From 43fe2ef18c726cda86b44f9478d7582603c85198 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 21:56:52 +0100 Subject: [PATCH 041/198] fix: emit `secureConnect` for tls sockets correctly --- src/interceptors/net/socket-controller.ts | 37 ++++++++----------- .../compliance/http-event-connect.test.ts | 22 ++++++----- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 91d59cb0a..b358d177b 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -417,8 +417,6 @@ export class TcpSocketController extends SocketController { }, }) - realSocket - this.socket.removeListener('drain', this.#onMockSocketDrain) this.socket.on('drain', this.#onMockSocketDrain) @@ -449,26 +447,21 @@ export class TlsSocketController extends TcpSocketController { public claim(): void { // Add this callback before "super.claim()" so it executes first. // TLSWrap methods have to be patched before TCPWrap fires "oncomplete". - if (this.socket.connecting) { - this.pendingConnection.then(() => { - /** - * Mock this to prevent the "Error: Worker exited unexpectedly" error. - * This will trigger when "secure" is emitted. - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 - */ - this.socket._handle.verifyError = () => void 0 - - this.socket._handle.start = () => { - /** - * Mock a successful SSL handshake. - * This will emit "secureConnect" and "secure" on the TLS socket and trigger "tlsSocket._finishInit". - * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L878 - */ - this.socket._handle.onhandshakedone() - this.socket._handle.onnewsession(1, Buffer.alloc(0)) - } - }) - } + const handle = this.socket._handle + + handle.start = () => void 0 + + /** + * Mock this to prevent the "Error: Worker exited unexpectedly" error. + * This will trigger when "secure" is emitted. + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 + */ + handle.verifyError = () => void 0 + + this.socket.once('connect', () => { + handle.onhandshakedone() + handle.onnewsession(1, Buffer.alloc(0)) + }) super.claim() } diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts index 50528324a..4df365335 100644 --- a/test/modules/http/compliance/http-event-connect.test.ts +++ b/test/modules/http/compliance/http-event-connect.test.ts @@ -64,15 +64,16 @@ it('emits the "secureConnect" event for a mocked HTTPS request', async () => { const connectListener = vi.fn<(input: string) => void>() const request = https.get(httpServer.https.url('/')) request.on('socket', (socket) => { - socket.on('connect', () => connectListener('connect')) - socket.on('secureConnect', () => connectListener('secureConnect')) + socket + .on('connect', () => connectListener('connect')) + .on('secureConnect', () => connectListener('secureConnect')) }) await waitForClientRequest(request) - expect(connectListener).toHaveBeenNthCalledWith(1, 'connect') - expect(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') - expect(connectListener).toHaveBeenCalledTimes(2) + expect.soft(connectListener).toHaveBeenNthCalledWith(1, 'connect') + expect.soft(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') + expect.soft(connectListener).toHaveBeenCalledTimes(2) }) it('emits the "secureConnect" event for a bypassed HTTPS request', async () => { @@ -81,13 +82,14 @@ it('emits the "secureConnect" event for a bypassed HTTPS request', async () => { rejectUnauthorized: false, }) request.on('socket', (socket) => { - socket.on('connect', () => connectListener('connect')) - socket.on('secureConnect', () => connectListener('secureConnect')) + socket + .on('connect', () => connectListener('connect')) + .on('secureConnect', () => connectListener('secureConnect')) }) await waitForClientRequest(request) - expect(connectListener).toHaveBeenNthCalledWith(1, 'connect') - expect(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') - expect(connectListener).toHaveBeenCalledTimes(2) + expect.soft(connectListener).toHaveBeenNthCalledWith(1, 'connect') + expect.soft(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') + expect.soft(connectListener).toHaveBeenCalledTimes(2) }) From 14d0e45d79fab51735cbe657edfe138defc29cb8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 22:02:55 +0100 Subject: [PATCH 042/198] test: migrate remaining tests to new interceptor --- .../compliance/http-req-get-with-body.test.ts | 8 +++---- .../http/compliance/http-req-method.test.ts | 4 ++-- .../http-req-url-to-http-options.test.ts | 4 ++-- .../http/compliance/http-req-write.test.ts | 21 +++++++++---------- .../http/compliance/http-request-ipv6.test.ts | 7 ++++--- .../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 | 4 ++-- .../http-res-read-multiple-times.test.ts | 4 ++-- .../compliance/http-res-set-encoding.test.ts | 8 +++---- .../http-response-headers-folding.test.ts | 4 ++-- .../compliance/http-socket-listeners.test.ts | 4 ++-- .../http/compliance/http-ssl-socket.test.ts | 16 ++++++++++---- .../http/compliance/http-timeout.test.ts | 4 ++-- .../http-unhandled-exception.test.ts | 4 ++-- .../http/compliance/http-unix-socket.test.ts | 4 ++-- .../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 | 4 ++-- test/modules/http/http-performance.test.ts | 8 +++---- ...ncurrent-different-response-source.test.ts | 8 +++---- .../http-concurrent-same-host.test.ts | 4 ++-- ...ttp-empty-readable-stream-response.test.ts | 4 ++-- ...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 | 4 ++-- 29 files changed, 83 insertions(+), 87 deletions(-) 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..139ac26d0 100644 --- a/test/modules/http/compliance/http-req-get-with-body.test.ts +++ b/test/modules/http/compliance/http-req-get-with-body.test.ts @@ -1,13 +1,11 @@ -/** - * @see https://github.com/nock/nock/issues/2826 - */ +// @see https://github.com/nock/nock/issues/2826 import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -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..d344cb5f1 100644 --- a/test/modules/http/compliance/http-req-method.test.ts +++ b/test/modules/http/compliance/http-req-method.test.ts @@ -3,10 +3,10 @@ */ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..e873b3a38 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,10 +2,10 @@ import { urlToHttpOptions } from 'node:url' import http from 'node:http' import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..d6f0d472f 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 - */ +// @vitest-environment node import { Readable } from 'node:stream' import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import express from 'express' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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()) }) @@ -28,7 +26,8 @@ beforeAll(async () => { }) afterEach(() => { - vi.resetAllMocks() + vi.clearAllMocks() + interceptor.removeAllListeners() }) afterAll(async () => { @@ -53,7 +52,7 @@ it('writes string request body', async () => { const expectedBody = 'onetwothree' expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(await text()).toEqual(expectedBody) + await expect(text()).resolves.toEqual(expectedBody) }) it('writes JSON request body', async () => { @@ -72,7 +71,7 @@ it('writes JSON request body', async () => { const expectedBody = `{"key":"value"}` expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(await text()).toEqual(expectedBody) + await expect(text()).resolves.toEqual(expectedBody) }) it('writes Buffer request body', async () => { @@ -91,7 +90,7 @@ it('writes Buffer request body', async () => { const expectedBody = `{"key":"value"}` expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(await text()).toEqual(expectedBody) + await expect(text()).resolves.toEqual(expectedBody) }) it('supports Readable as the request body', async () => { @@ -192,7 +191,7 @@ it('calls all write callbacks before the mocked response', async () => { const { text } = await waitForClientRequest(request) expect(requestBodyCallback).toHaveBeenCalledWith('one') - expect(await text()).toBe('hello world') + await expect(text()).resolves.toBe('hello world') }) it('calls the write callbacks when reading request body in the interceptor', async () => { @@ -218,7 +217,7 @@ it('calls the write callbacks when reading request body in the interceptor', asy // Must be able to read the request stream in the interceptor. expect(requestBodyCallback).toHaveBeenCalledWith('onetwothree') // Must send the correct request body to the server. - expect(await text()).toBe('onetwothree') + await expect(text()).resolves.toBe('onetwothree') }) /** diff --git a/test/modules/http/compliance/http-request-ipv6.test.ts b/test/modules/http/compliance/http-request-ipv6.test.ts index d7e0a839a..21e24dbc6 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 { httpGet } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { DeferredPromise } from '@open-draft/deferred-promise' +import { httpGet } from '../../../helpers' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-request-without-options.test.ts b/test/modules/http/compliance/http-request-without-options.test.ts index 0b87faabe..18fcc6fcb 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 http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..5a6bfd922 100644 --- a/test/modules/http/compliance/http-res-destroy.test.ts +++ b/test/modules/http/compliance/http-res-destroy.test.ts @@ -1,15 +1,15 @@ // @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { HttpServer } from '@open-draft/test-server/lib/http' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' 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..3a30f3033 100644 --- a/test/modules/http/compliance/http-res-non-configurable.test.ts +++ b/test/modules/http/compliance/http-res-non-configurable.test.ts @@ -5,12 +5,12 @@ 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 { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpRequestInterceptor } from '../../../../src/interceptors/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..5d3831019 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -4,7 +4,7 @@ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' // The actual server is here for A/B purpose only. @@ -15,7 +15,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..806d56759 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 @@ -8,7 +8,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestEventMap } from '../../../../src' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..51e841a9a 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 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' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..1d84ec9a2 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 http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-socket-listeners.test.ts b/test/modules/http/compliance/http-socket-listeners.test.ts index aa2623597..7fe0be44f 100644 --- a/test/modules/http/compliance/http-socket-listeners.test.ts +++ b/test/modules/http/compliance/http-socket-listeners.test.ts @@ -8,7 +8,7 @@ import http from 'node:http' import { Socket } from 'node:net' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -17,7 +17,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index f86e702c1..7666e556d 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -5,9 +5,9 @@ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import type { TLSSocket } from 'node:tls' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -41,7 +41,11 @@ it('emits a correct TLS Socket instance for a handled HTTPS request', async () = expect(socket.getSession()).toBeUndefined() expect(socket.getProtocol()).toBe('TLSv1.3') expect(socket.isSessionReused()).toBe(false) - expect(socket.getCipher()).toEqual({ name: 'AES256-SHA', standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', version: 'TLSv1.3' }) + expect(socket.getCipher()).toEqual({ + name: 'AES256-SHA', + standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', + version: 'TLSv1.3', + }) }) it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () => { @@ -59,5 +63,9 @@ it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () expect(socket.getSession()).toBeUndefined() expect(socket.getProtocol()).toBe('TLSv1.3') - expect(socket.getCipher()).toEqual({ name: 'AES256-SHA', standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', version: 'TLSv1.3' }) + expect(socket.getCipher()).toEqual({ + name: 'AES256-SHA', + standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', + version: 'TLSv1.3', + }) }) diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts index c93127b09..15514dffe 100644 --- a/test/modules/http/compliance/http-timeout.test.ts +++ b/test/modules/http/compliance/http-timeout.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { sleep } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -13,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 2adf5a81a..1f7a13bf7 100644 --- a/test/modules/http/compliance/http-unhandled-exception.test.ts +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -1,10 +1,10 @@ // @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-unix-socket.test.ts b/test/modules/http/compliance/http-unix-socket.test.ts index 1599fc331..e99f4348e 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -6,7 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from 'vitest' import path from 'node:path' import http from 'node:http' import { promisify } from 'node:util' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' // const HTTP_SOCKET_PATH = mockFs.resolve('./test.sock') @@ -22,7 +22,7 @@ const httpServer = http.createServer((req, res) => { } }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await new Promise((resolve) => { diff --git a/test/modules/http/compliance/http-upgrade.test.ts b/test/modules/http/compliance/http-upgrade.test.ts index ea286ae36..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..b3cf2b100 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -4,10 +4,10 @@ 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..e53a3f45c 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -9,14 +9,14 @@ 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' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..ce2b6b101 100644 --- a/test/modules/http/compliance/https-custom-agent.test.ts +++ b/test/modules/http/compliance/https-custom-agent.test.ts @@ -3,7 +3,7 @@ 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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/http-performance.test.ts b/test/modules/http/http-performance.test.ts index a22cff9c4..2dc36d4d2 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,10 +1,8 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { httpGet, PromisifiedResponse, useCors } from '../../helpers' -import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../src/interceptors/http' function arrayWith(length: number, mapFn: (index: number) => V): V[] { return new Array(length).fill(null).map((_, index) => mapFn(index)) @@ -31,7 +29,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/regressions/http-concurrent-different-response-source.test.ts b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts index c0b34c8bb..35565581b 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,9 +1,7 @@ -/** - * @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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { httpGet } from '../../../helpers' import { sleep } from '../../../../test/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/regressions/http-concurrent-same-host.test.ts b/test/modules/http/regressions/http-concurrent-same-host.test.ts index d90efbdab..c42451eed 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -4,11 +4,11 @@ */ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' let requests: Array = [] -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { requests.push(request) controller.respondWith(new Response()) 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..6ddfbdbf8 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 @@ -3,10 +3,10 @@ */ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() 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..4c8638986 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 @@ -5,7 +5,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..19064e82d 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 @@ -4,12 +4,12 @@ */ import http from 'node:http' import path from 'node:path' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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..f0d4413d2 100644 --- a/test/modules/http/regressions/http-socket-timeout.ts +++ b/test/modules/http/regressions/http-socket-timeout.ts @@ -9,7 +9,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' 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' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { @@ -17,7 +17,7 @@ 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 })) }) From 43504413c5bdf26d8a48efee01987c50d2a044ef Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Feb 2026 22:13:42 +0100 Subject: [PATCH 043/198] fix: treat write `callback` as optional --- src/interceptors/net/socket-controller.ts | 10 +++------- src/utils/bufferUtils.ts | 13 +++++++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index b358d177b..ff43c6d50 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -145,14 +145,10 @@ function toServerSocket(socket: T): T { // Push data to the client socket when server "socket.write()" is called. if (property === 'write') { - return ( - chunk: any, - encoding: BufferEncoding, - callback: (error?: Error | null) => void - ) => { + return ((chunk, encoding, callback) => { socket.push(toBuffer(chunk, encoding), encoding) - callback() - } + callback?.() + }) as net.Socket['write'] } return getRealValue() diff --git a/src/utils/bufferUtils.ts b/src/utils/bufferUtils.ts index 45d726664..6d2eee581 100644 --- a/src/utils/bufferUtils.ts +++ b/src/utils/bufferUtils.ts @@ -1,3 +1,5 @@ +import { findLastIndex } from 'node_modules/es-toolkit/dist/compat/compat.mjs' + const encoder = new TextEncoder() export function encodeBuffer(text: string): Uint8Array { @@ -22,8 +24,15 @@ export function toArrayBuffer(array: Uint8Array): ArrayBuffer { } export function toBuffer( - data: string | Buffer, + data: string | Buffer | Uint8Array, encoding?: BufferEncoding ): Buffer { - return Buffer.isBuffer(data) ? data : Buffer.from(data, encoding) + if (Buffer.isBuffer(data)) { + return data + } + if (data instanceof Uint8Array) { + return Buffer.from(data.buffer) + } + + return Buffer.from(data, encoding) } From e9dbbcce66e8d6c5a896a9f741e01d74567056d7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 11:03:26 +0100 Subject: [PATCH 044/198] fix: use `ServerResponse` for mock response handling --- src/interceptors/http/index.ts | 120 +++++------ src/interceptors/net/socket-controller.ts | 12 +- .../compliance/http-socket-listeners.test.ts | 11 - .../http/compliance/http-timeout.test.ts | 196 +++++++++--------- .../http-response-readable-stream.test.ts | 81 ++++---- 5 files changed, 201 insertions(+), 219 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 881e06c19..80406dbac 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -1,4 +1,7 @@ import net from 'node:net' +import { Readable } from 'node:stream' +import type { ReadableStream } from 'node:stream/web' +import { pipeline } from 'node:stream/promises' import { invariant } from 'outvariant' import { Interceptor } from '../../Interceptor' import { type HttpRequestEventMap } from '../../glossary' @@ -18,6 +21,7 @@ import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' import { kRawSocket } from '../net/socket-controller' +import { unwrapPendingData } from '../net/utils/flush-writes' const log = createLogger('HttpRequestInterceptor') @@ -225,7 +229,7 @@ export class HttpRequestInterceptor extends Interceptor { request: Request response: Response }): Promise { - const { socket, response } = args + const { socket, request, response } = args if (socket.destroyed) { return @@ -238,90 +242,62 @@ export class HttpRequestInterceptor extends Interceptor { invariant( !socket.connecting, - 'Failed to mock a response: socket has not connected' + 'Failed to mock a response for "%s %s": socket has not connected', + request.method, + request.url ) - const { STATUS_CODES } = await import('node:http') + const { STATUS_CODES, ServerResponse, IncomingMessage } = + await import('node:http') - const statusText = - response.statusText || STATUS_CODES[response.status] || '' - const statusLine = `HTTP/1.1 ${response.status} ${statusText}\r\n` + /** + * Use native server response handling in Node.js. + * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/_http_server.js#L202 + */ + const serverResponse = new ServerResponse(new IncomingMessage(socket)) - const rawResponseHeaders = getRawFetchHeaders(response.headers) - const isChunkedEncoding = - response.headers.get('transfer-encoding') === 'chunked' + const responseSocket = new net.Socket() - let headersString = '' - for (const [name, value] of rawResponseHeaders) { - headersString += `${name}: ${value}\r\n` + responseSocket._writeGeneric = (writev, data, encoding, callback) => { + unwrapPendingData(data, (chunk, encoding) => { + socket.push(toBuffer(chunk), encoding) + }) + callback?.() } - headersString += '\r\n' - - const httpMessageHeaders = statusLine + headersString - log('writing http response message headers...\n', httpMessageHeaders) + responseSocket._destroy = () => { + /** + * 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() + } - // Flush the mocked response headers. - // This will trigger the "response" event in "ClientRequest". - socket.push(Buffer.from(httpMessageHeaders)) + serverResponse.assignSocket(responseSocket) - if (response.body) { - try { - const reader = response.body.getReader() - - while (true) { - const { done, value } = await reader.read() - - if (done) { - break - } - - /** - * Validate that the chunk is a valid type before pushing to the socket. - * If it's not a Buffer, string, or TypedArray, socket.push() will emit - * an async error event that bypasses our try/catch. We need to catch - * this case and handle it synchronously. - */ - if ( - value != null && - typeof value !== 'string' && - !Buffer.isBuffer(value) && - !(value instanceof Uint8Array) && - !ArrayBuffer.isView(value) - ) { - throw new Error('Invalid chunk type') - } - - if (isChunkedEncoding) { - const chunkSize = value.byteLength.toString(16) - socket.push(Buffer.from(`${chunkSize}\r\n`)) - socket.push(value) - socket.push(Buffer.from('\r\n')) - } else { - socket.push(value) - } - } - } catch (error) { - if (error instanceof Error) { - /** - * Destroy the socket if the response stream errored. - * @see https://github.com/mswjs/interceptors/issues/738 - */ - socket.destroy() - return - } - } + serverResponse.removeHeader('connection') + serverResponse.removeHeader('date') - log('response stream handling done!') - } + const rawResponseHeaders = getRawFetchHeaders(response.headers) + serverResponse.writeHead( + response.status, + response.statusText || STATUS_CODES[response.status], + rawResponseHeaders + ) - if (isChunkedEncoding) { - socket.push(Buffer.from('0\r\n\r\n')) + if (response.body) { + await pipeline( + Readable.fromWeb(response.body as ReadableStream), + serverResponse + ) + } else { + serverResponse.end() } - /** - * @todo Keep-Alive requests shouldn't end the stream here. - */ socket.push(null) } } diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index ff43c6d50..9927ddf61 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -382,11 +382,21 @@ export class TcpSocketController extends SocketController { } } + const createNewSocket = () => { + const realSocket = this.createConnection() + + if (this.socket.timeout != null) { + realSocket.setTimeout(this.socket.timeout) + } + + return realSocket + } + // If keepalive, reuse the existing real socket. const realSocket = this.#passthroughSocket && !this.#passthroughSocket.destroyed ? this.#passthroughSocket - : this.createConnection() + : createNewSocket() if (realSocket !== this.#passthroughSocket) { this.#passthroughSocket = realSocket diff --git a/test/modules/http/compliance/http-socket-listeners.test.ts b/test/modules/http/compliance/http-socket-listeners.test.ts index 7fe0be44f..67b304f0c 100644 --- a/test/modules/http/compliance/http-socket-listeners.test.ts +++ b/test/modules/http/compliance/http-socket-listeners.test.ts @@ -39,19 +39,8 @@ it('removes all event listeners from a passthrough socket after closing', async pendingSocket.resolve(socket) }) - const socket = await pendingSocket const { res, text } = await waitForClientRequest(request) expect.soft(res.statusCode).toBe(200) await expect.soft(text()).resolves.toBe('ok') - - const passthroughSocket = Reflect.get(socket, 'originalSocket') as Socket - expect(passthroughSocket).toBeInstanceOf(Socket) - - await expect - .poll( - // @ts-expect-error Node.js internals - () => passthroughSocket._events - ) - .toEqual({}) }) diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts index 15514dffe..e33341a07 100644 --- a/test/modules/http/compliance/http-timeout.test.ts +++ b/test/modules/http/compliance/http-timeout.test.ts @@ -1,14 +1,13 @@ // @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { - await sleep(200) + await setTimeout(200) res.status(500).end() }) }) @@ -31,27 +30,30 @@ afterAll(async () => { it('respects the "timeout" option for a handled request', async () => { interceptor.on('request', async ({ controller }) => { - await sleep(200) + await setTimeout(200) controller.respondWith(new Response('hello world')) }) - const errorListener = vi.fn() - const timeoutListener = vi.fn() - const responseListener = vi.fn() const request = http.get('http://localhost/resource', { timeout: 10, }) - request.on('error', errorListener) - request.on('timeout', () => { - timeoutListener() - // Request must be destroyed manually on timeout. - request.destroy() - }) - request.on('response', responseListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + const responseListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + const timeoutListener = vi.fn() + + request + .on('response', responseListener) + .on('timeout', () => { + timeoutListener() + // Request must be destroyed manually on timeout. + request.destroy() + }) + .on('error', errorListener) + .on('close', closeListener) + + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(timeoutListener).toHaveBeenCalledTimes(1) @@ -64,23 +66,26 @@ it('respects the "timeout" option for a handled request', async () => { }) it('respects the "timeout" option for a bypassed request', async () => { - const errorListener = vi.fn() - const timeoutListener = vi.fn() - const responseListener = vi.fn() const request = http.get(httpServer.http.url('/resource'), { timeout: 10, }) - request.on('error', errorListener) - request.on('timeout', () => { - timeoutListener() - // Request must be destroyed manually on timeout. - request.destroy() - }) - request.on('response', responseListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + const responseListener = vi.fn() + const timeoutListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + request + .on('response', responseListener) + .on('error', errorListener) + .on('timeout', () => { + timeoutListener() + // Request must be destroyed manually on timeout. + request.destroy() + }) + .on('close', closeListener) + + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(timeoutListener).toHaveBeenCalledTimes(1) @@ -98,18 +103,21 @@ it('respects a "setTimeout()" on a handled request', async () => { async start(controller) { // Emulate a long pending response stream // to trigger the request timeout. - await sleep(200) + await setTimeout(200) controller.enqueue(new TextEncoder().encode('hello')) }, }) + controller.respondWith(new Response(stream)) }) - const errorListener = vi.fn() - const timeoutListener = vi.fn() + const request = http.get('http://localhost/resource') + const setTimeoutCallback = vi.fn() const responseListener = vi.fn() - const request = http.get('http://localhost/resource') + const timeoutListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() /** * @note `request.setTimeout(n)` is NOT equivalent to @@ -127,16 +135,16 @@ it('respects a "setTimeout()" on a handled request', async () => { */ request.setTimeout(10, setTimeoutCallback) - request.on('error', errorListener) - request.on('timeout', () => { - timeoutListener() - request.destroy() - }) - request.on('response', responseListener) + request + .on('response', responseListener) + .on('timeout', () => { + timeoutListener() + request.destroy() + }) + .on('error', errorListener) + .on('close', closeListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(timeoutListener).toHaveBeenCalledTimes(1) @@ -150,22 +158,24 @@ it('respects a "setTimeout()" on a handled request', async () => { }) it('respects a "setTimeout()" on a bypassed request', async () => { - const errorListener = vi.fn() - const timeoutListener = vi.fn() - const responseListener = vi.fn() const request = http.get(httpServer.http.url('/resource')) request.setTimeout(10) - request.on('error', errorListener) - request.on('timeout', () => { - timeoutListener() - request.destroy() - }) - request.on('response', responseListener) + const responseListener = vi.fn() + const timeoutListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + request + .on('response', responseListener) + .on('timeout', () => { + timeoutListener() + request.destroy() + }) + .on('error', errorListener) + .on('close', closeListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(timeoutListener).toHaveBeenCalledTimes(1) @@ -181,53 +191,50 @@ it('respects the "socket.setTimeout()" for a handled request', async () => { interceptor.on('request', async ({ controller }) => { const stream = new ReadableStream({ async start(controller) { - // Emulate a long pending response stream - // to trigger the request timeout. - await sleep(200) + // Emulate a long pending response stream to trigger the request timeout. + await setTimeout(200) controller.enqueue(new TextEncoder().encode('hello')) }, }) + controller.respondWith(new Response(stream)) }) - const errorListener = vi.fn() - const setTimeoutCallback = vi.fn() - const responseListener = vi.fn() - const request = http.get('http://localhost/resource') + const request = http.get(httpServer.http.url('/resource')) - request.on('socket', (socket) => { - /** - * @note Setting timeout on the socket directly - * will NOT add the "timeout" listener to the request, - * unlike "request.setTimeout()". - */ - socket.setTimeout(10, () => { - setTimeoutCallback() - request.destroy() + const responseListener = vi.fn() + const setTimeoutCallback = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + request + .on('response', responseListener) + .on('socket', (socket) => { + /** + * @note Setting timeout on the socket directly will NOT add the "timeout" + * listener to the request, unlike "request.setTimeout()". + */ + socket + .setTimeout(10, setTimeoutCallback) + .on('timeout', () => socket.destroy()) }) - }) - - request.on('error', errorListener) - request.on('response', responseListener) + .on('error', errorListener) + .on('close', closeListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + await expect.poll(() => closeListener).toHaveBeenCalledOnce() - expect(request.destroyed).toBe(true) - expect(setTimeoutCallback).toHaveBeenCalledTimes(1) - expect(errorListener).toHaveBeenCalledWith( + expect.soft(request.destroyed).toBe(true) + expect.soft(setTimeoutCallback).toHaveBeenCalledTimes(1) + expect.soft(errorListener).toHaveBeenCalledWith( expect.objectContaining({ code: 'ECONNRESET', }) ) - expect(responseListener).not.toHaveBeenCalled() + expect.soft(responseListener).not.toHaveBeenCalled() }) it('respects the "socket.setTimeout()" for a bypassed request', async () => { - const errorListener = vi.fn() const setTimeoutCallback = vi.fn() - const responseListener = vi.fn() const request = http.get(httpServer.http.url('/resource')) request.on('socket', (socket) => { @@ -236,18 +243,21 @@ it('respects the "socket.setTimeout()" for a bypassed request', async () => { * will NOT add the "timeout" listener to the request, * unlike "request.setTimeout()". */ - socket.setTimeout(10, () => { - setTimeoutCallback() - request.destroy() - }) + socket + .setTimeout(10, setTimeoutCallback) + .on('timeout', () => socket.destroy()) }) - request.on('error', errorListener) - request.on('response', responseListener) + const responseListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + request + .on('response', responseListener) + .on('error', errorListener) + .on('close', closeListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(setTimeoutCallback).toHaveBeenCalledTimes(1) 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 5d7043196..99a83da12 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -1,12 +1,12 @@ // @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { performance } from 'node:perf_hooks' import http from 'node:http' import { Readable } from 'node:stream' +import { performance } from 'node:perf_hooks' import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, waitForClientRequest } from '../../../helpers' +import { waitForClientRequest } from '../../../helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> @@ -19,7 +19,7 @@ const httpServer = new HttpServer((app) => { }, }) - res.writeHead(200).flushHeaders() + res.writeHead(200) Readable.fromWeb(stream as any) .on('error', (error) => res.destroy(error)) .pipe(res) @@ -34,7 +34,7 @@ const httpServer = new HttpServer((app) => { }, }) - res.writeHead(200).flushHeaders() + res.writeHead(200) Readable.fromWeb(stream as any) .on('error', (error) => res.destroy(error)) .pipe(res) @@ -49,7 +49,7 @@ const httpServer = new HttpServer((app) => { }, }) - res.writeHead(200).flushHeaders() + res.writeHead(200) Readable.fromWeb(stream as any) .on('error', (error) => res.destroy(error)) .pipe(res) @@ -97,13 +97,13 @@ it('supports delays between the mock response stream chunks', async () => { const stream = new ReadableStream({ async start(controller) { controller.enqueue(encoder.encode('first')) - await sleep(150) + await setTimeout(150) controller.enqueue(encoder.encode('second')) - await sleep(150) + await setTimeout(150) controller.enqueue(encoder.encode('third')) - await sleep(150) + await setTimeout(150) controller.close() }, @@ -153,7 +153,7 @@ it('supports delays between the mock response stream chunks', async () => { expect(chunkTimings[2] - chunkTimings[1]).toBeGreaterThanOrEqual(140) }) -it('handles immediate mock response stream errors as request errors', async () => { +it('handles immediate mock response stream errors as response errors', async () => { const streamError = new Error('stream error') interceptor.on('request', ({ controller }) => { @@ -188,28 +188,34 @@ it('handles immediate mock response stream errors as request errors', async () = response.on('data', responseDataListener).on('error', responseErrorListener) }) + /** + * @note Response stream errors are handled differently in Node.js + * depending on whether the response headers were flushed by the server. + * Normally, Node.js buffers the message headers until (1) response ends; + * (2) the first response chunk is sent. We maintain that default behavior + * for performance considerations. Whether headers are flushed has no effect + * on the client, that's an implementation detail. + */ await expect - .poll(() => responseErrorListener) + .poll(() => requestErrorListener) .toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ code: 'ECONNRESET' }) + expect.objectContaining({ + code: 'ECONNRESET', + message: 'socket hang up', + }) ) - expect.soft(responseDataListener).not.toHaveBeenCalled() - expect.soft(request.destroyed).toBe(true) - expect.soft(requestResponseListener).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - statusCode: 200, - statusMessage: 'OK', - }) - ) - expect.soft(requestErrorListener).not.toHaveBeenCalled() + expect.soft(requestResponseListener).not.toHaveBeenCalled() expect.soft(requestCloseListener).toHaveBeenCalledOnce() + expect.soft(responseDataListener).not.toHaveBeenCalled() + expect.soft(responseErrorListener).not.toHaveBeenCalled() + expect.soft(socketErrorListener).not.toHaveBeenCalled() expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) }) -it('handles immediate bypassed response stream errors as response errors', async () => { +it('handles immediate bypassed response stream errors as request errors', async () => { const request = http.get(httpServer.http.url('/stream/immediate-error')) const socketErrorListener = vi.fn() @@ -233,32 +239,25 @@ it('handles immediate bypassed response stream errors as response errors', async }) await expect - .poll(() => responseErrorListener) + .poll(() => requestErrorListener) .toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ code: 'ECONNRESET' }) + expect.objectContaining({ + code: 'ECONNRESET', + message: 'socket hang up', + }) ) - expect.soft(responseDataListener).not.toHaveBeenCalled() - expect.soft(request.destroyed).toBe(true) - expect.soft(requestResponseListener).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - statusCode: 200, - statusMessage: 'OK', - }) - ) - expect.soft(requestErrorListener).not.toHaveBeenCalled() + expect.soft(requestResponseListener).not.toHaveBeenCalled() expect.soft(requestCloseListener).toHaveBeenCalledOnce() + expect.soft(responseDataListener).not.toHaveBeenCalled() + expect.soft(responseErrorListener).not.toHaveBeenCalled() + expect.soft(socketErrorListener).not.toHaveBeenCalled() expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) }) -// -// -// -// - -it('handles delayed mock response stream errors as request errors', async () => { +it('handles delayed mock response stream errors as response errors', async () => { const streamError = new Error('stream error') interceptor.on('request', ({ controller }) => { @@ -368,7 +367,7 @@ it('handles delayed bypassed response stream errors as response errors', async ( expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) }) -it('treats unhandled exceptions during bypass response stream as request errors', async () => { +it('treats unhandled exceptions during bypass response stream as response errors', async () => { const request = http.get(httpServer.http.url('/stream/exception')) const requestErrorListener = vi.fn() @@ -400,7 +399,7 @@ it('treats unhandled exceptions during bypass response stream as request errors' ) }) -it('treats unhandled exceptions during mock response stream as request errors', async () => { +it('treats unhandled exceptions during mock response stream as response errors', async () => { interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ start(controller) { @@ -428,8 +427,6 @@ it('treats unhandled exceptions during mock response stream as request errors', .on('error', requestErrorListener) .on('close', requestCloseListener) - request.on('error', (error) => console.trace(error)) - await expect.poll(() => requestCloseListener).toHaveBeenCalledOnce() expect.soft(request.destroyed).toBe(true) expect.soft(requestErrorListener).not.toHaveBeenCalled() From a481667eaf9fe63c938214b668254addf730ba8d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 13:02:07 +0100 Subject: [PATCH 045/198] fix: support calling end in a write callback --- src/interceptors/http/http-parser.ts | 3 +- src/interceptors/http/index.ts | 4 +- src/interceptors/net/socket-controller.ts | 43 +++-- src/utils/handleRequest.ts | 12 -- .../http/compliance/http-req-write.test.ts | 150 ++++++++++++------ 5 files changed, 136 insertions(+), 76 deletions(-) diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index f65b5082a..211e17d28 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -130,7 +130,7 @@ export class HttpRequestParser extends HttpParser { * @note Provide the `read()` method so a `Readable` could be * used as the actual request body (the stream calls "read()"). */ - read() {}, + read: () => {}, }) const request = new Request(url, { @@ -155,6 +155,7 @@ export class HttpRequestParser extends HttpParser { this.#requestBodyStream.push(chunk) }, onMessageComplete: () => { + this.#rawHeadersBuffer.length = 0 this.#requestBodyStream?.push(null) }, }) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 80406dbac..d7b79e727 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -1,5 +1,6 @@ import net from 'node:net' import { Readable } from 'node:stream' +import { STATUS_CODES, ServerResponse, IncomingMessage } from 'node:http' import type { ReadableStream } from 'node:stream/web' import { pipeline } from 'node:stream/promises' import { invariant } from 'outvariant' @@ -247,9 +248,6 @@ export class HttpRequestInterceptor extends Interceptor { request.url ) - const { STATUS_CODES, ServerResponse, IncomingMessage } = - await import('node:http') - /** * Use native server response handling in Node.js. * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/_http_server.js#L202 diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 9927ddf61..7814ed90d 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -210,7 +210,7 @@ export class TcpSocketController extends SocketController { super(socket) // Implement the read method to prevent the "Error: read ENOTCONN" errors on non-existing hosts. - this.socket._read = () => void 0 + this.socket._read = () => {} // Store the unpatched write method once so we have access to it between socket state resets. this.#realWriteGeneric = this.socket._writeGeneric @@ -256,24 +256,47 @@ export class TcpSocketController extends SocketController { this.socket._writeGeneric = (...args) => { if (this.readyState === SocketController.PENDING) { this.#push(args[1]) + + /** + * @note Execute the write callbacks while the socket is still pending. + * This prevents the socket from getting stuck when calling ".end()" in a write callback. + */ + if (typeof args[3] === 'function') { + args[3]() + + /** + * @note Replace the original write callback with an empty function. + * This prevents the "TypeError: cb is not a function" error on "Socket.onClose". + */ + args[3] = () => {} + } + return this.#realWriteGeneric.apply(this.socket, args) } + /** + * Handle "_writeGeneric" calls scheduled after the "connect" event. + * These are writes performed while connecting, and for the mocked socket + * they must be ignored. There's nowhere to flush them. Calling "_writeGeneric" + * past this point will result in "Error: write EBADF". + * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 + */ if (this.readyState === SocketController.MOCKED) { - /** - * Handle "_writeGeneric" calls scheduled after the "connect" event. - * These are writes performed while connecting, and for the mocked socket - * they must be ignored. There's nowhere to flush them. Calling "_writeGeneric" - * past this point will result in "Error: write EBADF". - * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 - */ + const callback = args[3] + + // Mock connection still means the socket emits the "connect" event + // and tries to flush any buffered writes to the server. Since there's + // nowhere to flush them, skip writing and only invoke the callback + // that will reset pending data/encoding. if (this.socket._pendingData) { - this.socket._pendingData = null - this.socket._pendingEncoding = '' + // this.socket._pendingData = null + // this.socket._pendingEncoding = '' + callback?.() return } this.#push(args[1]) + callback?.() return } diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 4ef6fcd5a..ed95d086a 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -77,18 +77,6 @@ export async function handleRequest( return false } - // Add the last "request" listener to check if the request - // has been handled in any way. If it hasn't, resolve the - // response promise with undefined. - // options.emitter.once('request', async ({ requestId: pendingRequestId }) => { - // if ( - // pendingRequestId === options.requestId && - // options.controller.readyState === RequestController.PENDING - // ) { - // await options.controller.passthrough() - // } - // }) - const requestAbortPromise = new DeferredPromise() /** diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index d6f0d472f..4b1be3be2 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -3,6 +3,7 @@ import { Readable } from 'node:stream' import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import express from 'express' +import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { sleep, waitForClientRequest } from '../../../helpers' @@ -13,12 +14,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptedRequestBody = vi.fn() - const interceptor = new HttpRequestInterceptor() -interceptor.on('request', async ({ request }) => { - interceptedRequestBody(await request.clone().text()) -}) beforeAll(async () => { interceptor.apply() @@ -26,17 +22,21 @@ beforeAll(async () => { }) afterEach(() => { - vi.clearAllMocks() interceptor.removeAllListeners() }) afterAll(async () => { interceptor.dispose() - vi.restoreAllMocks() await httpServer.close() }) it('writes string request body', async () => { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request }) => { + requestBodyPromise.resolve(await request.clone().text()) + }) + const req = http.request(httpServer.http.url('/resource'), { method: 'POST', headers: { @@ -49,13 +49,18 @@ it('writes string request body', async () => { req.end('three') const { text } = await waitForClientRequest(req) - const expectedBody = 'onetwothree' - expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - await expect(text()).resolves.toEqual(expectedBody) + await expect(requestBodyPromise).resolves.toBe('onetwothree') + await expect(text()).resolves.toEqual('onetwothree') }) it('writes JSON request body', async () => { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request }) => { + requestBodyPromise.resolve(await request.clone().text()) + }) + const req = http.request(httpServer.http.url('/resource'), { method: 'POST', headers: { @@ -68,13 +73,18 @@ it('writes JSON request body', async () => { req.end('}') const { text } = await waitForClientRequest(req) - const expectedBody = `{"key":"value"}` - expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - await expect(text()).resolves.toEqual(expectedBody) + await expect(requestBodyPromise).resolves.toBe(`{"key":"value"}`) + await expect(text()).resolves.toEqual(`{"key":"value"}`) }) it('writes Buffer request body', async () => { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request }) => { + requestBodyPromise.resolve(await request.clone().text()) + }) + const req = http.request(httpServer.http.url('/resource'), { method: 'POST', headers: { @@ -87,13 +97,18 @@ it('writes Buffer request body', async () => { req.end(Buffer.from('}')) const { text } = await waitForClientRequest(req) - const expectedBody = `{"key":"value"}` - expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - await expect(text()).resolves.toEqual(expectedBody) + await expect(requestBodyPromise).resolves.toBe(`{"key":"value"}`) + await expect(text()).resolves.toEqual(`{"key":"value"}`) }) it('supports Readable as the request body', async () => { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request }) => { + requestBodyPromise.resolve(await request.clone().text()) + }) + const request = http.request(httpServer.http.url('/resource'), { method: 'POST', headers: { @@ -112,7 +127,7 @@ it('supports Readable as the request body', async () => { readable.pipe(request) await waitForClientRequest(request) - expect(interceptedRequestBody).toHaveBeenCalledWith('hello world') + await expect(requestBodyPromise).resolves.toBe('hello world') }) it('calls the write callback when writing an empty string', async () => { @@ -125,7 +140,7 @@ it('calls the write callback when writing an empty string', async () => { request.end() await waitForClientRequest(request) - expect(writeCallback).toHaveBeenCalledTimes(1) + expect(writeCallback).toHaveBeenCalledOnce() }) it('calls the write callback when writing an empty Buffer', async () => { @@ -139,58 +154,112 @@ it('calls the write callback when writing an empty Buffer', async () => { await waitForClientRequest(request) - expect(writeCallback).toHaveBeenCalledTimes(1) + expect(writeCallback).toHaveBeenCalledOnce() }) it('emits "finish" for a passthrough request', async () => { const prefinishListener = vi.fn() const finishListener = vi.fn() + const request = http.request(httpServer.http.url('/resource')) + request.on('prefinish', prefinishListener) request.on('finish', finishListener) request.end() await waitForClientRequest(request) - expect(prefinishListener).toHaveBeenCalledTimes(1) - expect(finishListener).toHaveBeenCalledTimes(1) + expect(prefinishListener).toHaveBeenCalledOnce() + expect(finishListener).toHaveBeenCalledOnce() }) it('emits "finish" for a mocked request', async () => { - interceptor.once('request', ({ controller }) => { + interceptor.on('request', ({ controller }) => { controller.respondWith(new Response()) }) const prefinishListener = vi.fn() const finishListener = vi.fn() + const request = http.request(httpServer.http.url('/resource')) + request.on('prefinish', prefinishListener) request.on('finish', finishListener) request.end() await waitForClientRequest(request) - expect(prefinishListener).toHaveBeenCalledTimes(1) - expect(finishListener).toHaveBeenCalledTimes(1) + expect(prefinishListener).toHaveBeenCalledOnce() + expect(finishListener).toHaveBeenCalledOnce() }) -it('calls all write callbacks before the mocked response', async () => { - const requestBodyCallback = vi.fn() - interceptor.once('request', async ({ request, controller }) => { - requestBodyCallback(await request.text()) +it('supports ending a mocked request in a write callback', async () => { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request, controller }) => { + requestBodyPromise.resolve(await request.text()) controller.respondWith(new Response('hello world')) }) const request = http.request(httpServer.http.url('/resource'), { method: 'POST', }) + + const firstWriteCallback = vi.fn() + const secondWriteCallback = vi.fn() + const requestEndCallback = vi.fn() + request.write('one', () => { - request.end() + firstWriteCallback() + + request.write('two', () => { + secondWriteCallback() + + request.end(requestEndCallback) + }) }) const { text } = await waitForClientRequest(request) - expect(requestBodyCallback).toHaveBeenCalledWith('one') + expect(firstWriteCallback).toHaveBeenCalledBefore(secondWriteCallback) + expect(secondWriteCallback).toHaveBeenCalledBefore(requestEndCallback) + expect(requestEndCallback).toHaveBeenCalledOnce() + + await expect(requestBodyPromise).resolves.toBe('onetwo') + await expect(text()).resolves.toBe('hello world') +}) + +/** + * @see https://github.com/mswjs/interceptors/issues/684 + */ +it('supports ending a bypassed request in a write callback', async () => { + const requestWriteCallback = vi.fn() + + const request = http.request(httpServer.http.url('/resource'), { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + }) + + const firstWriteCallback = vi.fn() + const secondWriteCallback = vi.fn() + const requestEndCallback = vi.fn() + + request.write('hello', () => { + firstWriteCallback() + + request.write(' world', () => { + secondWriteCallback() + + request.end(requestEndCallback) + }) + }) + + const { text } = await waitForClientRequest(request) + + expect(firstWriteCallback).toHaveBeenCalledBefore(secondWriteCallback) + expect(secondWriteCallback).toHaveBeenCalledBefore(requestEndCallback) + expect(requestEndCallback).toHaveBeenCalledOnce() + await expect(text()).resolves.toBe('hello world') }) @@ -198,7 +267,7 @@ it('calls the write callbacks when reading request body in the interceptor', asy const requestBodyCallback = vi.fn() const requestWriteCallback = vi.fn() - interceptor.once('request', async ({ request }) => { + interceptor.on('request', async ({ request }) => { requestBodyCallback(await request.text()) }) @@ -219,22 +288,3 @@ it('calls the write callbacks when reading request body in the interceptor', asy // Must send the correct request body to the server. await expect(text()).resolves.toBe('onetwothree') }) - -/** - * @see https://github.com/mswjs/interceptors/issues/684 - */ -it('calls the write callback once for a request that ends inside a write', async () => { - const requestWriteCallback = vi.fn() - - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - }) - request.write('one', 'utf8', () => { - requestWriteCallback() - request.end() - }) - - await waitForClientRequest(request) - expect(requestWriteCallback).toHaveBeenCalledTimes(1) -}) From 71aefd766874866b4f412f5661701a5077baaa62 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 13:37:17 +0100 Subject: [PATCH 046/198] fix: support `socket.address()` --- src/interceptors/net/socket-controller.ts | 22 ++++++++++++++ src/interceptors/net/utils/address-info.ts | 23 ++++++++++++++ test/modules/http/compliance/http.test.ts | 35 ++++++++++++---------- 3 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 src/interceptors/net/utils/address-info.ts diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 7814ed90d..e1c93c55d 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -5,6 +5,8 @@ import { DeferredPromise } from '@open-draft/deferred-promise' import { toBuffer } from '../../utils/bufferUtils' import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' +import { NetworkConnectionOptions } from './utils/normalize-net-connect-args' +import { getAddressInfoByConnectionOptions } from './utils/address-info' const kListenerWrap = Symbol('kListenerWrap') @@ -199,6 +201,7 @@ export class TcpSocketController extends SocketController { protected pendingConnection: DeferredPromise<[TcpWrap, TcpHandle]> + #connectionOptions?: NetworkConnectionOptions #realWriteGeneric: net.Socket['_writeGeneric'] #passthroughSocket: net.Socket | null = null #passthroughPausedBuffer: Array = [] @@ -215,6 +218,13 @@ export class TcpSocketController extends SocketController { // Store the unpatched write method once so we have access to it between socket state resets. this.#realWriteGeneric = this.socket._writeGeneric + this.socket.connect = new Proxy(this.socket.connect, { + apply: (target, thisArg, argArray) => { + this.#connectionOptions = argArray[0] + return Reflect.apply(target, thisArg, argArray) + }, + }) + /** * @note A single socket can be reused for connections to the same host. * When one connection ends, the Agent frees the socket, then uses it @@ -359,6 +369,16 @@ export class TcpSocketController extends SocketController { super.claim() if (this.socket.connecting) { + /** + * Patch the "getsockname" on the handle in case Node.js decides to handle its errors. + * Run this if the socket is connecting because "_handle" can be null if socket timed out. + * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/net.js#L971 + */ + this.socket._handle.getsockname = () => 0 + this.socket.address = () => { + return getAddressInfoByConnectionOptions(this.#connectionOptions) + } + this.pendingConnection.then(([request, handle]) => { /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 @@ -446,6 +466,8 @@ export class TcpSocketController extends SocketController { }, }) + this.socket.address = realSocket.address.bind(realSocket) + this.socket.removeListener('drain', this.#onMockSocketDrain) this.socket.on('drain', this.#onMockSocketDrain) diff --git a/src/interceptors/net/utils/address-info.ts b/src/interceptors/net/utils/address-info.ts new file mode 100644 index 000000000..024a50e40 --- /dev/null +++ b/src/interceptors/net/utils/address-info.ts @@ -0,0 +1,23 @@ +import net from 'node:net' +import { NetworkConnectionOptions } from './normalize-net-connect-args' + +export function getAddressInfoByConnectionOptions( + options?: NetworkConnectionOptions +): ReturnType { + if (options == null) { + return {} + } + + const isIPv6 = options.family === 6 || net.isIPv6(options.host || '') + const ipAddress = options.host + ? net.isIP(options.host) !== 0 + ? options.host + : null + : null + + return { + address: ipAddress || isIPv6 ? '::1' : '127.0.0.1', + port: options.port || (options.protocol === 'https:' ? 443 : 80), + family: isIPv6 ? 'ipv6' : 'ipv4', + } +} diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index b3cf2b100..ab900313c 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -166,42 +166,46 @@ it('mocks response to a non-existing host', async () => { expect(requestListener).toHaveBeenCalledTimes(1) }) -it('returns socket address for a mocked request', async () => { +it('returns socket address for a mocked IPv4 request', async () => { interceptor.on('request', async ({ controller }) => { controller.respondWith(new Response()) }) const addressPromise = new DeferredPromise() - const request = http.get('http://example.com') - request.once('socket', (socket) => { - socket.once('connect', () => { + const request = http.get('http://any.localhost/path') + request.on('socket', (socket) => { + socket.on('connect', () => { addressPromise.resolve(socket.address()) }) }) await expect(addressPromise).resolves.toEqual({ address: '127.0.0.1', - family: 'IPv4', + family: 'ipv4', port: 80, }) }) -it('returns socket address for a mocked request with family: 6', async () => { +it('returns socket address for a mocked IPv6 request', async () => { interceptor.on('request', async ({ controller }) => { controller.respondWith(new Response()) }) const addressPromise = new DeferredPromise() - const request = http.get('http://example.com', { family: 6 }) - request.once('socket', (socket) => { - socket.once('connect', () => { + const request = http.get({ + hostname: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + port: 80, + path: '/', + }) + request.on('socket', (socket) => { + socket.on('connect', () => { addressPromise.resolve(socket.address()) }) }) await expect(addressPromise).resolves.toEqual({ address: '::1', - family: 'IPv6', + family: 'ipv6', port: 80, }) }) @@ -213,15 +217,15 @@ it('returns socket address for a mocked request with IPv6 hostname', async () => const addressPromise = new DeferredPromise() const request = http.get('http://[::1]') - request.once('socket', (socket) => { - socket.once('connect', () => { + request.on('socket', (socket) => { + socket.on('connect', () => { addressPromise.resolve(socket.address()) }) }) await expect(addressPromise).resolves.toEqual({ address: '::1', - family: 'IPv6', + family: 'ipv6', port: 80, }) }) @@ -229,8 +233,9 @@ it('returns socket address for a mocked request with IPv6 hostname', async () => it('returns socket address for a bypassed request', async () => { const addressPromise = new DeferredPromise() const request = http.get(httpServer.http.url('/user')) - request.once('socket', (socket) => { - socket.once('connect', () => { + + request.on('socket', (socket) => { + socket.on('connect', () => { addressPromise.resolve(socket.address()) }) }) From 8287643e3eee23baaf4b9cdbfc4e63ca92be8b39 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 13:59:25 +0100 Subject: [PATCH 047/198] fix: implement `getSession` and `getCipher` on tls handle --- src/interceptors/net/socket-controller.ts | 17 ++++++- .../http/compliance/http-ssl-socket.test.ts | 47 ++++++++++++------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index e1c93c55d..498897132 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -50,6 +50,9 @@ declare module 'node:tls' { start: () => void onhandshakedone: () => void onnewsession: (sessionId: unknown, session: Buffer) => void + getSession: () => Buffer + getServername: () => string + getCipher: () => { name: string; standardName: string; version: string } verifyError: () => void } } @@ -496,7 +499,7 @@ export class TlsSocketController extends TcpSocketController { } public claim(): void { - // Add this callback before "super.claim()" so it executes first. + // Run this logic before "super.claim()" so it executes first. // TLSWrap methods have to be patched before TCPWrap fires "oncomplete". const handle = this.socket._handle @@ -509,6 +512,18 @@ export class TlsSocketController extends TcpSocketController { */ handle.verifyError = () => void 0 + handle.getSession = () => { + return Buffer.from('mocked session') + } + + handle.getCipher = () => { + return { + name: 'TLS_AES_256_GCM_SHA384', + standardName: 'TLS_AES_256_GCM_SHA384', + version: 'TLSv1.3', + } + } + this.socket.once('connect', () => { handle.onhandshakedone() handle.onnewsession(1, Buffer.alloc(0)) diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index 7666e556d..d07a1a293 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -1,24 +1,29 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' -import type { TLSSocket } from 'node:tls' +import { TLSSocket } from 'node:tls' import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const interceptor = new HttpRequestInterceptor() -beforeAll(() => { +const httpServer = new HttpServer((app) => { + app.get('/', (req, res) => res.status(200).end()) +}) + +beforeAll(async () => { interceptor.apply() + await httpServer.listen() }) afterEach(() => { interceptor.removeAllListeners() }) -afterAll(() => { +afterAll(async () => { interceptor.dispose() + await httpServer.close() }) it('emits a correct TLS Socket instance for a handled HTTPS request', async () => { @@ -32,40 +37,48 @@ it('emits a correct TLS Socket instance for a handled HTTPS request', async () = const socket = await socketPromise - // Must be a TLS socket. + expect(socket).toBeInstanceOf(TLSSocket) 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.getSession()).toBeInstanceOf(Buffer) expect(socket.getProtocol()).toBe('TLSv1.3') expect(socket.isSessionReused()).toBe(false) expect(socket.getCipher()).toEqual({ - name: 'AES256-SHA', - standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', + name: 'TLS_AES_256_GCM_SHA384', + standardName: 'TLS_AES_256_GCM_SHA384', version: 'TLSv1.3', }) }) it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () => { - const request = https.get('https://example.com') + const request = https.get(httpServer.https.url('/'), { + rejectUnauthorized: false, + }) const socketPromise = new DeferredPromise() - request.on('socket', socketPromise.resolve) + const secureConnectListener = vi.fn() + + request.on('socket', (socket) => { + socketPromise.resolve(socket as TLSSocket) + socket.on('secureConnect', secureConnectListener) + }) const socket = await socketPromise + await expect.poll(() => secureConnectListener).toHaveBeenCalledOnce() - // Must be a TLS socket. + expect(socket).toBeInstanceOf(TLSSocket) 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.getSession()).toBeInstanceOf(Buffer) expect(socket.getProtocol()).toBe('TLSv1.3') expect(socket.getCipher()).toEqual({ - name: 'AES256-SHA', - standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', + name: 'TLS_AES_256_GCM_SHA384', + standardName: 'TLS_AES_256_GCM_SHA384', version: 'TLSv1.3', }) }) From 53fa1c5dbaccb5e996f88124cfbd056025ae6d73 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 14:08:46 +0100 Subject: [PATCH 048/198] test: fix unit socket test to remove the socket file --- test/modules/http/compliance/http-unix-socket.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/modules/http/compliance/http-unix-socket.test.ts b/test/modules/http/compliance/http-unix-socket.test.ts index e99f4348e..fa941d419 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -3,13 +3,13 @@ * @see https://github.com/mswjs/interceptors/pull/722 */ import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from 'vitest' +import fs from 'node:fs' import path from 'node:path' import http from 'node:http' import { promisify } from 'node:util' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' -// const HTTP_SOCKET_PATH = mockFs.resolve('./test.sock') const HTTP_SOCKET_PATH = path.join(__dirname, './test-http.sock') const httpServer = http.createServer((req, res) => { @@ -25,6 +25,10 @@ const httpServer = http.createServer((req, res) => { const interceptor = new HttpRequestInterceptor() beforeAll(async () => { + if (fs.existsSync(HTTP_SOCKET_PATH)) { + await fs.promises.rm(HTTP_SOCKET_PATH) + } + await new Promise((resolve) => { httpServer.listen(HTTP_SOCKET_PATH, resolve) }) @@ -39,6 +43,10 @@ afterEach(() => { afterAll(async () => { interceptor.dispose() await promisify(httpServer.close.bind(httpServer))() + + if (fs.existsSync(HTTP_SOCKET_PATH)) { + await fs.promises.rm(HTTP_SOCKET_PATH) + } }) it('supports passthrough HTTP GET requests over a unix socket', async () => { From 75942fb467bcd8410d4680772b8b265239cb51e1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 17:25:07 +0100 Subject: [PATCH 049/198] chore: use node 22 and 24 for ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc7147083..826e8d654 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: macos-latest strategy: matrix: - node: [20, 22] + node: [22, 24] steps: - name: Checkout uses: actions/checkout@v4 From 413cb341bff8fd78c71734250a449ad048cde49e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 17:27:35 +0100 Subject: [PATCH 050/198] test: migrate other tests to the new interceptor --- test/features/events/request.test.ts | 7 ++----- test/features/events/response.test.ts | 4 ++-- .../WebSocket/third-party/socket.io.send.bypass.test.ts | 4 ++-- test/third-party/axios.test.ts | 4 ++-- test/third-party/follow-redirect-http.test.ts | 4 ++-- test/third-party/got.test.ts | 4 ++-- test/third-party/miniflare.test.ts | 4 ++-- test/third-party/node-fetch.test.ts | 6 +++--- test/third-party/supertest.test.ts | 4 ++-- 9 files changed, 19 insertions(+), 22 deletions(-) diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 7a1604977..69d0891d7 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -9,7 +9,7 @@ import { REQUEST_ID_REGEXP, waitForClientRequest, } from '../../helpers' -import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../src/interceptors/http' import { BatchInterceptor } from '../../../src/BatchInterceptor' import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest' import { RequestController } from '../../../src/RequestController' @@ -26,10 +26,7 @@ const requestListener = const interceptor = new BatchInterceptor({ name: 'batch-interceptor', - interceptors: [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - ], + interceptors: [new HttpRequestInterceptor(), new XMLHttpRequestInterceptor()], }) interceptor.on('request', requestListener) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 0bdd6ba7e..7a67f0530 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -7,7 +7,7 @@ import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestEventMap } from '../../../src' import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest' import { BatchInterceptor } from '../../../src/BatchInterceptor' -import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../src/interceptors/http' import { FetchInterceptor } from '../../../src/interceptors/fetch' import { useCors, @@ -40,7 +40,7 @@ const httpServer = new HttpServer((app) => { const interceptor = new BatchInterceptor({ name: 'batch-interceptor', interceptors: [ - new ClientRequestInterceptor(), + new HttpRequestInterceptor(), new XMLHttpRequestInterceptor(), new FetchInterceptor(), ], diff --git a/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts b/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts index cac34040e..ecea99abf 100644 --- a/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts +++ b/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts @@ -5,11 +5,11 @@ import { io } from 'socket.io-client' import { Server } from 'socket.io' import { BatchInterceptor } from '../../../../src' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const interceptor = new BatchInterceptor({ name: 'test-interceptor', - interceptors: [new ClientRequestInterceptor(), new WebSocketInterceptor()], + interceptors: [new HttpRequestInterceptor(), new WebSocketInterceptor()], }) const wss = new Server() diff --git a/test/third-party/axios.test.ts b/test/third-party/axios.test.ts index 7ae7700ba..037968868 100644 --- a/test/third-party/axios.test.ts +++ b/test/third-party/axios.test.ts @@ -3,7 +3,7 @@ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import axios from 'axios' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../src/interceptors/http' import { useCors } from '../helpers' function createMockResponse() { @@ -37,7 +37,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await httpServer.listen() diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index 42ce1c587..f40d9e371 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -2,10 +2,10 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { https } from 'follow-redirects' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../src/interceptors/http' import { waitForClientRequest } from '../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const server = new HttpServer((app) => { app.post('/resource', (req, res) => { diff --git a/test/third-party/got.test.ts b/test/third-party/got.test.ts index 2c6b8604d..46cf3097b 100644 --- a/test/third-party/got.test.ts +++ b/test/third-party/got.test.ts @@ -1,7 +1,7 @@ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import got from 'got' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../src/interceptors/http' import { sleep } from '../helpers' const httpServer = new HttpServer((app) => { @@ -10,7 +10,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index cd7102892..a9ab12588 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -1,7 +1,7 @@ // @vitest-environment miniflare import { afterAll, afterEach, beforeAll, expect, test, vi } from 'vitest' import { BatchInterceptor } from '../../src' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../src/interceptors/http' import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' import { FetchInterceptor } from '../../src/interceptors/fetch' import { httpGet, httpsGet } from '../helpers' @@ -9,7 +9,7 @@ import { httpGet, httpsGet } from '../helpers' const interceptor = new BatchInterceptor({ name: 'setup-server', interceptors: [ - new ClientRequestInterceptor(), + new HttpRequestInterceptor(), new XMLHttpRequestInterceptor(), new FetchInterceptor(), ], diff --git a/test/third-party/node-fetch.test.ts b/test/third-party/node-fetch.test.ts index bde857172..e86be8365 100644 --- a/test/third-party/node-fetch.test.ts +++ b/test/third-party/node-fetch.test.ts @@ -2,7 +2,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import fetch from 'node-fetch' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../src/interceptors/http' process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' @@ -15,7 +15,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', function testListener({ request, controller }) { if ([httpServer.http.url(), httpServer.https.url()].includes(request.url)) { @@ -89,7 +89,7 @@ it('bypasses any request when the interceptor is restored', async () => { }) it('does not throw an error if there are multiple interceptors', async () => { - const secondInterceptor = new ClientRequestInterceptor() + const secondInterceptor = new HttpRequestInterceptor() secondInterceptor.apply() const response = await fetch(httpServer.http.url('/get')) diff --git a/test/third-party/supertest.test.ts b/test/third-party/supertest.test.ts index 64594c1ee..52ada88fc 100644 --- a/test/third-party/supertest.test.ts +++ b/test/third-party/supertest.test.ts @@ -3,14 +3,14 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import express from 'express' import supertest from 'supertest' import { HttpRequestEventMap } from '../../src' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../src/interceptors/http' const requestListener = vi.fn<(...args: HttpRequestEventMap['request']) => void>() const responseListener = vi.fn<(...args: HttpRequestEventMap['response']) => void>() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', requestListener) interceptor.on('response', responseListener) From c8dac7450d91152c679b2de1b145a020b6cb26f0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 18:48:52 +0100 Subject: [PATCH 051/198] feat: support `CONNECT` requests --- src/interceptors/http/http-parser.ts | 11 +- src/interceptors/http/index.ts | 32 +++- src/utils/fetchUtils.ts | 96 +++++++++- .../compliance/http-modify-request.test.ts | 17 +- .../http/intercept/http-connect.test.ts | 177 ++++++++++++++++++ 5 files changed, 304 insertions(+), 29 deletions(-) create mode 100644 test/modules/http/intercept/http-connect.test.ts diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index 211e17d28..de4be6ea9 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -7,7 +7,7 @@ import { import net from 'node:net' import { Readable } from 'node:stream' import { invariant } from 'outvariant' -import { FetchResponse } from '../../utils/fetchUtils' +import { FetchRequest, FetchResponse } from '../../utils/fetchUtils' type HttpParserKind = typeof HTTPParser.REQUEST | typeof HTTPParser.RESPONSE @@ -124,7 +124,6 @@ export class HttpRequestParser extends HttpParser { url.password = '' } - const canHaveBody = method !== 'HEAD' && method !== 'GET' this.#requestBodyStream = new Readable({ /** * @note Provide the `read()` method so a `Readable` could be @@ -133,15 +132,11 @@ export class HttpRequestParser extends HttpParser { read: () => {}, }) - const request = new Request(url, { + const request = new FetchRequest(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, + body: Readable.toWeb(this.#requestBodyStream) as any, }) options.onRequest(request) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index d7b79e727..a7563233c 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -23,6 +23,7 @@ import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' import { kRawSocket } from '../net/socket-controller' import { unwrapPendingData } from '../net/utils/flush-writes' +import { FetchResponse } from '../../utils/fetchUtils' const log = createLogger('HttpRequestInterceptor') @@ -134,13 +135,36 @@ export class HttpRequestInterceptor extends Interceptor { httpMessage: string | Buffer, encoding?: BufferEncoding ): string => { + const parts = httpMessage.toString(encoding).split('\r\n') + const headersEndIndex = parts.findIndex( + (field) => field === '' + ) + const httpMessageHeaderPairs = parts.slice( + 1, + headersEndIndex + ) + const httpMessageHeaders = FetchResponse.parseRawHeaders( + httpMessageHeaderPairs.flatMap((header) => + header.split(': ') + ) + ) + const rawHeaders = getRawFetchHeaders(request.headers) - const nextHeaders = rawHeaders + + for (const [name, value] of rawHeaders) { + httpMessageHeaders.set(name, value) + } + + const httpMessageHeadersString = Array.from( + httpMessageHeaders + ) .map(([name, value]) => `${name}: ${value}`) .join('\r\n') - - const parts = httpMessage.toString(encoding).split('\r\n') - parts.splice(1, parts.indexOf('') - 1, nextHeaders) + parts.splice( + 1, + headersEndIndex - 1, + httpMessageHeadersString + ) return parts.join('\r\n') } diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index a20c69819..d0522eb8a 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -100,10 +100,13 @@ export class FetchResponse extends Response { * @note Undici keeps an internal "Symbol(state)" that holds * the actual value of response status. Update that in Node.js. */ - const state = getValueBySymbol('state', this) + const internalState = getValueBySymbol( + 'state', + this + ) - if (state) { - state.status = status + if (internalState) { + internalState.status = status } else { Object.defineProperty(this, 'status', { value: status, @@ -117,3 +120,90 @@ export class FetchResponse extends Response { FetchResponse.setUrl(init.url, this) } } + +export class FetchRequest extends Request { + /** + * Check if the given method describes a request that is + * allowed to have a body. + */ + static isRequestWithBody(method: string): boolean { + return ( + method !== 'HEAD' && + method !== 'GET' && + !FetchRequest.isForbiddenMethod(method) + ) + } + + /** + * Check if the given request method is forbidden. + * @see https://fetch.spec.whatwg.org/#methods + */ + static isForbiddenMethod(method: string): boolean { + return method === 'CONNECT' || method === 'TRACE' || method === 'TRACK' + } + + constructor(input: RequestInfo | URL, init?: RequestInit) { + const method = init?.method || 'GET' + const safeMethod = FetchRequest.isForbiddenMethod(method) ? 'GET' : method + + super(input, { + ...(init || {}), + method: safeMethod, + headers: init?.headers, + // @ts-expect-error Undocumented Fetch property. + duplex: FetchRequest.isRequestWithBody(method) ? 'half' : undefined, + body: FetchRequest.isRequestWithBody(method) ? init?.body : null, + }) + + if (method !== safeMethod) { + this.#setUnconfigurableProperty('method', method) + } + + if (method === 'CONNECT') { + const isRequest = input instanceof Request + const url = new URL(isRequest ? input.url : input) + + let authority: string + + /** + * @note URL in Node.js treats "http://127.0.0.1:1334/localhost:80" urls + * as "localhost:80", where "localhost:" is the protocol. Likely a bug. + */ + if (url.protocol === 'localhost:') { + authority = url.href + } else { + authority = url.pathname.replace(/^\/+/, '') + } + + /** + * @note Define "url" as a getter because Undici uses their own + * logic to resolve the "request.url" property. Simply reassigning + * its value doesn't do anything. This is a destructive action + * but it's safe because "CONNECT" requests are forbidden per fetch. + */ + Object.defineProperty(this, 'url', { + get: () => authority, + enumerable: true, + configurable: true, + }) + } + } + + #setUnconfigurableProperty( + key: T, + value: Request[T] + ): void { + const internalState = getValueBySymbol('state', this) + + if (internalState) { + Reflect.set(internalState, key, value) + } else { + Object.defineProperty(this, key, { + value, + enumerable: true, + configurable: true, + writable: false, + }) + } + } +} diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index 99ff30361..f00b04978 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -41,21 +41,10 @@ it('allows modifying the outgoing headers for a request without a body', async ( const request = http.get(server.http.url('/user')) const { res } = await waitForClientRequest(request) - expect(res.headers['x-appended-header']).toBe('modified') -}) - -it('allows modifying the outgoing headers for a request with a body', async () => { - interceptor.on('request', ({ request }) => { - request.headers.set('x-appended-header', 'modified') + expect(res.headers).toMatchObject({ + connection: 'keep-alive', + 'x-appended-header': 'modified', }) - - const request = http.request(server.http.url('/user')) - request.write('hello') - request.end(' world') - - const { res } = await waitForClientRequest(request) - - expect(res.headers['x-appended-header']).toBe('modified') }) it('allows modifying the outgoing request headers in a request with a body', async () => { diff --git a/test/modules/http/intercept/http-connect.test.ts b/test/modules/http/intercept/http-connect.test.ts new file mode 100644 index 000000000..d24a73458 --- /dev/null +++ b/test/modules/http/intercept/http-connect.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment node +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import net from 'node:net' +import http from 'node:http' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' + +const interceptor = new HttpRequestInterceptor() + +const httpServer = new HttpServer((app) => { + app.get('/resource', (req, res) => { + res.send('original') + }) +}) + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('intercepts a "CONNECT" request using IP as the authority', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.respondWith(new Response()) + }) + + const connectListener = vi.fn() + const responseListener = vi.fn() + + const serverHost = `${httpServer.http.address.host}:${httpServer.http.address.port}` + + const request = http + .request({ + method: 'CONNECT', + host: '127.0.0.1', + port: 1337, + /** + * @note CONNECT requests use "path" to describe the requested authority + * in a "host:port" format. + */ + path: serverHost, + }) + .end() + + request.on('connect', connectListener).on('response', responseListener) + + await expect.poll(() => connectListener).toHaveBeenCalledOnce() + expect(connectListener).toHaveBeenCalledExactlyOnceWith( + // The mocked response sent from the interceptor. + expect.objectContaining({ + statusCode: 200, + statusMessage: 'OK', + }), + expect.any(net.Socket), + expect.any(Buffer) + ) + + // CONNECT requests do NOT produce an actual response. + expect(responseListener).not.toHaveBeenCalled() + + const interceptedRequest = await requestPromise + + expect.soft(interceptedRequest.method).toBe('CONNECT') + expect + .soft(interceptedRequest.url, 'Sets connect authority as the request URL') + .toBe(serverHost) + expect.soft(Array.from(interceptedRequest.headers)).toEqual([ + ['connection', 'keep-alive'], + ['host', '127.0.0.1:1337'], + ]) +}) + +/** + * @note This test exists only because Node.js has a bug parsing + * URLs like "http://127.0.0.1:1337/localhost:80". It would treat "localhost:" + * as a protocol. + */ +it('intercepts a "CONNECT" request using "localhost" as the authority', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.respondWith(new Response()) + }) + + const connectListener = vi.fn() + const responseListener = vi.fn() + + const serverHost = `localhost:${httpServer.http.address.port}` + + const request = http + .request({ + method: 'CONNECT', + host: '127.0.0.1', + port: 1337, + path: serverHost, + }) + .end() + + request.on('connect', connectListener).on('response', responseListener) + + await expect.poll(() => connectListener).toHaveBeenCalledOnce() + expect(connectListener).toHaveBeenCalledExactlyOnceWith( + // The mocked response sent from the interceptor. + expect.objectContaining({ + statusCode: 200, + statusMessage: 'OK', + }), + expect.any(net.Socket), + expect.any(Buffer) + ) + + // CONNECT requests do NOT produce an actual response. + expect(responseListener).not.toHaveBeenCalled() + + const interceptedRequest = await requestPromise + + expect.soft(interceptedRequest.method).toBe('CONNECT') + expect + .soft(interceptedRequest.url, 'Sets connect authority as the request URL') + .toBe(serverHost) + expect.soft(Array.from(interceptedRequest.headers)).toEqual([ + ['connection', 'keep-alive'], + ['host', '127.0.0.1:1337'], + ]) +}) + +it('errors the intercepted "CONNECT" request', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.errorWith(new Error('Custom reason')) + }) + + const connectListener = vi.fn() + const responseListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + const serverHost = `localhost:${httpServer.http.address.port}` + + const request = http + .request({ + method: 'CONNECT', + host: '127.0.0.1', + port: 1337, + path: serverHost, + }) + .end() + + request + .on('connect', connectListener) + .on('response', responseListener) + .on('error', errorListener) + .on('close', closeListener) + + await expect.poll(() => errorListener).toHaveBeenCalledOnce() + expect(errorListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'Custom reason' }) + ) + expect(closeListener).toHaveBeenCalledOnce() + expect(connectListener).not.toHaveBeenCalled() + expect(responseListener).not.toHaveBeenCalled() +}) From a12e91592bc3f4c3b6348a3ac08749be96b73374 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 18:58:47 +0100 Subject: [PATCH 052/198] docs: document socket reusage and `CONNECT` requests --- discoveries/http.mdx | 55 +++++++++++++++++++++++++++++++++++++++++++ discoveries/https.mdx | 25 -------------------- 2 files changed, 55 insertions(+), 25 deletions(-) create mode 100644 discoveries/http.mdx delete mode 100644 discoveries/https.mdx diff --git a/discoveries/http.mdx b/discoveries/http.mdx new file mode 100644 index 000000000..330bec7ab --- /dev/null +++ b/discoveries/http.mdx @@ -0,0 +1,55 @@ +# General + +## Reusing sockets + +By default, `Agent` will try to reuse any sockets that were not explicitly closed via `Connection: close` request header. Here's what happens when a socket gets reused: + +1. `free` is emitted on the socket to notify that it's done with the previous request. +2. `connect` is _not_ emitted on this socket anymore since the connection has already been established. No connection attempt will be made whatsoever (`socket._handle.connect` won't be called). +3. The next request's HTTP message is written into the socket. + +## `CONNECT` requests + +While forbidden by the Fetch API specification, `CONNECT` requests are possible in Node.js. Here's what's special about connect requests: + +- Are sent to the _running_ server (`options.host` + `options.port)` to notify it about the intent to connect somewhere esle. +- Do _not_ actually establish any connections. Instead, the server handles the same socket instance to establish that connection. +- Never recieve the response (`response` is never emitted.) + +```ts +const client = http.request({ + // Actual server handling the "CONNECT" request. + host: '127.0.0.1', + port: 1337, + // The (proxy) authority in a "host:port" format. + path: 'www.example.com:80' +}) + +client.on('connect', (request, socket) => { + // Use "socket" to write to the authority... +}) +``` + +# HTTPS/TLS + +### `TLSWrap` + +Regular `net.Socket` connections finalize in the `afterConnect` callback [attached](https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/net.js#L1142) to the `TCPWrap` request. A TLS sockets\, while extending `net.Socket` and still relying on `afterConnect` being called to transition the socket into the connected state, _do not_ rely on `_handle.oncomplete`. **It is never called**. + +A TLS socket wraps `net.Socket._handle` in a `TLSWrap`, which becomes responsible for the connection. It calls the `afterConnect` callback _internally_, in the C++ code, and there's no public method to trigger it. + +> TLS wrap has its own methods, like `onhandshakedone` and `verifyError`. + +### Connection event sequence + +> For brevity: "wrap" means the TCP socket TLS extends, "socket" means the TLS socket. + +1. `tls.connect()`. +2. `new TLSSocket(options.socket)`. +3. `TLSSocket.prototype._wrapHandle` (wraps TCPWrap in TLSWrap). +4. Calls `socket._handle.start()` (or schedules it until wrap emits "connect"). +5. Wrap emits "connect". +6. Socket emits "connect". +7. Socket validates the connection (handshake, certificate validation). +7. Socket emits "secureConnect". +7. Socket emits "secure". diff --git a/discoveries/https.mdx b/discoveries/https.mdx deleted file mode 100644 index dc96f327e..000000000 --- a/discoveries/https.mdx +++ /dev/null @@ -1,25 +0,0 @@ -# HTTPS/TLS - -## `tls.TLSSocket` - -### Handle - -Regular `net.Socket` connections finalize in the `afterConnect` callback [attached](https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/net.js#L1142) to the `TCPWrap` request. A TLS sockets\, while extending `net.Socket` and still relying on `afterConnect` being called to transition the socket into the connected state, _do not_ rely on `_handle.oncomplete`. **It is never called**. - -A TLS socket wraps `net.Socket._handle` in a `TLSWrap`, which becomes responsible for the connection. It calls the `afterConnect` callback _internally_, in the C++ code, and there's no public method to trigger it. - -> TLS wrap has its own methods, like `onhandshakedone` and `verifyError`. - -### Connection event sequence - -> For brevity: "wrap" means the TCP socket TLS extends, "socket" means the TLS socket. - -1. `tls.connect()`. -2. `new TLSSocket(options.socket)`. -3. `TLSSocket.prototype._wrapHandle` (wraps TCPWrap in TLSWrap). -4. Calls `socket._handle.start()` (or schedules it until wrap emits "connect"). -5. Wrap emits "connect". -6. Socket emits "connect". -7. Socket validates the connection (handshake, certificate validation). -7. Socket emits "secureConnect". -7. Socket emits "secure". From a9c63e6eb4f9174d9ae302165171d6c14261d991 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 24 Feb 2026 19:00:01 +0100 Subject: [PATCH 053/198] docs: remove the write->end limitation --- README.md | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/README.md b/README.md index fb5e324f6..3f343feaa 100644 --- a/README.md +++ b/README.md @@ -104,21 +104,6 @@ You can respond to the intercepted HTTP request by constructing a Fetch API Resp - Does **not** provide any request matching logic; - Does **not** handle requests by default. -## Limitations - -- Interceptors will hang indefinitely if you call `req.end()` in the `connect` event listener of the respective `socket`: - -```ts -req.on('socket', (socket) => { - socket.on('connect', () => { - // ❌ While this is allowed in Node.js, this cannot be handled in Interceptors. - req.end() - }) -}) -``` - -> This limitation is intrinsic to the interception algorithm used by the library. In order for it to emit the `connect` event on the socket, the library must know if you've handled the request in any way (e.g. responded with a mocked response or errored it). For that, it emits the `request` event on the interceptor where you can handle the request. Since you can consume the request stream in the `request` event, it waits until the request body stream is complete (i.e. until `req.end()` is called). This creates a catch 22 that causes this limitation. - ## Getting started ```bash From d12b02179dc2712e7a6ae7d44b524957348d2bd0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 25 Feb 2026 15:54:43 +0100 Subject: [PATCH 054/198] fix(wip): support full proxy flows with `CONNECT` --- package.json | 1 + pnpm-lock.yaml | 3 ++ src/interceptors/http/index.ts | 4 +- .../http/intercept/http-connect.test.ts | 41 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 857077779..d3741f09e 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "follow-redirects": "^1.15.1", "got": "^14.4.6", "happy-dom": "^17.3.0", + "https-proxy-agent": "^7.0.6", "jsdom": "^26.1.0", "node-fetch": "3.3.2", "simple-git-hooks": "^2.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33c659355..84626f710 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: happy-dom: specifier: ^17.3.0 version: 17.3.0 + https-proxy-agent: + specifier: ^7.0.6 + version: 7.0.6 jsdom: specifier: ^26.1.0 version: 26.1.0 diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index a7563233c..00f1e2dd8 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -320,6 +320,8 @@ export class HttpRequestInterceptor extends Interceptor { serverResponse.end() } - socket.push(null) + if (request.method !== 'CONNECT') { + socket.push(null) + } } } diff --git a/test/modules/http/intercept/http-connect.test.ts b/test/modules/http/intercept/http-connect.test.ts index d24a73458..e10744107 100644 --- a/test/modules/http/intercept/http-connect.test.ts +++ b/test/modules/http/intercept/http-connect.test.ts @@ -1,10 +1,15 @@ // @vitest-environment node +/** + * @see https://github.com/mswjs/interceptors/issues/481 + */ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import net from 'node:net' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' +import { HttpsProxyAgent } from 'https-proxy-agent' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { waitForClientRequest } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -175,3 +180,39 @@ it('errors the intercepted "CONNECT" request', async () => { expect(connectListener).not.toHaveBeenCalled() expect(responseListener).not.toHaveBeenCalled() }) + +it.skip('mocks the entire proxy flow end-to-end', async () => { + interceptor.on('request', ({ request, controller }) => { + console.log('-->', request.method, request.url) + + if (request.method === 'CONNECT') { + return controller.respondWith(new Response()) + } + + controller.respondWith(new Response('mock')) + }) + + const connectListener = vi.fn() + const responseListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + const agent = new HttpsProxyAgent('http://non-existing.remote/server') + + const request = http + .request({ + hostname: '127.0.0.1', + port: 80, + path: '/', + agent, + }) + .end() + + request.on('connect', connectListener) + + await expect.poll(() => connectListener).toHaveBeenCalledOnce() + + /** + * @todo @fixme Finish this test. + */ +}) From 38d9137151dd005d158999237324f7ee3efbeb9a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 25 Feb 2026 16:12:16 +0100 Subject: [PATCH 055/198] fix(http): set `response.url` to `request.url` --- src/interceptors/http/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 00f1e2dd8..ff4ffb27f 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -86,6 +86,8 @@ export class HttpRequestInterceptor extends Interceptor { socketController.claim() + FetchResponse.setUrl(request.url, response) + const respond = async () => { await this.respondWith({ socket: socketController[kRawSocket], @@ -206,6 +208,8 @@ export class HttpRequestInterceptor extends Interceptor { return } + FetchResponse.setUrl(request.url, response) + await emitAsync(this.emitter, 'response', { requestId, request, From 392f0249db2a56c2dd7e621240abc55f0d20cf31 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 25 Feb 2026 17:26:00 +0100 Subject: [PATCH 056/198] feat(wip): request `initiator` via `async_hooks` --- src/glossary.ts | 8 ++- src/interceptors/ClientRequest/new.ts | 66 ++++++++++++++++++ src/interceptors/XMLHttpRequest/new.ts | 58 +++++++++++++++ src/interceptors/fetch/node.ts | 61 ++++++++++++++++ src/interceptors/http/index.ts | 11 ++- src/interceptors/net/index.ts | 1 + src/request-context.ts | 23 ++++++ src/utils/handleRequest.ts | 2 + test/features/events/response.test.ts | 16 +++-- test/features/request-initiator.test.ts | 93 +++++++++++++++++++++++++ 10 files changed, 331 insertions(+), 8 deletions(-) create mode 100644 src/interceptors/ClientRequest/new.ts create mode 100644 src/interceptors/XMLHttpRequest/new.ts create mode 100644 src/interceptors/fetch/node.ts create mode 100644 src/request-context.ts create mode 100644 test/features/request-initiator.test.ts diff --git a/src/glossary.ts b/src/glossary.ts index 201585270..4faa9b965 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -13,18 +13,20 @@ export type RequestCredentials = 'omit' | 'include' | 'same-origin' export type HttpRequestEventMap = { request: [ args: { + initiator: unknown request: Request requestId: string controller: RequestController - } + }, ] response: [ args: { + initiator: unknown response: Response isMockedResponse: boolean request: Request requestId: string - } + }, ] unhandledException: [ args: { @@ -32,6 +34,6 @@ export type HttpRequestEventMap = { request: Request requestId: string controller: RequestController - } + }, ] } diff --git a/src/interceptors/ClientRequest/new.ts b/src/interceptors/ClientRequest/new.ts new file mode 100644 index 000000000..f3377e28c --- /dev/null +++ b/src/interceptors/ClientRequest/new.ts @@ -0,0 +1,66 @@ +import http from 'node:http' +import https from 'node:https' +import { HttpRequestEventMap } from '../../glossary' +import { Interceptor } from '../../Interceptor' +import { runInRequestContext } from '../../request-context' + +export class ClientRequestInterceptor extends Interceptor { + static symbol = Symbol('client-request-interceptor') + + constructor() { + super(ClientRequestInterceptor.symbol) + } + + protected setup(): void { + const RealClientRequest = http.ClientRequest + + http.ClientRequest = new Proxy(http.ClientRequest, { + construct(target, args, newTarget) { + return runInRequestContext(() => { + return Reflect.construct(target, args, newTarget) + }) + }, + }) + + const { get: realHttpGet, request: realHttpRequest } = http + http.get = new Proxy(http.get, { + apply(target, thisArg, argArray) { + return runInRequestContext(() => { + return Reflect.apply(target, thisArg, argArray) + }) + }, + }) + http.request = new Proxy(http.request, { + apply(target, thisArg, argArray) { + return runInRequestContext(() => { + return Reflect.apply(target, thisArg, argArray) + }) + }, + }) + + const { get: realHttpsGet, request: realHttpsRequest } = https + https.get = new Proxy(http.get, { + apply(target, thisArg, argArray) { + return runInRequestContext(() => { + return Reflect.apply(target, thisArg, argArray) + }) + }, + }) + https.request = new Proxy(http.request, { + apply(target, thisArg, argArray) { + return runInRequestContext(() => { + return Reflect.apply(target, thisArg, argArray) + }) + }, + }) + + this.subscriptions.push(() => { + http.ClientRequest = RealClientRequest + + http.get = realHttpGet + http.request = realHttpRequest + https.get = realHttpsGet + https.request = realHttpsRequest + }) + } +} diff --git a/src/interceptors/XMLHttpRequest/new.ts b/src/interceptors/XMLHttpRequest/new.ts new file mode 100644 index 000000000..6705ea73f --- /dev/null +++ b/src/interceptors/XMLHttpRequest/new.ts @@ -0,0 +1,58 @@ +import { invariant } from 'outvariant' +import { requestContext } from '../../request-context' +import { Interceptor } from '../../Interceptor' +import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' +import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' + +export class XMLHttpRequestInterceptor extends Interceptor { + static interceptorSymbol = Symbol('xhr-interceptor') + + constructor() { + super(XMLHttpRequestInterceptor.interceptorSymbol) + } + + protected checkEnvironment() { + return hasConfigurableGlobal('XMLHttpRequest') + } + + protected setup(): void { + const RealXMLHttpRequest = globalThis.XMLHttpRequest + + invariant( + !(RealXMLHttpRequest as any)[IS_PATCHED_MODULE], + 'Failed to patch the "XMLHttpRequest" module: already patched.' + ) + + globalThis.XMLHttpRequest = new Proxy(globalThis.XMLHttpRequest, { + construct(target, argArray, newTarget) { + const xmlHttpRequest = Reflect.construct(target, argArray, newTarget) + + /** + * @note Use `.enterWith()` here because XHR in JSDOM is implemented + * via `http`/`https`. This makes the initiator cascading work properly. + */ + requestContext.enterWith({ initiator: xmlHttpRequest }) + + /** + * @todo Do we need to exit the async context at some point? + */ + + return xmlHttpRequest + }, + }) + + Object.defineProperty(globalThis.XMLHttpRequest, IS_PATCHED_MODULE, { + enumerable: true, + configurable: true, + value: true, + }) + + this.subscriptions.push(() => { + Object.defineProperty(globalThis.XMLHttpRequest, IS_PATCHED_MODULE, { + value: undefined, + }) + + globalThis.XMLHttpRequest = RealXMLHttpRequest + }) + } +} diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts new file mode 100644 index 000000000..0b7c31b40 --- /dev/null +++ b/src/interceptors/fetch/node.ts @@ -0,0 +1,61 @@ +import { invariant } from 'outvariant' +import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' +import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' +import { Interceptor } from '../../Interceptor' +import { canParseUrl } from '../../utils/canParseUrl' +import { requestContext, runInRequestContext } from '../../request-context' + +export class FetchInterceptor extends Interceptor { + static symbol = Symbol('fetch') + + constructor() { + super(FetchInterceptor.symbol) + } + + protected checkEnvironment() { + return hasConfigurableGlobal('fetch') + } + + protected setup(): void { + const realFetch = globalThis.fetch + + invariant( + !(realFetch as any)[IS_PATCHED_MODULE], + 'Failed to patch the "fetch" module: already patched.' + ) + + globalThis.fetch = (input, init) => { + /** + * @note Resolve potentially relative request URL + * against the present `location`. This is mainly + * for native `fetch` in JSDOM. + * @see https://github.com/mswjs/msw/issues/1625 + */ + const resolvedInput = + typeof input === 'string' && + typeof location !== 'undefined' && + !canParseUrl(input) + ? new URL(input, location.href) + : input + + const request = new Request(resolvedInput, init) + + requestContext.enterWith({ initiator: request }) + return realFetch(input, init) + } + + Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, { + enumerable: true, + configurable: true, + value: true, + }) + + this.subscriptions.push(() => { + globalThis.fetch = realFetch + + Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, { + value: undefined, + }) + }) + } +} diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index ff4ffb27f..2ac8d8a85 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -24,11 +24,12 @@ import { createLogger } from '../../utils/logger' import { kRawSocket } from '../net/socket-controller' import { unwrapPendingData } from '../net/utils/flush-writes' import { FetchResponse } from '../../utils/fetchUtils' +import { requestContext } from '../../request-context' const log = createLogger('HttpRequestInterceptor') export class HttpRequestInterceptor extends Interceptor { - static symbol = Symbol('client-request-interceptor') + static symbol = Symbol('http-request-interceptor') constructor() { super(HttpRequestInterceptor.symbol) @@ -63,6 +64,11 @@ export class HttpRequestInterceptor extends Interceptor { baseUrl, }) + // Get the request initiator from the async context, if any. + // Use the underlying socket as a fallback. + const parentInitiator = requestContext.getStore()?.initiator + const initiator = parentInitiator || socket + const requestParser = new HttpRequestParser({ connectionOptions: { method: httpMethod, @@ -119,6 +125,7 @@ export class HttpRequestInterceptor extends Interceptor { process.nextTick(async () => { await emitAsync(this.emitter, 'response', { + initiator, requestId, request, response: responseClone, @@ -211,6 +218,7 @@ export class HttpRequestInterceptor extends Interceptor { FetchResponse.setUrl(request.url, response) await emitAsync(this.emitter, 'response', { + initiator, requestId, request, response, @@ -229,6 +237,7 @@ export class HttpRequestInterceptor extends Interceptor { }) await handleRequest({ + initiator, request, requestId, controller: requestController, diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 34ced9596..1e0d9f729 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -54,6 +54,7 @@ export class SocketInterceptor extends Interceptor { }) log('connecting the socket...') + return socket.connect(connectionOptions, connectionCallback) } diff --git a/src/request-context.ts b/src/request-context.ts new file mode 100644 index 000000000..0af639074 --- /dev/null +++ b/src/request-context.ts @@ -0,0 +1,23 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +interface RequestContext { + initiator: unknown +} + +export const requestContext = new AsyncLocalStorage() + +export function runInRequestContext(callback: () => T): T { + const parentInitiator = requestContext.getStore()?.initiator + + if (parentInitiator) { + return callback() + } + + const context: RequestContext = { initiator: 'TEMP' } + + return requestContext.run(context, () => { + const initiator = callback() + context.initiator = initiator + return initiator + }) +} diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index ed95d086a..8c3651b3b 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -14,6 +14,7 @@ import { isNodeLikeError } from './isNodeLikeError' import { isObject } from './isObject' interface HandleRequestOptions { + initiator: unknown requestId: string request: Request emitter: Emitter @@ -103,6 +104,7 @@ export async function handleRequest( // By the end of this promise, the developer cannot affect the // request anymore. const requestListenersPromise = emitAsync(options.emitter, 'request', { + initiator: options.initiator, requestId: options.requestId, request: options.request, controller: options.controller, diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 7a67f0530..53395c132 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -65,19 +65,19 @@ interceptor.on('request', ({ request, controller }) => { beforeAll(async () => { // Allow XHR requests to the local HTTPS server with a self-signed certificate. window._resourceLoader._strictSSL = false - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterEach(() => { interceptor.removeAllListeners('response') - vi.resetAllMocks() + vi.clearAllMocks() }) afterAll(async () => { interceptor.dispose() + vi.restoreAllMocks() await httpServer.close() }) @@ -130,6 +130,7 @@ it('ClientRequest: emits the "response" event upon the original response', async headers: { 'x-request-custom': 'yes', }, + rejectUnauthorized: false, }) req.write('request-body') req.end() @@ -191,11 +192,18 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () expect(originalRequest.responseText).toEqual('mocked-response-text') }) -it('XMLHttpRequest: emits the "response" event upon the original response', async () => { +it.only('XMLHttpRequest: emits the "response" event upon the original response', async () => { const responseListener = vi.fn<(...args: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) + interceptor.on('request', ({ request }) => { + console.trace('->', request.method, request.url) + }) + interceptor.on('response', ({ response }) => { + console.log('RESPONSE', response.status) + }) + const originalRequest = await createXMLHttpRequest((req) => { req.open('POST', httpServer.https.url('/account')) req.setRequestHeader('x-request-custom', 'yes') diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts new file mode 100644 index 000000000..d8a5e4c8a --- /dev/null +++ b/test/features/request-initiator.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import http from 'node:http' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { BatchInterceptor } from '../../src/BatchInterceptor' +import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest/new' +import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest/new' +import { FetchInterceptor } from '../../src/interceptors/fetch/node' +import { HttpRequestInterceptor } from '../../src/interceptors/http' +import { createXMLHttpRequest, waitForClientRequest } from '../helpers' + +const interceptor = new BatchInterceptor({ + name: 'interceptor', + interceptors: [ + new HttpRequestInterceptor(), + new ClientRequestInterceptor(), + new XMLHttpRequestInterceptor(), + new FetchInterceptor(), + ], +}) + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('exposes the initiator of a mocked ClientRequest request', async () => { + const initiatorPromise = new DeferredPromise() + interceptor.on('request', ({ initiator, controller }) => { + initiatorPromise.resolve(initiator as XMLHttpRequest) + controller.respondWith(new Response('mocked')) + }) + + const request = http.get('http://localhost:3001/api') + const { text } = await waitForClientRequest(request) + + await expect(initiatorPromise).resolves.toEqual(request) + await expect(text()).resolves.toBe('mocked') +}) + +it('exposes the initiator of a mocked XMLHttpRequest request', async () => { + const initiatorPromise = new DeferredPromise() + interceptor.on('request', ({ initiator, controller }) => { + initiatorPromise.resolve(initiator as XMLHttpRequest) + + controller.respondWith( + new Response('mocked', { + headers: { + 'Access-Control-Allow-Origin': '*', + }, + }) + ) + }) + + const request = await createXMLHttpRequest((request) => { + request.open('GET', 'http://localhost/api') + request.send() + }) + + await expect(initiatorPromise).resolves.toEqual(request) + expect.soft(request.responseText).toBe('mocked') +}) + +/** + * @fixme HttpRequestInterceptor doesn't support global `fetch` (Undici) right now. + * Once it does, this test will pass. + */ +it.skip('exposes the initiator of a mocked fetch request', async () => { + const initiatorPromise = new DeferredPromise() + interceptor.on('request', ({ initiator, controller }) => { + initiatorPromise.resolve(initiator as XMLHttpRequest) + + controller.respondWith( + new Response('mocked', { + headers: { + 'Access-Control-Allow-Origin': '*', + }, + }) + ) + }) + + const request = new Request('http://localhost/api') + const response = await fetch(request) + + await expect(initiatorPromise).resolves.toEqual(request) +}) From 7f43fb8e78119adaa5f8d8cad2f5ec2070d741f8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 25 Feb 2026 18:09:09 +0100 Subject: [PATCH 057/198] test: add http module import compliance tests --- .../http/compliance/http-import.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 test/modules/http/compliance/http-import.test.ts diff --git a/test/modules/http/compliance/http-import.test.ts b/test/modules/http/compliance/http-import.test.ts new file mode 100644 index 000000000..57a0cb4ef --- /dev/null +++ b/test/modules/http/compliance/http-import.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment node +import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import * as http from 'node:http' +import * as https from 'node:https' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { waitForClientRequest } from '../../../helpers' + +const interceptor = new HttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks a response to a request made via * as http import', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = http.request('http://localhost/api').end() + const { res, text } = await waitForClientRequest(request) + + expect(res.statusCode).toBe(200) + await expect(text()).resolves.toBe('hello world') +}) + +it('mocks a response to a request made via * as https import', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = https.request('https://localhost/api').end() + const { res, text } = await waitForClientRequest(request) + + expect(res.statusCode).toBe(200) + await expect(text()).resolves.toBe('hello world') +}) From f1ae30cc351547f24db4cec21f778d4f5a8a3472 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 25 Feb 2026 19:17:26 +0100 Subject: [PATCH 058/198] fix: get request method from the parser, not options --- _http_common.d.ts | 2 ++ src/interceptors/http/http-parser.ts | 22 +++++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/_http_common.d.ts b/_http_common.d.ts index e28a21f10..476227de8 100644 --- a/_http_common.d.ts +++ b/_http_common.d.ts @@ -1,6 +1,8 @@ import type { Socket } from 'node:net' import type { IncomingMessage, OutgoingMessage } from 'node:http' +declare var methods: Array + declare var HTTPParser: { new (): HTTPParser REQUEST: 0 diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index de4be6ea9..36ab3bb50 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -1,4 +1,5 @@ import { + methods as HTTP_METHODS, HTTPParser, type HeadersCallback, type RequestHeadersCompleteCallback, @@ -97,14 +98,29 @@ export class HttpRequestParser extends HttpParser { _, __, rawHeaders = [], - ___, + rawMethod, path, ____, _____, ______, shouldKeepAlive ) => { - const method = options.connectionOptions.method?.toUpperCase() || 'GET' + /** + * @note When the socket is reused, "connectionOptions" will point + * to the "net.connect()" call options that established the connection, + * which may differ from the description of the current request (e.g. method). + * Rely on the HTTPParser supplying us with the correct "rawMethod" number. + */ + const resolvedMethod = + (typeof rawMethod === 'string' + ? rawMethod + : typeof rawMethod === 'number' + ? HTTP_METHODS[rawMethod] + : options.connectionOptions.method) || + options.connectionOptions.method || + 'GET' + const finalMethod = resolvedMethod.toUpperCase() + const url = new URL(path || '', options.connectionOptions.url) const headers = FetchResponse.parseRawHeaders([ ...this.#rawHeadersBuffer, @@ -133,7 +149,7 @@ export class HttpRequestParser extends HttpParser { }) const request = new FetchRequest(url, { - method, + method: finalMethod, headers, credentials: 'same-origin', body: Readable.toWeb(this.#requestBodyStream) as any, From 0b1d9a8dc31cc91d4280a8e68a80e6ca0bf0f634 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 25 Feb 2026 19:17:39 +0100 Subject: [PATCH 059/198] chore: replace `waitForClientRequest` with `toWebResponse` --- src/interceptors/ClientRequest/index.test.ts | 14 ++- src/utils/fetchUtils.ts | 5 +- test/features/events/request.test.ts | 4 +- test/features/events/response.test.ts | 42 ++++--- .../get-client-request-body-stream.test.ts | 4 +- test/features/presets/node-preset.test.ts | 8 +- test/features/request-initiator.test.ts | 6 +- test/helpers.ts | 44 ++++--- test/modules/http/compliance/events.test.ts | 106 +++++++++-------- .../compliance/http-abort-controller.test.ts | 6 +- .../http/compliance/http-custom-agent.test.ts | 6 +- .../http/compliance/http-errors.test.ts | 20 ++-- .../compliance/http-event-connect.test.ts | 10 +- .../http-head-response-body.test.ts | 12 +- .../http/compliance/http-import.test.ts | 14 +-- .../http-max-header-fields-count.test.ts | 8 +- .../compliance/http-modify-request.test.ts | 10 +- .../compliance/http-req-get-with-body.test.ts | 6 +- .../http/compliance/http-req-method.test.ts | 12 +- .../http-req-url-to-http-options.test.ts | 14 +-- .../http/compliance/http-req-write.test.ts | 36 +++--- .../http-request-without-options.test.ts | 16 +-- .../http/compliance/http-res-callback.test.ts | 14 +-- .../http/compliance/http-res-destroy.test.ts | 10 +- .../http-res-non-configurable.test.ts | 12 +- .../compliance/http-res-raw-headers.test.ts | 50 ++++---- .../http-response-headers-folding.test.ts | 14 ++- .../compliance/http-socket-listeners.test.ts | 8 +- .../http/compliance/http-socket-reuse.test.ts | 38 +++--- .../http-unhandled-exception.test.ts | 34 +++--- .../http/compliance/http-unix-socket.test.ts | 34 +++--- test/modules/http/compliance/http.test.ts | 24 ++-- .../compliance/https-custom-agent.test.ts | 14 +-- test/modules/http/compliance/https.test.ts | 6 +- .../intercept/http-client-request.test.ts | 27 +++-- test/modules/http/intercept/http.get.test.ts | 10 +- .../http/intercept/http.request.test.ts | 31 ++--- test/modules/http/intercept/https.get.test.ts | 6 +- .../http/intercept/https.request.test.ts | 17 +-- ...ttp-empty-readable-stream-response.test.ts | 14 +-- ...ttp-max-listeners-exceeded-warning.test.ts | 6 +- .../http-await-response-event.test.ts | 10 +- .../http/response/http-empty-response.test.ts | 16 ++- test/modules/http/response/http-https.test.ts | 112 +++++++----------- .../http/response/http-response-delay.test.ts | 15 +-- .../response/http-response-patching.test.ts | 23 ++-- .../http-response-readable-stream.test.ts | 6 +- .../http-response-transfer-encoding.test.ts | 12 +- test/third-party/follow-redirect-http.test.ts | 14 +-- 49 files changed, 490 insertions(+), 490 deletions(-) diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 98539017d..5d1892314 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -3,7 +3,11 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { ClientRequestInterceptor } from '.' -import { sleep, waitForClientRequest } from '../../../test/helpers' +import { + sleep, + toWebResponse, + waitForClientRequest, +} from '../../../test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -49,7 +53,7 @@ it('abort the request if the abort signal is emitted', async () => { }) const abortError = await abortErrorPromise - expect(abortError.name).toEqual('AbortError') + expect(abortError.name).toBe('AbortError') expect(request.destroyed).toBe(true) }) @@ -66,10 +70,10 @@ it('patch the Headers object correctly after dispose and reapply', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers['x-custom-header']).toEqual('Yes') + expect(response.headers.get('x-custom-header')).toBe('Yes') }) diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index d0522eb8a..da4393015 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -145,14 +145,15 @@ export class FetchRequest extends Request { constructor(input: RequestInfo | URL, init?: RequestInit) { const method = init?.method || 'GET' const safeMethod = FetchRequest.isForbiddenMethod(method) ? 'GET' : method + const isRequestWithBody = FetchRequest.isRequestWithBody(method) super(input, { ...(init || {}), method: safeMethod, headers: init?.headers, // @ts-expect-error Undocumented Fetch property. - duplex: FetchRequest.isRequestWithBody(method) ? 'half' : undefined, - body: FetchRequest.isRequestWithBody(method) ? init?.body : null, + duplex: isRequestWithBody ? 'half' : undefined, + body: isRequestWithBody ? init?.body : null, }) if (method !== safeMethod) { diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 69d0891d7..11d31c391 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -7,7 +7,7 @@ import { createXMLHttpRequest, useCors, REQUEST_ID_REGEXP, - waitForClientRequest, + toWebResponse, } from '../../helpers' import { HttpRequestInterceptor } from '../../../src/interceptors/http' import { BatchInterceptor } from '../../../src/BatchInterceptor' @@ -54,7 +54,7 @@ it('ClientRequest: emits the "request" event upon the request', async () => { }) req.write(JSON.stringify({ userId: 'abc-123' })) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(requestListener).toHaveBeenCalledTimes(1) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 53395c132..2a3ac125c 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -9,11 +9,7 @@ import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequ import { BatchInterceptor } from '../../../src/BatchInterceptor' import { HttpRequestInterceptor } from '../../../src/interceptors/http' import { FetchInterceptor } from '../../../src/interceptors/fetch' -import { - useCors, - createXMLHttpRequest, - waitForClientRequest, -} from '../../helpers' +import { useCors, createXMLHttpRequest, toWebResponse } from '../../helpers' declare namespace window { export const _resourceLoader: { @@ -94,30 +90,32 @@ it('ClientRequest: emits the "response" event for a mocked response', async () = }) req.end() - const { res } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) // Must receive a mocked response. - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') expect(responseListener).toHaveBeenCalledTimes(1) - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[0] + { + const [{ response, request, isMockedResponse }] = + responseListener.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.https.url('/user')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('mocked') - await expect(response.text()).resolves.toBe('mocked-response-text') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-response-type')).toBe('mocked') + await expect(response.text()).resolves.toBe('mocked-response-text') - expect(isMockedResponse).toBe(true) + expect(isMockedResponse).toBe(true) + } }) it('ClientRequest: emits the "response" event upon the original response', async () => { @@ -134,7 +132,7 @@ it('ClientRequest: emits the "response" event upon the original response', async }) req.write('request-body') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(responseListener).toHaveBeenCalledTimes(1) diff --git a/test/features/get-client-request-body-stream.test.ts b/test/features/get-client-request-body-stream.test.ts index 2e0e7db17..0395481ff 100644 --- a/test/features/get-client-request-body-stream.test.ts +++ b/test/features/get-client-request-body-stream.test.ts @@ -7,7 +7,7 @@ import { DeferredPromise } from '@open-draft/deferred-promise' import { BatchInterceptor } from '../../src' import interceptors from '../../src/presets/node' import { getClientRequestBodyStream } from '../../src/utils/node' -import { waitForClientRequest } from '../helpers' +import { toWebResponse } from '../helpers' const interceptor = new BatchInterceptor({ name: 'interceptor', @@ -52,7 +52,7 @@ it('returns the underlying request body stream for http.ClientRequest', async () request.write('hello world') request.end() - await waitForClientRequest(request) + await toWebResponse(request) const requestBodyStream = await requestBodyStreamPromise expect(requestBodyStream).toBeInstanceOf(Readable) diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index 3b12d6898..c44f09934 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { BatchInterceptor } from '../../../lib/node/index.mjs' import nodeInterceptors from '../../../lib/node/presets/node.mjs' -import { createXMLHttpRequest, waitForClientRequest } from '../../helpers' +import { createXMLHttpRequest, toWebResponse } from '../../helpers' const interceptor = new BatchInterceptor({ name: 'node-preset-interceptor', @@ -30,7 +30,7 @@ afterAll(() => { it('intercepts and mocks a ClientRequest', async () => { const request = http.get('http://localhost:3001/resource') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must call the "request" event listener. expect(requestListener).toHaveBeenCalledWith( @@ -41,8 +41,8 @@ it('intercepts and mocks a ClientRequest', async () => { ) // The listener must send back a mocked response. - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('mocked') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('mocked') }) it('intercepts and mocks an XMLHttpRequest (jsdom)', async () => { diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index d8a5e4c8a..d6690109b 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -7,7 +7,7 @@ import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest/n import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest/new' import { FetchInterceptor } from '../../src/interceptors/fetch/node' import { HttpRequestInterceptor } from '../../src/interceptors/http' -import { createXMLHttpRequest, waitForClientRequest } from '../helpers' +import { createXMLHttpRequest, toWebResponse } from '../helpers' const interceptor = new BatchInterceptor({ name: 'interceptor', @@ -39,10 +39,10 @@ it('exposes the initiator of a mocked ClientRequest request', async () => { }) const request = http.get('http://localhost:3001/api') - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) await expect(initiatorPromise).resolves.toEqual(request) - await expect(text()).resolves.toBe('mocked') + await expect(response.text()).resolves.toBe('mocked') }) it('exposes the initiator of a mocked XMLHttpRequest request', async () => { diff --git a/test/helpers.ts b/test/helpers.ts index cd4ba87e5..c25099396 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,12 +1,14 @@ import { urlToHttpOptions } from 'node:url' -import https from 'node:https' import zlib from 'node:zlib' +import https from 'node:https' +import { Readable } from 'node:stream' import http, { ClientRequest, IncomingMessage, RequestOptions } from 'node:http' +import { RequestHandler } from 'express' +import { DeferredPromise } from '@open-draft/deferred-promise' import { Page } from '@playwright/test' import { getIncomingMessageBody } from '../src/interceptors/ClientRequest/utils/getIncomingMessageBody' import { SerializedRequest } from '../src/RemoteHttpInterceptor' -import { RequestHandler } from 'express' -import { DeferredPromise } from '@open-draft/deferred-promise' +import { FetchResponse } from '../src/utils/fetchUtils' export const REQUEST_ID_REGEXP = /^\w{9,}$/ @@ -285,25 +287,31 @@ export function createBrowserXMLHttpRequest(page: Page) { } } -export async function waitForClientRequest( +export async function toWebResponse( request: http.ClientRequest -): Promise<{ - res: http.IncomingMessage - text(): Promise -}> { - return new Promise((resolve, reject) => { - request.on('response', async (response) => { - response.setEncoding('utf8') - resolve({ - res: response, - text: getIncomingMessageBody.bind(null, response), +): Promise<[Response, http.IncomingMessage]> { + const pendingResponse = new DeferredPromise< + [Response, http.IncomingMessage] + >() + + request + .on('response', (response) => { + const responseBody = response.destroyed + ? null + : (Readable.toWeb(response) as ReadableStream) + + const fetchResponse = new FetchResponse(responseBody, { + status: response.statusCode, + statusText: response.statusMessage, + headers: FetchResponse.parseRawHeaders(response.rawHeaders), }) + + pendingResponse.resolve([fetchResponse, response]) }) + .on('error', (error) => pendingResponse.reject(error)) + .on('abort', () => pendingResponse.reject(new Error('Request aborted'))) - request.on('error', reject) - request.on('abort', reject) - request.on('timeout', reject) - }) + return pendingResponse } export function sleep(duration: number): Promise { diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index 71e79e8bf..cc6d20f03 100644 --- a/test/modules/http/compliance/events.test.ts +++ b/test/modules/http/compliance/events.test.ts @@ -4,7 +4,7 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { HttpRequestEventMap } from '../../../../src/glossary' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' +import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { @@ -29,7 +29,7 @@ it('emits the "request" event for an outgoing request without body', async () => vi.fn<(...args: HttpRequestEventMap['request']) => void>() interceptor.once('request', requestListener) - await waitForClientRequest( + await toWebResponse( http.get(httpServer.http.url('/'), { headers: { 'x-custom-header': 'yes', @@ -63,7 +63,7 @@ it('emits the "request" event for an outgoing request with a body', async () => }) request.write('post-payload') request.end() - await waitForClientRequest(request) + await toWebResponse(request) expect(requestListener).toHaveBeenCalledTimes(1) @@ -93,35 +93,37 @@ it('emits the "response" event for a mocked response', async () => { 'x-custom-header': 'yes', }, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must emit the "response" interceptor event. expect(responseListener).toHaveBeenCalledTimes(1) - const { - response, - requestId, - request: requestFromListener, - isMockedResponse, - } = responseListener.mock.calls[0][0] - expect(response).toBeInstanceOf(Response) - expect(response.status).toBe(200) - expect(await response.text()).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( - Object.fromEntries(requestFromListener.headers.entries()) - ).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(requestFromListener.body).toBe(null) + { + const { + response, + requestId, + request: requestFromListener, + isMockedResponse, + } = responseListener.mock.calls[0][0] + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(200) + 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( + Object.fromEntries(requestFromListener.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('hello world') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello world') }) it('emits the "response" event for a bypassed response', async () => { @@ -134,33 +136,35 @@ it('emits the "response" event for a bypassed response', async () => { 'x-custom-header': 'yes', }, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must emit the "response" interceptor event. expect(responseListener).toHaveBeenCalledTimes(1) - const { - response, - requestId, - request: requestFromListener, - isMockedResponse, - } = responseListener.mock.calls[0][0] - expect(response).toBeInstanceOf(Response) - expect(response.status).toBe(200) - expect(await response.text()).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( - Object.fromEntries(requestFromListener.headers.entries()) - ).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(requestFromListener.body).toBe(null) + { + const { + response, + requestId, + request: requestFromListener, + isMockedResponse, + } = responseListener.mock.calls[0][0] + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(200) + 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( + Object.fromEntries(requestFromListener.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.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original-response') }) diff --git a/test/modules/http/compliance/http-abort-controller.test.ts b/test/modules/http/compliance/http-abort-controller.test.ts index 780b5720b..16b10a4de 100644 --- a/test/modules/http/compliance/http-abort-controller.test.ts +++ b/test/modules/http/compliance/http-abort-controller.test.ts @@ -5,7 +5,7 @@ import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, waitForClientRequest } from '../../../helpers' +import { sleep, toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { @@ -89,7 +89,7 @@ it('handles a request within a timeout', async () => { }) request.on('timeout', timeoutListener) - await waitForClientRequest(request) + await toWebResponse(request) expect(request.destroyed).toBe(false) expect(timeoutListener).not.toHaveBeenCalled() @@ -108,7 +108,7 @@ it('respects "AbortSignal.timeout()" for a handled request', async () => { }) request.on('timeout', timeoutListener) - const abortError = await waitForClientRequest(request).then( + const abortError = await toWebResponse(request).then( () => expect.fail('must not return any response'), (error) => error ) diff --git a/test/modules/http/compliance/http-custom-agent.test.ts b/test/modules/http/compliance/http-custom-agent.test.ts index ab31b93c7..3fe1cfe82 100644 --- a/test/modules/http/compliance/http-custom-agent.test.ts +++ b/test/modules/http/compliance/http-custom-agent.test.ts @@ -4,7 +4,7 @@ import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../../test/helpers' +import { toWebResponse } from '../../../../test/helpers' const interceptor = new HttpRequestInterceptor() @@ -37,7 +37,7 @@ it('preserves the context of the "createConnection" function in a custom http ag const agent = new CustomHttpAgent() const request = http.get(httpServer.http.url('/resource'), { agent }) - await waitForClientRequest(request) + await toWebResponse(request) const [context] = createConnectionContextSpy.mock.calls[0] || [] expect(context.constructor.name).toBe('CustomHttpAgent') @@ -57,7 +57,7 @@ it('preserves the context of the "createConnection" function in a custom https a agent, rejectUnauthorized: false, }) - await waitForClientRequest(request) + await toWebResponse(request) const [context] = createConnectionContextSpy.mock.calls[0] expect(context.constructor.name).toBe('CustomHttpsAgent') diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index 9b7f167a5..e7cab3dc9 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, waitForClientRequest } from '../../../helpers' +import { sleep, toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -40,10 +40,10 @@ it('suppresses ECONNREFUSED error given a mocked response', async () => { const errorListener = vi.fn() request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked') expect(errorListener).not.toHaveBeenCalled() }) @@ -83,10 +83,10 @@ it('suppresses ENOTFOUND error given a mocked response', async () => { const errorListener = vi.fn() request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked') expect(errorListener).not.toHaveBeenCalled() }) @@ -118,10 +118,10 @@ it('suppresses EHOSTUNREACH error given a mocked response', async () => { const errorListener = vi.fn() request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked') }) it('forwards EHOSTUNREACH error for a bypassed request', async () => { diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts index 4df365335..56fc25698 100644 --- a/test/modules/http/compliance/http-event-connect.test.ts +++ b/test/modules/http/compliance/http-event-connect.test.ts @@ -4,7 +4,7 @@ import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../../test/helpers' +import { toWebResponse } from '../../../../test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { @@ -39,7 +39,7 @@ it('emits the "connect" event for a mocked HTTP request', async () => { socket.on('connect', connectListener) }) - await waitForClientRequest(request) + await toWebResponse(request) expect(connectListener).toHaveBeenCalledOnce() }) @@ -52,7 +52,7 @@ it('emits the "connect" event for a bypassed HTTP request', async () => { socket.on('connect', socketConnectListener) }) - await waitForClientRequest(request) + await toWebResponse(request) expect(socketConnectListener).toHaveBeenCalledOnce() }) @@ -69,7 +69,7 @@ it('emits the "secureConnect" event for a mocked HTTPS request', async () => { .on('secureConnect', () => connectListener('secureConnect')) }) - await waitForClientRequest(request) + await toWebResponse(request) expect.soft(connectListener).toHaveBeenNthCalledWith(1, 'connect') expect.soft(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') @@ -87,7 +87,7 @@ it('emits the "secureConnect" event for a bypassed HTTPS request', async () => { .on('secureConnect', () => connectListener('secureConnect')) }) - await waitForClientRequest(request) + await toWebResponse(request) expect.soft(connectListener).toHaveBeenNthCalledWith(1, 'connect') expect.soft(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') 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 930ea2d3b..943b3a7d0 100644 --- a/test/modules/http/compliance/http-head-response-body.test.ts +++ b/test/modules/http/compliance/http-head-response-body.test.ts @@ -2,7 +2,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -26,11 +26,9 @@ it('ignores response body in a mocked response to a HEAD request', async () => { }) const request = http.request('http://example.com', { method: 'HEAD' }).end() - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - // Must return the correct mocked response. - expect(res.statusCode).toBe(200) - expect(res.headers).toHaveProperty('x-custom-header', 'yes') - // Must ignore the response body. - expect(await text()).toBe('') + expect(response.status).toBe(200) + expect(response.headers.get('x-custom-header')).toBe('yes') + await expect(response.text(), 'Ignores the response body').resolves.toBe('') }) diff --git a/test/modules/http/compliance/http-import.test.ts b/test/modules/http/compliance/http-import.test.ts index 57a0cb4ef..c94eedf8d 100644 --- a/test/modules/http/compliance/http-import.test.ts +++ b/test/modules/http/compliance/http-import.test.ts @@ -3,7 +3,7 @@ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import * as http from 'node:http' import * as https from 'node:https' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -25,10 +25,10 @@ it('mocks a response to a request made via * as http import', async () => { }) const request = http.request('http://localhost/api').end() - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('hello world') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') }) it('mocks a response to a request made via * as https import', async () => { @@ -37,8 +37,8 @@ it('mocks a response to a request made via * as https import', async () => { }) const request = https.request('https://localhost/api').end() - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('hello world') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') }) 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 1d3a93d45..9a2a0018e 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 @@ -3,7 +3,7 @@ import http from 'node:http' import { afterAll, afterEach, beforeAll, it, expect } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -42,7 +42,7 @@ it('supports requests with more than default maximum header fields count', async request.setHeaders(new Headers(headersPairs)) request.end() - await waitForClientRequest(request) + await toWebResponse(request) const requestHeaders = await requestHeadersPromise expect(Array.from(requestHeaders)).toEqual([ @@ -77,7 +77,7 @@ it('supports multiple parallel "slow" requests', async () => { request.setHeaders(new Headers(headersPairs)) request.end() - await waitForClientRequest(request) + await toWebResponse(request) const requestHeaders = await requestHeadersPromise expect(Array.from(requestHeaders)).toEqual([ @@ -113,7 +113,7 @@ it('supports responses with more than default maximum header fields count', asyn const request = http.get('http://localhost/irrelevant') request.end() - await waitForClientRequest(request) + await toWebResponse(request) const responseHeaders = await responseHeadersPromise expect(Array.from(responseHeaders)).toEqual(responseHeadersPairs) diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index f00b04978..50a042113 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -3,7 +3,7 @@ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const server = new HttpServer((app) => { app.use('/user', (req, res) => { @@ -39,9 +39,9 @@ it('allows modifying the outgoing headers for a request without a body', async ( }) const request = http.get(server.http.url('/user')) - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.headers).toMatchObject({ + expect(Object.fromEntries(response.headers)).toMatchObject({ connection: 'keep-alive', 'x-appended-header': 'modified', }) @@ -56,7 +56,7 @@ it('allows modifying the outgoing request headers in a request with a body', asy request.write('post-payload') request.end() - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.headers['x-appended-header']).toBe('modified') + expect(response.headers.get('x-appended-header')).toBe('modified') }) 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 139ac26d0..5cc4c6497 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,7 +2,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const interceptor = new HttpRequestInterceptor() @@ -65,8 +65,8 @@ it('allows an HTTP GET request with a body', async () => { request.write('hello world') request.end() - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('hello world') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('hello world') const interceptedRequest = await interceptedRequestPromise // The Fetch API representation of this request must NOT have any body. diff --git a/test/modules/http/compliance/http-req-method.test.ts b/test/modules/http/compliance/http-req-method.test.ts index d344cb5f1..b6cf0b1a0 100644 --- a/test/modules/http/compliance/http-req-method.test.ts +++ b/test/modules/http/compliance/http-req-method.test.ts @@ -1,10 +1,8 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -35,8 +33,8 @@ it('supports lowercase HTTP methods', async () => { const request = http .request('http://localhost/resource', { method: 'get' }) .end() - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('hello world') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') }) 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 e873b3a38..0c74504ad 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 @@ -3,7 +3,7 @@ import { urlToHttpOptions } from 'node:url' import http from 'node:http' import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../../test/helpers' +import { toWebResponse } from '../../../../test/helpers' const interceptor = new HttpRequestInterceptor() @@ -29,10 +29,10 @@ it('supports `urlToHttpOptions()` as the ClientRequest options', async () => { const request = http .request(urlToHttpOptions(new URL('http://localhost'))) .end() - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('hello world') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') expect(requestCallback).toHaveBeenCalledOnce() const [fetchRequest] = requestCallback.mock.calls[0] @@ -56,10 +56,10 @@ it('supports augmented `urlToHttpOptions()` as the ClientRequest options', async }, }) .end(JSON.stringify({ hello: 'world' })) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('hello world') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') expect(requestCallback).toHaveBeenCalledOnce() const [fetchRequest] = requestCallback.mock.calls[0] diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 4b1be3be2..fcf9ff51c 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -6,7 +6,7 @@ import express from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, waitForClientRequest } from '../../../helpers' +import { sleep, toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.post('/resource', express.text({ type: '*/*' }), (req, res) => { @@ -48,10 +48,10 @@ it('writes string request body', async () => { req.write('two') req.end('three') - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) await expect(requestBodyPromise).resolves.toBe('onetwothree') - await expect(text()).resolves.toEqual('onetwothree') + await expect(response.text()).resolves.toEqual('onetwothree') }) it('writes JSON request body', async () => { @@ -72,10 +72,10 @@ it('writes JSON request body', async () => { req.write(':"value"') req.end('}') - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) await expect(requestBodyPromise).resolves.toBe(`{"key":"value"}`) - await expect(text()).resolves.toEqual(`{"key":"value"}`) + await expect(response.text()).resolves.toEqual(`{"key":"value"}`) }) it('writes Buffer request body', async () => { @@ -96,10 +96,10 @@ it('writes Buffer request body', async () => { req.write(Buffer.from(':"value"')) req.end(Buffer.from('}')) - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) await expect(requestBodyPromise).resolves.toBe(`{"key":"value"}`) - await expect(text()).resolves.toEqual(`{"key":"value"}`) + await expect(response.text()).resolves.toEqual(`{"key":"value"}`) }) it('supports Readable as the request body', async () => { @@ -126,7 +126,7 @@ it('supports Readable as the request body', async () => { readable.pipe(request) - await waitForClientRequest(request) + await toWebResponse(request) await expect(requestBodyPromise).resolves.toBe('hello world') }) @@ -138,7 +138,7 @@ it('calls the write callback when writing an empty string', async () => { const writeCallback = vi.fn() request.write('', writeCallback) request.end() - await waitForClientRequest(request) + await toWebResponse(request) expect(writeCallback).toHaveBeenCalledOnce() }) @@ -152,7 +152,7 @@ it('calls the write callback when writing an empty Buffer', async () => { request.write(Buffer.from(''), writeCallback) request.end() - await waitForClientRequest(request) + await toWebResponse(request) expect(writeCallback).toHaveBeenCalledOnce() }) @@ -167,7 +167,7 @@ it('emits "finish" for a passthrough request', async () => { request.on('finish', finishListener) request.end() - await waitForClientRequest(request) + await toWebResponse(request) expect(prefinishListener).toHaveBeenCalledOnce() expect(finishListener).toHaveBeenCalledOnce() @@ -187,7 +187,7 @@ it('emits "finish" for a mocked request', async () => { request.on('finish', finishListener) request.end() - await waitForClientRequest(request) + await toWebResponse(request) expect(prefinishListener).toHaveBeenCalledOnce() expect(finishListener).toHaveBeenCalledOnce() @@ -219,14 +219,14 @@ it('supports ending a mocked request in a write callback', async () => { }) }) - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) expect(firstWriteCallback).toHaveBeenCalledBefore(secondWriteCallback) expect(secondWriteCallback).toHaveBeenCalledBefore(requestEndCallback) expect(requestEndCallback).toHaveBeenCalledOnce() await expect(requestBodyPromise).resolves.toBe('onetwo') - await expect(text()).resolves.toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') }) /** @@ -254,13 +254,13 @@ it('supports ending a bypassed request in a write callback', async () => { }) }) - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) expect(firstWriteCallback).toHaveBeenCalledBefore(secondWriteCallback) expect(secondWriteCallback).toHaveBeenCalledBefore(requestEndCallback) expect(requestEndCallback).toHaveBeenCalledOnce() - await expect(text()).resolves.toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') }) it('calls the write callbacks when reading request body in the interceptor', async () => { @@ -279,12 +279,12 @@ it('calls the write callbacks when reading request body in the interceptor', asy request.write('two', requestWriteCallback) request.end('three', requestWriteCallback) - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must call each write callback once. expect(requestWriteCallback).toHaveBeenCalledTimes(3) // Must be able to read the request stream in the interceptor. expect(requestBodyCallback).toHaveBeenCalledWith('onetwothree') // Must send the correct request body to the server. - await expect(text()).resolves.toBe('onetwothree') + await expect(response.text()).resolves.toBe('onetwothree') }) 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 18fcc6fcb..9ad67ea24 100644 --- a/test/modules/http/compliance/http-request-without-options.test.ts +++ b/test/modules/http/compliance/http-request-without-options.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { vi, it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -32,7 +32,7 @@ it('supports "http.request()" without any arguments', async () => { request.on('response', responseListener) request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) expect(errorListener).not.toHaveBeenCalled() expect(responseListener).toHaveBeenCalledTimes(1) @@ -40,8 +40,8 @@ it('supports "http.request()" without any arguments', async () => { expect(request.method).toBe('GET') expect(request.protocol).toBe('http:') expect(request.host).toBe('localhost') - expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('Mocked') }) it('supports "http.get()" without any arguments', async () => { @@ -56,7 +56,7 @@ it('supports "http.get()" without any arguments', async () => { request.on('response', responseListener) request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) expect(errorListener).not.toHaveBeenCalled() expect(responseListener).toHaveBeenCalledTimes(1) @@ -64,6 +64,6 @@ it('supports "http.get()" without any arguments', async () => { expect(request.method).toBe('GET') expect(request.protocol).toBe('http:') expect(request.host).toBe('localhost') - expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('Mocked') }) diff --git a/test/modules/http/compliance/http-res-callback.test.ts b/test/modules/http/compliance/http-res-callback.test.ts index ac44337a9..1bb1318f4 100644 --- a/test/modules/http/compliance/http-res-callback.test.ts +++ b/test/modules/http/compliance/http-res-callback.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/get', (req, res) => { @@ -39,9 +39,9 @@ it('calls a custom callback once when the request is bypassed', async () => { responseCallback ) - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - await expect.soft(text()).resolves.toBe('original') + await expect.soft(response.text()).resolves.toBe('original') expect.soft(responseCallback).toHaveBeenCalledOnce() }) @@ -65,10 +65,10 @@ it('calls a custom callback once when the response is mocked', async () => { responseCallback ) - const { text, res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - await expect.soft(text()).resolves.toBe('mocked') - expect.soft(res.statusCode).toBe(403) - expect.soft(res.statusMessage).toBe('Forbidden') + await expect.soft(response.text()).resolves.toBe('mocked') + expect.soft(response.status).toBe(403) + expect.soft(response.statusText).toBe('Forbidden') expect.soft(responseCallback).toHaveBeenCalledOnce() }) diff --git a/test/modules/http/compliance/http-res-destroy.test.ts b/test/modules/http/compliance/http-res-destroy.test.ts index 5a6bfd922..ca4ebfdc2 100644 --- a/test/modules/http/compliance/http-res-destroy.test.ts +++ b/test/modules/http/compliance/http-res-destroy.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/lib/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => res.sendStatus(200)) @@ -37,9 +37,9 @@ it('emits the "error" event when a bypassed response is destroyed', async () => response.destroy(new Error('reason')) }) - const { res } = await waitForClientRequest(request) + const [, rawResponse] = await toWebResponse(request) - expect(res.destroyed).toBe(true) + expect(rawResponse.destroyed).toBe(true) expect(socketErrorListener).toHaveBeenCalledOnce() expect(socketErrorListener).toHaveBeenCalledWith(new Error('reason')) }) @@ -60,9 +60,9 @@ it('emits the "error" event when a mocked response is destroyed', async () => { response.destroy(new Error('reason')) }) - const { res } = await waitForClientRequest(request) + const [, rawResponse] = await toWebResponse(request) - expect(res.destroyed).toBe(true) + expect(rawResponse.destroyed).toBe(true) expect(socketErrorListener).toHaveBeenCalledOnce() expect(socketErrorListener).toHaveBeenCalledWith(new Error('reason')) }) 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 3a30f3033..36f153135 100644 --- a/test/modules/http/compliance/http-res-non-configurable.test.ts +++ b/test/modules/http/compliance/http-res-non-configurable.test.ts @@ -8,7 +8,7 @@ import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { FetchResponse } from '../../../../src/utils/fetchUtils' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -40,12 +40,12 @@ it('handles non-configurable responses from the actual server', async () => { }) const request = http.get(httpServer.http.url('/resource')) - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must passthrough non-configurable responses // (i.e. those that cannot be created using the Fetch API). - expect(res.statusCode).toBe(101) - expect(res.statusMessage).toBe('Switching Protocols') + expect(response.status).toBe(101) + expect(response.statusText).toBe('Switching Protocols') // Must expose the exact response in the listener. await expect(responsePromise).resolves.toHaveProperty('status', 101) @@ -66,9 +66,9 @@ it('supports mocking non-configurable responses', async () => { }) const request = http.get('http://localhost/irrelevant') - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(101) + expect(response.status).toBe(101) // Must expose the exact response in the listener. await expect(responsePromise).resolves.toHaveProperty('status', 101) 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 5d3831019..c4ca3212b 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -5,7 +5,7 @@ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' // The actual server is here for A/B purpose only. const httpServer = new HttpServer((app) => { @@ -43,12 +43,14 @@ it('preserves raw response headers (object init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(Object.fromEntries(response.headers)).toStrictEqual({ + 'x-custom-header': 'Yes', + }) }) it('preserves raw response headers (array init)', async () => { @@ -61,12 +63,14 @@ it('preserves raw response headers (array init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(Object.fromEntries(response.headers)).toStrictEqual({ + 'x-custom-header': 'Yes', + }) }) it('preserves raw response headers (set after init)', async () => { @@ -80,12 +84,12 @@ it('preserves raw response headers (set after init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes', 'x-My-Header', '1']) ) - expect(res.headers).toStrictEqual({ + expect(Object.fromEntries(response.headers)).toStrictEqual({ 'x-custom-header': 'Yes', 'x-my-header': '1', }) @@ -103,9 +107,9 @@ it('preserves raw response headers (append after init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining([ 'X-CustoM-HeadeR', 'Yes', @@ -115,7 +119,7 @@ it('preserves raw response headers (append after init)', async () => { '2', ]) ) - expect(res.headers).toStrictEqual({ + expect(Object.fromEntries(response.headers)).toStrictEqual({ 'x-custom-header': 'Yes', 'x-my-header': '1, 2', }) @@ -136,12 +140,12 @@ it('preserves raw response headers (delete after init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers).toStrictEqual({ + expect(Object.fromEntries(response.headers)).toStrictEqual({ 'x-custom-header': 'Yes', }) }) @@ -155,20 +159,22 @@ it('preserves raw response headers (standalone Headers)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(Object.fromEntries(response.headers)).toStrictEqual({ + 'x-custom-header': 'Yes', + }) }) it('preserves raw response headers for unmocked request', async () => { const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers['x-custom-header']).toEqual('Yes') + expect(response.headers.get('x-custom-header')).toBe('Yes') }) 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 1d84ec9a2..2cf78b34b 100644 --- a/test/modules/http/compliance/http-response-headers-folding.test.ts +++ b/test/modules/http/compliance/http-response-headers-folding.test.ts @@ -2,7 +2,7 @@ import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' import http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -30,10 +30,12 @@ it('preserves the original mocked response headers casing in "rawHeaders"', asyn }) const request = http.get('http://localhost/resource') - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toStrictEqual(['X-CustoM-HeadeR', 'Yes']) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(rawResponse.rawHeaders).toStrictEqual(['X-CustoM-HeadeR', 'Yes']) + expect(Object.fromEntries(response.headers)).toStrictEqual({ + 'x-custom-header': 'Yes', + }) }) it('folds duplicate response headers for a mocked response', async () => { @@ -67,7 +69,7 @@ it('folds duplicate response headers for a mocked response', async () => { }) const request = http.get('http://localhost/resource') - const { res } = await waitForClientRequest(request) + const [, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual(responseHeaders) + expect(rawResponse.rawHeaders).toEqual(responseHeaders) }) diff --git a/test/modules/http/compliance/http-socket-listeners.test.ts b/test/modules/http/compliance/http-socket-listeners.test.ts index 67b304f0c..2b575a283 100644 --- a/test/modules/http/compliance/http-socket-listeners.test.ts +++ b/test/modules/http/compliance/http-socket-listeners.test.ts @@ -9,7 +9,7 @@ import { Socket } from 'node:net' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { @@ -39,8 +39,8 @@ it('removes all event listeners from a passthrough socket after closing', async pendingSocket.resolve(socket) }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - await expect.soft(text()).resolves.toBe('ok') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('ok') }) diff --git a/test/modules/http/compliance/http-socket-reuse.test.ts b/test/modules/http/compliance/http-socket-reuse.test.ts index ba8dde8cf..fe7494e43 100644 --- a/test/modules/http/compliance/http-socket-reuse.test.ts +++ b/test/modules/http/compliance/http-socket-reuse.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/get', (req, res) => { @@ -43,10 +43,10 @@ it('allows reusing the same socket for mixed mocked/bypassed requests', async () const request = https.get(httpServer.https.url('/get'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - await expect.soft(text()).resolves.toBe('original') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original') } { @@ -57,10 +57,10 @@ it('allows reusing the same socket for mixed mocked/bypassed requests', async () const request = https.get(httpServer.https.url('/mock'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(301) - await expect.soft(text()).resolves.toBe('') + expect.soft(response.status).toBe(301) + await expect.soft(response.text()).resolves.toBe('') } }) @@ -73,20 +73,20 @@ it('allows reusing the same socket for multiple mocked requests', async () => { const request = https.get(httpServer.https.url('/mock'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - await expect.soft(text()).resolves.toBe('mocked') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('mocked') } { const request = https.get(httpServer.https.url('/mock'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - await expect.soft(text()).resolves.toBe('mocked') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('mocked') } }) @@ -95,19 +95,19 @@ it('allows reusing the same socket for multiple bypassed requests', async () => const request = https.get(httpServer.https.url('/get'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - await expect.soft(text()).resolves.toBe('original') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original') } { const request = https.get(httpServer.https.url('/get'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - await expect.soft(text()).resolves.toBe('original') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original') } }) diff --git a/test/modules/http/compliance/http-unhandled-exception.test.ts b/test/modules/http/compliance/http-unhandled-exception.test.ts index 1f7a13bf7..98ed23daf 100644 --- a/test/modules/http/compliance/http-unhandled-exception.test.ts +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -2,7 +2,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -24,11 +24,11 @@ it('handles a thrown Response as a mocked response', async () => { }) const request = http.get('http://localhost/resource') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') - expect(await text()).toBe('hello world') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('hello world') }) it('treats unhandled interceptor errors as 500 responses', async () => { @@ -37,11 +37,11 @@ it('treats unhandled interceptor errors as 500 responses', async () => { }) const request = http.get('http://localhost/resource') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(500) - expect(res.statusMessage).toBe('Unhandled Exception') - expect(JSON.parse(await text())).toEqual({ + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + await expect(response.json()).resolves.toEqual({ name: 'Error', message: 'Custom error', stack: expect.any(String), @@ -61,7 +61,7 @@ it('handles exceptions by default if "unhandledException" listener is provided b const requestErrorListener = vi.fn() request.on('error', requestErrorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must emit the "unhandledException" interceptor event. expect(unhandledExceptionListener).toHaveBeenCalledWith( @@ -74,9 +74,9 @@ it('handles exceptions by default if "unhandledException" listener is provided b // Since the "unhandledException" listener didn't handle the // exception, it will be translated to the 500 error response // (the default behavior). - expect(res.statusCode).toBe(500) - expect(res.statusMessage).toBe('Unhandled Exception') - expect(JSON.parse(await text())).toEqual({ + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + await expect(response.json()).resolves.toEqual({ name: 'Error', message: 'Custom error', stack: expect.any(String), @@ -102,11 +102,11 @@ it('handles exceptions as instructed in "unhandledException" listener (mock resp const requestErrorListener = vi.fn() request.on('error', requestErrorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') - expect(await text()).toBe('fallback response') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('fallback response') expect(unhandledExceptionListener).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/test/modules/http/compliance/http-unix-socket.test.ts b/test/modules/http/compliance/http-unix-socket.test.ts index fa941d419..0cbfff62e 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -8,7 +8,7 @@ import path from 'node:path' import http from 'node:http' import { promisify } from 'node:util' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const HTTP_SOCKET_PATH = path.join(__dirname, './test-http.sock') @@ -57,10 +57,10 @@ it('supports passthrough HTTP GET requests over a unix socket', async () => { 'X-Custom-Header': 'custom-value', }, }) - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers['x-custom-header']).toBe('custom-value') + expect.soft(response.status).toBe(200) + expect.soft(response.headers.get('x-custom-header')).toBe('custom-value') }) it('supports passthrough HTTP POST requests over a unix socket', async () => { @@ -75,14 +75,14 @@ it('supports passthrough HTTP POST requests over a unix socket', async () => { }) request.end('hello world') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers).toMatchObject({ + expect.soft(response.status).toBe(200) + expect.soft(Object.fromEntries(response.headers)).toMatchObject({ 'content-type': 'application/json', 'content-length': '11', }) - await expect.soft(text()).resolves.toBe('hello world') + await expect.soft(response.text()).resolves.toBe('hello world') }) it('supports passthrough HTTP GET requests to a non-existing unix socket', async () => { @@ -91,7 +91,7 @@ it('supports passthrough HTTP GET requests to a non-existing unix socket', async path: '/irrelevant', }) - await expect(waitForClientRequest(request)).rejects.toThrow( + await expect(toWebResponse(request)).rejects.toThrow( expect.objectContaining({ code: 'ENOENT', message: 'connect ENOENT non-existing.sock', @@ -111,11 +111,11 @@ it('mocks a response to HTTP GET requests over a unix socket', async () => { 'X-Custom-Header': 'custom-value', }, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers['x-custom-header']).toBe('custom-value') - await expect.soft(text()).resolves.toBe('hello world') + expect.soft(response.status).toBe(200) + expect.soft(response.headers.get('x-custom-header')).toBe('custom-value') + await expect.soft(response.text()).resolves.toBe('hello world') }) it('mocks a response to HTTP POST requests over a unix socket', async () => { @@ -137,9 +137,9 @@ it('mocks a response to HTTP POST requests over a unix socket', async () => { }) request.end('request-payload') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers['x-custom-header']).toBe('custom-value') - await expect.soft(text()).resolves.toBe('request-payload') + expect.soft(response.status).toBe(200) + expect.soft(response.headers.get('x-custom-header')).toBe('custom-value') + await expect.soft(response.text()).resolves.toBe('request-payload') }) diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index ab900313c..abc33389c 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -5,7 +5,7 @@ import express from 'express' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -42,7 +42,7 @@ it('bypasses a request to the existing host', async () => { }) request.write(JSON.stringify({ name: 'john' })) request.end() - const { text, res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] @@ -55,8 +55,8 @@ it('bypasses a request to the existing host', async () => { await expect(requestFromListener.json()).resolves.toEqual({ name: 'john' }) // Must receive the correct response. - expect(res.headers).toHaveProperty('x-custom-header', 'yes') - await expect(text()).resolves.toBe('hello, john') + expect(response.headers.get('x-custom-header')).toBe('yes') + await expect(response.text()).resolves.toBe('hello, john') expect(requestListener).toHaveBeenCalledTimes(1) }) @@ -70,7 +70,7 @@ it('errors on a request to a non-existing host', async () => { request.on('error', (error) => errorPromise.resolve(error)) request.end() - await expect(() => waitForClientRequest(request)).rejects.toThrow( + await expect(() => toWebResponse(request)).rejects.toThrow( 'getaddrinfo ENOTFOUND abc123-non-existing.lol' ) @@ -109,7 +109,7 @@ it('mocked request to an existing host', async () => { }) request.write(JSON.stringify({ name: 'john' })) request.end() - const { text, res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] @@ -121,8 +121,8 @@ it('mocked request to an existing host', async () => { await expect(requestFromListener.json()).resolves.toEqual({ name: 'john' }) // Must receive the correct response. - expect(res.headers).toHaveProperty('x-custom-header', 'mocked') - await expect(text()).resolves.toBe('howdy, john') + expect(response.headers.get('x-custom-header')).toBe('mocked') + await expect(response.text()).resolves.toBe('howdy, john') expect(requestListener).toHaveBeenCalledTimes(1) }) @@ -149,7 +149,7 @@ it('mocks response to a non-existing host', async () => { }) request.write(JSON.stringify({ name: 'john' })) request.end() - const { text, res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] @@ -161,8 +161,8 @@ it('mocks response to a non-existing host', async () => { await expect(requestFromListener.json()).resolves.toEqual({ name: 'john' }) // Must receive the correct response. - expect(res.headers).toHaveProperty('x-custom-header', 'mocked') - await expect(text()).resolves.toBe('howdy, john') + expect(response.headers.get('x-custom-header')).toBe('mocked') + await expect(response.text()).resolves.toBe('howdy, john') expect(requestListener).toHaveBeenCalledTimes(1) }) @@ -240,7 +240,7 @@ it('returns socket address for a bypassed request', async () => { }) }) - await waitForClientRequest(request) + await toWebResponse(request) await expect(addressPromise).resolves.toEqual({ address: httpServer.http.address.host, diff --git a/test/modules/http/compliance/https-custom-agent.test.ts b/test/modules/http/compliance/https-custom-agent.test.ts index ce2b6b101..8ae40e92e 100644 --- a/test/modules/http/compliance/https-custom-agent.test.ts +++ b/test/modules/http/compliance/https-custom-agent.test.ts @@ -4,7 +4,7 @@ import https from 'node:https' import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { @@ -37,8 +37,8 @@ it('supports https.Agent instance as a custom agent for a mocked request', async agent: new https.Agent(), }) - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('hello world') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('hello world') }) it('supports https.Agent instance as a custom agent for a passthrough request', async () => { @@ -48,8 +48,8 @@ it('supports https.Agent instance as a custom agent for a passthrough request', }), }) - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('original response') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('original response') }) it('supports http.Agent instance as a custom agent for a passthrough request', async () => { @@ -77,6 +77,6 @@ it('supports http.Agent instance as a custom agent for a passthrough request', a rejectUnauthorized: false, }) - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('original response') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('original response') }) diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 792dd749f..1e406851f 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { @@ -54,7 +54,7 @@ it('emits correct events for a mocked HTTPS request', async () => { .on('error', socketErrorListener) }) - await waitForClientRequest(request) + await toWebResponse(request) expect.soft(socketListener).toHaveBeenCalledOnce() expect.soft(socketReadyListener).toHaveBeenCalledOnce() @@ -92,7 +92,7 @@ it('emits correct events for a passthrough HTTPS request', async () => { .on('error', socketErrorListener) }) - await waitForClientRequest(request) + await toWebResponse(request) expect.soft(socketListener).toHaveBeenCalledOnce() expect.soft(socketConnectListener).toHaveBeenCalledOnce() diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index 72a7d9004..2ff62c4f2 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -1,8 +1,9 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { httpsAgent, HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' +import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' import { RequestController } from '../../../../src/RequestController' import { HttpRequestEventMap } from '../../../../src/glossary' @@ -48,7 +49,7 @@ it('intercepts an HTTP ClientRequest request with request options', async () => }) req.end() - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] @@ -65,7 +66,7 @@ it('intercepts an HTTP ClientRequest request with request options', async () => expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - await expect(text()).resolves.toBe('original-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTP ClientRequest request with URL string', async () => { @@ -78,7 +79,7 @@ it('intercepts an HTTP ClientRequest request with URL string', async () => { req.setHeader('x-custom-header', 'yes') req.end() - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] @@ -95,7 +96,7 @@ it('intercepts an HTTP ClientRequest request with URL string', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - await expect(text()).resolves.toBe('original-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTP ClientRequest request with URL instance', async () => { @@ -108,7 +109,7 @@ it('intercepts an HTTP ClientRequest request with URL instance', async () => { req.setHeader('x-custom-header', 'yes') req.end() - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] @@ -125,7 +126,7 @@ it('intercepts an HTTP ClientRequest request with URL instance', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - await expect(text()).resolves.toBe('original-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with URL string', async () => { @@ -141,7 +142,7 @@ it('intercepts an HTTPS ClientRequest request with URL string', async () => { req.setHeader('x-custom-header', 'yes') req.end() - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] @@ -158,7 +159,7 @@ it('intercepts an HTTPS ClientRequest request with URL string', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - await expect(text()).resolves.toBe('original-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with URL instance', async () => { @@ -174,7 +175,7 @@ it('intercepts an HTTPS ClientRequest request with URL instance', async () => { req.setHeader('x-custom-header', 'yes') req.end() - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] @@ -191,7 +192,7 @@ it('intercepts an HTTPS ClientRequest request with URL instance', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - await expect(text()).resolves.toBe('original-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with request options', async () => { @@ -212,7 +213,7 @@ it('intercepts an HTTPS ClientRequest request with request options', async () => }) req.end() - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] @@ -229,7 +230,7 @@ it('intercepts an HTTPS ClientRequest request with request options', async () => expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - await expect(text()).resolves.toBe('original-body') + await expect(response.text()).resolves.toBe('original-body') }) it('restores the original ClientRequest class after disposal', async () => { diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index d96bff578..b600d0364 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -2,7 +2,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' +import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' import { RequestController } from '../../../../src/RequestController' import { HttpRequestEventMap } from '../../../../src/glossary' @@ -39,7 +39,7 @@ it('intercepts an http.get request', async () => { }, }) - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -55,7 +55,7 @@ it('intercepts an http.get request', async () => { expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) - await expect(text()).resolves.toBe('user-body') + await expect(response.text()).resolves.toBe('user-body') }) it('intercepts an http.get request given RequestOptions without a protocol', async () => { @@ -66,7 +66,7 @@ it('intercepts an http.get request given RequestOptions without a protocol', asy port: httpServer.http.address.port, path: '/user?id=123', }) - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -82,5 +82,5 @@ it('intercepts an http.get request given RequestOptions without a protocol', asy expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) - await expect(text()).resolves.toBe('user-body') + await expect(response.text()).resolves.toBe('user-body') }) diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 795bda40f..a604220af 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -1,8 +1,9 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' 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 { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src/glossary' import { RequestController } from '../../../../src/RequestController' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' @@ -23,16 +24,17 @@ const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterEach(() => { - vi.resetAllMocks() + vi.clearAllMocks() }) afterAll(async () => { interceptor.dispose() + vi.restoreAllMocks() await httpServer.close() }) @@ -45,7 +47,7 @@ it('intercepts a HEAD request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -73,7 +75,7 @@ it('intercepts a GET request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -104,7 +106,7 @@ it('intercepts a POST request', async () => { req.write('post-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -134,7 +136,7 @@ it('intercepts a PUT request', async () => { }) req.write('put-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -164,7 +166,7 @@ it('intercepts a PATCH request', async () => { }) req.write('patch-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -184,7 +186,7 @@ it('intercepts a PATCH request', async () => { }) it('intercepts a DELETE request', async () => { - const url = httpServer.http.url('/user?id=123') + const url = httpServer.http.url('/user?id=1234') const req = http.request(url, { method: 'DELETE', headers: { @@ -192,7 +194,8 @@ it('intercepts a DELETE request', async () => { }, }) req.end() - await waitForClientRequest(req) + + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -220,7 +223,7 @@ it('intercepts an http.request given RequestOptions without a protocol', async ( path: '/user?id=123', }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -243,7 +246,7 @@ it('intercepts an http.request path in url and options', async () => { callback ) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -267,7 +270,7 @@ it('intercepts an http.request with custom "auth" option', async () => { auth, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -290,7 +293,7 @@ it('intercepts an http.request with a URL with "username" and "password"', async ) ) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 8161b03e6..a25a02cb2 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,7 +1,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' +import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src/glossary' import { RequestController } from '../../../../src/RequestController' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' @@ -39,7 +39,7 @@ it('intercepts a GET request', async () => { }, }) - await waitForClientRequest(request) + await toWebResponse(request) expect(resolver).toHaveBeenCalledTimes(1) @@ -70,7 +70,7 @@ it('intercepts an https.get request given RequestOptions without a protocol', as // Suppress the "certificate has expired" error. rejectUnauthorized: false, }) - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index fc55e6a42..9e7de2bfa 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -1,8 +1,9 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' 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 { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src' import { RequestController } from '../../../../src/RequestController' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' @@ -48,7 +49,7 @@ it('intercepts a HEAD request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -73,7 +74,7 @@ it('intercepts a GET request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -99,7 +100,7 @@ it('intercepts a POST request', async () => { }) req.write('post-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -125,7 +126,7 @@ it('intercepts a PUT request', async () => { }) req.write('put-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -151,7 +152,7 @@ it('intercepts a PATCH request', async () => { }) req.write('patch-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -176,7 +177,7 @@ it('intercepts a DELETE request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -199,7 +200,7 @@ it('intercepts an http.request request given RequestOptions without a protocol', path: '/user?id=123', }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) 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 6ddfbdbf8..19ebf4c78 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,10 +1,8 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const interceptor = new HttpRequestInterceptor() @@ -27,9 +25,9 @@ it('responds to a request with an empty ReadableStream', async () => { }) const request = http.get('http://example.com') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') - expect(await text()).toBe('') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.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 4c8638986..6c98855df 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 @@ -6,7 +6,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -32,9 +32,9 @@ afterAll(async () => { it('does not buffer socket pushes for a passthrough request', async () => { const request = http.get(httpServer.http.url('/')) - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - await expect(text()).resolves.toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') expect( request.socket?.listenerCount('connect'), 'Must not add "connection" listeners to the socket. Those listeners mean no "_handle" exists on the mock socket.' 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 2bfb6efb8..4b963b892 100644 --- a/test/modules/http/response/http-await-response-event.test.ts +++ b/test/modules/http/response/http-await-response-event.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { @@ -42,10 +42,10 @@ it('awaits asynchronous response event listener for a mocked response', async () tag('before-request') const request = http.get('http://localhost/') - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) tag('after-request') - await expect(text()).resolves.toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') expect.soft(tag).toHaveBeenNthCalledWith(1, 'before-request') expect.soft(tag).toHaveBeenNthCalledWith(2, 'response') @@ -64,10 +64,10 @@ it('awaits asynchronous response event listener for the original response', asyn tag('before-request') const request = http.get(httpServer.http.url('/resource')) - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) tag('after-request') - await expect(text()).resolves.toBe('original response') + await expect(response.text()).resolves.toBe('original response') expect.soft(tag).toHaveBeenNthCalledWith(1, 'before-request') expect.soft(tag).toHaveBeenNthCalledWith(2, 'response') diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts index a8c5f7373..707966a60 100644 --- a/test/modules/http/response/http-empty-response.test.ts +++ b/test/modules/http/response/http-empty-response.test.ts @@ -1,9 +1,7 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const interceptor = new HttpRequestInterceptor() @@ -23,12 +21,12 @@ it('supports responding with an empty mocked response', async () => { }) const request = http.get('http://localhost') - const { res, text } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) + expect.soft(response.status).toBe(200) // Must not set any response headers that were not // explicitly provided in the mocked response. - expect.soft(res.headers).toEqual({}) - expect.soft(res.rawHeaders).toEqual([]) - await expect(text()).resolves.toBe('') + expect.soft(Object.fromEntries(response.headers)).toEqual({}) + expect.soft(rawResponse.rawHeaders).toEqual([]) + await expect(response.text()).resolves.toBe('') }) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index d1cc7c0e1..088f589e0 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -4,7 +4,7 @@ import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -49,93 +49,75 @@ afterAll(async () => { it('responds to a handled request issued by "http.get"', async () => { const req = http.get('http://any.localhost/non-existing') - const { res, text } = await waitForClientRequest(req) - - expect(res).toMatchObject>({ - statusCode: 301, - statusMessage: 'Moved Permanently', - headers: { - 'content-type': 'text/plain', - }, - }) - await expect(text()).resolves.toEqual('mocked') + const [response] = await toWebResponse(req) + + expect(response.status).toBe(301) + expect(response.statusText).toBe('Moved Permanently') + expect(response.headers.get('content-type')).toBe('text/plain') + await expect(response.text()).resolves.toEqual('mocked') }) it('responds to a handled request issued by "https.get"', async () => { const req = https.get('https://any.localhost/non-existing') - const { res, text } = await waitForClientRequest(req) - - expect(res).toMatchObject>({ - statusCode: 301, - statusMessage: 'Moved Permanently', - headers: { - 'content-type': 'text/plain', - }, - }) - await expect(text()).resolves.toEqual('mocked') + const [response] = await toWebResponse(req) + + expect(response.status).toBe(301) + expect(response.statusText).toBe('Moved Permanently') + expect(response.headers.get('content-type')).toBe('text/plain') + await expect(response.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 [response] = await toWebResponse(req) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - await expect(text()).resolves.toEqual('/get') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toEqual('/get') }) it('bypasses an unhandled request issued by "https.get"', async () => { const req = https.get(httpServer.https.url('/get'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - await expect(text()).resolves.toEqual('/get') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toEqual('/get') }) it('responds to a handled request issued by "http.request"', async () => { const req = http.request('http://any.localhost/non-existing') req.end() - const { res, text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) - expect(res.statusCode).toBe(301) - expect(res.statusMessage).toEqual('Moved Permanently') - expect(res.headers).toHaveProperty('content-type', 'text/plain') - await expect(text()).resolves.toEqual('mocked') + expect(response.status).toBe(301) + expect(response.statusText).toEqual('Moved Permanently') + expect(response.headers.get('content-type')).toBe('text/plain') + await expect(response.text()).resolves.toEqual('mocked') }) it('responds to a handled request issued by "https.request"', async () => { const req = https.request('https://any.localhost/non-existing') req.end() - const { res, text } = await waitForClientRequest(req) - - expect(res).toMatchObject>({ - statusCode: 301, - statusMessage: 'Moved Permanently', - headers: { - 'content-type': 'text/plain', - }, - }) - await expect(text()).resolves.toEqual('mocked') + const [response] = await toWebResponse(req) + + expect(response.status).toBe(301) + expect(response.statusText).toBe('Moved Permanently') + expect(response.headers.get('content-type')).toBe('text/plain') + await expect(response.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 [response] = await toWebResponse(req) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - await expect(text()).resolves.toEqual('/get') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toEqual('/get') }) it('bypasses an unhandled request issued by "https.request"', async () => { @@ -143,18 +125,16 @@ it('bypasses an unhandled request issued by "https.request"', async () => { rejectUnauthorized: false, }) req.end() - const { res, text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - await expect(text()).resolves.toEqual('/get') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.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) => { + await toWebResponse(req).catch((error) => { expect(error.message).toEqual('Custom exception message') }) }) @@ -163,11 +143,9 @@ 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 [response] = await toWebResponse(req) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - await expect(text()).resolves.toEqual('/') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toEqual('/') }) diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 6e52a8632..a68a7554b 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -1,7 +1,8 @@ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { sleep, waitForClientRequest } from '../../../helpers' +import { sleep, toWebResponse } from '../../../helpers' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const interceptor = new HttpRequestInterceptor() @@ -30,11 +31,11 @@ it('supports custom delay before responding with a mock', async () => { const requestStart = Date.now() const request = http.get('http://non-existing-host.com') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) const requestEnd = Date.now() - expect.soft(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('mocked response') + expect.soft(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked response') expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700) }) @@ -47,10 +48,10 @@ it('supports custom delay before receiving the original response', async () => { const requestStart = Date.now() const request = http.get(httpServer.http.url('/resource')) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) const requestEnd = Date.now() - expect.soft(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('original response') + expect.soft(response.status).toBe(200) + await expect(response.text()).resolves.toBe('original response') expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700) }) diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 2498dbfab..903fb75e7 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,8 +1,9 @@ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, waitForClientRequest } from '../../../helpers' +import { sleep, toWebResponse } from '../../../helpers' const server = new HttpServer((app) => { app.get('/original', async (req, res) => { @@ -23,19 +24,19 @@ async function getResponse(request: Request): Promise { await sleep(0) const originalRequest = http.get(server.http.url('/original')) - const { res, text } = await waitForClientRequest(originalRequest) + const [response, rawResponse] = await toWebResponse(originalRequest) const getHeader = (name: string): string | undefined => { - const value = res.headers[name] + const value = rawResponse.headers[name] return Array.isArray(value) ? value.join(', ') : value } - const responseText = (await text()) + ' world' + const responseText = (await response.text()) + ' world' resolve( new Response(responseText, { - status: res.statusCode, - statusText: res.statusMessage, + status: response.status, + statusText: response.statusText, headers: { 'X-Custom-Header': getHeader('x-custom-header') || '', }, @@ -66,10 +67,10 @@ afterAll(async () => { it('supports response patching', async () => { const req = http.get('http://localhost/mocked') - const { res, text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') - expect(res.headers['x-custom-header']).toBe('yes') - expect(await text()).toBe('hello world') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('x-custom-header')).toBe('yes') + await expect(response.text()).resolves.toBe('hello world') }) 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 99a83da12..b0686e29b 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -6,7 +6,7 @@ import { performance } from 'node:perf_hooks' import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> @@ -88,8 +88,8 @@ it('supports ReadableStream as a mocked response', async () => { }) const request = http.get('http://localhost/resource') - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('hello world') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('hello world') }) it('supports delays between the mock response stream chunks', async () => { 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 a0f41c193..5999e4bf7 100644 --- a/test/modules/http/response/http-response-transfer-encoding.test.ts +++ b/test/modules/http/response/http-response-transfer-encoding.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { waitForClientRequest } from '../../../helpers' +import { toWebResponse } from '../../../helpers' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const interceptor = new HttpRequestInterceptor() @@ -31,10 +31,10 @@ it('responds with a mocked "transfer-encoding: chunked" response', async () => { }) const request = http.get('http://localhost') - const { res, text } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers).toHaveProperty('transfer-encoding', 'chunked') - expect.soft(res.rawHeaders).toContain('Transfer-Encoding') - await expect(text()).resolves.toBe('hello world') + expect.soft(response.status).toBe(200) + expect.soft(response.headers.get('transfer-encoding')).toBe('chunked') + expect.soft(rawResponse.rawHeaders).toContain('Transfer-Encoding') + await expect(response.text()).resolves.toBe('hello world') }) diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index f40d9e371..5bd3ecf42 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { https } from 'follow-redirects' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../src/interceptors/http' -import { waitForClientRequest } from '../helpers' +import { toWebResponse } from '../helpers' const interceptor = new HttpRequestInterceptor() @@ -72,7 +72,7 @@ it('intercepts a request issued by "follow-redirects"', async () => { request.end(payload) - const { text } = await waitForClientRequest(request as any) + const [response] = await toWebResponse(request as any) await vi.waitFor(() => { expect(requestListener).toHaveBeenCalledTimes(2) @@ -100,7 +100,7 @@ it('intercepts a request issued by "follow-redirects"', async () => { // Response (original). expect(catchResponseUrl).toHaveBeenCalledWith(server.https.url('/user')) - expect(await text()).toBe('hello from the server') + await expect(response.text()).resolves.toBe('hello from the server') }) it('supports mocking a redirect response to the original response', async () => { @@ -132,7 +132,7 @@ it('supports mocking a redirect response to the original response', async () => request.end() - const { text } = await waitForClientRequest(request as any) + const [response] = await toWebResponse(request as any) // Intercepted redirect request (issued by "follow-redirects"). const [redirectedRequest] = requestListener.mock.calls[0] @@ -142,7 +142,7 @@ it('supports mocking a redirect response to the original response', async () => // Response (original). expect(catchResponseUrl).toHaveBeenCalledWith(server.https.url('/redirected')) - expect(await text()).toBe('redirected response') + await expect(response.text()).resolves.toBe('redirected response') }) it('supports mocking a redirect response to a mocked response', async () => { @@ -179,7 +179,7 @@ it('supports mocking a redirect response to a mocked response', async () => { request.end() - const { text } = await waitForClientRequest(request as any) + const [response] = await toWebResponse(request as any) // Intercepted redirect request (issued by "follow-redirects"). const [redirectedRequest] = requestListener.mock.calls[0] @@ -191,5 +191,5 @@ it('supports mocking a redirect response to a mocked response', async () => { expect(catchResponseUrl).toHaveBeenCalledWith( 'https://localhost:3000/redirected' ) - expect(await text()).toBe('mocked response') + await expect(response.text()).resolves.toBe('mocked response') }) From 42ad86858a5ece3a3cdcdf179135a9f1feda500b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 14:20:03 +0100 Subject: [PATCH 060/198] feat: support raw `undici` via support of write after connect --- discoveries/undici.mdx | 22 +++++ package.json | 2 +- pnpm-lock.yaml | 10 +-- src/interceptors/net/index.ts | 9 +- src/interceptors/net/socket-controller.ts | 42 +++++++-- .../net/utils/normalize-net-connect-args.ts | 1 + test/modules/fetch/compliance/undici.test.ts | 85 +++++++++++++++++++ .../response/http-socket-set-no-delay.test.ts | 35 ++++++++ .../net/compliance/socket-write.test.ts | 59 +++++++++++++ 9 files changed, 252 insertions(+), 13 deletions(-) create mode 100644 discoveries/undici.mdx create mode 100644 test/modules/fetch/compliance/undici.test.ts create mode 100644 test/modules/http/response/http-socket-set-no-delay.test.ts create mode 100644 test/modules/net/compliance/socket-write.test.ts diff --git a/discoveries/undici.mdx b/discoveries/undici.mdx new file mode 100644 index 000000000..9b7d035be --- /dev/null +++ b/discoveries/undici.mdx @@ -0,0 +1,22 @@ +# Undici + +## `connect` and writing the HTTP message + +Undici's `fetch` waits for the underlying socket to emit the `connect` event _before_ it writes the HTTP message to it. That is different than in `http.request()`, where the HTTP message is written _immediately_ but gets buffered (`socket._pendinData`) and flushed once `connect` is emitted. + +``` +# Node.js v22 +- dispatch() (lib/web/fetch/index.js:2129) + - agent.dispatch() + - Client.dispatch() + - _resume() + - connect() + - client[kConnector](options, cb*) + - Client.connect() + - net.connect(options) // no callback here + - socket.once('connect', cb*) // or "secureConnect" +``` + +> Note that the `cb` called on `connect` is _internal_. It's not provided as the connection callback to `net.connect()`, which makes it invisible to the interceptor. + +Then `connectH1()` calls `writeH1()` to write the HTTP message. diff --git a/package.json b/package.json index d3741f09e..19ed7bec6 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "supertest": "^7.0.0", "tsdown": "^0.18.1", "typescript": "^5.8.2", - "undici": "^7.4.0", + "undici": "^7.22.0", "vitest": "^3.0.8", "vitest-environment-miniflare": "^2.14.1", "wait-for-expect": "^3.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84626f710..e2ff2d805 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,8 +154,8 @@ importers: specifier: ^5.8.2 version: 5.8.2 undici: - specifier: ^7.4.0 - version: 7.4.0 + specifier: ^7.22.0 + version: 7.22.0 vitest: specifier: ^3.0.8 version: 3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) @@ -2943,8 +2943,8 @@ packages: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} - undici@7.4.0: - resolution: {integrity: sha512-PUQM3/es3noM24oUn10u3kNNap0AbxESOmnssmW+dOi9yGwlUSi5nTNYl3bNbTkWOF8YZDkx2tCmj9OtQ3iGGw==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} unicorn-magic@0.1.0: @@ -6077,7 +6077,7 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@7.4.0: {} + undici@7.22.0: {} unicorn-magic@0.1.0: {} diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 1e0d9f729..0f4717974 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -32,10 +32,11 @@ export class SocketInterceptor extends Interceptor { const realNetConnect = net.connect net.connect = (...args: [any, any]) => { + log('net.connect()', args) + const [connectionOptions, connectionCallback] = normalizeNetConnectArgs(args) - log('net.connect()') log({ connectionOptions, connectionCallback }) const socket = new net.Socket() @@ -55,6 +56,12 @@ export class SocketInterceptor extends Interceptor { log('connecting the socket...') + // Patch the lookup option so DNS lookup always succeeds. + // Passthrough connections are created with the original options and won't be affected. + connectionOptions.lookup = (hostname, dnsOptions, callback) => { + callback(null, [{ address: '127.0.0.1', family: 4 }]) + } + return socket.connect(connectionOptions, connectionCallback) } diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 498897132..d0aedc6f5 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -180,8 +180,8 @@ export abstract class SocketController { public claim(): void { invariant( - this.readyState !== SocketController.MOCKED, - 'Failed to claim a TLS socket: already claimed' + this.readyState !== SocketController.PASSTHROUGH, + 'Failed to claim a TLS socket: already passthrough' ) this.readyState = SocketController.MOCKED @@ -222,9 +222,9 @@ export class TcpSocketController extends SocketController { this.#realWriteGeneric = this.socket._writeGeneric this.socket.connect = new Proxy(this.socket.connect, { - apply: (target, thisArg, argArray) => { - this.#connectionOptions = argArray[0] - return Reflect.apply(target, thisArg, argArray) + apply: (target, thisArg, args) => { + this.#connectionOptions = args[0] + return Reflect.apply(target, thisArg, args) }, }) @@ -248,11 +248,35 @@ export class TcpSocketController extends SocketController { this.#reset() } + // protected preemtiveConnect(): void { + // this.socket.emit('connect') + // } + #reset(): void { this.readyState = SocketController.PENDING this.pendingConnection = new DeferredPromise() const wrapHandle = (handle: TcpHandle) => { + this.pendingConnection.then(() => { + process.nextTick(() => { + /** + * @note If by this point the socket hasn't been handled, + * is still connecting, doesn't have any writes buffered, + * and has a "connect" listener, assume it's the "write after connect" + * scenario (e.g. undici). In that case, auto-claim the socket to + * transition to the connected state appropriately to its handle. + */ + if ( + this.readyState === SocketController.PENDING && + this.socket.connecting && + this.socket._pendingData == null && + this.socket.listenerCount('connect') > 0 + ) { + this.claim() + } + }) + }) + handle.connect = handle.connect6 = (request) => { this.pendingConnection.resolve([request, handle]) } @@ -268,7 +292,13 @@ export class TcpSocketController extends SocketController { this.socket._writeGeneric = (...args) => { if (this.readyState === SocketController.PENDING) { - this.#push(args[1]) + // Socket might write immediately, before the "connection" interceptor event is emitted. + // In those cases, schedule the emit on the next tick to ensure the server socket emits "data". + if (this.socket.listenerCount('internal:write') === 0) { + process.nextTick(() => this.#push(args[1])) + } else { + this.#push(args[1]) + } /** * @note Execute the write callbacks while the socket is still pending. diff --git a/src/interceptors/net/utils/normalize-net-connect-args.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts index fbadd5199..34ed1ef51 100644 --- a/src/interceptors/net/utils/normalize-net-connect-args.ts +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -13,6 +13,7 @@ export interface NetworkConnectionOptions { localAddress?: string | null localPort?: number | null timeout?: number + lookup?: net.LookupFunction } export type NetConnectArgs = diff --git a/test/modules/fetch/compliance/undici.test.ts b/test/modules/fetch/compliance/undici.test.ts new file mode 100644 index 000000000..dffd3d5c7 --- /dev/null +++ b/test/modules/fetch/compliance/undici.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment node +import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { fetch, request } from 'undici' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' + +const interceptor = new HttpRequestInterceptor() +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks an HTTP request made with "fetch"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { 'x-custom-header': 'yes' }, + }) + ) + }) + + const response = await fetch('http://any.host.here/api') + + expect.soft(response.status).toBe(200) + expect.soft(Object.fromEntries(response.headers)).toEqual({ + 'x-custom-header': 'yes', + }) + await expect.soft(response.text()).resolves.toBe('hello world') +}) + +it('mocks an HTTPS request made with "fetch"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { 'x-custom-header': 'yes' }, + }) + ) + }) + + const response = await fetch('https://any.host.here/api') + + expect.soft(response.status).toBe(200) + expect.soft(Object.fromEntries(response.headers)).toEqual({ + 'x-custom-header': 'yes', + }) + await expect.soft(response.text()).resolves.toBe('hello world') +}) + +it('mocks an HTTP request made with "request"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { 'x-custom-header': 'yes' }, + }) + ) + }) + + const response = await request('http://any.host.here/api') + + expect.soft(response.statusCode).toBe(200) + expect.soft(response.headers).toEqual({ 'x-custom-header': 'yes' }) + await expect.soft(response.body.text()).resolves.toBe('hello world') +}) + +it('mocks an HTTPS request made with "request"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { 'x-custom-header': 'yes' }, + }) + ) + }) + + const response = await request('https://any.host.here/api') + + expect.soft(response.statusCode).toBe(200) + expect.soft(response.headers).toEqual({ 'x-custom-header': 'yes' }) + await expect.soft(response.body.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/http/response/http-socket-set-no-delay.test.ts b/test/modules/http/response/http-socket-set-no-delay.test.ts new file mode 100644 index 000000000..37dce115b --- /dev/null +++ b/test/modules/http/response/http-socket-set-no-delay.test.ts @@ -0,0 +1,35 @@ +// @vitest-environment node +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import http from 'node:http' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { toWebResponse } from '../../../helpers' + +const interceptor = new HttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('supports mocking a request with the socket "noDelay" set to true', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = http.get('http://any.host.here/api') + request.on('socket', (socket) => { + socket.setNoDelay(true) + }) + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/net/compliance/socket-write.test.ts b/test/modules/net/compliance/socket-write.test.ts new file mode 100644 index 000000000..c491c0f17 --- /dev/null +++ b/test/modules/net/compliance/socket-write.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment node +import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import net from 'node:net' +import { SocketInterceptor } from '../../../../src/interceptors/net' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('emits "data" on the server socket for writes before connect', async () => { + const interceptorDataListener = vi.fn() + + interceptor.on('connection', ({ socket }) => { + socket.on('data', interceptorDataListener) + }) + + const socket = net.connect(80, 'any.host.com') + const closeListener = vi.fn() + + socket.on('close', closeListener) + socket.write('hello', () => socket.destroy()) + + await expect.poll(() => closeListener).toHaveBeenCalledOnce() + await expect + .poll(() => interceptorDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('hello')) +}) + +it('emits "data" on the server socket for writes after connect', async () => { + const interceptorDataListener = vi.fn() + + interceptor.on('connection', ({ socket }) => { + socket.on('data', interceptorDataListener) + }) + + const socket = net.connect(80, 'any.host.com') + const closeListener = vi.fn() + + socket + .on('connect', () => { + socket.write('hello', () => socket.destroy()) + }) + .on('close', closeListener) + + await expect.poll(() => closeListener).toHaveBeenCalledOnce() + expect(interceptorDataListener).toHaveBeenCalledExactlyOnceWith( + Buffer.from('hello') + ) +}) From af3524bd7ba0ca258f1e5dd2a77e66d5c4dba017 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 14:38:11 +0100 Subject: [PATCH 061/198] fix: skip `buffer` encoded first messages --- src/interceptors/http/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 2ac8d8a85..f67addf8c 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -142,8 +142,16 @@ export class HttpRequestInterceptor extends Interceptor { passthrough: () => { const transformRequestMessage = ( httpMessage: string | Buffer, - encoding?: BufferEncoding - ): string => { + encoding?: BufferEncoding | 'buffer' + ): string | Buffer => { + /** + * @note Socket can write a buffer (e.g. uploaded file) even before + * it writes the HTTP message. Bypass those cases. + */ + if (encoding === 'buffer') { + return httpMessage + } + const parts = httpMessage.toString(encoding).split('\r\n') const headersEndIndex = parts.findIndex( (field) => field === '' From 1dea6a0a1ab03b2a932815d92540378eacdbec50 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 16:35:05 +0100 Subject: [PATCH 062/198] fix: call "connect" listeners for "connect->write" issue --- src/interceptors/net/socket-controller.ts | 57 ++++++++++--------- .../http-post-missing-first-bytes.test.ts | 13 ++--- .../response/http-socket-set-no-delay.test.ts | 35 ------------ 3 files changed, 35 insertions(+), 70 deletions(-) delete mode 100644 test/modules/http/response/http-socket-set-no-delay.test.ts diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index d0aedc6f5..25b8b65cd 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -180,8 +180,8 @@ export abstract class SocketController { public claim(): void { invariant( - this.readyState !== SocketController.PASSTHROUGH, - 'Failed to claim a TLS socket: already passthrough' + this.readyState !== SocketController.MOCKED, + 'Failed to claim a TLS socket: already claimed' ) this.readyState = SocketController.MOCKED @@ -248,10 +248,6 @@ export class TcpSocketController extends SocketController { this.#reset() } - // protected preemtiveConnect(): void { - // this.socket.emit('connect') - // } - #reset(): void { this.readyState = SocketController.PENDING this.pendingConnection = new DeferredPromise() @@ -272,7 +268,9 @@ export class TcpSocketController extends SocketController { this.socket._pendingData == null && this.socket.listenerCount('connect') > 0 ) { - this.claim() + for (const listener of this.socket.listeners('connect')) { + listener() + } } }) }) @@ -398,27 +396,32 @@ export class TcpSocketController extends SocketController { this.#passthroughSocket?.resume() } - public claim(): void { - super.claim() + protected mockConnection(): void { + if (!this.socket.connecting) { + return + } + + /** + * Patch the "getsockname" on the handle in case Node.js decides to handle its errors. + * Run this if the socket is connecting because "_handle" can be null if socket timed out. + * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/net.js#L971 + */ + this.socket._handle.getsockname = () => 0 + this.socket.address = () => { + return getAddressInfoByConnectionOptions(this.#connectionOptions) + } - if (this.socket.connecting) { + this.pendingConnection.then(([request, handle]) => { /** - * Patch the "getsockname" on the handle in case Node.js decides to handle its errors. - * Run this if the socket is connecting because "_handle" can be null if socket timed out. - * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/net.js#L971 + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 */ - this.socket._handle.getsockname = () => 0 - this.socket.address = () => { - return getAddressInfoByConnectionOptions(this.#connectionOptions) - } + request.oncomplete(0, handle, request, true, true) + }) + } - this.pendingConnection.then(([request, handle]) => { - /** - * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 - */ - request.oncomplete(0, handle, request, true, true) - }) - } + public claim(): void { + super.claim() + this.mockConnection() } public errorWith(reason?: Error): void { @@ -528,8 +531,8 @@ export class TlsSocketController extends TcpSocketController { super(socket, createConnection) } - public claim(): void { - // Run this logic before "super.claim()" so it executes first. + protected mockConnection(): void { + // Run this logic before the parent's class method so it executes first. // TLSWrap methods have to be patched before TCPWrap fires "oncomplete". const handle = this.socket._handle @@ -559,7 +562,7 @@ export class TlsSocketController extends TcpSocketController { handle.onnewsession(1, Buffer.alloc(0)) }) - super.claim() + super.mockConnection() } public passthrough(): tls.TLSSocket { 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 19064e82d..dbb533576 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 @@ -4,10 +4,10 @@ */ import http from 'node:http' import path from 'node:path' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { vi, afterAll, beforeAll, afterEach, it, expect } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import superagent from 'superagent' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' const interceptor = new HttpRequestInterceptor() @@ -34,7 +34,7 @@ afterAll(async () => { await httpServer.close() }) -it('does not skip first request bytes on passthrough POST request', async () => { +it('does not skip the first request bytes on passthrough POST request', async () => { const socketDataCallback = vi.fn() const underlyingServer = httpServer['_http'] as http.Server @@ -58,16 +58,13 @@ it('does not skip first request bytes on passthrough POST request', async () => expect.fail('Request must not error') }) - expect(response.status).toBe(200) - // Must send the uploaded file to the server. - expect(response.body).toEqual({ + expect.soft(response.status).toBe(200) + expect.soft(response.body).toEqual({ contentType: expect.stringMatching('multipart/form-data; boundary='), contentLength: '3723', }) - // Must send correct request headers. - expect(socketDataCallback).toHaveBeenNthCalledWith( - 1, + expect(socketDataCallback).toHaveBeenCalledExactlyOnceWith( expect.stringContaining('POST /upload HTTP/1.1\r\n') ) }) diff --git a/test/modules/http/response/http-socket-set-no-delay.test.ts b/test/modules/http/response/http-socket-set-no-delay.test.ts deleted file mode 100644 index 37dce115b..000000000 --- a/test/modules/http/response/http-socket-set-no-delay.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'node:http' -import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' - -const interceptor = new HttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('supports mocking a request with the socket "noDelay" set to true', async () => { - interceptor.on('request', ({ controller }) => { - controller.respondWith(new Response('hello world')) - }) - - const request = http.get('http://any.host.here/api') - request.on('socket', (socket) => { - socket.setNoDelay(true) - }) - const [response] = await toWebResponse(request) - - expect.soft(response.status).toBe(200) - await expect.soft(response.text()).resolves.toBe('hello world') -}) From b09260ffb7c400e8b7a538f52ff37d567e170675 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 16:36:32 +0100 Subject: [PATCH 063/198] chore: remove protected `mockConnection` --- src/interceptors/net/socket-controller.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 25b8b65cd..306099689 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -396,7 +396,9 @@ export class TcpSocketController extends SocketController { this.#passthroughSocket?.resume() } - protected mockConnection(): void { + public claim(): void { + super.claim() + if (!this.socket.connecting) { return } @@ -419,11 +421,6 @@ export class TcpSocketController extends SocketController { }) } - public claim(): void { - super.claim() - this.mockConnection() - } - public errorWith(reason?: Error): void { this.socket.destroy(reason) } @@ -531,7 +528,7 @@ export class TlsSocketController extends TcpSocketController { super(socket, createConnection) } - protected mockConnection(): void { + public claim(): void { // Run this logic before the parent's class method so it executes first. // TLSWrap methods have to be patched before TCPWrap fires "oncomplete". const handle = this.socket._handle @@ -562,7 +559,7 @@ export class TlsSocketController extends TcpSocketController { handle.onnewsession(1, Buffer.alloc(0)) }) - super.mockConnection() + super.claim() } public passthrough(): tls.TLSSocket { From 4c7a842ea90eb7a57439cf26f002e24e82995445 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 18:09:09 +0100 Subject: [PATCH 064/198] test: add req.end after socket connect test --- src/interceptors/net/index.ts | 6 +- src/interceptors/net/socket-controller.ts | 28 ++++++++- .../http-req-end-after-connect.test.ts | 61 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 test/modules/http/compliance/http-req-end-after-connect.test.ts diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 0f4717974..e5011e812 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -58,7 +58,11 @@ export class SocketInterceptor extends Interceptor { // Patch the lookup option so DNS lookup always succeeds. // Passthrough connections are created with the original options and won't be affected. - connectionOptions.lookup = (hostname, dnsOptions, callback) => { + connectionOptions.lookup = function mockLookup( + hostname, + dnsOptions, + callback + ) { callback(null, [{ address: '127.0.0.1', family: 4 }]) } diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 306099689..f8e229f91 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -15,7 +15,7 @@ export const kRawSocket = Symbol('kRawSocket') export const kMockState = Symbol('kMockState') export const kTlsSocket = Symbol('kTlsSocket') -const log = createLogger('MockSocket') +const log = createLogger('SocketController') // Internally, Node.js represents the result of various operations // by the number they return: 0 (error), 1 (success). @@ -223,6 +223,8 @@ export class TcpSocketController extends SocketController { this.socket.connect = new Proxy(this.socket.connect, { apply: (target, thisArg, args) => { + log('socket.connect()', args) + this.#connectionOptions = args[0] return Reflect.apply(target, thisArg, args) }, @@ -236,7 +238,10 @@ export class TcpSocketController extends SocketController { * would behave correctly. */ socket - .on('free', () => this.#reset()) + .on('free', () => { + log('socket has been freed!') + this.#reset() + }) .on('close', () => { this.#passthroughSocket = null this.#passthroughPausedBuffer = [] @@ -249,11 +254,15 @@ export class TcpSocketController extends SocketController { } #reset(): void { + log('resetting the socket...') + this.readyState = SocketController.PENDING this.pendingConnection = new DeferredPromise() const wrapHandle = (handle: TcpHandle) => { this.pendingConnection.then(() => { + log('connection request resolved!', this.readyState) + process.nextTick(() => { /** * @note If by this point the socket hasn't been handled, @@ -268,6 +277,8 @@ export class TcpSocketController extends SocketController { this.socket._pendingData == null && this.socket.listenerCount('connect') > 0 ) { + log('assume connect->write socket, calling "connect" listeners...') + for (const listener of this.socket.listeners('connect')) { listener() } @@ -276,8 +287,11 @@ export class TcpSocketController extends SocketController { }) handle.connect = handle.connect6 = (request) => { + log('handle.connect()') this.pendingConnection.resolve([request, handle]) } + + log('socket handle wrapped! waiting for connection request...') } if (this.socket._handle) { @@ -289,6 +303,8 @@ export class TcpSocketController extends SocketController { } this.socket._writeGeneric = (...args) => { + log('socket write:', args, this.readyState) + if (this.readyState === SocketController.PENDING) { // Socket might write immediately, before the "connection" interceptor event is emitted. // In those cases, schedule the emit on the next tick to ensure the server socket emits "data". @@ -341,6 +357,7 @@ export class TcpSocketController extends SocketController { return } + log('writing to passthrough:', args) return this.#realWriteGeneric.apply(this.socket, args) } } @@ -400,9 +417,12 @@ export class TcpSocketController extends SocketController { super.claim() if (!this.socket.connecting) { + log('socket already connected, skipping claim...') return } + log('claim!') + /** * Patch the "getsockname" on the handle in case Node.js decides to handle its errors. * Run this if the socket is connecting because "_handle" can be null if socket timed out. @@ -414,6 +434,8 @@ export class TcpSocketController extends SocketController { } this.pendingConnection.then(([request, handle]) => { + log('connection request resolved, mocking the connection...') + /** * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 */ @@ -434,6 +456,8 @@ export class TcpSocketController extends SocketController { ): net.Socket { super.passthrough() + log('passthrough!') + /** * @note Modify the pending data to be flushed to the passthrough socket. * In HTTP, this allows sending different request headers (e.g. modified in the listener). diff --git a/test/modules/http/compliance/http-req-end-after-connect.test.ts b/test/modules/http/compliance/http-req-end-after-connect.test.ts new file mode 100644 index 000000000..01085f90c --- /dev/null +++ b/test/modules/http/compliance/http-req-end-after-connect.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import http from 'node:http' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { toWebResponse } from '../../../helpers' + +const interceptor = new HttpRequestInterceptor() + +const httpServer = new HttpServer((app) => { + app.get('/resource', (req, res) => res.send('original')) +}) + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('intercepts an HTTP request that ends after the socket has connected', async () => { + const request = http.request(httpServer.http.url('/resource')) + request.on('socket', (socket) => { + socket.on('connect', () => request.end()) + }) + + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original') +}) + +it('mocks an HTTP request that ends after the socket has connected', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = http.request(httpServer.http.url('/mocked'), { + /** + * Force no Agent so the opened socket from the previous test doesn't + * get reused for this one. When Node.js reuses a socket, it never emits + * the "connect" event on the socket again. + */ + agent: false, + }) + request.on('socket', (socket) => { + socket.on('connect', () => request.end()) + }) + + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello world') +}) From 1ba37012e43db1cf0cfc0d9d51f1da114d5ba6b5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 18:12:13 +0100 Subject: [PATCH 065/198] docs: mention end after connect risk --- discoveries/http.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discoveries/http.mdx b/discoveries/http.mdx index 330bec7ab..4ddcf99b9 100644 --- a/discoveries/http.mdx +++ b/discoveries/http.mdx @@ -8,6 +8,8 @@ By default, `Agent` will try to reuse any sockets that were not explicitly close 2. `connect` is _not_ emitted on this socket anymore since the connection has already been established. No connection attempt will be made whatsoever (`socket._handle.connect` won't be called). 3. The next request's HTTP message is written into the socket. +> With this in mind, `socket.on('connect', () => request.end())` is a potentially faulty logic. A reused socket will never emit that event and the request will pend indefinitely. + ## `CONNECT` requests While forbidden by the Fetch API specification, `CONNECT` requests are possible in Node.js. Here's what's special about connect requests: From 7fa3dad0a222c15f66b29b99c0754123be1e45d9 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 18:35:59 +0100 Subject: [PATCH 066/198] fix: forward the `keylog` tls event --- src/interceptors/net/socket-controller.ts | 5 +- .../https-peer-certificate.test.ts | 132 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 test/modules/http/regressions/https-peer-certificate.test.ts diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index f8e229f91..c6c16b226 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -280,7 +280,7 @@ export class TcpSocketController extends SocketController { log('assume connect->write socket, calling "connect" listeners...') for (const listener of this.socket.listeners('connect')) { - listener() + listener.apply(this.socket) } } }) @@ -607,6 +607,9 @@ export class TlsSocketController extends TcpSocketController { .on('session', (...args) => { this.socket.emit('session', ...args) }) + .on('keylog', (line) => { + this.socket.emit('keylog', line) + }) return realSocket } diff --git a/test/modules/http/regressions/https-peer-certificate.test.ts b/test/modules/http/regressions/https-peer-certificate.test.ts new file mode 100644 index 000000000..100d14163 --- /dev/null +++ b/test/modules/http/regressions/https-peer-certificate.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node +/** + * @see https://github.com/nock/nock/issues/2930#issuecomment-3960523903 + */ +import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import net from 'node:net' +import tls from 'node:tls' +import https from 'node:https' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { toWebResponse } from '../../../../test/helpers' + +const interceptor = new HttpRequestInterceptor() + +const httpServer = https.createServer( + { + cert: ` +-----BEGIN CERTIFICATE----- +MIIDRzCCAi8CFDtKJ0FurS/QAxK6A2cZY7yFCM6CMA0GCSqGSIb3DQEBCwUAMGAx +CzAJBgNVBAYTAkZSMQwwCgYDVQQIDANJZEYxDjAMBgNVBAcMBVBhcmlzMREwDwYD +VQQKDAhTdG9yZGF0YTEMMAoGA1UECwwDRGV2MRIwEAYDVQQDDAlsb2NhbGhvc3Qw +HhcNMjAwNDAxMTA1MTE3WhcNNDcwODE3MTA1MTE3WjBgMQswCQYDVQQGEwJGUjEM +MAoGA1UECAwDSWRGMQ4wDAYDVQQHDAVQYXJpczERMA8GA1UECgwIU3RvcmRhdGEx +DDAKBgNVBAsMA0RldjESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA7uEZtx/xuqr/BMNfFy/KVqNqncgUOqRpme6e+VQd +Y2Vcn+Xovtajkti9Luf6whUPRiCWj0312QWzBQqnryNcmrydAzMfD2MBEfiBZp0L +xKF9p+ETfeIyU5A+bTxo3ejD894QmqgEYiPCBKepWcboaM85NUihEUXbGMWS11n1 +MqU7nNTZhI+KWhCMV+EIn7cY0ckd6WWlhw+RPtHosjDQcS/j96HoY4YxyhUMceyD +npJgCffMTiI9Hw6huQSeNVWdnJ3DLUH4JCCAkLHxOWMmNGNKP0h9VwaXRVYnJU9Q +c0oOAHSvzMFznTs4erDsRR+OM/LI3z4sDNNFhKor5QrEYwIDAQABMA0GCSqGSIb3 +DQEBCwUAA4IBAQAl1gGK+NfK4A34ztoiMdyocW/WHdCKAmCBlioALc3yKtoc+w7w +wrLqyktmL7YH36gB5AIgpcNkov8RqvIckdc3t6vFs8hzmR6qadKd7SRxKofc/mvv +mAmJY9XweH7yGVqWDSEzisS8xW7rRydwfBNNT9l8JP6xjWOXNpMzm+/3dNrKGywp +XJAPJbp0p9UFdRS9z4Tqeirfi60il/iaM+YVYyMD/sPyyVunu5hRw4HKpK0yiXHC ++pJlWrAhCxBw7Ye0ItN/P1ONMqpDpyzVaExjUJUf2SEgS4E7nbDSRoD7bFjDlUTc +IheCQLxQiuY/x9s0PORBmRG7ozEJe8BKfGUa +-----END CERTIFICATE----- `, + key: ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA7uEZtx/xuqr/BMNfFy/KVqNqncgUOqRpme6e+VQdY2Vcn+Xo +vtajkti9Luf6whUPRiCWj0312QWzBQqnryNcmrydAzMfD2MBEfiBZp0LxKF9p+ET +feIyU5A+bTxo3ejD894QmqgEYiPCBKepWcboaM85NUihEUXbGMWS11n1MqU7nNTZ +hI+KWhCMV+EIn7cY0ckd6WWlhw+RPtHosjDQcS/j96HoY4YxyhUMceyDnpJgCffM +TiI9Hw6huQSeNVWdnJ3DLUH4JCCAkLHxOWMmNGNKP0h9VwaXRVYnJU9Qc0oOAHSv +zMFznTs4erDsRR+OM/LI3z4sDNNFhKor5QrEYwIDAQABAoIBAQCEjWgFk7ZBDM3B +yN+lMCGo/bkVoIaJG951SlHwrFo6Y26IU71Y2CWgQKCJvLQKqkD1evPQxUPcjysN +ayItLwQd4PeHZQChOyDG5gx38kErdSkS1PRJ8BBZCjt5xgGy0Yyab+jqyLzV8F2i +055HcPZZ4lMuXAT0Xrz6+/dFhGdpF/CPjLYq/KkX2QXO4Sx6iiVEHYr66Y5rcDGx +n9DHeBAeMh8W/FwGE7LBM5kEM9XkTGBh5u0HHVkauZsJ/3I4DEluDUVMkqayU5Z0 +/h2CfnGX1I5wlpHOIB9YniehOVYWfFYl4KwWtQTibJy3H1ZcXUsNI/YR5sUpeA5c +EjhImAZxAoGBAPiPrCknwvs4ud17vfRwi5m3JmSgGEWu0gi4GNetK0XXyLbVWb4N +rD57zVGuXt/MVE2eN+dAmP+bhokR3uLIM8JcOYzr87/6teB1ORfyJB71+Iog59Ti +hMOUNTUUbhlrSQXkmADMXayGGAX5oge5OgJrgcaejOQJ7cwAoRIeVck9AoGBAPYH +QDeQ6fvcAysYLWrbbFFmyKGLuT0sy4boj9qqUXkKcTvaU9uk73/oQrrdJJ+VH47L +J4KgLmLr4XxvJsW/9Lj7DAoIptL/JiraRI40T2bTJbuej38SYF1aHACHj0P1Bb5j +pUEllDLT1DShxxCduSULepoqwFo99rHRL1Kp/14fAoGAUq8wfQxOD1YCdkwYl3zs +43iKnASprlyGYAIluXFQqM4sZa25ScCwoKR8W4Se6OHG1X8hZ5sUiksJSQWZ2GTy +2t/lARzom99hq0YzdOTG4Um/oOtrU2T69ziRLpQaP/hxdTVi3zkcnCyLR0mQffM+ ++dkbdZ/+jElFQoyfCDDxJp0CgYEA9WmkMAlYrYf4jRsv6sB32vcZOLOkkpZFaww+ +utNcM84rx5VwQs/Sq5cmQTnol1rsQMb7YXyg6MH8ieBiH63r0j1x8+xPZHdpPiO9 +cNBTR/FlWTLAVvQgtd31wr12NkaKdTD2nfZ7TvwoWFvrsvJxxbcek/wDJcFbfGJ6 +vw2eEucCgYAH7UURHZBeTY6faj0jjtwwWEavFPlIN2ytg44cn5OXvlZWuJ/C36ve +UNj277WBVCQGTnbMRdj5CokdUz/Y2coLZCRsPv8dMCiymC7Wzib63PF0F2zApZrG +g2wjK5TFm7cVzl/Bm0EZnpf/kQ2+QQGroeVQqoGkELEqj3FlDFW1Dw== +-----END RSA PRIVATE KEY----- + `, + }, + (req, res) => { + res.writeHead(200).end() + } +) + +beforeAll(async () => { + interceptor.apply() + + await new Promise((resolve) => { + httpServer.listen(0, '127.0.0.1', resolve) + }) +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +}) + +it('supports a custom HTTPS agent that assigns the socket certificate', async () => { + class CustomHttpsAgent extends https.Agent { + public certificate?: tls.PeerCertificate + + constructor() { + super({ rejectUnauthorized: false }) + + this.once('keylog', (line, socket: tls.TLSSocket) => { + socket.once('secureConnect', () => { + this.certificate = socket.getPeerCertificate(false) + }) + }) + } + } + + const agent = new CustomHttpsAgent() + const request = https.request({ + host: '127.0.0.1', + port: (httpServer.address() as net.AddressInfo).port, + agent, + }) + request.end() + + await toWebResponse(request) + + expect(agent.certificate).toEqual( + expect.objectContaining({ + fingerprint: + 'AC:E6:F1:6B:7A:41:2D:3F:13:E2:64:81:29:C3:4F:94:B1:A5:34:E6', + pubkey: expect.any(Buffer), + valid_from: expect.any(String), + valid_to: expect.any(String), + }) + ) +}) From 1906637b68fc786243afae9669736601f44c61ad Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 18:37:35 +0100 Subject: [PATCH 067/198] fix: forward `OCSPResponse` tls event --- src/interceptors/net/socket-controller.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index c6c16b226..5bac93186 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -607,8 +607,11 @@ export class TlsSocketController extends TcpSocketController { .on('session', (...args) => { this.socket.emit('session', ...args) }) - .on('keylog', (line) => { - this.socket.emit('keylog', line) + .on('keylog', (...args) => { + this.socket.emit('keylog', ...args) + }) + .on('OCSPResponse', (...args) => { + this.socket.emit('OCSPResponse', ...args) }) return realSocket From 465462ca1b26742c402c2d2e959611e2c10f2677 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 18:52:44 +0100 Subject: [PATCH 068/198] test(xhr): add the OPTIONS preflight request test --- src/interceptors/XMLHttpRequest/new.ts | 4 +- test/features/events/request.test.ts | 85 +++++++++++++++++++------- 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/new.ts b/src/interceptors/XMLHttpRequest/new.ts index 6705ea73f..df584065c 100644 --- a/src/interceptors/XMLHttpRequest/new.ts +++ b/src/interceptors/XMLHttpRequest/new.ts @@ -24,8 +24,8 @@ export class XMLHttpRequestInterceptor extends Interceptor ) globalThis.XMLHttpRequest = new Proxy(globalThis.XMLHttpRequest, { - construct(target, argArray, newTarget) { - const xmlHttpRequest = Reflect.construct(target, argArray, newTarget) + construct(target, args, newTarget) { + const xmlHttpRequest = Reflect.construct(target, args, newTarget) /** * @note Use `.enterWith()` here because XHR in JSDOM is implemented diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 11d31c391..a7d724d7c 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -2,16 +2,15 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestEventMap } from '../../../src' import { createXMLHttpRequest, useCors, REQUEST_ID_REGEXP, toWebResponse, } from '../../helpers' -import { HttpRequestInterceptor } from '../../../src/interceptors/http' import { BatchInterceptor } from '../../../src/BatchInterceptor' -import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest' +import { HttpRequestInterceptor } from '../../../src/interceptors/http' +import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/new' import { RequestController } from '../../../src/RequestController' const httpServer = new HttpServer((app) => { @@ -21,14 +20,10 @@ const httpServer = new HttpServer((app) => { }) }) -const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() - const interceptor = new BatchInterceptor({ name: 'batch-interceptor', interceptors: [new HttpRequestInterceptor(), new XMLHttpRequestInterceptor()], }) -interceptor.on('request', requestListener) beforeAll(async () => { interceptor.apply() @@ -36,7 +31,7 @@ beforeAll(async () => { }) afterEach(() => { - vi.resetAllMocks() + interceptor.removeAllListeners() }) afterAll(async () => { @@ -45,6 +40,9 @@ afterAll(async () => { }) it('ClientRequest: emits the "request" event upon the request', async () => { + const requestListener = vi.fn() + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user') const req = http.request(url, { method: 'POST', @@ -64,13 +62,16 @@ it('ClientRequest: emits the "request" event upon the request', async () => { expect(request.url).toBe(url) expect(request.headers.get('content-type')).toBe('application/json') expect(request.credentials).toBe('same-origin') - expect(await request.json()).toEqual({ userId: 'abc-123' }) + await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) -it('XMLHttpRequest: emits the "request" event upon the request', async () => { +it('XMLHttpRequest: emits the "request" event upon the request (no CORS)', async () => { + const requestListener = vi.fn() + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user') await createXMLHttpRequest((req) => { req.open('POST', url) @@ -79,18 +80,10 @@ it('XMLHttpRequest: emits the "request" event upon the request', async () => { }) /** - * @note There are 3 requests that happen: - * 1. POST by XMLHttpRequestInterceptor. - * 2. OPTIONS request by ClientRequestInterceptor. - * 3. POST by ClientRequestInterceptor (XHR in JSDOM relies on ClientRequest). - * - * But there will only be 2 "request" events emitted: - * 1. POST by XMLHttpRequestInterceptor. - * 2. OPTIONS request by ClientRequestInterceptor. - * The second POST that bubbles down from XHR to ClientRequest is deduped - * via the "INTERNAL_REQUEST_ID_HEADER_NAME" request header. + * @note This XHR request, while cross-origin, doesn't have any other criteria + * to trigger the OPTIONS preflight request. */ - expect(requestListener).toHaveBeenCalledTimes(2) + expect(requestListener).toHaveBeenCalledTimes(1) const [{ request, requestId, controller }] = requestListener.mock.calls[0] @@ -98,7 +91,55 @@ it('XMLHttpRequest: emits the "request" event upon the request', async () => { expect(request.url).toBe(url) expect(request.headers.get('content-type')).toBe('application/json') expect(request.credentials).toBe('same-origin') - expect(await request.json()).toEqual({ userId: 'abc-123' }) + await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect(controller).toBeInstanceOf(RequestController) + + expect(requestId).toMatch(REQUEST_ID_REGEXP) +}) + +it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)', async () => { + const requestListener = vi.fn() + interceptor.on('request', requestListener) + + const url = httpServer.http.url('/user') + await createXMLHttpRequest((req) => { + req.open('POST', url) + req.setRequestHeader('Content-Type', 'application/json') + /** + * @note The addition of this custom header triggers the OPTIONS request in XHR. + */ + req.setRequestHeader('X-Custom-Header', 'yes') + req.send(JSON.stringify({ userId: 'abc-123' })) + }) + + expect(requestListener).toHaveBeenCalledTimes(2) + + expect(requestListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + request: expect.objectContaining({ + method: 'OPTIONS', + url, + }), + }) + ) + expect(requestListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + request: expect.objectContaining({ + method: 'POST', + url, + }), + }) + ) + + const [{ request, requestId, controller }] = requestListener.mock.calls[1] + + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers.get('content-type')).toBe('application/json') + expect(request.credentials).toBe('same-origin') + await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) From 1ea54073fddd50ed14d97962956fc116c801af3b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 26 Feb 2026 21:06:14 +0100 Subject: [PATCH 069/198] fix(tls): remove `_start` connect listeners on passthrough --- package.json | 1 - pnpm-lock.yaml | 8 - src/interceptors/net/socket-controller.ts | 18 +- test/features/events/response.test.ts | 180 ++++++++++++------ .../websocket.client.addEventListener.test.ts | 19 +- ...bsocket.client.removeEventListener.test.ts | 17 +- 6 files changed, 146 insertions(+), 97 deletions(-) diff --git a/package.json b/package.json index 19ed7bec6..a78a834a3 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,6 @@ "undici": "^7.22.0", "vitest": "^3.0.8", "vitest-environment-miniflare": "^2.14.1", - "wait-for-expect": "^3.0.2", "web-encoding": "^1.1.5", "webpack": "^5.105.0", "webpack-http-server": "^0.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2ff2d805..76b90f91f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,9 +162,6 @@ importers: vitest-environment-miniflare: specifier: ^2.14.1 version: 2.14.4(vitest@3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)) - wait-for-expect: - specifier: ^3.0.2 - version: 3.0.2 web-encoding: specifier: ^1.1.5 version: 1.1.5 @@ -3073,9 +3070,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - wait-for-expect@3.0.2: - resolution: {integrity: sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==} - watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -6199,8 +6193,6 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - wait-for-expect@3.0.2: {} - watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 5bac93186..26bd4da24 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -590,15 +590,25 @@ export class TlsSocketController extends TcpSocketController { const realSocket = super.passthrough() as tls.TLSSocket /** - * @note Remove the internal "connect" listener added when the mock socket was created. - * If preserved, that connect will prevent the mock socket from transitioning into the - * connected state. + * @note Remove the internal "connect" listener added by the TLS socket. + * Normally, that listener manages the SSL handshake. But since we're in passthrough, + * we delegate that to the real socket. Leaving the listener on the mock socket while + * inheriting the real socket's handle will result in the handshake performed twice, which is a no-op. + * @see https://github.com/nodejs/node/blob/abddfc921bf2af02a04a6a5d2bca8e2d91d80958/lib/internal/tls/wrap.js#L1105 * * This prevents the following error: * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo &) at ../src/crypto/crypto_tls.cc:589 # Assertion failed: !wrap->started_ */ - this.socket.removeListener('connect', this.socket._start) + for (const connectListener of this.socket.listeners('connect')) { + if ( + connectListener === this.socket._start || + ('listener' in connectListener && + connectListener.listener === this.socket._start) + ) { + this.socket.removeListener('connect', connectListener as () => void) + } + } realSocket .on('secure', () => { diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 2a3ac125c..458a65dad 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -1,13 +1,15 @@ // @vitest-environment jsdom import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' -import waitForExpect from 'wait-for-expect' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestEventMap } from '../../../src' -import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest' import { BatchInterceptor } from '../../../src/BatchInterceptor' +import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/new' import { HttpRequestInterceptor } from '../../../src/interceptors/http' +/** + * @note Using "fetch/node" interceptor won't work in JSDOM. + */ import { FetchInterceptor } from '../../../src/interceptors/fetch' import { useCors, createXMLHttpRequest, toWebResponse } from '../../helpers' @@ -42,21 +44,21 @@ const interceptor = new BatchInterceptor({ ], }) -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - if (url.pathname === '/user') { - controller.respondWith( - new Response('mocked-response-text', { - status: 200, - statusText: 'OK', - headers: { - 'x-response-type': 'mocked', - }, - }) - ) - } -}) +// interceptor.on('request', ({ request, controller }) => { +// const url = new URL(request.url) + +// if (url.pathname === '/user') { +// controller.respondWith( +// new Response('mocked-response-text', { +// status: 200, +// statusText: 'OK', +// headers: { +// 'x-response-type': 'mocked', +// }, +// }) +// ) +// } +// }) beforeAll(async () => { // Allow XHR requests to the local HTTPS server with a self-signed certificate. @@ -67,8 +69,8 @@ beforeAll(async () => { }) afterEach(() => { - interceptor.removeAllListeners('response') vi.clearAllMocks() + interceptor.removeAllListeners() }) afterAll(async () => { @@ -78,6 +80,15 @@ afterAll(async () => { }) it('ClientRequest: emits the "response" event for a mocked response', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('mocked-response-text', { + statusText: 'OK', + headers: { 'x-response-type': 'mocked' }, + }) + ) + }) + const responseListener = vi.fn<(...args: HttpRequestEventMap['response']) => void>() interceptor.once('response', responseListener) @@ -87,6 +98,7 @@ it('ClientRequest: emits the "response" event for a mocked response', async () = headers: { 'x-request-custom': 'yes', }, + rejectUnauthorized: false, }) req.end() @@ -96,7 +108,7 @@ it('ClientRequest: emits the "response" event for a mocked response', async () = expect(response.status).toBe(200) expect(response.statusText).toBe('OK') - expect(responseListener).toHaveBeenCalledTimes(1) + expect(responseListener).toHaveBeenCalledOnce() { const [{ response, request, isMockedResponse }] = @@ -134,7 +146,7 @@ it('ClientRequest: emits the "response" event upon the original response', async req.end() await toWebResponse(req) - expect(responseListener).toHaveBeenCalledTimes(1) + expect(responseListener).toHaveBeenCalledOnce() const [{ response, request, isMockedResponse }] = responseListener.mock.calls[0] @@ -155,72 +167,101 @@ it('ClientRequest: emits the "response" event upon the original response', async }) it('XMLHttpRequest: emits the "response" event upon a mocked response', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'x-request-custom', + }, + }) + ) + } + + controller.respondWith( + new Response('mocked-response-text', { + statusText: 'OK', + headers: { + 'access-control-allow-origin': '*', + 'x-response-type': 'mocked', + }, + }) + ) + }) + const responseListener = vi.fn<(...args: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) + const url = 'http://any.host.here/resource' const originalRequest = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/user')) + req.open('GET', url) req.setRequestHeader('x-request-custom', 'yes') req.send() }) - expect(responseListener).toHaveBeenCalledTimes(1) + expect(responseListener).toHaveBeenCalledTimes(2) + expect(responseListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + request: expect.objectContaining({ method: 'OPTIONS', url }), + }) + ) + expect(responseListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + request: expect.objectContaining({ method: 'GET', url }), + }) + ) const [{ response, request, isMockedResponse }] = - responseListener.mock.calls.find(([{ request }]) => { - // The first response event will be from the "OPTIONS" preflight request. - return request.method === 'GET' - })! + responseListener.mock.calls[1] - expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + expect.soft(request.headers.get('x-request-custom')).toBe('yes') + expect.soft(request.credentials).toBe('same-origin') expect(request.body).toBe(null) - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('mocked') + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect.soft(response.url).toBe(request.url) + expect.soft(response.headers.get('x-response-type')).toBe('mocked') await expect(response.text()).resolves.toBe('mocked-response-text') expect(isMockedResponse).toBe(true) - // Original response. - expect(originalRequest.responseText).toEqual('mocked-response-text') + expect(originalRequest.responseText).toBe('mocked-response-text') }) -it.only('XMLHttpRequest: emits the "response" event upon the original response', async () => { +it('XMLHttpRequest: emits the "response" event upon the original response', async () => { const responseListener = vi.fn<(...args: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) - interceptor.on('request', ({ request }) => { - console.trace('->', request.method, request.url) - }) - interceptor.on('response', ({ response }) => { - console.log('RESPONSE', response.status) - }) - + const url = httpServer.https.url('/account') const originalRequest = await createXMLHttpRequest((req) => { - req.open('POST', httpServer.https.url('/account')) + req.open('POST', url) req.setRequestHeader('x-request-custom', 'yes') req.send('request-body') }) - /** - * @note The "response" event will be emitted twice because XMLHttpRequest - * is polyfilled by "http.ClientRequest" in Node.js. When this request will be - * passthrough to the ClientRequest, it will perform an "OPTIONS" request first, - * thus two request/response events emitted. - */ expect(responseListener).toHaveBeenCalledTimes(2) + expect(responseListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + request: expect.objectContaining({ method: 'OPTIONS', url }), + }) + ) + expect(responseListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + request: expect.objectContaining({ method: 'POST', url }), + }) + ) - // Lookup the correct response listener call. const [{ response, request, isMockedResponse }] = - responseListener.mock.calls.find(([{ request }]) => { - return request.method === 'POST' - })! + responseListener.mock.calls[1] expect(request).toBeDefined() expect(response).toBeDefined() @@ -239,11 +280,21 @@ it.only('XMLHttpRequest: emits the "response" event upon the original response', expect(isMockedResponse).toBe(false) - // Original response. - expect(originalRequest.responseText).toEqual('original-response-text') + expect(originalRequest.responseText).toBe('original-response-text') }) it('fetch: emits the "response" event upon a mocked response', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('mocked-response-text', { + statusText: 'OK', + headers: { + 'x-response-type': 'mocked', + }, + }) + ) + }) + const responseListenerArgs = new DeferredPromise< HttpRequestEventMap['response'][0] >() @@ -314,6 +365,17 @@ it('fetch: emits the "response" event upon the original response', async () => { }) it('supports reading the request and response bodies in the "response" listener', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('mocked-response-text', { + statusText: 'OK', + headers: { + 'x-response-type': 'mocked', + }, + }) + ) + }) + const requestCallback = vi.fn() const responseCallback = vi.fn() const responseListener = vi.fn< @@ -329,9 +391,7 @@ it('supports reading the request and response bodies in the "response" listener' body: 'request-body', }) - await waitForExpect(() => { - expect(responseListener).toHaveBeenCalledTimes(1) - }) + await expect.poll(() => responseListener).toHaveBeenCalledOnce() expect(requestCallback).toHaveBeenCalledWith('request-body') expect(responseCallback).toHaveBeenCalledWith('mocked-response-text') diff --git a/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts b/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts index 3e2ca5c12..aa88c4bcb 100644 --- a/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts @@ -1,8 +1,5 @@ -/** - * @vitest-environment node-with-websocket - */ +// @vitest-environment node-with-websocket import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import waitForExpect from 'wait-for-expect' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() @@ -24,14 +21,13 @@ it('adds event listener for the "message" event', async () => { const ws = new WebSocket('wss://example.com') ws.onopen = () => ws.send('hello') - await waitForExpect(() => { - expect(messageListener).toHaveBeenCalledTimes(1) - expect(messageListener).toHaveBeenCalledWith( + await expect + .poll(() => messageListener) + .toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ data: 'hello', }) ) - }) }) it('adds event listener for the "close" event', async () => { @@ -43,14 +39,13 @@ it('adds event listener for the "close" event', async () => { const ws = new WebSocket('wss://example.com') ws.onopen = () => ws.close() - await waitForExpect(() => { - expect(closeListener).toHaveBeenCalledTimes(1) - expect(closeListener).toHaveBeenCalledWith( + await expect + .poll(() => closeListener) + .toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ code: 1000, reason: '', wasClean: true, }) ) - }) }) diff --git a/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts b/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts index cbc09ef8c..df82ea970 100644 --- a/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts @@ -1,8 +1,5 @@ -/** - * @vitest-environment node-with-websocket - */ +// @vitest-environment node-with-websocket import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import waitForExpect from 'wait-for-expect' import { WebSocketClientConnection, WebSocketInterceptor, @@ -32,16 +29,12 @@ it('removes the listener for the given event', async () => { const ws = new WebSocket('wss://example.com') ws.onopen = () => ws.send('hello') - await waitForExpect(() => { - expect(firstListener).toHaveBeenCalledTimes(1) - expect(secondListener).toHaveBeenCalledTimes(1) - }) + await expect.poll(() => firstListener).toHaveBeenCalledOnce() + await expect.poll(() => secondListener).toHaveBeenCalledOnce() capturedClient?.removeEventListener('message', secondListener) ws.send('hello') - await waitForExpect(() => { - expect(firstListener).toHaveBeenCalledTimes(2) - expect(secondListener).toHaveBeenCalledTimes(1) - }) + await expect.poll(() => firstListener).toHaveBeenCalledTimes(2) + await expect.poll(() => secondListener).toHaveBeenCalledOnce() }) From 15c97eb8b4d1e0e92c059e49356b8ddd91e58b00 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 12:15:38 +0100 Subject: [PATCH 070/198] fix(tls): always close the socket #onRealSocketError --- src/interceptors/net/socket-controller.ts | 29 +++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 26bd4da24..94161dea4 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -278,10 +278,7 @@ export class TcpSocketController extends SocketController { this.socket.listenerCount('connect') > 0 ) { log('assume connect->write socket, calling "connect" listeners...') - - for (const listener of this.socket.listeners('connect')) { - listener.apply(this.socket) - } + this.onPreemptiveConnect() } }) }) @@ -362,6 +359,12 @@ export class TcpSocketController extends SocketController { } } + protected onPreemptiveConnect() { + for (const listener of this.socket.listeners('connect')) { + listener.apply(this.socket) + } + } + /** * Push the given data to the server socket. * This has no effect on the public-facing socket and is used @@ -402,7 +405,15 @@ export class TcpSocketController extends SocketController { } #onRealSocketError = (error: Error) => { + log('real socket error, forwarding...', error) + this.socket.destroy(error) + + // The handle swap in passthrough (this.socket._handle = realSocket._handle) + // breaks Node's internal close machinery—destroy() emits "error" but never + // emits "close". Consumers like Undici wait for "close" to finalize the + // request, so we must emit it manually. + process.nextTick(() => this.socket.emit('close', true)) } #onRealSocketEnd = () => { @@ -552,6 +563,16 @@ export class TlsSocketController extends TcpSocketController { super(socket, createConnection) } + protected onPreemptiveConnect(): void { + super.onPreemptiveConnect() + + // For TLS sockets, also invoke the "secureConnect" callbacks since some consumers, + // like Undici, listen to those to start writing to the socket. + for (const listener of this.socket.listeners('secureConnect')) { + listener.apply(this.socket) + } + } + public claim(): void { // Run this logic before the parent's class method so it executes first. // TLSWrap methods have to be patched before TCPWrap fires "oncomplete". From 38127e90c46c114a665be12af3362353c76a275d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 12:17:31 +0100 Subject: [PATCH 071/198] chore: always assume `https:` protocol for tls.TLSSocket --- src/interceptors/http/index.ts | 2 +- .../net/utils/connection-options-to-url.ts | 17 ++++++++++++----- .../net/utils/normalize-net-connect-args.ts | 1 - 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index f67addf8c..36ffac6ad 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -56,7 +56,7 @@ export class HttpRequestInterceptor extends Interceptor { httpMethod ) - const baseUrl = connectionOptionsToUrl(connectionOptions) + const baseUrl = connectionOptionsToUrl(connectionOptions, socket) log('handling http message...', { httpMessage, diff --git a/src/interceptors/net/utils/connection-options-to-url.ts b/src/interceptors/net/utils/connection-options-to-url.ts index 5fdfbbb1f..ffc4c395d 100644 --- a/src/interceptors/net/utils/connection-options-to-url.ts +++ b/src/interceptors/net/utils/connection-options-to-url.ts @@ -1,13 +1,20 @@ import net from 'node:net' +import tls from 'node:tls' 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 connectionOptionsToUrl(options: NetworkConnectionOptions): URL { +export function connectionOptionsToUrl( + options: NetworkConnectionOptions, + socket: net.Socket +): URL { const isIPv6 = options.family === 6 || net.isIPv6(options.host || '') - const protocol = getProtocolByConnectionOptions(options) + const protocol = + socket instanceof tls.TLSSocket + ? 'https:' + : getProtocolByConnectionOptions(options) const host = options.host || 'localhost' const url = new URL(`${protocol}//${isIPv6 ? `[${host}]` : host}`) @@ -36,14 +43,14 @@ export function connectionOptionsToUrl(options: NetworkConnectionOptions): URL { function getProtocolByConnectionOptions( options: NetworkConnectionOptions -): string { +): 'https:' | 'http:' | (string & {}) { if (options.protocol) { return options.protocol } - if (options.secure) { + if (options.port === 443) { return 'https:' } - return options.port === 443 ? 'https:' : 'http:' + return 'http:' } diff --git a/src/interceptors/net/utils/normalize-net-connect-args.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts index 34ed1ef51..542756a0f 100644 --- a/src/interceptors/net/utils/normalize-net-connect-args.ts +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -2,7 +2,6 @@ import net from 'node:net' import url from 'node:url' export interface NetworkConnectionOptions { - secure?: boolean | null port?: number | null path: string | null host?: string | null From a73e6af29545211d909df7eb6a6fec23b407fced Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 12:21:16 +0100 Subject: [PATCH 072/198] test(fetch): improve the test cases --- test/features/events/response.test.ts | 104 +++++++++++--------------- 1 file changed, 42 insertions(+), 62 deletions(-) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 458a65dad..5a49ae5c6 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -7,10 +7,6 @@ import { HttpRequestEventMap } from '../../../src' import { BatchInterceptor } from '../../../src/BatchInterceptor' import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/new' import { HttpRequestInterceptor } from '../../../src/interceptors/http' -/** - * @note Using "fetch/node" interceptor won't work in JSDOM. - */ -import { FetchInterceptor } from '../../../src/interceptors/fetch' import { useCors, createXMLHttpRequest, toWebResponse } from '../../helpers' declare namespace window { @@ -37,29 +33,9 @@ const httpServer = new HttpServer((app) => { const interceptor = new BatchInterceptor({ name: 'batch-interceptor', - interceptors: [ - new HttpRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], + interceptors: [new HttpRequestInterceptor(), new XMLHttpRequestInterceptor()], }) -// interceptor.on('request', ({ request, controller }) => { -// const url = new URL(request.url) - -// if (url.pathname === '/user') { -// controller.respondWith( -// new Response('mocked-response-text', { -// status: 200, -// statusText: 'OK', -// headers: { -// 'x-response-type': 'mocked', -// }, -// }) -// ) -// } -// }) - beforeAll(async () => { // Allow XHR requests to the local HTTPS server with a self-signed certificate. window._resourceLoader._strictSSL = false @@ -328,41 +304,45 @@ it('fetch: emits the "response" event upon a mocked response', async () => { expect(isMockedResponse).toBe(true) }) -it('fetch: emits the "response" event upon the original response', async () => { - const responseListenerArgs = new DeferredPromise< - HttpRequestEventMap['response'][0] - >() - interceptor.on('response', (args) => { - responseListenerArgs.resolve({ - ...args, - request: args.request.clone(), +it( + 'fetch: emits the "response" event upon the original response', + { timeout: 1500 }, + async () => { + const responseListenerArgs = new DeferredPromise< + HttpRequestEventMap['response'][0] + >() + interceptor.on('response', (args) => { + responseListenerArgs.resolve({ + ...args, + request: args.request.clone(), + }) }) - }) - await fetch(httpServer.https.url('/account'), { - method: 'POST', - headers: { - 'x-request-custom': 'yes', - }, - body: 'request-body', - }) + await fetch(httpServer.http.url('/account'), { + method: 'POST', + headers: { + 'x-request-custom': 'yes', + }, + body: 'request-body', + }) - const { response, request, isMockedResponse } = await responseListenerArgs + const { response, request, isMockedResponse } = await responseListenerArgs - expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.https.url('/account')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('request-body') + expect(request.method).toBe('POST') + expect(request.url).toBe(httpServer.http.url('/account')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('request-body') - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('original') - await expect(response.text()).resolves.toBe('original-response-text') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-response-type')).toBe('original') + await expect(response.text()).resolves.toBe('original-response-text') - expect(isMockedResponse).toBe(false) -}) + expect(isMockedResponse).toBe(false) + } +) it('supports reading the request and response bodies in the "response" listener', async () => { interceptor.on('request', ({ controller }) => { @@ -378,21 +358,21 @@ it('supports reading the request and response bodies in the "response" listener' const requestCallback = vi.fn() const responseCallback = vi.fn() - const responseListener = vi.fn< - (...args: HttpRequestEventMap['response']) => void - >(async ({ request, response }) => { + + interceptor.on('response', async ({ request, response }) => { requestCallback(await request.clone().text()) responseCallback(await response.clone().text()) }) - interceptor.on('response', responseListener) await fetch(httpServer.https.url('/user'), { method: 'POST', body: 'request-body', }) - await expect.poll(() => responseListener).toHaveBeenCalledOnce() - - expect(requestCallback).toHaveBeenCalledWith('request-body') - expect(responseCallback).toHaveBeenCalledWith('mocked-response-text') + await expect + .poll(() => requestCallback) + .toHaveBeenCalledExactlyOnceWith('request-body') + await expect + .poll(() => responseCallback) + .toHaveBeenCalledExactlyOnceWith('mocked-response-text') }) From 0c9befe37bb008a85418956d00b8727580508926 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 12:24:15 +0100 Subject: [PATCH 073/198] fix(fetch): set `initiator` to the `request` --- src/interceptors/fetch/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index 9eabba4b9..4789a6862 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -90,6 +90,7 @@ export class FetchInterceptor extends Interceptor { const responseClone = originalResponse.clone() await emitAsync(this.emitter, 'response', { + initiator: requestCloneForResponseEvent, response: responseClone, isMockedResponse: false, request: requestCloneForResponseEvent, @@ -156,6 +157,7 @@ export class FetchInterceptor extends Interceptor { // the response promise. This ensures all your logic finishes // before the interceptor resolves the pending response. await emitAsync(this.emitter, 'response', { + initiator: request, // Clone the mocked response for the "response" event listener. // This way, the listener can read the response and not lock its body // for the actual fetch consumer. @@ -183,6 +185,7 @@ export class FetchInterceptor extends Interceptor { ) await handleRequest({ + initiator: request, request, requestId, emitter: this.emitter, From f210c4eb2aefc731a047ac2510fb609cf729142d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 12:46:18 +0100 Subject: [PATCH 074/198] chore: add `applyPatch` to simplify adding/restoring patches --- src/glossary.ts | 2 - src/interceptors/WebSocket/index.ts | 193 +++++++------- src/interceptors/XMLHttpRequest/index.ts | 43 +-- src/interceptors/XMLHttpRequest/new.ts | 63 ++--- src/interceptors/fetch/index.ts | 325 +++++++++++------------ src/interceptors/fetch/node.ts | 66 ++--- src/interceptors/net/index.ts | 180 +++++++------ src/utils/apply-patch.ts | 50 ++++ src/utils/hasConfigurableGlobal.ts | 4 +- 9 files changed, 448 insertions(+), 478 deletions(-) create mode 100644 src/utils/apply-patch.ts diff --git a/src/glossary.ts b/src/glossary.ts index 4faa9b965..e819bf53c 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -1,7 +1,5 @@ import type { RequestController } from './RequestController' -export const IS_PATCHED_MODULE: unique symbol = Symbol('isPatchedModule') - /** * @note Export `RequestController` as a type only. * It's never meant to be created in the userland. diff --git a/src/interceptors/WebSocket/index.ts b/src/interceptors/WebSocket/index.ts index f79c7798c..d6fd70575 100644 --- a/src/interceptors/WebSocket/index.ts +++ b/src/interceptors/WebSocket/index.ts @@ -18,6 +18,7 @@ import { import { bindEvent } from './utils/bindEvent' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { emitAsync } from '../../utils/emitAsync' +import { applyPatch } from '../../utils/apply-patch' export { type WebSocketData, @@ -69,7 +70,7 @@ export type WebSocketConnectionData = { * the global `WebSocket` class. */ export class WebSocketInterceptor extends Interceptor { - static symbol = Symbol('websocket') + static symbol = Symbol('websocket-interceptor') constructor() { super(WebSocketInterceptor.symbol) @@ -80,112 +81,98 @@ export class WebSocketInterceptor extends Interceptor { } protected setup(): void { - const originalWebSocketDescriptor = Object.getOwnPropertyDescriptor( - globalThis, - 'WebSocket' - ) - - const WebSocketProxy = new Proxy(globalThis.WebSocket, { - construct: ( - target, - args: ConstructorParameters, - newTarget - ) => { - const [url, protocols] = args - - const createConnection = (): WebSocket => { - return Reflect.construct(target, args, newTarget) - } - - // All WebSocket instances are mocked and don't forward - // any events to the original server (no connection established). - // To forward the events, the user must use the "server.send()" API. - const socket = new WebSocketOverride(url, protocols) - const transport = new WebSocketClassTransport(socket) - - // Emit the "connection" event to the interceptor on the next tick - // so the client can modify WebSocket options, like "binaryType" - // while the connection is already pending. - queueMicrotask(async () => { - try { - const server = new WebSocketServerConnection( - socket, - transport, - createConnection - ) - - const hasConnectionListeners = - this.emitter.listenerCount('connection') > 0 - - // The "globalThis.WebSocket" class stands for - // the client-side connection. Assume it's established - // as soon as the WebSocket instance is constructed. - await emitAsync(this.emitter, 'connection', { - client: new WebSocketClientConnection(socket, transport), - server, - info: { - protocols, - }, - }) - - if (hasConnectionListeners) { - socket[kPassthroughPromise].resolve(false) - } else { - socket[kPassthroughPromise].resolve(true) - - server.connect() - - // Forward the "open" event from the original server - // to the mock WebSocket client in the case of a passthrough connection. - server.addEventListener('open', () => { - socket.dispatchEvent(bindEvent(socket, new Event('open'))) + this.subscriptions.push( + applyPatch(globalThis, 'WebSocket', () => { + return new Proxy(globalThis.WebSocket, { + construct: ( + target, + args: ConstructorParameters, + newTarget + ) => { + const [url, protocols] = args + + const createConnection = (): WebSocket => { + return Reflect.construct(target, args, newTarget) + } - // Forward the original connection protocol to the - // mock WebSocket client. - if (server['realWebSocket']) { - socket.protocol = server['realWebSocket'].protocol + // All WebSocket instances are mocked and don't forward + // any events to the original server (no connection established). + // To forward the events, the user must use the "server.send()" API. + const socket = new WebSocketOverride(url, protocols) + const transport = new WebSocketClassTransport(socket) + + // Emit the "connection" event to the interceptor on the next tick + // so the client can modify WebSocket options, like "binaryType" + // while the connection is already pending. + queueMicrotask(async () => { + try { + const server = new WebSocketServerConnection( + socket, + transport, + createConnection + ) + + const hasConnectionListeners = + this.emitter.listenerCount('connection') > 0 + + // The "globalThis.WebSocket" class stands for + // the client-side connection. Assume it's established + // as soon as the WebSocket instance is constructed. + await emitAsync(this.emitter, 'connection', { + client: new WebSocketClientConnection(socket, transport), + server, + info: { + protocols, + }, + }) + + if (hasConnectionListeners) { + socket[kPassthroughPromise].resolve(false) + } else { + socket[kPassthroughPromise].resolve(true) + + server.connect() + + // Forward the "open" event from the original server + // to the mock WebSocket client in the case of a passthrough connection. + server.addEventListener('open', () => { + socket.dispatchEvent(bindEvent(socket, new Event('open'))) + + // Forward the original connection protocol to the + // mock WebSocket client. + if (server['realWebSocket']) { + socket.protocol = server['realWebSocket'].protocol + } + }) + } + } catch (error) { + /** + * @note Translate unhandled exceptions during the connection + * handling (i.e. interceptor exceptions) as WebSocket connection + * closures with error. This prevents from the exceptions occurring + * in `queueMicrotask` from being process-wide and uncatchable. + */ + if (error instanceof Error) { + socket.dispatchEvent(new Event('error')) + + // No need to close the connection if it's already being closed. + // E.g. the interceptor called `client.close()` and then threw an error. + if ( + socket.readyState !== WebSocket.CLOSING && + socket.readyState !== WebSocket.CLOSED + ) { + socket[kClose](1011, error.message, false) + } + + console.error(error) } - }) - } - } catch (error) { - /** - * @note Translate unhandled exceptions during the connection - * handling (i.e. interceptor exceptions) as WebSocket connection - * closures with error. This prevents from the exceptions occurring - * in `queueMicrotask` from being process-wide and uncatchable. - */ - if (error instanceof Error) { - socket.dispatchEvent(new Event('error')) - - // No need to close the connection if it's already being closed. - // E.g. the interceptor called `client.close()` and then threw an error. - if ( - socket.readyState !== WebSocket.CLOSING && - socket.readyState !== WebSocket.CLOSED - ) { - socket[kClose](1011, error.message, false) } + }) - console.error(error) - } - } + return socket + }, }) - - return socket - }, - }) - - Object.defineProperty(globalThis, 'WebSocket', { - value: WebSocketProxy, - configurable: true, - }) - - this.subscriptions.push(() => { - Object.defineProperty( - globalThis, - 'WebSocket', - originalWebSocketDescriptor! - ) - }) + }) + ) } } diff --git a/src/interceptors/XMLHttpRequest/index.ts b/src/interceptors/XMLHttpRequest/index.ts index 6b5208c70..155ad38b5 100644 --- a/src/interceptors/XMLHttpRequest/index.ts +++ b/src/interceptors/XMLHttpRequest/index.ts @@ -1,9 +1,9 @@ -import { invariant } from 'outvariant' import { Emitter } from 'strict-event-emitter' -import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' +import { HttpRequestEventMap } from '../../glossary' import { Interceptor } from '../../Interceptor' import { createXMLHttpRequestProxy } from './XMLHttpRequestProxy' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' +import { applyPatch } from '../../utils/apply-patch' export type XMLHttpRequestEmitter = Emitter @@ -21,41 +21,20 @@ export class XMLHttpRequestInterceptor extends Interceptor protected setup() { const logger = this.logger.extend('setup') - logger.info('patching "XMLHttpRequest" module...') + logger.info('patching "XMLHttpRequest"...') - const PureXMLHttpRequest = globalThis.XMLHttpRequest - - invariant( - !(PureXMLHttpRequest as any)[IS_PATCHED_MODULE], - 'Failed to patch the "XMLHttpRequest" module: already patched.' + this.subscriptions.push( + applyPatch(globalThis, 'XMLHttpRequest', () => { + return createXMLHttpRequestProxy({ + emitter: this.emitter, + logger: this.logger, + }) + }) ) - globalThis.XMLHttpRequest = createXMLHttpRequestProxy({ - emitter: this.emitter, - logger: this.logger, - }) - logger.info( - 'native "XMLHttpRequest" module patched!', + 'global "XMLHttpRequest" patched!', globalThis.XMLHttpRequest.name ) - - Object.defineProperty(globalThis.XMLHttpRequest, IS_PATCHED_MODULE, { - enumerable: true, - configurable: true, - value: true, - }) - - this.subscriptions.push(() => { - Object.defineProperty(globalThis.XMLHttpRequest, IS_PATCHED_MODULE, { - value: undefined, - }) - - globalThis.XMLHttpRequest = PureXMLHttpRequest - logger.info( - 'native "XMLHttpRequest" module restored!', - globalThis.XMLHttpRequest.name - ) - }) } } diff --git a/src/interceptors/XMLHttpRequest/new.ts b/src/interceptors/XMLHttpRequest/new.ts index df584065c..48c33ba16 100644 --- a/src/interceptors/XMLHttpRequest/new.ts +++ b/src/interceptors/XMLHttpRequest/new.ts @@ -1,8 +1,8 @@ -import { invariant } from 'outvariant' import { requestContext } from '../../request-context' import { Interceptor } from '../../Interceptor' -import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' +import { HttpRequestEventMap } from '../../glossary' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' +import { applyPatch } from '../../utils/apply-patch' export class XMLHttpRequestInterceptor extends Interceptor { static interceptorSymbol = Symbol('xhr-interceptor') @@ -16,43 +16,30 @@ export class XMLHttpRequestInterceptor extends Interceptor } protected setup(): void { - const RealXMLHttpRequest = globalThis.XMLHttpRequest - - invariant( - !(RealXMLHttpRequest as any)[IS_PATCHED_MODULE], - 'Failed to patch the "XMLHttpRequest" module: already patched.' - ) - - globalThis.XMLHttpRequest = new Proxy(globalThis.XMLHttpRequest, { - construct(target, args, newTarget) { - const xmlHttpRequest = Reflect.construct(target, args, newTarget) - - /** - * @note Use `.enterWith()` here because XHR in JSDOM is implemented - * via `http`/`https`. This makes the initiator cascading work properly. - */ - requestContext.enterWith({ initiator: xmlHttpRequest }) - - /** - * @todo Do we need to exit the async context at some point? - */ - - return xmlHttpRequest - }, - }) - - Object.defineProperty(globalThis.XMLHttpRequest, IS_PATCHED_MODULE, { - enumerable: true, - configurable: true, - value: true, - }) - - this.subscriptions.push(() => { - Object.defineProperty(globalThis.XMLHttpRequest, IS_PATCHED_MODULE, { - value: undefined, + this.logger.info('patching global "XMLHttpRequest"...') + + this.subscriptions.push( + applyPatch(globalThis, 'XMLHttpRequest', () => { + return new Proxy(globalThis.XMLHttpRequest, { + construct(target, args, newTarget) { + const xmlHttpRequest = Reflect.construct(target, args, newTarget) + + /** + * @note Use `.enterWith()` here because XHR in JSDOM is implemented + * via `http`/`https`. This makes the initiator cascading work properly. + */ + requestContext.enterWith({ initiator: xmlHttpRequest }) + + /** + * @todo Do we need to exit the async context at some point? + */ + + return xmlHttpRequest + }, + }) }) + ) - globalThis.XMLHttpRequest = RealXMLHttpRequest - }) + this.logger.info('global "XMLHttpRequest" patched!') } } diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index 4789a6862..5d7ffed5b 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -1,7 +1,6 @@ -import { invariant } from 'outvariant' import { until } from '@open-draft/until' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' +import { HttpRequestEventMap } from '../../glossary' import { Interceptor } from '../../Interceptor' import { RequestController } from '../../RequestController' import { emitAsync } from '../../utils/emitAsync' @@ -15,6 +14,7 @@ import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { FetchResponse } from '../../utils/fetchUtils' import { setRawRequest } from '../../getRawRequest' import { isResponseError } from '../../utils/responseUtils' +import { applyPatch } from '../../utils/apply-patch' export class FetchInterceptor extends Interceptor { static symbol = Symbol('fetch') @@ -28,190 +28,171 @@ export class FetchInterceptor extends Interceptor { } protected async setup() { - const pureFetch = globalThis.fetch - - invariant( - !(pureFetch as any)[IS_PATCHED_MODULE], - 'Failed to patch the "fetch" module: already patched.' - ) - - globalThis.fetch = async (input, init) => { - const requestId = createRequestId() - - /** - * @note Resolve potentially relative request URL - * against the present `location`. This is mainly - * for native `fetch` in JSDOM. - * @see https://github.com/mswjs/msw/issues/1625 - */ - const resolvedInput = - typeof input === 'string' && - typeof location !== 'undefined' && - !canParseUrl(input) - ? new URL(input, location.href) - : input - - const request = new Request(resolvedInput, init) - - /** - * @note Set the raw request only if a Request instance was provided to fetch. - */ - if (input instanceof Request) { - setRawRequest(request, input) - } - - const responsePromise = new DeferredPromise() - - const controller = new RequestController(request, { - passthrough: async () => { - this.logger.info('request has not been handled, passthrough...') + this.subscriptions.push( + applyPatch(globalThis, 'fetch', (realFetch) => { + return async (input, init) => { + const requestId = createRequestId() /** - * @note Clone the request instance right before performing it. - * This preserves any modifications made to the intercepted request - * in the "request" listener. This also allows the user to read the - * request body in the "response" listener (otherwise "unusable"). + * @note Resolve potentially relative request URL + * against the present `location`. This is mainly + * for native `fetch` in JSDOM. + * @see https://github.com/mswjs/msw/issues/1625 */ - const requestCloneForResponseEvent = request.clone() - - // Perform the intercepted request as-is. - const { error: responseError, data: originalResponse } = await until( - () => pureFetch(request) - ) - - if (responseError) { - return responsePromise.reject(responseError) - } - - this.logger.info('original fetch performed', originalResponse) - - if (this.emitter.listenerCount('response') > 0) { - this.logger.info('emitting the "response" event...') - - const responseClone = originalResponse.clone() - await emitAsync(this.emitter, 'response', { - initiator: requestCloneForResponseEvent, - response: responseClone, - isMockedResponse: false, - request: requestCloneForResponseEvent, - requestId, - }) - } - - // Resolve the response promise with the original response - // since the `fetch()` return this internal promise. - responsePromise.resolve(originalResponse) - }, - respondWith: async (rawResponse) => { - // Handle mocked `Response.error()` (i.e. request errors). - if (isResponseError(rawResponse)) { - this.logger.info('request has errored!', { response: rawResponse }) - responsePromise.reject(createNetworkError(rawResponse)) - return - } - - this.logger.info('received mocked response!', { - rawResponse, - }) + const resolvedInput = + typeof input === 'string' && + typeof location !== 'undefined' && + !canParseUrl(input) + ? new URL(input, location.href) + : input - // Decompress the mocked response body, if applicable. - const decompressedStream = decompressResponse(rawResponse) - const response = - decompressedStream === null - ? rawResponse - : new FetchResponse(decompressedStream, rawResponse) - - FetchResponse.setUrl(request.url, response) + const request = new Request(resolvedInput, init) /** - * Undici's handling of following redirect responses. - * Treat the "manual" redirect mode as a regular mocked response. - * This way, the client can manually follow the redirect it receives. - * @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1173 + * @note Set the raw request only if a Request instance was provided to fetch. */ - if (FetchResponse.isRedirectResponse(response.status)) { - // Reject the request promise if its `redirect` is set to `error` - // and it receives a mocked redirect response. - if (request.redirect === 'error') { - responsePromise.reject(createNetworkError('unexpected redirect')) - return - } - - if (request.redirect === 'follow') { - followFetchRedirect(request, response).then( - (response) => { - responsePromise.resolve(response) - }, - (reason) => { - responsePromise.reject(reason) - } - ) - return - } - } - - if (this.emitter.listenerCount('response') > 0) { - this.logger.info('emitting the "response" event...') - - // Await the response listeners to finish before resolving - // the response promise. This ensures all your logic finishes - // before the interceptor resolves the pending response. - await emitAsync(this.emitter, 'response', { - initiator: request, - // Clone the mocked response for the "response" event listener. - // This way, the listener can read the response and not lock its body - // for the actual fetch consumer. - response: response.clone(), - isMockedResponse: true, - request, - requestId, - }) + if (input instanceof Request) { + setRawRequest(request, input) } - responsePromise.resolve(response) - }, - errorWith: (reason) => { - this.logger.info('request has been aborted!', { reason }) - responsePromise.reject(reason) - }, - }) - - this.logger.info('[%s] %s', request.method, request.url) - this.logger.info('awaiting for the mocked response...') + const responsePromise = new DeferredPromise() + + const controller = new RequestController(request, { + passthrough: async () => { + this.logger.info('request has not been handled, passthrough...') + + /** + * @note Clone the request instance right before performing it. + * This preserves any modifications made to the intercepted request + * in the "request" listener. This also allows the user to read the + * request body in the "response" listener (otherwise "unusable"). + */ + const requestCloneForResponseEvent = request.clone() + + // Perform the intercepted request as-is. + const { error: responseError, data: originalResponse } = + await until(() => realFetch(request)) + + if (responseError) { + return responsePromise.reject(responseError) + } + + this.logger.info('original fetch performed', originalResponse) + + if (this.emitter.listenerCount('response') > 0) { + this.logger.info('emitting the "response" event...') + + const responseClone = originalResponse.clone() + await emitAsync(this.emitter, 'response', { + initiator: requestCloneForResponseEvent, + response: responseClone, + isMockedResponse: false, + request: requestCloneForResponseEvent, + requestId, + }) + } + + // Resolve the response promise with the original response + // since the `fetch()` return this internal promise. + responsePromise.resolve(originalResponse) + }, + respondWith: async (rawResponse) => { + // Handle mocked `Response.error()` (i.e. request errors). + if (isResponseError(rawResponse)) { + this.logger.info('request has errored!', { + response: rawResponse, + }) + responsePromise.reject(createNetworkError(rawResponse)) + return + } + + this.logger.info('received mocked response!', { + rawResponse, + }) + + // Decompress the mocked response body, if applicable. + const decompressedStream = decompressResponse(rawResponse) + const response = + decompressedStream === null + ? rawResponse + : new FetchResponse(decompressedStream, rawResponse) + + FetchResponse.setUrl(request.url, response) + + /** + * Undici's handling of following redirect responses. + * Treat the "manual" redirect mode as a regular mocked response. + * This way, the client can manually follow the redirect it receives. + * @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1173 + */ + if (FetchResponse.isRedirectResponse(response.status)) { + // Reject the request promise if its `redirect` is set to `error` + // and it receives a mocked redirect response. + if (request.redirect === 'error') { + responsePromise.reject( + createNetworkError('unexpected redirect') + ) + return + } - this.logger.info( - 'emitting the "request" event for %s listener(s)...', - this.emitter.listenerCount('request') - ) + if (request.redirect === 'follow') { + followFetchRedirect(request, response).then( + (response) => { + responsePromise.resolve(response) + }, + (reason) => { + responsePromise.reject(reason) + } + ) + return + } + } + + if (this.emitter.listenerCount('response') > 0) { + this.logger.info('emitting the "response" event...') + + // Await the response listeners to finish before resolving + // the response promise. This ensures all your logic finishes + // before the interceptor resolves the pending response. + await emitAsync(this.emitter, 'response', { + initiator: request, + // Clone the mocked response for the "response" event listener. + // This way, the listener can read the response and not lock its body + // for the actual fetch consumer. + response: response.clone(), + isMockedResponse: true, + request, + requestId, + }) + } + + responsePromise.resolve(response) + }, + errorWith: (reason) => { + this.logger.info('request has been aborted!', { reason }) + responsePromise.reject(reason) + }, + }) - await handleRequest({ - initiator: request, - request, - requestId, - emitter: this.emitter, - controller, - }) + this.logger.info('[%s] %s', request.method, request.url) + this.logger.info('awaiting for the mocked response...') - return responsePromise - } + this.logger.info( + 'emitting the "request" event for %s listener(s)...', + this.emitter.listenerCount('request') + ) - Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, { - enumerable: true, - configurable: true, - value: true, - }) + await handleRequest({ + initiator: request, + request, + requestId, + emitter: this.emitter, + controller, + }) - this.subscriptions.push(() => { - Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, { - value: undefined, + return responsePromise + } }) - - globalThis.fetch = pureFetch - - this.logger.info( - 'restored native "globalThis.fetch"!', - globalThis.fetch.name - ) - }) + ) } } diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 0b7c31b40..505944f53 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -1,9 +1,9 @@ -import { invariant } from 'outvariant' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' +import { HttpRequestEventMap } from '../../glossary' import { Interceptor } from '../../Interceptor' import { canParseUrl } from '../../utils/canParseUrl' -import { requestContext, runInRequestContext } from '../../request-context' +import { requestContext } from '../../request-context' +import { applyPatch } from '../../utils/apply-patch' export class FetchInterceptor extends Interceptor { static symbol = Symbol('fetch') @@ -17,45 +17,27 @@ export class FetchInterceptor extends Interceptor { } protected setup(): void { - const realFetch = globalThis.fetch - - invariant( - !(realFetch as any)[IS_PATCHED_MODULE], - 'Failed to patch the "fetch" module: already patched.' - ) - - globalThis.fetch = (input, init) => { - /** - * @note Resolve potentially relative request URL - * against the present `location`. This is mainly - * for native `fetch` in JSDOM. - * @see https://github.com/mswjs/msw/issues/1625 - */ - const resolvedInput = - typeof input === 'string' && - typeof location !== 'undefined' && - !canParseUrl(input) - ? new URL(input, location.href) - : input - - const request = new Request(resolvedInput, init) - - requestContext.enterWith({ initiator: request }) - return realFetch(input, init) - } - - Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, { - enumerable: true, - configurable: true, - value: true, - }) - - this.subscriptions.push(() => { - globalThis.fetch = realFetch - - Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, { - value: undefined, + this.subscriptions.push( + applyPatch(globalThis, 'fetch', (realFetch) => { + return (input, init) => { + /** + * @note Resolve potentially relative request URL against the present `location`. + * This is mainly for native `fetch` in browser-like environments. + * @see https://github.com/mswjs/msw/issues/1625 + */ + const resolvedInput = + typeof input === 'string' && + typeof location !== 'undefined' && + !canParseUrl(input) + ? new URL(input, location.href) + : input + + const request = new Request(resolvedInput, init) + + requestContext.enterWith({ initiator: request }) + return realFetch(input, init) + } }) - }) + ) } } diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index e5011e812..422c38c93 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -8,6 +8,7 @@ import { import { TcpSocketController, TlsSocketController } from './socket-controller' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' import { createLogger } from '../../utils/logger' +import { applyPatch } from '../../utils/apply-patch' interface SocketEventMap { connection: [ @@ -29,101 +30,104 @@ export class SocketInterceptor extends Interceptor { } protected setup(): void { - const realNetConnect = net.connect - - net.connect = (...args: [any, any]) => { - log('net.connect()', args) - - const [connectionOptions, connectionCallback] = - normalizeNetConnectArgs(args) - - log({ connectionOptions, connectionCallback }) - - const socket = new net.Socket() - const controller = new TcpSocketController(socket, () => { - return realNetConnect(...args) - }) - - process.nextTick(() => { - this.emitter.emit('connection', { - socket: controller.serverSocket, - controller, - connectionOptions, - }) - - log('emitted "connection" event!') + this.subscriptions.push( + applyPatch(net, 'connect', (realNetConnect) => { + return (...args: [any, any]) => { + log('net.connect()', args) + + const [connectionOptions, connectionCallback] = + normalizeNetConnectArgs(args) + + log({ connectionOptions, connectionCallback }) + + const socket = new net.Socket() + const controller = new TcpSocketController(socket, () => { + return realNetConnect(...args) + }) + + process.nextTick(() => { + this.emitter.emit('connection', { + socket: controller.serverSocket, + controller, + connectionOptions, + }) + + log('emitted "connection" event!') + }) + + log('connecting the socket...') + + // Patch the lookup option so DNS lookup always succeeds. + // Passthrough connections are created with the original options and won't be affected. + connectionOptions.lookup = function mockLookup( + hostname, + dnsOptions, + callback + ) { + callback(null, [{ address: '127.0.0.1', family: 4 }]) + } + + return socket.connect(connectionOptions, connectionCallback) + } + }), + applyPatch(tls, 'connect', (realTlsConnect) => { + return (...args: [any, any]) => { + log('tls.connect()', args) + + const [tlsConnectionOptions, secureConnectionCallback] = + normalizeTlsConnectArgs(args) + + const tlsSocket = realTlsConnect( + { + ...tlsConnectionOptions, + /** + * Use a fake IP address to bypass DNS lookup. + * This ensures that "connectionAttempt" event fires even for non-existent hosts. + * Node.js skips DNS resolution when the host is an IP address, going directly to + * "internalConnect()" which emits "connectionAttempt". + * @see https://github.com/nodejs/node/blob/5babc8d5c91914ce0fb708e647c144570c671c50/lib/net.js + * + * @todo This will produce invalid "lookup" event on the socket, failing compliance. + */ + host: '127.0.0.1', + /** + * Suppress unauthorized connection errors to allow mocking connections to non-existing hosts. + * This prevents the "Error: Hostname/IP does not match certificate's altnames: Cert does not contain a DNS name" error. + * @note Passthrough scenarios will respect the original "rejectUnauthorized" option. + */ + rejectUnauthorized: false, + }, + secureConnectionCallback + ) + + const controller = new TlsSocketController(tlsSocket, () => { + return realTlsConnect(...args) + }) + + process.nextTick(() => { + this.emitter.emit('connection', { + socket: controller.serverSocket, + controller, + connectionOptions: tlsConnectionOptions, + }) + + log('emitted the "connection" event!') + }) + + return tlsSocket + } }) - - log('connecting the socket...') - - // Patch the lookup option so DNS lookup always succeeds. - // Passthrough connections are created with the original options and won't be affected. - connectionOptions.lookup = function mockLookup( - hostname, - dnsOptions, - callback - ) { - callback(null, [{ address: '127.0.0.1', family: 4 }]) - } - - return socket.connect(connectionOptions, connectionCallback) - } - - const realNetCreateConnection = net.createConnection - net.createConnection = net.connect + ) /** - * TLS. + * @note `net.createConnection()` is an alias for `net.connect()`. + * But we still have to reassign it to point to the patched `net.connect()`. */ - - const realTlsConnect = tls.connect - tls.connect = (...args: [any, any]) => { - const [tlsConnectionOptions, secureConnectionCallback] = - normalizeTlsConnectArgs(args) - - const tlsSocket = realTlsConnect( - { - ...tlsConnectionOptions, - /** - * Use a fake IP address to bypass DNS lookup. - * This ensures that "connectionAttempt" event fires even for non-existent hosts. - * Node.js skips DNS resolution when the host is an IP address, going directly to - * "internalConnect()" which emits "connectionAttempt". - * @see https://github.com/nodejs/node/blob/5babc8d5c91914ce0fb708e647c144570c671c50/lib/net.js - * - * @todo This will produce invalid "lookup" event on the socket, failing compliance. - */ - host: '127.0.0.1', - /** - * Suppress unauthorized connection errors to allow mocking connections to non-existing hosts. - * This prevents the "Error: Hostname/IP does not match certificate's altnames: Cert does not contain a DNS name" error. - * @note Passthrough scenarios will respect the original "rejectUnauthorized" option. - */ - rejectUnauthorized: false, - }, - secureConnectionCallback - ) - - const controller = new TlsSocketController(tlsSocket, () => { - return realTlsConnect(...args) - }) - - process.nextTick(() => { - this.emitter.emit('connection', { - socket: controller.serverSocket, - controller, - connectionOptions: tlsConnectionOptions, - }) - }) - - return tlsSocket - } + const { createConnection: realNetCreateConnection } = net + net.createConnection = net.connect this.subscriptions.push(() => { - net.connect = realNetConnect net.createConnection = realNetCreateConnection - - tls.connect = realTlsConnect }) } } diff --git a/src/utils/apply-patch.ts b/src/utils/apply-patch.ts new file mode 100644 index 000000000..29505c9b4 --- /dev/null +++ b/src/utils/apply-patch.ts @@ -0,0 +1,50 @@ +import { invariant } from 'outvariant' + +export const IS_PATCHED_MODULE: unique symbol = Symbol('isPatchedModule') + +/** + * Apply a patch for the given property on the owner object. + * Returns a function that reverts that patch. + */ +export function applyPatch, K extends keyof T>( + owner: T, + key: K, + patch: (realValue: T[K]) => T[K] +): () => void { + const realValue = owner[key] + + invariant(!realValue[IS_PATCHED_MODULE], 'Failed to patch "%s"', key) + + const originalDescriptor = Object.getOwnPropertyDescriptor(owner, key) + + if (originalDescriptor) { + Object.defineProperty(owner, key, { + value: patch(realValue), + configurable: true, + }) + } else { + owner[key] = patch(realValue) + } + + Object.defineProperty(realValue, IS_PATCHED_MODULE, { + value: true, + enumerable: false, + configurable: true, + }) + + return () => { + /** + * @note If the property was defined as a descriptor, preserve it. + * For example, `globalThis.WebSocket` is defined that way in browser-likes. + */ + if (originalDescriptor) { + Object.defineProperty(owner, key, originalDescriptor) + } else { + owner[key] = realValue + } + + Object.defineProperty(realValue, IS_PATCHED_MODULE, { + value: undefined, + }) + } +} diff --git a/src/utils/hasConfigurableGlobal.ts b/src/utils/hasConfigurableGlobal.ts index c83045332..0b7e678ae 100644 --- a/src/utils/hasConfigurableGlobal.ts +++ b/src/utils/hasConfigurableGlobal.ts @@ -2,7 +2,9 @@ * Returns a boolean indicating whether the given global property * is defined and is configurable. */ -export function hasConfigurableGlobal(propertyName: string): boolean { +export function hasConfigurableGlobal( + propertyName: keyof typeof globalThis +): boolean { const descriptor = Object.getOwnPropertyDescriptor(globalThis, propertyName) // The property is not set at all. From aba4cffc13a8ffc0cb4d31f7df2897013107c861 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 12:49:07 +0100 Subject: [PATCH 075/198] test: fix `fetch` initiator test --- test/features/request-initiator.test.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index d6690109b..faad12376 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -4,7 +4,7 @@ import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { BatchInterceptor } from '../../src/BatchInterceptor' import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest/new' -import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest/new' +import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest/node' import { FetchInterceptor } from '../../src/interceptors/fetch/node' import { HttpRequestInterceptor } from '../../src/interceptors/http' import { createXMLHttpRequest, toWebResponse } from '../helpers' @@ -65,14 +65,10 @@ it('exposes the initiator of a mocked XMLHttpRequest request', async () => { }) await expect(initiatorPromise).resolves.toEqual(request) - expect.soft(request.responseText).toBe('mocked') + expect(request.responseText).toBe('mocked') }) -/** - * @fixme HttpRequestInterceptor doesn't support global `fetch` (Undici) right now. - * Once it does, this test will pass. - */ -it.skip('exposes the initiator of a mocked fetch request', async () => { +it('exposes the initiator of a mocked fetch request', async () => { const initiatorPromise = new DeferredPromise() interceptor.on('request', ({ initiator, controller }) => { initiatorPromise.resolve(initiator as XMLHttpRequest) @@ -89,5 +85,10 @@ it.skip('exposes the initiator of a mocked fetch request', async () => { const request = new Request('http://localhost/api') const response = await fetch(request) - await expect(initiatorPromise).resolves.toEqual(request) + /** + * @note Use "toMatchObject" instead of "toEqual" to ignore the difference + * in internal symbols on the request, which Undici modifies after "fetch". + */ + await expect(initiatorPromise).resolves.toMatchObject(request) + await expect(response.text()).resolves.toBe('mocked') }) From 1a2d6de135e6927aad814c7cf2b06366c69c350f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 12:57:13 +0100 Subject: [PATCH 076/198] feat: remove `getRawRequest` --- package.json | 12 +- src/getRawRequest.ts | 21 --- src/index.ts | 1 - .../ClientRequest/MockHttpSocket.ts | 6 +- .../XMLHttpRequestController.ts | 3 +- .../XMLHttpRequest/{new.ts => node.ts} | 0 src/interceptors/fetch/index.ts | 3 +- src/utils/node/index.ts | 39 ------ test/features/events/request.test.ts | 2 +- test/features/events/response.test.ts | 2 +- .../get-client-request-body-stream.test.ts | 84 ----------- test/features/get-raw-request.test.ts | 132 ------------------ tsdown.config.mts | 1 - 13 files changed, 7 insertions(+), 299 deletions(-) delete mode 100644 src/getRawRequest.ts rename src/interceptors/XMLHttpRequest/{new.ts => node.ts} (100%) delete mode 100644 src/utils/node/index.ts delete mode 100644 test/features/get-client-request-body-stream.test.ts delete mode 100644 test/features/get-raw-request.test.ts diff --git a/package.json b/package.json index a78a834a3..69f32b61d 100644 --- a/package.json +++ b/package.json @@ -65,16 +65,6 @@ "./presets/browser": { "browser": "./lib/browser/presets/browser.mjs", "node": null - }, - "./utils/node": { - "node": { - "require": "./lib/node/utils/node/index.cjs", - "import": "./lib/node/utils/node/index.mjs" - }, - "browser": null, - "require": "./lib/node/utils/node/index.cjs", - "import": "./lib/node/utils/node/index.mjs", - "default": "./lib/node/utils/node/index.cjs" } }, "author": "Artem Zakharchenko", @@ -187,4 +177,4 @@ "path": "./node_modules/cz-conventional-changelog" } } -} \ No newline at end of file +} diff --git a/src/getRawRequest.ts b/src/getRawRequest.ts deleted file mode 100644 index 3818711ca..000000000 --- a/src/getRawRequest.ts +++ /dev/null @@ -1,21 +0,0 @@ -const kRawRequest = Symbol('kRawRequest') - -/** - * Returns a raw request instance associated with this request. - * - * @example - * interceptor.on('request', ({ request }) => { - * const rawRequest = getRawRequest(request) - * - * if (rawRequest instanceof http.ClientRequest) { - * console.log(rawRequest.rawHeaders) - * } - * }) - */ -export function getRawRequest(request: Request): unknown | undefined { - return Reflect.get(request, kRawRequest) -} - -export function setRawRequest(request: Request, rawRequest: unknown): void { - Reflect.set(request, kRawRequest, rawRequest) -} diff --git a/src/index.ts b/src/index.ts index 75f83d8ee..4a09d0436 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,5 +11,4 @@ export { createRequestId } from './createRequestId' export { getCleanUrl } from './utils/getCleanUrl' export { encodeBuffer, decodeBuffer } from './utils/bufferUtils' export { FetchResponse } from './utils/fetchUtils' -export { getRawRequest } from './getRawRequest' export { resolveWebSocketUrl } from './utils/resolveWebSocketUrl' diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 2f4c28f03..a945c1912 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -16,8 +16,6 @@ import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnect import { createRequestId } from '../../createRequestId' import { getRawFetchHeaders } from './utils/recordRawHeaders' import { FetchResponse } from '../../utils/fetchUtils' -import { setRawRequest } from '../../getRawRequest' -import { setRawRequestBodyStream } from '../../utils/node' import { freeParser } from './utils/parserUtils' type HttpConnectionOptions = any @@ -589,12 +587,12 @@ export class MockHttpSocket extends MockSocket { // Set the raw `http.ClientRequest` instance on the request instance. // This is useful for cases like getting the raw headers of the request. - setRawRequest(this.request, Reflect.get(this, '_httpMessage')) + // setRawRequest(this.request, Reflect.get(this, '_httpMessage')) // Create a copy of the request body stream and store it on the request. // This is only needed for the consumers who wish to read the request body stream // of requests that cannot have a body per Fetch API specification (i.e. GET, HEAD). - setRawRequestBodyStream(this.request, this.requestStream) + // setRawRequestBodyStream(this.request, this.requestStream) // Skip handling the request that's already being handled // by another (parent) interceptor. For example, XMLHttpRequest diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index b9b1fa04f..c7f7d70c0 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -15,7 +15,6 @@ import { createResponse } from './utils/createResponse' import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { createRequestId } from '../../createRequestId' import { getBodyByteLength } from './utils/getBodyByteLength' -import { setRawRequest } from '../../getRawRequest' const kIsRequestHandled = Symbol('kIsRequestHandled') const IS_NODE = isNodeProcess() @@ -709,7 +708,7 @@ export class XMLHttpRequestController { }, }) define(fetchRequest, 'headers', proxyHeaders) - setRawRequest(fetchRequest, this.request) + // setRawRequest(fetchRequest, this.request) this.logger.info('converted request to a Fetch API Request!', fetchRequest) diff --git a/src/interceptors/XMLHttpRequest/new.ts b/src/interceptors/XMLHttpRequest/node.ts similarity index 100% rename from src/interceptors/XMLHttpRequest/new.ts rename to src/interceptors/XMLHttpRequest/node.ts diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index 5d7ffed5b..de3bd9bb1 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -12,7 +12,6 @@ import { followFetchRedirect } from './utils/followRedirect' import { decompressResponse } from './utils/decompression' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { FetchResponse } from '../../utils/fetchUtils' -import { setRawRequest } from '../../getRawRequest' import { isResponseError } from '../../utils/responseUtils' import { applyPatch } from '../../utils/apply-patch' @@ -52,7 +51,7 @@ export class FetchInterceptor extends Interceptor { * @note Set the raw request only if a Request instance was provided to fetch. */ if (input instanceof Request) { - setRawRequest(request, input) + // setRawRequest(request, input) } const responsePromise = new DeferredPromise() diff --git a/src/utils/node/index.ts b/src/utils/node/index.ts deleted file mode 100644 index e4f0e72c1..000000000 --- a/src/utils/node/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ClientRequest } from 'node:http' -import { Readable } from 'node:stream' -import { invariant } from 'outvariant' -import { getRawRequest } from '../../getRawRequest' - -const kRawRequestBodyStream = Symbol('kRawRequestBodyStream') - -/** - * Returns the request body stream of the given request. - * @note This is only relevant in the context of `http.ClientRequest`. - * This function will throw if the given `request` wasn't created based on - * the `http.ClientRequest` instance. - * You must rely on the web stream consumers for other request clients. - */ -export function getClientRequestBodyStream(request: Request): Readable { - const rawRequest = getRawRequest(request) - - invariant( - rawRequest instanceof ClientRequest, - `Failed to retrieve raw request body stream: request is not an instance of "http.ClientRequest". Note that you can only use the "getClientRequestBodyStream" function with the requests issued by "http.clientRequest".` - ) - - const requestBodyStream = Reflect.get(request, kRawRequestBodyStream) - - invariant( - requestBodyStream instanceof Readable, - 'Failed to retrieve raw request body stream: corrupted stream (%s)', - typeof requestBodyStream - ) - - return requestBodyStream -} - -export function setRawRequestBodyStream( - request: Request, - stream: Readable -): void { - Reflect.set(request, kRawRequestBodyStream, stream) -} diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index a7d724d7c..3f35e8f7d 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -10,7 +10,7 @@ import { } from '../../helpers' import { BatchInterceptor } from '../../../src/BatchInterceptor' import { HttpRequestInterceptor } from '../../../src/interceptors/http' -import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/new' +import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/node' import { RequestController } from '../../../src/RequestController' const httpServer = new HttpServer((app) => { diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 5a49ae5c6..cac8bb123 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -5,7 +5,7 @@ import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestEventMap } from '../../../src' import { BatchInterceptor } from '../../../src/BatchInterceptor' -import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/new' +import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/node' import { HttpRequestInterceptor } from '../../../src/interceptors/http' import { useCors, createXMLHttpRequest, toWebResponse } from '../../helpers' diff --git a/test/features/get-client-request-body-stream.test.ts b/test/features/get-client-request-body-stream.test.ts deleted file mode 100644 index 0395481ff..000000000 --- a/test/features/get-client-request-body-stream.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// @vitest-environment node -import http from 'node:http' -import { Readable } from 'node:stream' -import { text } from 'node:stream/consumers' -import { afterAll, afterEach, beforeAll, it, expect } from 'vitest' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { BatchInterceptor } from '../../src' -import interceptors from '../../src/presets/node' -import { getClientRequestBodyStream } from '../../src/utils/node' -import { toWebResponse } from '../helpers' - -const interceptor = new BatchInterceptor({ - name: 'interceptor', - interceptors, -}) - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('returns the underlying request body stream for http.ClientRequest', async () => { - const requestBodyStreamPromise = new DeferredPromise() - - interceptor.on('request', ({ request, controller }) => { - try { - const requestBodyStream = getClientRequestBodyStream(request) - requestBodyStreamPromise.resolve(requestBodyStream) - } catch (error) { - requestBodyStreamPromise.reject(error) - } - - controller.respondWith(new Response()) - }) - - const request = http.request('http://localhost:3000/resource', { - method: 'GET', - headers: { - /** - * @note The `content-length` header is required to send payload in a GET request. - */ - 'content-length': '11', - }, - }) - request.write('hello world') - request.end() - - await toWebResponse(request) - const requestBodyStream = await requestBodyStreamPromise - expect(requestBodyStream).toBeInstanceOf(Readable) - - await expect(text(requestBodyStream)).resolves.toBe('hello world') -}) - -it('throws if the request is not an instance of http.ClientRequest', async () => { - const requestBodyStreamPromise = new DeferredPromise() - - interceptor.on('request', ({ request, controller }) => { - try { - const requestBodyStream = getClientRequestBodyStream(request) - requestBodyStreamPromise.resolve(requestBodyStream) - } catch (error) { - requestBodyStreamPromise.reject(error) - } - - controller.respondWith(new Response()) - }) - - fetch('http://localhost:3000/resource', { - method: 'POST', - body: 'hello world', - }) - - await expect(requestBodyStreamPromise).rejects.toThrow( - `Failed to retrieve raw request body stream: request is not an instance of "http.ClientRequest". Note that you can only use the "getClientRequestBodyStream" function with the requests issued by "http.clientRequest".` - ) -}) diff --git a/test/features/get-raw-request.test.ts b/test/features/get-raw-request.test.ts deleted file mode 100644 index 45d5e6bb9..000000000 --- a/test/features/get-raw-request.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// @vitest-environment jsdom -import { it, expect, afterAll, afterEach, beforeAll } from 'vitest' -import http from 'node:http' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { BatchInterceptor, getRawRequest } from '../../src' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '../../src/interceptors/fetch' -import { createXMLHttpRequest } from '../helpers' - -const interceptor = new BatchInterceptor({ - name: 'batch-interceptor', - interceptors: [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], -}) - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('returns a reference to a raw http.ClientRequest instance', async () => { - const rawRequestPromise = new DeferredPromise() - - interceptor.on('request', ({ request, controller }) => { - const rawRequest = getRawRequest(request) - if (rawRequest instanceof http.ClientRequest) { - rawRequestPromise.resolve(rawRequest) - } else { - console.error(rawRequest) - rawRequestPromise.reject( - new Error('Expected rawRequest to be an instance of http.ClientRequest') - ) - } - - controller.respondWith(new Response()) - }) - - http - .request('http://localhost', { - headers: { - 'X-CustoM-HeadeR-NamE': 'value', - }, - }) - .end() - - const rawRequest = await rawRequestPromise - expect(rawRequest).toBeInstanceOf(http.ClientRequest) - expect(rawRequest.getRawHeaderNames()).toContain('X-CustoM-HeadeR-NamE') -}) - -it('returns a reference to a raw XMLHttpRequest instance', async () => { - const rawRequestPromise = new DeferredPromise() - interceptor.on('request', ({ request, controller }) => { - const rawRequest = getRawRequest(request) - - if (rawRequest instanceof XMLHttpRequest) { - rawRequestPromise.resolve(rawRequest) - } else { - console.error(rawRequest) - rawRequestPromise.reject( - new Error('Expected rawRequest to be an instance of XMLHttpRequest') - ) - } - - controller.respondWith(new Response('hello world')) - }) - - await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost:3000') - request.withCredentials = true - request.send() - }) - - const rawRequest = await rawRequestPromise - expect(rawRequest).toBeInstanceOf(XMLHttpRequest) - expect(rawRequest.withCredentials).toBe(true) - expect(rawRequest.responseText).toBe('hello world') -}) - -it('returns a reference to a raw Request instance (fetch)', async () => { - const rawRequestPromise = new DeferredPromise() - interceptor.on('request', ({ request, controller }) => { - const rawRequest = getRawRequest(request) - - if (rawRequest instanceof Request) { - rawRequestPromise.resolve(rawRequest) - } else { - console.error(rawRequest) - rawRequestPromise.reject( - new Error('Expected rawRequest to be an instance of XMLHttpRequest') - ) - } - - controller.respondWith(new Response('hello world')) - }) - - const request = new Request('http://localhost:3000') - await fetch(request) - - const rawRequest = await rawRequestPromise - expect(rawRequest).toEqual(request) -}) - -it('returns undefined for a non-Request input (fetch)', async () => { - const rawRequestPromise = new DeferredPromise() - interceptor.on('request', ({ request, controller }) => { - const rawRequest = getRawRequest(request) - - if (typeof rawRequest === 'undefined') { - rawRequestPromise.resolve(rawRequest) - } else { - console.error(rawRequest) - rawRequestPromise.reject(new Error('Expected rawRequest to be undefined')) - } - - controller.respondWith(new Response('hello world')) - }) - - await fetch('http://localhost:3000') - await expect(rawRequestPromise).resolves.toBeUndefined() -}) diff --git a/tsdown.config.mts b/tsdown.config.mts index ca1fe487f..2e945c622 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -6,7 +6,6 @@ export default defineConfig([ entry: [ './src/index.ts', './src/presets/node.ts', - './src/utils/node/index.ts', './src/RemoteHttpInterceptor.ts', './src/interceptors/ClientRequest/index.ts', './src/interceptors/XMLHttpRequest/index.ts', From 7af1ed4883b921cf0e56372629dd757b68b4055a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 13:17:33 +0100 Subject: [PATCH 077/198] test: rename the test suite --- .../{socket-write.test.ts => socket-server-data.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/modules/net/compliance/{socket-write.test.ts => socket-server-data.test.ts} (100%) diff --git a/test/modules/net/compliance/socket-write.test.ts b/test/modules/net/compliance/socket-server-data.test.ts similarity index 100% rename from test/modules/net/compliance/socket-write.test.ts rename to test/modules/net/compliance/socket-server-data.test.ts From e47b7ce339c5bce95040eae0a72ecc382442891b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 13:22:38 +0100 Subject: [PATCH 078/198] chore: remove manual `vitest` imports in tests --- src/BatchInterceptor.test.ts | 1 - src/Interceptor.test.ts | 1 - src/RequestController.test.ts | 1 - src/createRequestId.test.ts | 1 - src/interceptors/ClientRequest/index.test.ts | 1 - .../ClientRequest/utils/getIncomingMessageBody.test.ts | 1 - .../ClientRequest/utils/normalizeClientRequestArgs.test.ts | 1 - .../ClientRequest/utils/recordRawHeaders.test.ts | 1 - src/interceptors/Socket/MockSocket.test.ts | 1 - .../Socket/utils/normalizeSocketWriteArgs.test.ts | 1 - src/interceptors/WebSocket/utils/bindEvent.test.ts | 1 - src/interceptors/WebSocket/utils/events.test.ts | 1 - .../XMLHttpRequest/utils/concateArrayBuffer.test.ts | 1 - src/interceptors/XMLHttpRequest/utils/createEvent.test.ts | 1 - .../XMLHttpRequest/utils/getBodyByteLength.test.ts | 1 - src/utils/bufferUtils.test.ts | 1 - src/utils/cloneObject.test.ts | 1 - src/utils/createProxy.test.ts | 6 ++++-- src/utils/findPropertySource.test.ts | 1 - src/utils/getCleanUrl.test.ts | 1 - src/utils/getUrlByRequestOptions.test.ts | 1 - src/utils/getValueBySymbol.test.ts | 1 - src/utils/hasConfigurableGlobal.test.ts | 1 - src/utils/isObject.test.ts | 1 - src/utils/parseJson.test.ts | 1 - test/envs/node-with-websocket.ts | 3 +-- test/envs/react-native-like.ts | 3 +-- test/features/events/request.test.ts | 1 - test/features/events/response.test.ts | 1 - test/features/presets/node-preset.test.ts | 1 - test/features/remote/remote.test.ts | 1 - test/features/request-initiator.test.ts | 1 - .../WebSocket/compliance/websocket.client.events.test.ts | 1 - .../WebSocket/compliance/websocket.client.send.test.ts | 1 - test/modules/WebSocket/compliance/websocket.close.test.ts | 1 - .../WebSocket/compliance/websocket.connection.test.ts | 1 - .../WebSocket/compliance/websocket.constructor.test.ts | 1 - test/modules/WebSocket/compliance/websocket.default.test.ts | 1 - test/modules/WebSocket/compliance/websocket.events.test.ts | 1 - .../modules/WebSocket/compliance/websocket.protocol.test.ts | 1 - .../WebSocket/compliance/websocket.reuse-listeners.test.ts | 1 - test/modules/WebSocket/compliance/websocket.send.test.ts | 1 - .../WebSocket/compliance/websocket.server.close.test.ts | 1 - .../WebSocket/compliance/websocket.server.connect.test.ts | 1 - .../WebSocket/compliance/websocket.server.events.test.ts | 1 - .../WebSocket/compliance/websocket.server.socket.test.ts | 1 - test/modules/WebSocket/compliance/websocket.setters.test.ts | 1 - .../exchange/websocket.client.addEventListener.test.ts | 1 - .../WebSocket/exchange/websocket.client.close.test.ts | 1 - .../exchange/websocket.client.removeEventListener.test.ts | 1 - .../WebSocket/exchange/websocket.client.send.test.ts | 1 - .../exchange/websocket.interceptor.exception.test.ts | 1 - .../modules/WebSocket/exchange/websocket.readystate.test.ts | 1 - .../WebSocket/exchange/websocket.server.connect.test.ts | 1 - test/modules/WebSocket/intercept/websocket.dispose.test.ts | 1 - test/modules/WebSocket/intercept/websocket.send.test.ts | 1 - .../WebSocket/intercept/websocket.server.events.test.ts | 1 - .../WebSocket/third-party/socket.io.send.bypass.test.ts | 1 - .../compliance/xhr-add-event-listener.test.ts | 1 - .../compliance/xhr-event-callback-null.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-event-handlers.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-events-order.test.ts | 1 - .../compliance/xhr-middleware-exception.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-modify-request.test.ts | 1 - .../compliance/xhr-no-response-headers.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-request-headers.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-request-method.test.ts | 1 - .../compliance/xhr-response-body-empty.test.ts | 1 - .../compliance/xhr-response-body-json-invalid.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-response-body-xml.test.ts | 1 - .../xhr-response-headers-case-sensitivity.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-response-headers.test.ts | 1 - .../compliance/xhr-response-non-configurable.test.ts | 1 - .../XMLHttpRequest/compliance/xhr-response-type.test.ts | 1 - test/modules/XMLHttpRequest/compliance/xhr-status.test.ts | 1 - test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts | 1 - test/modules/XMLHttpRequest/features/events.test.ts | 1 - .../modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts | 1 - .../XMLHttpRequest/regressions/xhr-0-status-code.test.ts | 1 - .../regressions/xhr-axios-xhr-adapter.test.ts | 1 - .../regressions/xhr-compressed-response.test.ts | 1 - .../regressions/xhr-location-undefined.test.ts | 1 - .../regressions/xhr-request-body-length.test.ts | 1 - .../XMLHttpRequest/response/xhr-response-error.test.ts | 1 - .../response/xhr-response-without-body.test.ts | 1 - test/modules/XMLHttpRequest/response/xhr.test.ts | 1 - test/modules/fetch/compliance/abort-conrtoller.test.ts | 1 - .../modules/fetch/compliance/fetch-follow-redirects.test.ts | 1 - .../fetch/compliance/fetch-request-body-used.test.ts | 1 - test/modules/fetch/compliance/fetch-response-error.test.ts | 1 - .../modules/fetch/compliance/fetch-response-headers.test.ts | 1 - .../compliance/fetch-response-non-configurable.test.ts | 1 - test/modules/fetch/compliance/fetch-response-url.test.ts | 1 - .../fetch/compliance/response-content-encoding.test.ts | 1 - test/modules/fetch/compliance/undici.test.ts | 1 - test/modules/fetch/fetch-exception.test.ts | 1 - test/modules/fetch/fetch-request-controller.test.ts | 1 - test/modules/fetch/intercept/fetch-relative-url.test.ts | 1 - test/modules/fetch/intercept/fetch.request.test.ts | 1 - test/modules/fetch/intercept/fetch.test.ts | 1 - .../fetch/response/fetch-await-response-event.test.ts | 1 - test/modules/http/compliance/events.test.ts | 1 - test/modules/http/compliance/http-abort-controller.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 - .../modules/http/compliance/http-head-response-body.test.ts | 1 - test/modules/http/compliance/http-import.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 - .../http/compliance/http-req-end-after-connect.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-callback.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-socket-listeners.test.ts | 1 - test/modules/http/compliance/http-socket-reuse.test.ts | 1 - test/modules/http/compliance/http-ssl-socket.test.ts | 1 - test/modules/http/compliance/http-timeout.test.ts | 1 - .../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 | 1 - .../http/intercept/http-client-request-agent.test.ts | 1 - test/modules/http/intercept/http-client-request.test.ts | 1 - test/modules/http/intercept/http-connect.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 - .../http/regressions/http-post-missing-first-bytes.test.ts | 1 - test/modules/http/regressions/http-socket-timeout.test.ts | 1 - .../modules/http/regressions/https-peer-certificate.test.ts | 1 - .../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/modules/net/compliance/socket-server-data.test.ts | 1 - test/modules/net/example.test.ts | 1 - test/third-party/axios.test.ts | 1 - test/third-party/follow-redirect-http.test.ts | 1 - test/third-party/got.test.ts | 1 - test/third-party/miniflare-xhr.test.ts | 1 - test/third-party/miniflare.test.ts | 1 - test/third-party/node-fetch.test.ts | 1 - test/third-party/supertest.test.ts | 1 - test/tsconfig.json | 5 +++-- test/vitest.config.js | 1 + vitest.config.mjs | 1 + 172 files changed, 11 insertions(+), 174 deletions(-) diff --git a/src/BatchInterceptor.test.ts b/src/BatchInterceptor.test.ts index 5891d6267..544e62dea 100644 --- a/src/BatchInterceptor.test.ts +++ b/src/BatchInterceptor.test.ts @@ -1,4 +1,3 @@ -import { vi, it, expect, afterEach } from 'vitest' import { Interceptor } from './Interceptor' import { BatchInterceptor } from './BatchInterceptor' diff --git a/src/Interceptor.test.ts b/src/Interceptor.test.ts index e8d78f8a3..50d11e1e7 100644 --- a/src/Interceptor.test.ts +++ b/src/Interceptor.test.ts @@ -1,4 +1,3 @@ -import { describe, vi, it, expect, afterEach } from 'vitest' import { Interceptor, getGlobalSymbol, diff --git a/src/RequestController.test.ts b/src/RequestController.test.ts index eeed75f0a..aaa7705a5 100644 --- a/src/RequestController.test.ts +++ b/src/RequestController.test.ts @@ -1,4 +1,3 @@ -import { vi, it, expect } from 'vitest' import { RequestController, type RequestControllerSource, diff --git a/src/createRequestId.test.ts b/src/createRequestId.test.ts index a59b2c049..8c1ae0747 100644 --- a/src/createRequestId.test.ts +++ b/src/createRequestId.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { createRequestId } from './createRequestId' import { REQUEST_ID_REGEXP } from '../test/helpers' diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 5d1892314..12a25094a 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts index 5cbcd1dd8..0ad3f16eb 100644 --- a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts +++ b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { IncomingMessage } from 'node:http' import { Socket } from 'net' import * as zlib from 'zlib' diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts index 8cdc7ec16..45ac78fa0 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { parse } from 'url' import { globalAgent as httpGlobalAgent, RequestOptions } from 'node:http' import { diff --git a/src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts b/src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts index db401b327..3cb0aca06 100644 --- a/src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts +++ b/src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, afterEach } from 'vitest' import { recordRawFetchHeaders, restoreHeadersPrototype, diff --git a/src/interceptors/Socket/MockSocket.test.ts b/src/interceptors/Socket/MockSocket.test.ts index 61235cf36..ee7adb10c 100644 --- a/src/interceptors/Socket/MockSocket.test.ts +++ b/src/interceptors/Socket/MockSocket.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { Socket } from 'node:net' -import { vi, it, expect } from 'vitest' import { MockSocket } from './MockSocket' it(`keeps the socket connecting until it's destroyed`, () => { diff --git a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts index 32f2e1d5a..97d3287e7 100644 --- a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts +++ b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { it, expect } from 'vitest' import { normalizeSocketWriteArgs } from './normalizeSocketWriteArgs' it('normalizes .write()', () => { diff --git a/src/interceptors/WebSocket/utils/bindEvent.test.ts b/src/interceptors/WebSocket/utils/bindEvent.test.ts index 7871961f0..9998dcaa8 100644 --- a/src/interceptors/WebSocket/utils/bindEvent.test.ts +++ b/src/interceptors/WebSocket/utils/bindEvent.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { it, expect } from 'vitest' import { bindEvent } from './bindEvent' it('sets the "target" on the given event', () => { diff --git a/src/interceptors/WebSocket/utils/events.test.ts b/src/interceptors/WebSocket/utils/events.test.ts index 84a211633..9be4547d8 100644 --- a/src/interceptors/WebSocket/utils/events.test.ts +++ b/src/interceptors/WebSocket/utils/events.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { describe, it, expect } from 'vitest' import { CancelableMessageEvent, CloseEvent } from './events' describe(CancelableMessageEvent, () => { diff --git a/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts b/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts index fab59abd6..eb5a422db 100644 --- a/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts +++ b/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { concatArrayBuffer } from './concatArrayBuffer' const encoder = new TextEncoder() diff --git a/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts b/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts index 0b2321691..a2b2a12dc 100644 --- a/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts +++ b/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect } from 'vitest' import { createEvent } from './createEvent' import { EventPolyfill } from '../polyfills/EventPolyfill' diff --git a/src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts b/src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts index eacebb2a5..503b96729 100644 --- a/src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts +++ b/src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect } from 'vitest' import { getBodyByteLength } from './getBodyByteLength' const url = 'http://localhost' diff --git a/src/utils/bufferUtils.test.ts b/src/utils/bufferUtils.test.ts index 7c5545561..519ccd380 100644 --- a/src/utils/bufferUtils.test.ts +++ b/src/utils/bufferUtils.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { decodeBuffer, encodeBuffer } from './bufferUtils' it('encodes utf-8 string', () => { diff --git a/src/utils/cloneObject.test.ts b/src/utils/cloneObject.test.ts index 85a1dbbb3..c75a71470 100644 --- a/src/utils/cloneObject.test.ts +++ b/src/utils/cloneObject.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { cloneObject } from './cloneObject' it('clones a shallow object', () => { diff --git a/src/utils/createProxy.test.ts b/src/utils/createProxy.test.ts index acbd165f5..70cefc6b3 100644 --- a/src/utils/createProxy.test.ts +++ b/src/utils/createProxy.test.ts @@ -1,4 +1,3 @@ -import { vi, it, expect } from 'vitest' import { createProxy } from './createProxy' it('does not interfere with default constructors', () => { @@ -86,7 +85,10 @@ it('infer prototype descriptors', () => { it('spies on the constructor', () => { const OriginalClass = class { - constructor(public name: string, public age: number) {} + constructor( + public name: string, + public age: number + ) {} } const constructorCall = vi.fn< diff --git a/src/utils/findPropertySource.test.ts b/src/utils/findPropertySource.test.ts index cce02ca9e..faac766ea 100644 --- a/src/utils/findPropertySource.test.ts +++ b/src/utils/findPropertySource.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { findPropertySource } from './findPropertySource' it('returns the source for objects without prototypes', () => { diff --git a/src/utils/getCleanUrl.test.ts b/src/utils/getCleanUrl.test.ts index 9f70ca81d..defdcb066 100644 --- a/src/utils/getCleanUrl.test.ts +++ b/src/utils/getCleanUrl.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from 'vitest' import { getCleanUrl } from './getCleanUrl' describe('getCleanUrl', () => { diff --git a/src/utils/getUrlByRequestOptions.test.ts b/src/utils/getUrlByRequestOptions.test.ts index e2da49a12..cfbde182d 100644 --- a/src/utils/getUrlByRequestOptions.test.ts +++ b/src/utils/getUrlByRequestOptions.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { Agent as HttpAgent } from 'node:http' import { RequestOptions, Agent as HttpsAgent } from 'node:https' import { getUrlByRequestOptions } from './getUrlByRequestOptions' diff --git a/src/utils/getValueBySymbol.test.ts b/src/utils/getValueBySymbol.test.ts index 9e2b21c8c..edf890363 100644 --- a/src/utils/getValueBySymbol.test.ts +++ b/src/utils/getValueBySymbol.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { getValueBySymbol } from './getValueBySymbol' it('returns undefined given a non-existing symbol', () => { diff --git a/src/utils/hasConfigurableGlobal.test.ts b/src/utils/hasConfigurableGlobal.test.ts index 2501e0b52..3157df2ad 100644 --- a/src/utils/hasConfigurableGlobal.test.ts +++ b/src/utils/hasConfigurableGlobal.test.ts @@ -1,4 +1,3 @@ -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' import { hasConfigurableGlobal } from './hasConfigurableGlobal' beforeAll(() => { diff --git a/src/utils/isObject.test.ts b/src/utils/isObject.test.ts index ea4e99d60..6e826320c 100644 --- a/src/utils/isObject.test.ts +++ b/src/utils/isObject.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { isObject } from './isObject' it('returns true given an object', () => { diff --git a/src/utils/parseJson.test.ts b/src/utils/parseJson.test.ts index b02103481..bd7fdcc18 100644 --- a/src/utils/parseJson.test.ts +++ b/src/utils/parseJson.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { parseJson } from './parseJson' it('parses a given string into JSON', () => { diff --git a/test/envs/node-with-websocket.ts b/test/envs/node-with-websocket.ts index 2189fc392..606f05c44 100644 --- a/test/envs/node-with-websocket.ts +++ b/test/envs/node-with-websocket.ts @@ -1,8 +1,7 @@ /** * Node.js environment superset that has a global WebSocket API. */ -import type { Environment } from 'vitest' -import { builtinEnvironments } from 'vitest/environments' +import { builtinEnvironments, type Environment } from 'vitest/environments' import { WebSocket } from 'undici' export default { diff --git a/test/envs/react-native-like.ts b/test/envs/react-native-like.ts index 66df92679..6f7ef79f8 100644 --- a/test/envs/react-native-like.ts +++ b/test/envs/react-native-like.ts @@ -1,8 +1,7 @@ /** * React Native-like environment for Vitest. */ -import type { Environment } from 'vitest' -import { builtinEnvironments } from 'vitest/environments' +import { builtinEnvironments, type Environment } from 'vitest/environments' export default { name: 'react-native-like', diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 3f35e8f7d..5422e674d 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index cac8bb123..9bd140edf 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index c44f09934..8e38ee1d4 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { BatchInterceptor } from '../../../lib/node/index.mjs' import nodeInterceptors from '../../../lib/node/presets/node.mjs' diff --git a/test/features/remote/remote.test.ts b/test/features/remote/remote.test.ts index 0c57abe78..194b51e94 100644 --- a/test/features/remote/remote.test.ts +++ b/test/features/remote/remote.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' import * as path from 'path' import { spawn } from 'child_process' import { RemoteHttpResolver } from '../../../src/RemoteHttpInterceptor' diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index faad12376..9bde4f044 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { BatchInterceptor } from '../../src/BatchInterceptor' diff --git a/test/modules/WebSocket/compliance/websocket.client.events.test.ts b/test/modules/WebSocket/compliance/websocket.client.events.test.ts index 7ad6a9463..24e83376b 100644 --- a/test/modules/WebSocket/compliance/websocket.client.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.client.events.test.ts @@ -3,7 +3,6 @@ * This test suite asserts that the "client" connection object * dispatches the right events in different scenarios. */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketData, WebSocketInterceptor, diff --git a/test/modules/WebSocket/compliance/websocket.client.send.test.ts b/test/modules/WebSocket/compliance/websocket.client.send.test.ts index d26606f18..0cdf0ff64 100644 --- a/test/modules/WebSocket/compliance/websocket.client.send.test.ts +++ b/test/modules/WebSocket/compliance/websocket.client.send.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { beforeAll, afterEach, afterAll, vi, it, expect } from 'vitest' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.close.test.ts b/test/modules/WebSocket/compliance/websocket.close.test.ts index 9f4054928..13f9ce1de 100644 --- a/test/modules/WebSocket/compliance/websocket.close.test.ts +++ b/test/modules/WebSocket/compliance/websocket.close.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#dom-websocket-close */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/compliance/websocket.connection.test.ts b/test/modules/WebSocket/compliance/websocket.connection.test.ts index bed8b2ef9..ea4713370 100644 --- a/test/modules/WebSocket/compliance/websocket.connection.test.ts +++ b/test/modules/WebSocket/compliance/websocket.connection.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node-with-websocket */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.constructor.test.ts b/test/modules/WebSocket/compliance/websocket.constructor.test.ts index 352497182..463fece8b 100644 --- a/test/modules/WebSocket/compliance/websocket.constructor.test.ts +++ b/test/modules/WebSocket/compliance/websocket.constructor.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org//#dom-websocket-websocket */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.default.test.ts b/test/modules/WebSocket/compliance/websocket.default.test.ts index 643db532f..2d00859b2 100644 --- a/test/modules/WebSocket/compliance/websocket.default.test.ts +++ b/test/modules/WebSocket/compliance/websocket.default.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#the-websocket-interface */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.events.test.ts b/test/modules/WebSocket/compliance/websocket.events.test.ts index 69b7209f9..d48fab588 100644 --- a/test/modules/WebSocket/compliance/websocket.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.events.test.ts @@ -3,7 +3,6 @@ * This test suite asserts that the intercepted WebSocket client * still dispatches the correct events in mocked/bypassed scenarios. */ -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' diff --git a/test/modules/WebSocket/compliance/websocket.protocol.test.ts b/test/modules/WebSocket/compliance/websocket.protocol.test.ts index 986362547..fabc9276f 100644 --- a/test/modules/WebSocket/compliance/websocket.protocol.test.ts +++ b/test/modules/WebSocket/compliance/websocket.protocol.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#dom-websocket-close */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' diff --git a/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts index 76a79ac6a..42ea3313a 100644 --- a/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts +++ b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' diff --git a/test/modules/WebSocket/compliance/websocket.send.test.ts b/test/modules/WebSocket/compliance/websocket.send.test.ts index 772f85c31..b6c6336dd 100644 --- a/test/modules/WebSocket/compliance/websocket.send.test.ts +++ b/test/modules/WebSocket/compliance/websocket.send.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#dom-websocket-send */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { Data, WebSocketServer } from 'ws' import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' diff --git a/test/modules/WebSocket/compliance/websocket.server.close.test.ts b/test/modules/WebSocket/compliance/websocket.server.close.test.ts index 9eb03dc54..52cbc19c2 100644 --- a/test/modules/WebSocket/compliance/websocket.server.close.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.close.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node-with-websocket import { DeferredPromise } from '@open-draft/deferred-promise' -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer, Data } from 'ws' import { WebSocketInterceptor, diff --git a/test/modules/WebSocket/compliance/websocket.server.connect.test.ts b/test/modules/WebSocket/compliance/websocket.server.connect.test.ts index bb0ecad4d..9732de168 100644 --- a/test/modules/WebSocket/compliance/websocket.server.connect.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.connect.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { Data, WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.server.events.test.ts b/test/modules/WebSocket/compliance/websocket.server.events.test.ts index 500d24d5a..660e70e16 100644 --- a/test/modules/WebSocket/compliance/websocket.server.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.events.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.server.socket.test.ts b/test/modules/WebSocket/compliance/websocket.server.socket.test.ts index 6425adf64..e4424b4df 100644 --- a/test/modules/WebSocket/compliance/websocket.server.socket.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.socket.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/modules/WebSocket/compliance/websocket.setters.test.ts b/test/modules/WebSocket/compliance/websocket.setters.test.ts index 887110460..46fefbf92 100644 --- a/test/modules/WebSocket/compliance/websocket.setters.test.ts +++ b/test/modules/WebSocket/compliance/websocket.setters.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node-with-websocket */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts b/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts index aa88c4bcb..e2e5fb030 100644 --- a/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.client.close.test.ts b/test/modules/WebSocket/exchange/websocket.client.close.test.ts index c4b1c9a9e..965fb9e4d 100644 --- a/test/modules/WebSocket/exchange/websocket.client.close.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.close.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node-with-websocket */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' diff --git a/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts b/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts index df82ea970..55a40c98a 100644 --- a/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { WebSocketClientConnection, WebSocketInterceptor, diff --git a/test/modules/WebSocket/exchange/websocket.client.send.test.ts b/test/modules/WebSocket/exchange/websocket.client.send.test.ts index 1db3c713a..a7d929477 100644 --- a/test/modules/WebSocket/exchange/websocket.client.send.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.send.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node-with-websocket */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' diff --git a/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts b/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts index a02e72a45..360a2165c 100644 --- a/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts +++ b/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { it, expect, beforeAll, afterEach, afterAll, vi } from 'vitest' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.readystate.test.ts b/test/modules/WebSocket/exchange/websocket.readystate.test.ts index c45a6fa08..689d6a8c4 100644 --- a/test/modules/WebSocket/exchange/websocket.readystate.test.ts +++ b/test/modules/WebSocket/exchange/websocket.readystate.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/exchange/websocket.server.connect.test.ts b/test/modules/WebSocket/exchange/websocket.server.connect.test.ts index bc7002e4b..de42b623a 100644 --- a/test/modules/WebSocket/exchange/websocket.server.connect.test.ts +++ b/test/modules/WebSocket/exchange/websocket.server.connect.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' diff --git a/test/modules/WebSocket/intercept/websocket.dispose.test.ts b/test/modules/WebSocket/intercept/websocket.dispose.test.ts index 7f0dc6730..6a2ff6a14 100644 --- a/test/modules/WebSocket/intercept/websocket.dispose.test.ts +++ b/test/modules/WebSocket/intercept/websocket.dispose.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/intercept/websocket.send.test.ts b/test/modules/WebSocket/intercept/websocket.send.test.ts index e0c7cedb2..dfa9e2216 100644 --- a/test/modules/WebSocket/intercept/websocket.send.test.ts +++ b/test/modules/WebSocket/intercept/websocket.send.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' diff --git a/test/modules/WebSocket/intercept/websocket.server.events.test.ts b/test/modules/WebSocket/intercept/websocket.server.events.test.ts index bb8dd5ff4..e7cf74d3d 100644 --- a/test/modules/WebSocket/intercept/websocket.server.events.test.ts +++ b/test/modules/WebSocket/intercept/websocket.server.events.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts b/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts index ecea99abf..97ca3c0d1 100644 --- a/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts +++ b/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node import http from 'node:http' -import { vi, beforeAll, beforeEach, afterAll, it, expect } from 'vitest' import { io } from 'socket.io-client' import { Server } from 'socket.io' import { BatchInterceptor } from '../../../../src' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts index 9c144b5f5..dc7c2ee8a 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/mswjs/msw/issues/273 */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts index 00dd8fd3d..fa1583a01 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts @@ -2,7 +2,6 @@ * @note https://xhr.spec.whatwg.org/#event-handlers */ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts index 8ade2023b..d7dfc8e6b 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts @@ -2,7 +2,6 @@ * @note https://xhr.spec.whatwg.org/#event-handlers */ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts index 9d346a30e..fb48cd336 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts @@ -2,7 +2,6 @@ /** * @see https://xhr.spec.whatwg.org/#events */ -import { Mock, vi, it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest, useCors } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index c7f6fd772..e972c2705 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/mswjs/msw/issues/355 */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import axios from 'axios' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts index 6f4b53dbf..d62637185 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest, useCors } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts index b4dcda3bb..c342f02e1 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts index 97b629648..aa6291d65 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts index 13fbbf78a..8355b36b3 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest, useCors } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts index 77aa6e0b3..a923db158 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts index 8415bbd4e..d9a3ac4d1 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts index 21850fdb1..8d6b2a123 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts index f6301e858..993082c0c 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, describe, beforeAll, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts index 7b5cbec7d..22060bc59 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { createXMLHttpRequest, useCors } from '../../../helpers' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts index 3a422f4ad..14dfae449 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest, useCors } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts index ac6aa5f98..1a56e1ce1 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-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 { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { FetchResponse } from '../../../../src/utils/fetchUtils' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts index 1f942d860..9327612e8 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' import { encodeBuffer } from '../../../../src' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { toArrayBuffer } from '../../../../src/utils/bufferUtils' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts index 9ce9a6f12..6c018f85b 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/mswjs/interceptors/issues/281 */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts index 160c625f8..1a774c4b5 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/mswjs/interceptors/issues/7 */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { sleep } from '../../../../test/helpers' diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index e068ceaf0..a155c126a 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts index 4dd3dc2b1..573e99887 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import type { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts index 2696e3508..6828ba97d 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/mswjs/interceptors/issues/335 */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts index 06f2d40f1..d714840bc 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts @@ -3,7 +3,6 @@ * @note This issue is only reproducible in "happy-dom". * @see https://github.com/mswjs/msw/issues/1816 */ -import { beforeAll, afterAll, it, expect } from 'vitest' import axios from 'axios' /** * @note Use `Response` from Undici because "happy-dom" diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts index dfd688902..0fc894c7f 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/mswjs/interceptors/issues/308 */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import zlib from 'zlib' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts index 0aa492a7f..cd282698e 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts @@ -1,5 +1,4 @@ // @vitest-environment react-native-like -import { it, expect, beforeAll, afterAll } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts index 2d7db060c..ae6e28026 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts index f3b50ffd0..7ec9c4f77 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterAll, vi } from 'vitest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts index d5f53728f..0a419567c 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest, useCors } from '../../../helpers' diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts index c1bfe3491..cc950f757 100644 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest, useCors } from '../../../helpers' diff --git a/test/modules/fetch/compliance/abort-conrtoller.test.ts b/test/modules/fetch/compliance/abort-conrtoller.test.ts index bcb07b6d5..79384573b 100644 --- a/test/modules/fetch/compliance/abort-conrtoller.test.ts +++ b/test/modules/fetch/compliance/abort-conrtoller.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { afterEach, afterAll, beforeAll, expect, it } from 'vitest' import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/fetch/compliance/fetch-follow-redirects.test.ts b/test/modules/fetch/compliance/fetch-follow-redirects.test.ts index b9eca3bb2..f3bebdda9 100644 --- a/test/modules/fetch/compliance/fetch-follow-redirects.test.ts +++ b/test/modules/fetch/compliance/fetch-follow-redirects.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 { FetchInterceptor } from '../../../../src/interceptors/fetch' diff --git a/test/modules/fetch/compliance/fetch-request-body-used.test.ts b/test/modules/fetch/compliance/fetch-request-body-used.test.ts index 214f5321d..1e1884cbc 100644 --- a/test/modules/fetch/compliance/fetch-request-body-used.test.ts +++ b/test/modules/fetch/compliance/fetch-request-body-used.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import * as express from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/fetch/compliance/fetch-response-error.test.ts b/test/modules/fetch/compliance/fetch-response-error.test.ts index 481b38cef..7a76c18b5 100644 --- a/test/modules/fetch/compliance/fetch-response-error.test.ts +++ b/test/modules/fetch/compliance/fetch-response-error.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { FetchInterceptor } from '../../../../src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-headers.test.ts b/test/modules/fetch/compliance/fetch-response-headers.test.ts index 494704ca8..b94178bda 100644 --- a/test/modules/fetch/compliance/fetch-response-headers.test.ts +++ b/test/modules/fetch/compliance/fetch-response-headers.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { FetchInterceptor } from '../../../../src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts index 41c7e8efd..714a02a9c 100644 --- a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts +++ b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { FetchInterceptor } from '../../../../src/interceptors/fetch' diff --git a/test/modules/fetch/compliance/fetch-response-url.test.ts b/test/modules/fetch/compliance/fetch-response-url.test.ts index a8f6c6299..93f6693c4 100644 --- a/test/modules/fetch/compliance/fetch-response-url.test.ts +++ b/test/modules/fetch/compliance/fetch-response-url.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { FetchInterceptor } from '../../../../src/interceptors/fetch' diff --git a/test/modules/fetch/compliance/response-content-encoding.test.ts b/test/modules/fetch/compliance/response-content-encoding.test.ts index f861c0f21..edfc6d93a 100644 --- a/test/modules/fetch/compliance/response-content-encoding.test.ts +++ b/test/modules/fetch/compliance/response-content-encoding.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 { compressResponse } from '../../../helpers' import { FetchInterceptor } from '../../../../src/interceptors/fetch' diff --git a/test/modules/fetch/compliance/undici.test.ts b/test/modules/fetch/compliance/undici.test.ts index dffd3d5c7..a248abf95 100644 --- a/test/modules/fetch/compliance/undici.test.ts +++ b/test/modules/fetch/compliance/undici.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { fetch, request } from 'undici' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/fetch/fetch-exception.test.ts b/test/modules/fetch/fetch-exception.test.ts index 4a2a9b6a1..73dda0520 100644 --- a/test/modules/fetch/fetch-exception.test.ts +++ b/test/modules/fetch/fetch-exception.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { FetchInterceptor } from '../../../src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/fetch-request-controller.test.ts b/test/modules/fetch/fetch-request-controller.test.ts index fd6d4f4ac..546802c55 100644 --- a/test/modules/fetch/fetch-request-controller.test.ts +++ b/test/modules/fetch/fetch-request-controller.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' import { InterceptorError } from '../../../src/InterceptorError' import { FetchInterceptor } from '../../../src/interceptors/fetch' diff --git a/test/modules/fetch/intercept/fetch-relative-url.test.ts b/test/modules/fetch/intercept/fetch-relative-url.test.ts index 49b4c9006..0e9b07e83 100644 --- a/test/modules/fetch/intercept/fetch-relative-url.test.ts +++ b/test/modules/fetch/intercept/fetch-relative-url.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment jsdom */ -import { it, expect, afterAll, afterEach, beforeAll } from 'vitest' import { FetchInterceptor } from '../../../../src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/intercept/fetch.request.test.ts b/test/modules/fetch/intercept/fetch.request.test.ts index 80412c753..a165381f2 100644 --- a/test/modules/fetch/intercept/fetch.request.test.ts +++ b/test/modules/fetch/intercept/fetch.request.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestEventMap } from '../../../../src' diff --git a/test/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index bd37f27cf..35b2cdff8 100644 --- a/test/modules/fetch/intercept/fetch.test.ts +++ b/test/modules/fetch/intercept/fetch.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/modules/fetch/response/fetch-await-response-event.test.ts b/test/modules/fetch/response/fetch-await-response-event.test.ts index 0a8b673a9..fd7ba5284 100644 --- a/test/modules/fetch/response/fetch-await-response-event.test.ts +++ b/test/modules/fetch/response/fetch-await-response-event.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { FetchInterceptor } from '../../../../src/interceptors/fetch' diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index cc6d20f03..0e3ff8b4d 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/compliance/http-abort-controller.test.ts b/test/modules/http/compliance/http-abort-controller.test.ts index 16b10a4de..f95b4eaee 100644 --- a/test/modules/http/compliance/http-abort-controller.test.ts +++ b/test/modules/http/compliance/http-abort-controller.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { setTimeout } from 'node:timers/promises' 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 3fe1cfe82..66fe63185 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 http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index e7cab3dc9..290190ad7 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, afterEach } from 'vitest' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts index 56fc25698..62a921972 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 http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' 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 943b3a7d0..7b667a87e 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 http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } from '../../../helpers' diff --git a/test/modules/http/compliance/http-import.test.ts b/test/modules/http/compliance/http-import.test.ts index c94eedf8d..71e3b628b 100644 --- a/test/modules/http/compliance/http-import.test.ts +++ b/test/modules/http/compliance/http-import.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import * as http from 'node:http' import * as https from 'node:https' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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 9a2a0018e..12f1b4aa5 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,6 +1,5 @@ // @vitest-environment node import http from 'node:http' -import { afterAll, afterEach, beforeAll, it, expect } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } 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 50a042113..978033e57 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index 2ff695365..32bc45951 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 http from 'node:http' import rateLimit from 'express-rate-limit' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/compliance/http-req-end-after-connect.test.ts b/test/modules/http/compliance/http-req-end-after-connect.test.ts index 01085f90c..f2e1cce77 100644 --- a/test/modules/http/compliance/http-req-end-after-connect.test.ts +++ b/test/modules/http/compliance/http-req-end-after-connect.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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 5cc4c6497..1dc9c8020 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,5 +1,4 @@ // @see https://github.com/nock/nock/issues/2826 -import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { toWebResponse } from '../../../helpers' diff --git a/test/modules/http/compliance/http-req-method.test.ts b/test/modules/http/compliance/http-req-method.test.ts index b6cf0b1a0..830a797c9 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 http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } 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 0c74504ad..0a5dd78ad 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,7 +1,6 @@ // @vitest-environment node import { urlToHttpOptions } from 'node:url' import http from 'node:http' -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } from '../../../../test/helpers' diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index fcf9ff51c..18fe6ed9b 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node import { Readable } from 'node:stream' -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import express from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/modules/http/compliance/http-request-ipv6.test.ts b/test/modules/http/compliance/http-request-ipv6.test.ts index 21e24dbc6..7c196a6da 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 { DeferredPromise } from '@open-draft/deferred-promise' import { httpGet } from '../../../helpers' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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 9ad67ea24..112bc6b41 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, afterAll } from 'vitest' import http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } from '../../../helpers' diff --git a/test/modules/http/compliance/http-res-callback.test.ts b/test/modules/http/compliance/http-res-callback.test.ts index 1bb1318f4..4122ebd2f 100644 --- a/test/modules/http/compliance/http-res-callback.test.ts +++ b/test/modules/http/compliance/http-res-callback.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/compliance/http-res-destroy.test.ts b/test/modules/http/compliance/http-res-destroy.test.ts index ca4ebfdc2..c6e9b113d 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/lib/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/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 36f153135..310b2830c 100644 --- a/test/modules/http/compliance/http-res-non-configurable.test.ts +++ b/test/modules/http/compliance/http-res-non-configurable.test.ts @@ -3,7 +3,6 @@ * @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 { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '../../../../src/interceptors/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 c4ca3212b..e1d584339 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/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 806d56759..5bd2979a5 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 http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestEventMap } from '../../../../src' 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 51e841a9a..8d1efc49a 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 http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' 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 2cf78b34b..f8d962c07 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 http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } from '../../../helpers' diff --git a/test/modules/http/compliance/http-socket-listeners.test.ts b/test/modules/http/compliance/http-socket-listeners.test.ts index 2b575a283..9175b50b3 100644 --- a/test/modules/http/compliance/http-socket-listeners.test.ts +++ b/test/modules/http/compliance/http-socket-listeners.test.ts @@ -3,7 +3,6 @@ * @see https://github.com/mswjs/msw/issues/2537 * @see https://github.com/mswjs/interceptors/pull/755 */ -import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { Socket } from 'node:net' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/compliance/http-socket-reuse.test.ts b/test/modules/http/compliance/http-socket-reuse.test.ts index fe7494e43..6d0617ed9 100644 --- a/test/modules/http/compliance/http-socket-reuse.test.ts +++ b/test/modules/http/compliance/http-socket-reuse.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index d07a1a293..a4109cd00 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 { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import { TLSSocket } from 'node:tls' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts index e33341a07..abd16a814 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 http from 'node:http' import { setTimeout } from 'node:timers/promises' 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 98ed23daf..7abc082e0 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 http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } 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 0cbfff62e..0543b2630 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/mswjs/interceptors/pull/722 */ -import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from 'vitest' import fs from 'node:fs' import path from 'node:path' import http from 'node:http' 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 abc33389c..7bfbd2a48 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 http from 'node:http' import express from 'express' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index e53a3f45c..9d989e826 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 { IncomingMessage } from 'node:http' import https from 'node:https' import { URL } from 'node:url' diff --git a/test/modules/http/compliance/https-custom-agent.test.ts b/test/modules/http/compliance/https-custom-agent.test.ts index 8ae40e92e..fff56a047 100644 --- a/test/modules/http/compliance/https-custom-agent.test.ts +++ b/test/modules/http/compliance/https-custom-agent.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } from '../../../helpers' diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 1e406851f..3448fb70f 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, afterEach } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index 2dc36d4d2..f21b80902 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { httpGet, PromisifiedResponse, useCors } from '../../helpers' import { HttpRequestInterceptor } from '../../../src/interceptors/http' 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 bb9f8f17b..a3986e5f0 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 @@ * With the socket-based interception, that's no longer the case. I've rewritten * this test suite to ensure we are *not* patching the agents anymore. */ -import { beforeAll, afterEach, afterAll, it, expect } from 'vitest' import net from 'node:net' 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 2ff62c4f2..5ac314d80 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 http from 'node:http' import { httpsAgent, HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/intercept/http-connect.test.ts b/test/modules/http/intercept/http-connect.test.ts index e10744107..3d29a2397 100644 --- a/test/modules/http/intercept/http-connect.test.ts +++ b/test/modules/http/intercept/http-connect.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/mswjs/interceptors/issues/481 */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import net from 'node:net' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index b600d0364..79c6c09c1 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,4 +1,3 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index a604220af..fb9346005 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index a25a02cb2..39c15336d 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,4 +1,3 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index 9e7de2bfa..701ea0f65 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 https from 'node:https' import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' 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 35565581b..44528a517 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 } 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 c42451eed..5164346e0 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 http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/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 19ebf4c78..625c45a21 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 http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { toWebResponse } 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 6c98855df..94979c947 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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 dbb533576..5e8cd2765 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 @@ -4,7 +4,6 @@ */ import http from 'node:http' import path from 'node:path' -import { vi, afterAll, beforeAll, afterEach, it, expect } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import superagent from 'superagent' import { HttpRequestInterceptor } from '../../../../src/interceptors/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/regressions/https-peer-certificate.test.ts b/test/modules/http/regressions/https-peer-certificate.test.ts index 100d14163..a639ef8cb 100644 --- a/test/modules/http/regressions/https-peer-certificate.test.ts +++ b/test/modules/http/regressions/https-peer-certificate.test.ts @@ -2,7 +2,6 @@ /** * @see https://github.com/nock/nock/issues/2930#issuecomment-3960523903 */ -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' import net from 'node:net' import tls from 'node:tls' import https from 'node:https' 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 4b963b892..fc168ac04 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts index 707966a60..b51349dd3 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 } from 'vitest' import http from 'node:http' import { toWebResponse } from '../../../helpers' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 088f589e0..3592e51d1 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index a68a7554b..df191b7a9 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { sleep, toWebResponse } from '../../../helpers' diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index ac871dac8..e2cc2fef7 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 http from 'node:http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 903fb75e7..1767c7c1c 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 http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/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 b0686e29b..de5f94a5e 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 http from 'node:http' import { Readable } from 'node:stream' import { performance } from 'node:perf_hooks' 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 5999e4bf7..000f5f6df 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 http from 'node:http' import { toWebResponse } from '../../../helpers' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/net/compliance/socket-server-data.test.ts b/test/modules/net/compliance/socket-server-data.test.ts index c491c0f17..0ecdd5321 100644 --- a/test/modules/net/compliance/socket-server-data.test.ts +++ b/test/modules/net/compliance/socket-server-data.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import net from 'node:net' import { SocketInterceptor } from '../../../../src/interceptors/net' diff --git a/test/modules/net/example.test.ts b/test/modules/net/example.test.ts index 1b4b0dba8..ea3994866 100644 --- a/test/modules/net/example.test.ts +++ b/test/modules/net/example.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import net from 'node:net' import { SocketInterceptor } from '../../../src/interceptors/net' diff --git a/test/third-party/axios.test.ts b/test/third-party/axios.test.ts index 037968868..27761f193 100644 --- a/test/third-party/axios.test.ts +++ b/test/third-party/axios.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import axios from 'axios' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index 5bd3ecf42..a352eee98 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { https } from 'follow-redirects' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../src/interceptors/http' diff --git a/test/third-party/got.test.ts b/test/third-party/got.test.ts index 46cf3097b..f86e2d46a 100644 --- a/test/third-party/got.test.ts +++ b/test/third-party/got.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import got from 'got' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../src/interceptors/http' diff --git a/test/third-party/miniflare-xhr.test.ts b/test/third-party/miniflare-xhr.test.ts index 24636a2df..3cba14e48 100644 --- a/test/third-party/miniflare-xhr.test.ts +++ b/test/third-party/miniflare-xhr.test.ts @@ -1,5 +1,4 @@ // @vitest-environment miniflare -import { afterAll, expect, test } from 'vitest' import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' let interceptor: XMLHttpRequestInterceptor diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index a9ab12588..958236a0a 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -1,5 +1,4 @@ // @vitest-environment miniflare -import { afterAll, afterEach, beforeAll, expect, test, vi } from 'vitest' import { BatchInterceptor } from '../../src' import { HttpRequestInterceptor } from '../../src/interceptors/http' import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' diff --git a/test/third-party/node-fetch.test.ts b/test/third-party/node-fetch.test.ts index e86be8365..14c261cfa 100644 --- a/test/third-party/node-fetch.test.ts +++ b/test/third-party/node-fetch.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' import fetch from 'node-fetch' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../src/interceptors/http' diff --git a/test/third-party/supertest.test.ts b/test/third-party/supertest.test.ts index 52ada88fc..f4b7d4785 100644 --- a/test/third-party/supertest.test.ts +++ b/test/third-party/supertest.test.ts @@ -1,5 +1,4 @@ // @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import express from 'express' import supertest from 'supertest' import { HttpRequestEventMap } from '../../src' diff --git a/test/tsconfig.json b/test/tsconfig.json index 1aa2a4a40..e1b948d00 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "target": "es6" + "target": "es6", + "types": ["node", "vitest/globals"], }, "include": ["**/*.test.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules"], } diff --git a/test/vitest.config.js b/test/vitest.config.js index 113b4fe64..91b354263 100644 --- a/test/vitest.config.js +++ b/test/vitest.config.js @@ -9,6 +9,7 @@ export default defineConfig({ 'vitest-environment-node-with-websocket': './envs/node-with-websocket', 'vitest-environment-react-native-like': './envs/react-native-like', }, + globals: true, }, resolve: { alias: { diff --git a/vitest.config.mjs b/vitest.config.mjs index caadb14b1..a1e1eea4e 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { include: ['./src/**/*.test.ts'], + globals: true, }, esbuild: { target: 'es2022', From c496894cc4cdca7f206c6d7070f49e96c02283b3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 13:59:45 +0100 Subject: [PATCH 079/198] fix(net): trigger passthrough if no "connection" listeners --- src/interceptors/net/index.ts | 38 ++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 422c38c93..4d3160903 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -46,11 +46,20 @@ export class SocketInterceptor extends Interceptor { }) process.nextTick(() => { - this.emitter.emit('connection', { - socket: controller.serverSocket, - controller, - connectionOptions, - }) + if ( + !this.emitter.emit('connection', { + socket: controller.serverSocket, + controller, + connectionOptions, + }) + ) { + log( + 'no "connection" listeners found on the interceptor, passthrough...' + ) + + controller.passthrough() + return + } log('emitted "connection" event!') }) @@ -105,11 +114,20 @@ export class SocketInterceptor extends Interceptor { }) process.nextTick(() => { - this.emitter.emit('connection', { - socket: controller.serverSocket, - controller, - connectionOptions: tlsConnectionOptions, - }) + if ( + !this.emitter.emit('connection', { + socket: controller.serverSocket, + controller, + connectionOptions: tlsConnectionOptions, + }) + ) { + log( + 'no "connection" listeners found on the interceptor, passthrough...' + ) + + controller.passthrough() + return + } log('emitted the "connection" event!') }) From e348decf1491ce6010adff1260fe7b37630db845 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 14:00:17 +0100 Subject: [PATCH 080/198] fix: interpret server-side `socket.end()` --- src/interceptors/net/socket-controller.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 94161dea4..ab7139424 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -156,6 +156,16 @@ function toServerSocket(socket: T): T { }) as net.Socket['write'] } + // Translate server-side "socket.end()" to client-sode "socket.push(null)". + if (property === 'end') { + const realEnd = getRealValue() as net.Socket['end'] + + return ((...args: Parameters) => { + socket.push(null) + return realEnd.apply(target, args) + }) as net.Socket['end'] + } + return getRealValue() }, }) @@ -420,6 +430,10 @@ export class TcpSocketController extends SocketController { this.socket.push(null) } + #onRealSocketClose = (hadError: boolean) => { + this.socket.emit('close', hadError) + } + #onMockSocketDrain = () => { this.#passthroughSocket?.resume() } @@ -544,12 +558,14 @@ export class TcpSocketController extends SocketController { .removeListener('data', this.#onRealSocketData) .removeListener('error', this.#onRealSocketError) .removeListener('end', this.#onRealSocketEnd) + .removeListener('close', this.#onRealSocketClose) realSocket .once('connect', this.#onRealSocketConnect) .on('data', this.#onRealSocketData) .on('error', this.#onRealSocketError) .on('end', this.#onRealSocketEnd) + .on('close', this.#onRealSocketClose) return realSocket } From 957f1c4af7d7d2f480d04327d117e9961cfcf4b3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 14:00:27 +0100 Subject: [PATCH 081/198] test: add "connect" compliance tests --- test/helpers.ts | 76 +++++++++++++++- .../net/compliance/socket-connect.test.ts | 91 +++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 test/modules/net/compliance/socket-connect.test.ts diff --git a/test/helpers.ts b/test/helpers.ts index c25099396..43f83c8ab 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,8 +1,10 @@ -import { urlToHttpOptions } from 'node:url' +import { invariant } from 'outvariant' import zlib from 'node:zlib' -import https from 'node:https' +import net from 'node:net' import { Readable } from 'node:stream' +import { urlToHttpOptions } from 'node:url' import http, { ClientRequest, IncomingMessage, RequestOptions } from 'node:http' +import https from 'node:https' import { RequestHandler } from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { Page } from '@playwright/test' @@ -349,3 +351,73 @@ export function compressResponse( return output } + +export async function createTestServer( + createServer: () => T +): Promise< + AsyncDisposable & { + instance: T + port: number + hostname: string + http: { + url: (path: string) => URL + } + https: { + url: (path: string) => URL + } + } +> { + const server = createServer() + + const pendingListen = new DeferredPromise() + + server + .listen(0, '127.0.0.1', () => { + pendingListen.resolve() + }) + .once('error', (error) => pendingListen.reject(error)) + + await pendingListen + + const rawAddress = server.address() + + invariant( + rawAddress != null, + 'Failed to open a test server: server address is null' + ) + invariant( + typeof rawAddress === 'object' && 'port' in rawAddress, + 'Failed to open a test server: server address is not AddressInfo' + ) + + const createUrlHelper = (protocol: 'https' | 'http') => { + return (path: string): URL => { + return new URL( + path, + new URL(`${protocol}://${rawAddress.address}:${rawAddress.port}`) + ) + } + } + + return { + async [Symbol.asyncDispose]() { + const pendingClose = new DeferredPromise() + server.close((error) => { + if (error) { + return pendingClose.reject(error) + } + + pendingClose.resolve() + }) + }, + instance: server, + port: rawAddress.port, + hostname: rawAddress.address, + http: { + url: createUrlHelper('http'), + }, + https: { + url: createUrlHelper('https'), + }, + } +} diff --git a/test/modules/net/compliance/socket-connect.test.ts b/test/modules/net/compliance/socket-connect.test.ts new file mode 100644 index 000000000..50fe743fe --- /dev/null +++ b/test/modules/net/compliance/socket-connect.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '../../../../src/interceptors/net' +import { createTestServer } from '../../../helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('emits the "connect" event for a claimed socket', async () => { + const interceptorConnectionListener = vi.fn() + interceptor.on('connection', ({ socket, controller }) => { + interceptorConnectionListener(socket) + controller.claim() + socket.end() + }) + + const socket = net.connect(80, '127.0.0.1') + + const connectListener = vi.fn() + const errorListener = vi.fn() + const endListener = vi.fn() + const closeListener = vi.fn() + socket + .on('connect', connectListener) + .on('error', errorListener) + .on('end', endListener) + .on('close', closeListener) + + socket.resume() + + expect(socket.connecting).toBe(true) + + await expect.poll(() => connectListener).toHaveBeenCalledOnce() + expect.soft(socket.connecting).toBe(false) + expect.soft(errorListener).not.toHaveBeenCalled() + expect + .soft(interceptorConnectionListener) + .toHaveBeenCalledExactlyOnceWith(expect.any(net.Socket)) + + await expect.poll(() => endListener).toHaveBeenCalledOnce() + await expect.poll(() => closeListener).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) +}) + +it('emits a "connect" event for a passthrough socket', async () => { + const serverConnectionListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + serverConnectionListener(socket) + socket.end() + }) + }) + + const socket = net.connect(server.port, server.hostname) + + const connectListener = vi.fn() + const errorListener = vi.fn() + const endListener = vi.fn() + const closeListener = vi.fn() + socket + .on('connect', connectListener) + .on('error', errorListener) + .on('end', endListener) + .on('close', closeListener) + + socket.resume() + + expect(socket.connecting).toBe(true) + + await expect.poll(() => connectListener).toHaveBeenCalledOnce() + expect.soft(socket.connecting).toBe(false) + expect.soft(errorListener).not.toHaveBeenCalled() + expect + .soft(serverConnectionListener) + .toHaveBeenCalledExactlyOnceWith(expect.any(net.Socket)) + + await expect.poll(() => endListener).toHaveBeenCalledOnce() + await expect.poll(() => closeListener).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) +}) From d081546ee46ef11f2ce4fde7fb2e6a0b7ee2648e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 14:02:40 +0100 Subject: [PATCH 082/198] test: add "error before connect" test case --- .../net/compliance/socket-connect.test.ts | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/test/modules/net/compliance/socket-connect.test.ts b/test/modules/net/compliance/socket-connect.test.ts index 50fe743fe..d809127c3 100644 --- a/test/modules/net/compliance/socket-connect.test.ts +++ b/test/modules/net/compliance/socket-connect.test.ts @@ -17,7 +17,7 @@ afterAll(() => { interceptor.dispose() }) -it('emits the "connect" event for a claimed socket', async () => { +it('emits the "connect" event for a mocked socket', async () => { const interceptorConnectionListener = vi.fn() interceptor.on('connection', ({ socket, controller }) => { interceptorConnectionListener(socket) @@ -53,7 +53,7 @@ it('emits the "connect" event for a claimed socket', async () => { expect(socket.connecting).toBe(false) }) -it('emits a "connect" event for a passthrough socket', async () => { +it('emits the "connect" event for a passthrough socket', async () => { const serverConnectionListener = vi.fn() await using server = await createTestServer(() => { return new net.Server((socket) => { @@ -89,3 +89,87 @@ it('emits a "connect" event for a passthrough socket', async () => { await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(socket.connecting).toBe(false) }) + +it('does not emit the "connect" event for a mocked socket if controller errors the connection', async () => { + const interceptorConnectionListener = vi.fn() + interceptor.on('connection', ({ socket, controller }) => { + interceptorConnectionListener(socket) + controller.errorWith(new Error('Custom reason')) + }) + + const socket = net.connect(80, '127.0.0.1') + + const connectListener = vi.fn() + const errorListener = vi.fn() + const endListener = vi.fn() + const closeListener = vi.fn() + socket + .on('connect', connectListener) + .on('error', errorListener) + .on('end', endListener) + .on('close', closeListener) + + socket.resume() + + expect(socket.connecting).toBe(true) + + await expect + .poll(() => errorListener) + .toHaveBeenCalledExactlyOnceWith(new Error('Custom reason')) + expect.soft(socket.connecting).toBe(false) + expect.soft(connectListener).not.toHaveBeenCalled() + expect.soft(endListener).not.toHaveBeenCalled() + expect + .soft(interceptorConnectionListener) + .toHaveBeenCalledExactlyOnceWith(expect.any(net.Socket)) + + await expect.poll(() => closeListener).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) +}) + +it('does not emit the "connect" event for a passthrough socket if controller errors the connection', async () => { + const interceptorConnectionListener = vi.fn() + interceptor.on('connection', ({ socket, controller }) => { + interceptorConnectionListener(socket) + controller.errorWith(new Error('Custom reason')) + }) + + const serverConnectionListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + serverConnectionListener(socket) + socket.end() + }) + }) + + const socket = net.connect(server.port, server.hostname) + + const connectListener = vi.fn() + const errorListener = vi.fn() + const endListener = vi.fn() + const closeListener = vi.fn() + socket + .on('connect', connectListener) + .on('error', errorListener) + .on('end', endListener) + .on('close', closeListener) + + socket.resume() + + expect(socket.connecting).toBe(true) + + await expect + .poll(() => errorListener) + .toHaveBeenCalledExactlyOnceWith(new Error('Custom reason')) + + expect.soft(socket.connecting).toBe(false) + expect.soft(connectListener).not.toHaveBeenCalled() + expect.soft(serverConnectionListener).not.toHaveBeenCalled() + expect.soft(endListener).not.toHaveBeenCalled() + expect + .soft(interceptorConnectionListener) + .toHaveBeenCalledExactlyOnceWith(expect.any(net.Socket)) + + await expect.poll(() => closeListener).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) +}) From c33afbd49e10b94b6b68471daacc6cd141223727 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 14:04:59 +0100 Subject: [PATCH 083/198] chore: unfold net tests --- test/modules/net/{compliance => }/socket-connect.test.ts | 4 ++-- test/modules/net/{compliance => }/socket-server-data.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename test/modules/net/{compliance => }/socket-connect.test.ts (97%) rename test/modules/net/{compliance => }/socket-server-data.test.ts (95%) diff --git a/test/modules/net/compliance/socket-connect.test.ts b/test/modules/net/socket-connect.test.ts similarity index 97% rename from test/modules/net/compliance/socket-connect.test.ts rename to test/modules/net/socket-connect.test.ts index d809127c3..117d704a5 100644 --- a/test/modules/net/compliance/socket-connect.test.ts +++ b/test/modules/net/socket-connect.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import net from 'node:net' -import { SocketInterceptor } from '../../../../src/interceptors/net' -import { createTestServer } from '../../../helpers' +import { SocketInterceptor } from '../../../src/interceptors/net' +import { createTestServer } from '../../helpers' const interceptor = new SocketInterceptor() diff --git a/test/modules/net/compliance/socket-server-data.test.ts b/test/modules/net/socket-server-data.test.ts similarity index 95% rename from test/modules/net/compliance/socket-server-data.test.ts rename to test/modules/net/socket-server-data.test.ts index 0ecdd5321..4443617b9 100644 --- a/test/modules/net/compliance/socket-server-data.test.ts +++ b/test/modules/net/socket-server-data.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import net from 'node:net' -import { SocketInterceptor } from '../../../../src/interceptors/net' +import { SocketInterceptor } from '../../../src/interceptors/net' const interceptor = new SocketInterceptor() From bc803c7dc7e09536663f0e9eb6ff23fe9ba68c6b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 17:09:11 +0100 Subject: [PATCH 084/198] chore: remove `utils/node` --- utils/node/package.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 utils/node/package.json diff --git a/utils/node/package.json b/utils/node/package.json deleted file mode 100644 index 1ce2fcab6..000000000 --- a/utils/node/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "main": "../../lib/node/utils/node.cjs", - "module": "../../lib/node/utils/node.mjs", - "browser": null, - "exports": { - ".": { - "import": "./../../lib/node/utils/node.mjs", - "default": "./../../lib/node/utils/node.cjs" - } - } -} From 5e03a9901d1b5e0e7b6e12c3699a9866def7fbcc Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 17:47:43 +0100 Subject: [PATCH 085/198] test: add `controller.errorWith` tests --- package.json | 8 +- pnpm-lock.yaml | 879 ++++++++++-------- src/interceptors/net/socket-controller.ts | 7 +- test/helpers.ts | 38 +- .../xhr-location-undefined.test.ts | 2 +- .../net/socket-controller-error-with.test.ts | 127 +++ tsconfig.json | 2 + 7 files changed, 669 insertions(+), 394 deletions(-) create mode 100644 test/modules/net/socket-controller-error-with.test.ts diff --git a/package.json b/package.json index 69f32b61d..107bdeed5 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,10 @@ "node": null } }, + "imports": { + "#/src/*": "./src/*", + "#/test/*": "./test/*" + }, "author": "Artem Zakharchenko", "license": "MIT", "engines": { @@ -125,7 +129,7 @@ "express-rate-limit": "^7.5.0", "follow-redirects": "^1.15.1", "got": "^14.4.6", - "happy-dom": "^17.3.0", + "happy-dom": "^20.7.0", "https-proxy-agent": "^7.0.6", "jsdom": "^26.1.0", "node-fetch": "3.3.2", @@ -138,7 +142,7 @@ "tsdown": "^0.18.1", "typescript": "^5.8.2", "undici": "^7.22.0", - "vitest": "^3.0.8", + "vitest": "^4.0.18", "vitest-environment-miniflare": "^2.14.1", "web-encoding": "^1.1.5", "webpack": "^5.105.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76b90f91f..0a28f8a55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,8 +118,8 @@ importers: specifier: ^14.4.6 version: 14.4.6 happy-dom: - specifier: ^17.3.0 - version: 17.3.0 + specifier: ^20.7.0 + version: 20.7.0 https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 @@ -157,11 +157,11 @@ importers: specifier: ^7.22.0 version: 7.22.0 vitest: - specifier: ^3.0.8 - version: 3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) vitest-environment-miniflare: specifier: ^2.14.1 - version: 2.14.4(vitest@3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)) + version: 2.14.4(vitest@4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0)) web-encoding: specifier: ^1.1.5 version: 1.1.5 @@ -322,141 +322,159 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -480,6 +498,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -674,93 +695,128 @@ packages: '@rolldown/pluginutils@1.0.0-beta.55': resolution: {integrity: sha512-vajw/B3qoi7aYnnD4BQ4VoCcXQWnF0roSwE2iynbNxgW4l9mFwtLmLmUhpDdcTBfKyZm1p/T0D13qG94XBLohA==} - '@rollup/rollup-android-arm-eabi@4.28.0': - resolution: {integrity: sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==} + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.28.0': - resolution: {integrity: sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA==} + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.28.0': - resolution: {integrity: sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==} + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.28.0': - resolution: {integrity: sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w==} + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.28.0': - resolution: {integrity: sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ==} + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.28.0': - resolution: {integrity: sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA==} + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.28.0': - resolution: {integrity: sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w==} + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.28.0': - resolution: {integrity: sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg==} + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.28.0': - resolution: {integrity: sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg==} + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.28.0': - resolution: {integrity: sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw==} + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.28.0': - resolution: {integrity: sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ==} + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.28.0': - resolution: {integrity: sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g==} + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.28.0': - resolution: {integrity: sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw==} + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.28.0': - resolution: {integrity: sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==} + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.28.0': - resolution: {integrity: sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw==} + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.28.0': - resolution: {integrity: sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg==} + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.28.0': - resolution: {integrity: sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A==} + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.28.0': - resolution: {integrity: sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ==} + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} cpu: [x64] os: [win32] @@ -774,6 +830,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@szmarczak/http-timer@5.0.1': resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} @@ -790,6 +849,9 @@ packages: '@types/busboy@1.5.4': resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -811,15 +873,15 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -898,43 +960,49 @@ packages: '@types/supertest@6.0.2': resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.0': resolution: {integrity: sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@vitest/expect@3.0.8': - resolution: {integrity: sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==} + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} - '@vitest/mocker@3.0.8': - resolution: {integrity: sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==} + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 + vite: ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.0.8': - resolution: {integrity: sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} - '@vitest/runner@3.0.8': - resolution: {integrity: sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} - '@vitest/snapshot@3.0.8': - resolution: {integrity: sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} - '@vitest/spy@3.0.8': - resolution: {integrity: sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} - '@vitest/utils@3.0.8': - resolution: {integrity: sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1186,9 +1254,9 @@ packages: caniuse-lite@1.0.30001767: resolution: {integrity: sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==} - chai@5.2.0: - resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} - engines: {node: '>=12'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} @@ -1205,10 +1273,6 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} - chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -1420,10 +1484,6 @@ packages: dedent@0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1534,6 +1594,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1549,8 +1613,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -1558,9 +1622,9 @@ packages: es-toolkit@1.44.0: resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} hasBin: true escalade@3.2.0: @@ -1609,8 +1673,8 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} - expect-type@1.2.0: - resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} express-fileupload@1.5.1: @@ -1805,9 +1869,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - happy-dom@17.3.0: - resolution: {integrity: sha512-dTwlpUHrhE0usQOd1Df9k461SOYQUWNl0G31mXCDj+N9//oPcDb+cchrSJzrXN6qxZ5sZSrLf5AfY702Zvddfw==} - engines: {node: '>=18.0.0'} + happy-dom@20.7.0: + resolution: {integrity: sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==} + engines: {node: '>=20.0.0'} has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} @@ -2131,9 +2195,6 @@ packages: resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==} engines: {node: '>=0.10.0'} - loupe@3.1.3: - resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} - lowercase-keys@3.0.0: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2141,8 +2202,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} @@ -2237,8 +2298,8 @@ packages: mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} - nanoid@3.3.8: - resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2373,10 +2434,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.0: - resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2416,8 +2473,8 @@ packages: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} - postcss@8.4.49: - resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} process-nextick-args@2.0.1: @@ -2557,8 +2614,8 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.28.0: - resolution: {integrity: sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2699,8 +2756,8 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - std-env@3.8.0: - resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} @@ -2832,16 +2889,8 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} tldts-core@6.1.83: @@ -2996,27 +3045,27 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-node@3.0.8: - resolution: {integrity: sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - - vite@5.4.11: - resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -3031,6 +3080,10 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true vitest-environment-miniflare@2.14.4: resolution: {integrity: sha512-DzwQWdY42sVYR6aUndw9FdCtl/i0oh3NkbkQpw+xq5aYQw5eiJn5kwnKaKQEWaoBe8Cso71X2i1EJGvi1jZ2xw==} @@ -3038,26 +3091,32 @@ packages: peerDependencies: vitest: '>=0.23.0' - vitest@3.0.8: - resolution: {integrity: sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.0.8 - '@vitest/ui': 3.0.8 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': optional: true '@vitest/ui': optional: true @@ -3178,6 +3237,18 @@ packages: utf-8-validate: optional: true + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3421,73 +3492,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/win32-x64@0.27.3': optional: true '@fastify/busboy@2.1.1': {} @@ -3508,6 +3588,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -3745,58 +3827,79 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.55': {} - '@rollup/rollup-android-arm-eabi@4.28.0': + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.28.0': + '@rollup/rollup-android-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.28.0': + '@rollup/rollup-darwin-arm64@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.28.0': + '@rollup/rollup-darwin-x64@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.28.0': + '@rollup/rollup-freebsd-arm64@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.28.0': + '@rollup/rollup-freebsd-x64@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.28.0': + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.28.0': + '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.28.0': + '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.28.0': + '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.28.0': + '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.28.0': + '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.28.0': + '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.28.0': + '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.28.0': + '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.28.0': + '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.28.0': + '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.28.0': + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true '@sec-ant/readable-stream@0.4.1': {} @@ -3805,6 +3908,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} + '@szmarczak/http-timer@5.0.1': dependencies: defer-to-connect: 2.0.1 @@ -3827,6 +3932,11 @@ snapshots: dependencies: '@types/node': 18.19.67 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 18.19.67 @@ -3851,6 +3961,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -3861,8 +3973,6 @@ snapshots: '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 - '@types/estree@1.0.6': {} - '@types/estree@1.0.8': {} '@types/express-fileupload@1.5.1': @@ -3964,55 +4074,60 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.0': dependencies: '@types/node': 18.19.67 + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.13.9 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 - '@vitest/expect@3.0.8': + '@vitest/expect@4.0.18': dependencies: - '@vitest/spy': 3.0.8 - '@vitest/utils': 3.0.8 - chai: 5.2.0 - tinyrainbow: 2.0.0 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 - '@vitest/mocker@3.0.8(vite@5.4.11(@types/node@22.13.9)(terser@5.36.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))': dependencies: - '@vitest/spy': 3.0.8 + '@vitest/spy': 4.0.18 estree-walker: 3.0.3 - magic-string: 0.30.17 + magic-string: 0.30.21 optionalDependencies: - vite: 5.4.11(@types/node@22.13.9)(terser@5.36.0) + vite: 7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0) - '@vitest/pretty-format@3.0.8': + '@vitest/pretty-format@4.0.18': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.0.3 - '@vitest/runner@3.0.8': + '@vitest/runner@4.0.18': dependencies: - '@vitest/utils': 3.0.8 + '@vitest/utils': 4.0.18 pathe: 2.0.3 - '@vitest/snapshot@3.0.8': + '@vitest/snapshot@4.0.18': dependencies: - '@vitest/pretty-format': 3.0.8 - magic-string: 0.30.17 + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.0.8': - dependencies: - tinyspy: 3.0.2 + '@vitest/spy@4.0.18': {} - '@vitest/utils@3.0.8': + '@vitest/utils@4.0.18': dependencies: - '@vitest/pretty-format': 3.0.8 - loupe: 3.1.3 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 '@webassemblyjs/ast@1.14.1': dependencies: @@ -4290,13 +4405,7 @@ snapshots: caniuse-lite@1.0.30001767: {} - chai@5.2.0: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.1.3 - pathval: 2.0.0 + chai@6.2.2: {} chalk@2.4.2: dependencies: @@ -4313,8 +4422,6 @@ snapshots: chardet@0.7.0: {} - check-error@2.1.1: {} - chrome-trace-event@1.0.4: {} cli-cursor@3.1.0: @@ -4519,8 +4626,6 @@ snapshots: dedent@0.7.0: {} - deep-eql@5.0.2: {} - deep-extend@0.6.0: {} defaults@1.0.4: @@ -4627,6 +4732,8 @@ snapshots: entities@4.5.0: {} + entities@7.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.2: @@ -4639,37 +4746,40 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} es-module-lexer@2.0.0: {} es-toolkit@1.44.0: {} - esbuild@0.21.5: + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 escalade@3.2.0: {} @@ -4692,7 +4802,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 etag@1.8.1: {} @@ -4714,7 +4824,7 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 - expect-type@1.2.0: {} + expect-type@1.3.0: {} express-fileupload@1.5.1: dependencies: @@ -4961,10 +5071,17 @@ snapshots: graceful-fs@4.2.11: {} - happy-dom@17.3.0: + happy-dom@20.7.0: dependencies: - webidl-conversions: 7.0.0 + '@types/node': 22.13.9 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 whatwg-mimetype: 3.0.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate has-flag@3.0.0: {} @@ -5259,15 +5376,13 @@ snapshots: longest@2.0.1: {} - loupe@3.1.3: {} - lowercase-keys@3.0.0: {} lru-cache@10.4.3: {} - magic-string@0.30.17: + magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 media-typer@0.3.0: {} @@ -5328,7 +5443,7 @@ snapshots: mute-stream@0.0.8: {} - nanoid@3.3.8: {} + nanoid@3.3.11: {} negotiator@0.6.3: {} @@ -5444,8 +5559,6 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.0: {} - picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5499,9 +5612,9 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss@8.4.49: + postcss@8.5.6: dependencies: - nanoid: 3.3.8 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5651,28 +5764,35 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.55 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.55 - rollup@4.28.0: + rollup@4.59.0: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.28.0 - '@rollup/rollup-android-arm64': 4.28.0 - '@rollup/rollup-darwin-arm64': 4.28.0 - '@rollup/rollup-darwin-x64': 4.28.0 - '@rollup/rollup-freebsd-arm64': 4.28.0 - '@rollup/rollup-freebsd-x64': 4.28.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.28.0 - '@rollup/rollup-linux-arm-musleabihf': 4.28.0 - '@rollup/rollup-linux-arm64-gnu': 4.28.0 - '@rollup/rollup-linux-arm64-musl': 4.28.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.28.0 - '@rollup/rollup-linux-riscv64-gnu': 4.28.0 - '@rollup/rollup-linux-s390x-gnu': 4.28.0 - '@rollup/rollup-linux-x64-gnu': 4.28.0 - '@rollup/rollup-linux-x64-musl': 4.28.0 - '@rollup/rollup-win32-arm64-msvc': 4.28.0 - '@rollup/rollup-win32-ia32-msvc': 4.28.0 - '@rollup/rollup-win32-x64-msvc': 4.28.0 + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 rrweb-cssom@0.8.0: {} @@ -5841,7 +5961,7 @@ snapshots: statuses@2.0.1: {} - std-env@3.8.0: {} + std-env@3.10.0: {} stream-combiner2@1.1.1: dependencies: @@ -5981,11 +6101,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.0.2: {} - - tinyrainbow@2.0.0: {} - - tinyspy@3.0.2: {} + tinyrainbow@3.0.3: {} tldts-core@6.1.83: {} @@ -6111,74 +6227,60 @@ snapshots: vary@1.1.2: {} - vite-node@3.0.8(@types/node@22.13.9)(terser@5.36.0): + vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0): dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.6.0 - pathe: 2.0.3 - vite: 5.4.11(@types/node@22.13.9)(terser@5.36.0) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.11(@types/node@22.13.9)(terser@5.36.0): - dependencies: - esbuild: 0.21.5 - postcss: 8.4.49 - rollup: 4.28.0 + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.13.9 fsevents: 2.3.3 + jiti: 2.4.2 terser: 5.36.0 - vitest-environment-miniflare@2.14.4(vitest@3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)): + vitest-environment-miniflare@2.14.4(vitest@4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0)): dependencies: '@miniflare/queues': 2.14.4 '@miniflare/runner-vm': 2.14.4 '@miniflare/shared': 2.14.4 '@miniflare/shared-test-environment': 2.14.4 undici: 5.28.4 - vitest: 3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) + vitest: 4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) transitivePeerDependencies: - bufferutil - utf-8-validate - vitest@3.0.8(@types/debug@4.1.12)(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0): - dependencies: - '@vitest/expect': 3.0.8 - '@vitest/mocker': 3.0.8(vite@5.4.11(@types/node@22.13.9)(terser@5.36.0)) - '@vitest/pretty-format': 3.0.8 - '@vitest/runner': 3.0.8 - '@vitest/snapshot': 3.0.8 - '@vitest/spy': 3.0.8 - '@vitest/utils': 3.0.8 - chai: 5.2.0 - debug: 4.4.3 - expect-type: 1.2.0 - magic-string: 0.30.17 + vitest@4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 - std-env: 3.8.0 + picomatch: 4.0.3 + std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.0.2 - tinyrainbow: 2.0.0 - vite: 5.4.11(@types/node@22.13.9)(terser@5.36.0) - vite-node: 3.0.8(@types/node@22.13.9)(terser@5.36.0) + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/debug': 4.1.12 '@types/node': 22.13.9 - happy-dom: 17.3.0 + happy-dom: 20.7.0 jsdom: 26.1.0 transitivePeerDependencies: + - jiti - less - lightningcss - msw @@ -6186,8 +6288,9 @@ snapshots: - sass-embedded - stylus - sugarss - - supports-color - terser + - tsx + - yaml w3c-xmlserializer@5.0.0: dependencies: @@ -6316,6 +6419,8 @@ snapshots: ws@8.18.1: {} + ws@8.19.0: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index ab7139424..b15ff7fd2 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -415,7 +415,12 @@ export class TcpSocketController extends SocketController { } #onRealSocketError = (error: Error) => { - log('real socket error, forwarding...', error) + if (this.socket.destroyed) { + log('real socket errored but mock socket already destroyed, skipping...') + return + } + + log('real socket errored, forwarding...', error) this.socket.destroy(error) diff --git a/test/helpers.ts b/test/helpers.ts index 43f83c8ab..0ec446a54 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -8,6 +8,7 @@ import https from 'node:https' import { RequestHandler } from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { Page } from '@playwright/test' +import { MockedFunction } from 'node_modules/vitest/dist' import { getIncomingMessageBody } from '../src/interceptors/ClientRequest/utils/getIncomingMessageBody' import { SerializedRequest } from '../src/RemoteHttpInterceptor' import { FetchResponse } from '../src/utils/fetchUtils' @@ -372,9 +373,7 @@ export async function createTestServer( const pendingListen = new DeferredPromise() server - .listen(0, '127.0.0.1', () => { - pendingListen.resolve() - }) + .listen(0, '127.0.0.1', () => pendingListen.resolve()) .once('error', (error) => pendingListen.reject(error)) await pendingListen @@ -421,3 +420,36 @@ export async function createTestServer( }, } } + +export function spyOnSocket(socket: net.Socket) { + const eventNames = [ + 'lookup', + 'connectionAttempt', + 'connectionAttemptFailed', + 'connectionAttemptTimeout', + 'connect', + 'ready', + 'data', + 'drain', + 'end', + 'error', + 'timeout', + 'close', + ] as const + + const events: Array = [] + const listeners = {} as Record< + (typeof eventNames)[number], + MockedFunction + > + + for (const eventName of eventNames) { + listeners[eventName] = vi.fn((...args) => events.push([eventName, ...args])) + socket.on(eventName, listeners[eventName]) + } + + return { + events, + listeners, + } +} diff --git a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts index cd282698e..6c4676007 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts @@ -40,5 +40,5 @@ it('throws on a request with a relative URL', async () => { }) } - expect(createRequest).toThrow('Invalid URL: /relative/url') + expect(createRequest).toThrow('Invalid URL') }) diff --git a/test/modules/net/socket-controller-error-with.test.ts b/test/modules/net/socket-controller-error-with.test.ts new file mode 100644 index 000000000..5067da160 --- /dev/null +++ b/test/modules/net/socket-controller-error-with.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('ends the connection before it is open', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ controller }) => { + controller.errorWith(reason) + }) + + const socket = net.connect(80, '127.0.0.1') + const { events, listeners } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) + expect(socket.closed).toBe(true) + expect(events).toEqual([ + ['error', reason], + ['close', true], + ]) +}) + +it('ends a mocked connection after it is open', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ socket, controller }) => { + socket.on('connect', () => controller.errorWith(reason)) + controller.claim() + }) + + const socket = net.connect(80, '127.0.0.1') + const { events, listeners } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['error', reason], + ['close', true], + ]) +}) + +it('ends a passthrough connection after it is open', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ socket, controller }) => { + socket.on('connect', () => controller.errorWith(reason)) + controller.passthrough() + }) + + await using server = await createTestServer(() => { + return new net.Server(() => {}) + }) + + const socket = net.connect(server.port, server.hostname) + const { events, listeners } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) + expect(socket.closed).toBe(true) + expect(events).toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['error', reason], + ['close', true], + ]) +}) + +it('ends the connection during a write', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ socket, controller }) => { + socket.on('data', () => controller.errorWith(reason)) + controller.claim() + }) + + const socket = net.connect(80, '127.0.0.1') + const { events, listeners } = spyOnSocket(socket) + + socket.on('connect', () => socket.write('hello')) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) + expect(socket.closed).toBe(true) + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['error', reason], + ['close', true], + ]) +}) + +it('has no effect if the client closed the connection', async () => { + const serverReason = new Error('Server reason') + interceptor.on('connection', ({ controller }) => { + controller.errorWith(serverReason) + }) + + const socket = net.connect(80, '127.0.0.1') + const { events, listeners } = spyOnSocket(socket) + + const clientReason = new Error('Client reason') + socket.destroy(clientReason) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) + expect(socket.closed).toBe(true) + expect(events).toEqual([ + ['error', clientReason], + ['close', true], + ]) +}) diff --git a/tsconfig.json b/tsconfig.json index 90e1fab14..8e3eb7bed 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,8 @@ "types": ["@types/node"], "baseUrl": ".", "paths": { + "#/src/*": ["./src/*"], + "#/test/*": ["./test/*"], "_http_common": ["./_http_common.d.ts"], "internal:brotli-decompress": [ "./src/interceptors/fetch/utils/brotli-decompress.ts", From 6747c43c5c0462872ef527610ebbf0da7f1e2ddc Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 17:51:42 +0100 Subject: [PATCH 086/198] chore: use `imports` aliases in tests --- test/features/events/request.test.ts | 10 +++++----- test/features/events/response.test.ts | 10 +++++----- test/features/presets/node-preset.test.ts | 2 +- test/features/remote/remote.test.ts | 2 +- test/features/request-initiator.test.ts | 12 ++++++------ test/helpers.ts | 6 +++--- .../compliance/websocket.client.events.test.ts | 2 +- .../compliance/websocket.client.send.test.ts | 2 +- .../WebSocket/compliance/websocket.close.test.ts | 2 +- .../compliance/websocket.connection.test.ts | 4 ++-- .../compliance/websocket.constructor.test.ts | 2 +- .../WebSocket/compliance/websocket.default.test.ts | 2 +- .../WebSocket/compliance/websocket.events.test.ts | 4 ++-- .../WebSocket/compliance/websocket.protocol.test.ts | 2 +- .../compliance/websocket.reuse-listeners.test.ts | 2 +- .../WebSocket/compliance/websocket.send.test.ts | 2 +- .../compliance/websocket.server.close.test.ts | 2 +- .../compliance/websocket.server.connect.test.ts | 2 +- .../compliance/websocket.server.events.test.ts | 2 +- .../compliance/websocket.server.socket.test.ts | 2 +- .../WebSocket/compliance/websocket.setters.test.ts | 2 +- .../websocket.client.addEventListener.test.ts | 2 +- .../exchange/websocket.client.close.browser.test.ts | 2 +- .../exchange/websocket.client.close.test.ts | 2 +- .../websocket.client.removeEventListener.test.ts | 2 +- .../exchange/websocket.client.send.browser.test.ts | 2 +- .../WebSocket/exchange/websocket.client.send.test.ts | 2 +- .../exchange/websocket.interceptor.exception.test.ts | 2 +- .../WebSocket/exchange/websocket.readystate.test.ts | 2 +- .../websocket.server.connect.browser.test.ts | 2 +- .../exchange/websocket.server.connect.test.ts | 2 +- .../WebSocket/intercept/websocket.dispose.test.ts | 2 +- .../intercept/websocket.send.browser.test.ts | 4 ++-- .../WebSocket/intercept/websocket.send.test.ts | 2 +- .../intercept/websocket.server.events.test.ts | 2 +- .../WebSocket/third-party/socket.io.browser.test.ts | 2 +- .../third-party/socket.io.send.bypass.test.ts | 6 +++--- .../compliance/xhr-add-event-listener.test.ts | 4 ++-- .../compliance/xhr-event-callback-null.test.ts | 4 ++-- .../compliance/xhr-event-handlers.browser.test.ts | 7 +++++-- .../compliance/xhr-event-handlers.test.ts | 4 ++-- .../compliance/xhr-events-order.test.ts | 4 ++-- .../compliance/xhr-middleware-exception.test.ts | 4 ++-- .../compliance/xhr-modify-request.test.ts | 4 ++-- .../compliance/xhr-no-response-headers.test.ts | 4 ++-- .../compliance/xhr-ready-state-enums.test.ts | 2 +- .../compliance/xhr-request-headers.test.ts | 4 ++-- .../compliance/xhr-request-method.test.ts | 4 ++-- .../compliance/xhr-response-body-empty.test.ts | 4 ++-- .../xhr-response-body-json-invalid.test.ts | 4 ++-- .../compliance/xhr-response-body-xml.test.ts | 4 ++-- .../xhr-response-headers-case-sensitivity.test.ts | 4 ++-- .../compliance/xhr-response-headers.test.ts | 4 ++-- .../compliance/xhr-response-non-configurable.test.ts | 6 +++--- .../compliance/xhr-response-progress.browser.test.ts | 4 ++-- .../compliance/xhr-response-type.test.ts | 8 ++++---- .../XMLHttpRequest/compliance/xhr-status.test.ts | 4 ++-- .../XMLHttpRequest/compliance/xhr-timeout.test.ts | 6 +++--- .../compliance/xhr-upload.browser.test.ts | 4 ++-- test/modules/XMLHttpRequest/features/events.test.ts | 8 ++++---- .../intercept/XMLHttpRequest.browser.test.ts | 2 +- .../XMLHttpRequest/intercept/XMLHttpRequest.test.ts | 10 +++++----- .../regressions/xhr-0-status-code.test.ts | 4 ++-- .../regressions/xhr-axios-xhr-adapter.test.ts | 2 +- .../xhr-compressed-response.browser.test.ts | 2 +- .../regressions/xhr-compressed-response.test.ts | 4 ++-- .../regressions/xhr-location-undefined.test.ts | 4 ++-- .../regressions/xhr-request-body-length.test.ts | 4 ++-- .../response/xhr-response-error.test.ts | 4 ++-- .../response/xhr-response-patching.browser.test.ts | 2 +- .../response/xhr-response-without-body.test.ts | 6 +++--- .../XMLHttpRequest/response/xhr.browser.test.ts | 4 ++-- test/modules/XMLHttpRequest/response/xhr.test.ts | 4 ++-- .../fetch/compliance/abort-conrtoller.test.ts | 4 ++-- .../fetch/compliance/fetch-follow-redirects.test.ts | 7 +++++-- .../fetch/compliance/fetch-request-body-used.test.ts | 2 +- .../fetch/compliance/fetch-response-error.test.ts | 2 +- .../fetch/compliance/fetch-response-headers.test.ts | 2 +- .../fetch-response-non-configurable.test.ts | 4 ++-- .../fetch/compliance/fetch-response-url.test.ts | 2 +- .../response-content-encoding.browser.test.ts | 6 +++--- .../compliance/response-content-encoding.test.ts | 6 +++--- test/modules/fetch/compliance/undici.test.ts | 2 +- test/modules/fetch/fetch-exception.browser.test.ts | 2 +- test/modules/fetch/fetch-exception.test.ts | 2 +- .../fetch/fetch-modify-request.browser.test.ts | 2 +- test/modules/fetch/fetch-request-controller.test.ts | 4 ++-- .../fetch/intercept/fetch-relative-url.test.ts | 2 +- test/modules/fetch/intercept/fetch.browser.test.ts | 2 +- .../fetch/intercept/fetch.clone.browser.test.ts | 2 +- .../fetch/intercept/fetch.request.browser.test.ts | 2 +- test/modules/fetch/intercept/fetch.request.test.ts | 8 ++++---- test/modules/fetch/intercept/fetch.test.ts | 10 +++++----- .../response/fetch-await-response-event.test.ts | 2 +- .../response/fetch-response-patching.browser.test.ts | 4 ++-- test/modules/fetch/response/fetch.browser.test.ts | 4 ++-- test/modules/http/compliance/events.test.ts | 6 +++--- .../http/compliance/http-abort-controller.test.ts | 4 ++-- .../http/compliance/http-custom-agent.test.ts | 4 ++-- test/modules/http/compliance/http-errors.test.ts | 4 ++-- .../http/compliance/http-event-connect.test.ts | 4 ++-- .../http/compliance/http-head-response-body.test.ts | 4 ++-- test/modules/http/compliance/http-import.test.ts | 4 ++-- .../compliance/http-max-header-fields-count.test.ts | 4 ++-- .../http/compliance/http-modify-request.test.ts | 4 ++-- test/modules/http/compliance/http-rate-limit.test.ts | 2 +- .../compliance/http-req-end-after-connect.test.ts | 4 ++-- .../http/compliance/http-req-get-with-body.test.ts | 4 ++-- test/modules/http/compliance/http-req-method.test.ts | 4 ++-- .../compliance/http-req-url-to-http-options.test.ts | 4 ++-- test/modules/http/compliance/http-req-write.test.ts | 4 ++-- .../http/compliance/http-request-ipv6.test.ts | 4 ++-- .../compliance/http-request-without-options.test.ts | 4 ++-- .../http/compliance/http-res-callback.test.ts | 4 ++-- .../modules/http/compliance/http-res-destroy.test.ts | 4 ++-- .../compliance/http-res-non-configurable.test.ts | 6 +++--- .../http/compliance/http-res-raw-headers.test.ts | 4 ++-- .../compliance/http-res-read-multiple-times.test.ts | 4 ++-- .../http/compliance/http-res-set-encoding.test.ts | 2 +- .../compliance/http-response-headers-folding.test.ts | 4 ++-- .../http/compliance/http-socket-listeners.test.ts | 4 ++-- .../http/compliance/http-socket-reuse.test.ts | 4 ++-- test/modules/http/compliance/http-ssl-socket.test.ts | 2 +- test/modules/http/compliance/http-timeout.test.ts | 2 +- .../http/compliance/http-unhandled-exception.test.ts | 4 ++-- .../modules/http/compliance/http-unix-socket.test.ts | 4 ++-- test/modules/http/compliance/http-upgrade.test.ts | 2 +- test/modules/http/compliance/http.test.ts | 4 ++-- .../http/compliance/https-constructor.test.ts | 4 ++-- .../http/compliance/https-custom-agent.test.ts | 4 ++-- test/modules/http/compliance/https.test.ts | 4 ++-- test/modules/http/http-performance.test.ts | 4 ++-- .../http/intercept/http-client-request-agent.test.ts | 2 +- .../http/intercept/http-client-request.test.ts | 8 ++++---- test/modules/http/intercept/http-connect.test.ts | 4 ++-- test/modules/http/intercept/http.get.test.ts | 8 ++++---- test/modules/http/intercept/http.request.test.ts | 8 ++++---- test/modules/http/intercept/https.get.test.ts | 8 ++++---- test/modules/http/intercept/https.request.test.ts | 8 ++++---- ...http-concurrent-different-response-source.test.ts | 6 +++--- .../regressions/http-concurrent-same-host.test.ts | 2 +- .../http-empty-readable-stream-response.test.ts | 4 ++-- .../http-max-listeners-exceeded-warning.test.ts | 4 ++-- .../http-post-missing-first-bytes.test.ts | 2 +- test/modules/http/regressions/http-socket-timeout.ts | 2 +- .../http/regressions/https-peer-certificate.test.ts | 4 ++-- .../http/response/http-await-response-event.test.ts | 4 ++-- .../http/response/http-empty-response.test.ts | 4 ++-- test/modules/http/response/http-https.test.ts | 4 ++-- .../http/response/http-response-delay.test.ts | 4 ++-- .../http/response/http-response-error.test.ts | 2 +- .../http/response/http-response-patching.test.ts | 4 ++-- .../response/http-response-readable-stream.test.ts | 4 ++-- .../response/http-response-transfer-encoding.test.ts | 4 ++-- test/modules/net/example.test.ts | 2 +- test/modules/net/socket-connect.test.ts | 4 ++-- test/modules/net/socket-server-data.test.ts | 2 +- test/playwright.extend.ts | 2 +- test/third-party/axios.test.ts | 4 ++-- test/third-party/follow-redirect-http.test.ts | 4 ++-- test/third-party/got.test.ts | 4 ++-- test/third-party/miniflare-xhr.test.ts | 2 +- test/third-party/miniflare.test.ts | 10 +++++----- test/third-party/node-fetch.test.ts | 2 +- test/third-party/supertest.test.ts | 4 ++-- 165 files changed, 319 insertions(+), 313 deletions(-) diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 5422e674d..8e3390d3c 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -6,11 +6,11 @@ import { useCors, REQUEST_ID_REGEXP, toWebResponse, -} from '../../helpers' -import { BatchInterceptor } from '../../../src/BatchInterceptor' -import { HttpRequestInterceptor } from '../../../src/interceptors/http' -import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/node' -import { RequestController } from '../../../src/RequestController' +} from '#/test/helpers' +import { BatchInterceptor } from '#/src/BatchInterceptor' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' +import { RequestController } from '#/src/RequestController' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 9bd140edf..54a020d7d 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -2,11 +2,11 @@ import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '../../../src' -import { BatchInterceptor } from '../../../src/BatchInterceptor' -import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest/node' -import { HttpRequestInterceptor } from '../../../src/interceptors/http' -import { useCors, createXMLHttpRequest, toWebResponse } from '../../helpers' +import { HttpRequestEventMap } from '#/src/index' +import { BatchInterceptor } from '#/src/BatchInterceptor' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { useCors, createXMLHttpRequest, toWebResponse } from '#/test/helpers' declare namespace window { export const _resourceLoader: { diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index 8e38ee1d4..8872b70e8 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -2,7 +2,7 @@ import http from 'node:http' import { BatchInterceptor } from '../../../lib/node/index.mjs' import nodeInterceptors from '../../../lib/node/presets/node.mjs' -import { createXMLHttpRequest, toWebResponse } from '../../helpers' +import { createXMLHttpRequest, toWebResponse } from '#/test/helpers' const interceptor = new BatchInterceptor({ name: 'node-preset-interceptor', diff --git a/test/features/remote/remote.test.ts b/test/features/remote/remote.test.ts index 194b51e94..f34fe4e0b 100644 --- a/test/features/remote/remote.test.ts +++ b/test/features/remote/remote.test.ts @@ -1,6 +1,6 @@ import * as path from 'path' import { spawn } from 'child_process' -import { RemoteHttpResolver } from '../../../src/RemoteHttpInterceptor' +import { RemoteHttpResolver } from '#/src/RemoteHttpInterceptor' const CHILD_PATH = path.resolve(__dirname, 'child.js') diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index 9bde4f044..52b69dbc4 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -1,12 +1,12 @@ // @vitest-environment jsdom import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { BatchInterceptor } from '../../src/BatchInterceptor' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest/new' -import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest/node' -import { FetchInterceptor } from '../../src/interceptors/fetch/node' -import { HttpRequestInterceptor } from '../../src/interceptors/http' -import { createXMLHttpRequest, toWebResponse } from '../helpers' +import { BatchInterceptor } from '#/src/BatchInterceptor' +import { ClientRequestInterceptor } from '#/src/interceptors/ClientRequest/new' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' +import { FetchInterceptor } from '#/src/interceptors/fetch/node' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { createXMLHttpRequest, toWebResponse } from '#/test/helpers' const interceptor = new BatchInterceptor({ name: 'interceptor', diff --git a/test/helpers.ts b/test/helpers.ts index 0ec446a54..f3e2dd52a 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -9,9 +9,9 @@ import { RequestHandler } from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { Page } from '@playwright/test' import { MockedFunction } from 'node_modules/vitest/dist' -import { getIncomingMessageBody } from '../src/interceptors/ClientRequest/utils/getIncomingMessageBody' -import { SerializedRequest } from '../src/RemoteHttpInterceptor' -import { FetchResponse } from '../src/utils/fetchUtils' +import { getIncomingMessageBody } from '#/src/interceptors/ClientRequest/utils/getIncomingMessageBody' +import { SerializedRequest } from '#/src/RemoteHttpInterceptor' +import { FetchResponse } from '#/src/utils/fetchUtils' export const REQUEST_ID_REGEXP = /^\w{9,}$/ diff --git a/test/modules/WebSocket/compliance/websocket.client.events.test.ts b/test/modules/WebSocket/compliance/websocket.client.events.test.ts index 24e83376b..75fc6ad8d 100644 --- a/test/modules/WebSocket/compliance/websocket.client.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.client.events.test.ts @@ -6,7 +6,7 @@ import { WebSocketData, WebSocketInterceptor, -} from '../../../../src/interceptors/WebSocket' +} from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.client.send.test.ts b/test/modules/WebSocket/compliance/websocket.client.send.test.ts index 0cdf0ff64..311c098a9 100644 --- a/test/modules/WebSocket/compliance/websocket.client.send.test.ts +++ b/test/modules/WebSocket/compliance/websocket.client.send.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node-with-websocket -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.close.test.ts b/test/modules/WebSocket/compliance/websocket.close.test.ts index 13f9ce1de..69854fb3e 100644 --- a/test/modules/WebSocket/compliance/websocket.close.test.ts +++ b/test/modules/WebSocket/compliance/websocket.close.test.ts @@ -3,7 +3,7 @@ * @see https://websockets.spec.whatwg.org/#dom-websocket-close */ import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForNextTick } from '../utils/waitForNextTick' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.connection.test.ts b/test/modules/WebSocket/compliance/websocket.connection.test.ts index ea4713370..089cf4199 100644 --- a/test/modules/WebSocket/compliance/websocket.connection.test.ts +++ b/test/modules/WebSocket/compliance/websocket.connection.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node-with-websocket */ import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' -import { REQUEST_ID_REGEXP } from '../../../helpers' +import { REQUEST_ID_REGEXP } from '#/test/helpers' import { waitForNextTick } from '../utils/waitForNextTick' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.constructor.test.ts b/test/modules/WebSocket/compliance/websocket.constructor.test.ts index 463fece8b..89c7d6920 100644 --- a/test/modules/WebSocket/compliance/websocket.constructor.test.ts +++ b/test/modules/WebSocket/compliance/websocket.constructor.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org//#dom-websocket-websocket */ -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.default.test.ts b/test/modules/WebSocket/compliance/websocket.default.test.ts index 2d00859b2..0e2924ed2 100644 --- a/test/modules/WebSocket/compliance/websocket.default.test.ts +++ b/test/modules/WebSocket/compliance/websocket.default.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#the-websocket-interface */ -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.events.test.ts b/test/modules/WebSocket/compliance/websocket.events.test.ts index d48fab588..54d19c5ce 100644 --- a/test/modules/WebSocket/compliance/websocket.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.events.test.ts @@ -5,9 +5,9 @@ */ import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' -import { sleep } from '../../../helpers' +import { sleep } from '#/test/helpers' const wsServer = new WebSocketServer({ host: '127.0.0.1', diff --git a/test/modules/WebSocket/compliance/websocket.protocol.test.ts b/test/modules/WebSocket/compliance/websocket.protocol.test.ts index fabc9276f..b187a39bd 100644 --- a/test/modules/WebSocket/compliance/websocket.protocol.test.ts +++ b/test/modules/WebSocket/compliance/websocket.protocol.test.ts @@ -3,7 +3,7 @@ * @see https://websockets.spec.whatwg.org/#dom-websocket-close */ import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts index 42ea3313a..45e24b4a1 100644 --- a/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts +++ b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node-with-websocket import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.send.test.ts b/test/modules/WebSocket/compliance/websocket.send.test.ts index b6c6336dd..54c30806e 100644 --- a/test/modules/WebSocket/compliance/websocket.send.test.ts +++ b/test/modules/WebSocket/compliance/websocket.send.test.ts @@ -4,7 +4,7 @@ */ import { Data, WebSocketServer } from 'ws' import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.server.close.test.ts b/test/modules/WebSocket/compliance/websocket.server.close.test.ts index 52cbc19c2..cf565265a 100644 --- a/test/modules/WebSocket/compliance/websocket.server.close.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.close.test.ts @@ -4,7 +4,7 @@ import { WebSocketServer, Data } from 'ws' import { WebSocketInterceptor, WebSocketServerConnection, -} from '../../../../src/interceptors/WebSocket/index' +} from '#/src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/compliance/websocket.server.connect.test.ts b/test/modules/WebSocket/compliance/websocket.server.connect.test.ts index 9732de168..434dd471a 100644 --- a/test/modules/WebSocket/compliance/websocket.server.connect.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.connect.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node-with-websocket import { Data, WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' diff --git a/test/modules/WebSocket/compliance/websocket.server.events.test.ts b/test/modules/WebSocket/compliance/websocket.server.events.test.ts index 660e70e16..6bbad779c 100644 --- a/test/modules/WebSocket/compliance/websocket.server.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.events.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node-with-websocket import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/compliance/websocket.server.socket.test.ts b/test/modules/WebSocket/compliance/websocket.server.socket.test.ts index e4424b4df..8b5d2a40e 100644 --- a/test/modules/WebSocket/compliance/websocket.server.socket.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.socket.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node-with-websocket -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/modules/WebSocket/compliance/websocket.setters.test.ts b/test/modules/WebSocket/compliance/websocket.setters.test.ts index 46fefbf92..15f57e42e 100644 --- a/test/modules/WebSocket/compliance/websocket.setters.test.ts +++ b/test/modules/WebSocket/compliance/websocket.setters.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node-with-websocket */ import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForNextTick } from '../utils/waitForNextTick' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts b/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts index e2e5fb030..4f2e87f8c 100644 --- a/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node-with-websocket -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.client.close.browser.test.ts b/test/modules/WebSocket/exchange/websocket.client.close.browser.test.ts index c70efa4c9..fcc76ea05 100644 --- a/test/modules/WebSocket/exchange/websocket.client.close.browser.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.close.browser.test.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../../playwright.extend' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' declare global { interface Window { diff --git a/test/modules/WebSocket/exchange/websocket.client.close.test.ts b/test/modules/WebSocket/exchange/websocket.client.close.test.ts index 965fb9e4d..a463d9dca 100644 --- a/test/modules/WebSocket/exchange/websocket.client.close.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.close.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node-with-websocket */ import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts b/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts index 55a40c98a..da35db0f5 100644 --- a/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts @@ -2,7 +2,7 @@ import { WebSocketClientConnection, WebSocketInterceptor, -} from '../../../../src/interceptors/WebSocket' +} from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.client.send.browser.test.ts b/test/modules/WebSocket/exchange/websocket.client.send.browser.test.ts index 498d3986c..b27b74a80 100644 --- a/test/modules/WebSocket/exchange/websocket.client.send.browser.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.send.browser.test.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../../playwright.extend' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' declare global { interface Window { diff --git a/test/modules/WebSocket/exchange/websocket.client.send.test.ts b/test/modules/WebSocket/exchange/websocket.client.send.test.ts index a7d929477..a8f846213 100644 --- a/test/modules/WebSocket/exchange/websocket.client.send.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.send.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node-with-websocket */ import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts b/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts index 360a2165c..120c2f831 100644 --- a/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts +++ b/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node-with-websocket -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.readystate.test.ts b/test/modules/WebSocket/exchange/websocket.readystate.test.ts index 689d6a8c4..6e3e91ed5 100644 --- a/test/modules/WebSocket/exchange/websocket.readystate.test.ts +++ b/test/modules/WebSocket/exchange/websocket.readystate.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node-with-websocket -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/exchange/websocket.server.connect.browser.test.ts b/test/modules/WebSocket/exchange/websocket.server.connect.browser.test.ts index 8dce4d19d..9773c3f57 100644 --- a/test/modules/WebSocket/exchange/websocket.server.connect.browser.test.ts +++ b/test/modules/WebSocket/exchange/websocket.server.connect.browser.test.ts @@ -1,6 +1,6 @@ import { WebSocketServer } from 'ws' import { test, expect } from '../../../playwright.extend' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' declare global { diff --git a/test/modules/WebSocket/exchange/websocket.server.connect.test.ts b/test/modules/WebSocket/exchange/websocket.server.connect.test.ts index de42b623a..4df3bcc4b 100644 --- a/test/modules/WebSocket/exchange/websocket.server.connect.test.ts +++ b/test/modules/WebSocket/exchange/websocket.server.connect.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node-with-websocket import { WebSocketServer } from 'ws' import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/intercept/websocket.dispose.test.ts b/test/modules/WebSocket/intercept/websocket.dispose.test.ts index 6a2ff6a14..93d24beaf 100644 --- a/test/modules/WebSocket/intercept/websocket.dispose.test.ts +++ b/test/modules/WebSocket/intercept/websocket.dispose.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node-with-websocket import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/intercept/websocket.send.browser.test.ts b/test/modules/WebSocket/intercept/websocket.send.browser.test.ts index 6b542376b..36c22b732 100644 --- a/test/modules/WebSocket/intercept/websocket.send.browser.test.ts +++ b/test/modules/WebSocket/intercept/websocket.send.browser.test.ts @@ -1,6 +1,6 @@ import { test, expect } from '../../../playwright.extend' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' -import { WebSocketData } from '../../../../src/interceptors/WebSocket/WebSocketTransport' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' +import { WebSocketData } from '#/src/interceptors/WebSocket/WebSocketTransport' declare global { interface Window { diff --git a/test/modules/WebSocket/intercept/websocket.send.test.ts b/test/modules/WebSocket/intercept/websocket.send.test.ts index dfa9e2216..e92492392 100644 --- a/test/modules/WebSocket/intercept/websocket.send.test.ts +++ b/test/modules/WebSocket/intercept/websocket.send.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node-with-websocket import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/intercept/websocket.server.events.test.ts b/test/modules/WebSocket/intercept/websocket.server.events.test.ts index e7cf74d3d..1b73c7197 100644 --- a/test/modules/WebSocket/intercept/websocket.server.events.test.ts +++ b/test/modules/WebSocket/intercept/websocket.server.events.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node-with-websocket import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/third-party/socket.io.browser.test.ts b/test/modules/WebSocket/third-party/socket.io.browser.test.ts index 41fec23f7..48e3ac3a0 100644 --- a/test/modules/WebSocket/third-party/socket.io.browser.test.ts +++ b/test/modules/WebSocket/third-party/socket.io.browser.test.ts @@ -5,7 +5,7 @@ import type { Encoder, Decoder } from 'socket.io-parser' import type { encodePacket, decodePacket } from 'engine.io-parser' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' declare global { interface Window { diff --git a/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts b/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts index 97ca3c0d1..1367ed4e1 100644 --- a/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts +++ b/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts @@ -2,9 +2,9 @@ import http from 'node:http' import { io } from 'socket.io-client' import { Server } from 'socket.io' -import { BatchInterceptor } from '../../../../src' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { BatchInterceptor } from '#/src/BatchInterceptor' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new BatchInterceptor({ name: 'test-interceptor', diff --git a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts index dc7c2ee8a..06818906e 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts @@ -2,8 +2,8 @@ /** * @see https://github.com/mswjs/msw/issues/273 */ -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts index fa1583a01..b87a2f701 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts @@ -3,8 +3,8 @@ */ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.browser.test.ts index 718b3bcb0..87e509cba 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.browser.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.browser.test.ts @@ -1,6 +1,6 @@ import { HttpServer } from '@open-draft/test-server/http' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.use(useCors) @@ -17,7 +17,10 @@ test.afterAll(async () => { await httpServer.close() }) -test('onloadend handler is called when not returning a mocked response', async ({ page, loadExample }) => { +test('onloadend handler is called when not returning a mocked response', async ({ + page, + loadExample, +}) => { await loadExample(require.resolve('./xhr-event-handlers.browser.runtime.js')) const { request, calls } = await page.evaluate(async (url) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts index d7dfc8e6b..06fce6dee 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts @@ -3,8 +3,8 @@ */ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts index fb48cd336..9a307de93 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts @@ -3,8 +3,8 @@ * @see https://xhr.spec.whatwg.org/#events */ import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest, useCors } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index e972c2705..5d8e8bf89 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -3,8 +3,8 @@ * @see https://github.com/mswjs/msw/issues/355 */ import axios from 'axios' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts index d62637185..4e6425279 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest, useCors } from '#/test/helpers' const server = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts index c342f02e1..38618a526 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts index aa6291d65..07486fd0b 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts index 8355b36b3..ab712aa09 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest, useCors } from '#/test/helpers' interface ResponseType { requestRawHeaders: Array diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts index a923db158..b74de0dd0 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts index d9a3ac4d1..4ddb62a34 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts index 8d6b2a123..b292a35d4 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts index 993082c0c..a2eecdba5 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const XML_STRING = 'Content' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts index 22060bc59..416a57684 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { createXMLHttpRequest, useCors } from '../../../helpers' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts index 14dfae449..818fdc0df 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest, useCors } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts index 1a56e1ce1..22b6d0505 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts @@ -3,9 +3,9 @@ * @see https://github.com/mswjs/msw/issues/2307 */ import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { FetchResponse } from '../../../../src/utils/fetchUtils' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { FetchResponse } from '#/src/utils/fetchUtils' +import { createXMLHttpRequest, useCors } from '#/test/helpers' import { DeferredPromise } from '@open-draft/deferred-promise' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-progress.browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-progress.browser.test.ts index f9e72897d..d54b2d5b5 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-progress.browser.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-progress.browser.test.ts @@ -2,9 +2,9 @@ * @see https://github.com/mswjs/interceptors/issues/614 */ import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' declare global { interface Window { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts index 9327612e8..7978d6804 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom -import { encodeBuffer } from '../../../../src' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { toArrayBuffer } from '../../../../src/utils/bufferUtils' -import { createXMLHttpRequest, readBlob } from '../../../helpers' +import { encodeBuffer } from '#/src/index' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { toArrayBuffer } from '#/src/utils/bufferUtils' +import { createXMLHttpRequest, readBlob } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts index 6c018f85b..f2f4b17a9 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts @@ -2,8 +2,8 @@ /** * @see https://github.com/mswjs/interceptors/issues/281 */ -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts index 1a774c4b5..6dc4a056f 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts @@ -3,9 +3,9 @@ * @see https://github.com/mswjs/interceptors/issues/7 */ import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { sleep } from '../../../../test/helpers' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { sleep } from '#/test/helpers' +import { createXMLHttpRequest } from '#/test/helpers' import { DeferredPromise } from '@open-draft/deferred-promise' const httpServer = new HttpServer((app) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts index 0d7ee9a5b..b20562e7d 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts @@ -3,9 +3,9 @@ */ import fileUpload from 'express-fileupload' import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' declare global { interface Window { diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index a155c126a..439d04499 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -1,13 +1,13 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { createXMLHttpRequest, useCors, REQUEST_ID_REGEXP, -} from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src' -import { RequestController } from '../../../../src/RequestController' +} from '#/test/helpers' +import { HttpRequestEventMap } from '#/src/index' +import { RequestController } from '#/src/RequestController' const server = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts index e0a36d6ba..d2e2bb28e 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts @@ -2,7 +2,7 @@ import { invariant } from 'outvariant' import { HttpServer } from '@open-draft/test-server/http' import { RequestHandler } from 'express-serve-static-core' import { test, expect } from '../../../playwright.extend' -import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../../../src/Interceptor' +import { INTERNAL_REQUEST_ID_HEADER_NAME } from '#/src/Interceptor' const httpServer = new HttpServer((app) => { const strictCorsMiddleware: RequestHandler = (req, res, next) => { diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts index 573e99887..044ceea7a 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts @@ -1,15 +1,15 @@ // @vitest-environment jsdom import type { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { createXMLHttpRequest, useCors, REQUEST_ID_REGEXP, -} from '../../../helpers' -import { toArrayBuffer, encodeBuffer } from '../../../../src/utils/bufferUtils' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestEventMap } from '../../../../src/glossary' +} from '#/test/helpers' +import { toArrayBuffer, encodeBuffer } from '#/src/utils/bufferUtils' +import { RequestController } from '#/src/RequestController' +import { HttpRequestEventMap } from '#/src/glossary' declare namespace window { export const _resourceLoader: { diff --git a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts index 6828ba97d..382d558b8 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts @@ -2,8 +2,8 @@ /** * @see https://github.com/mswjs/interceptors/issues/335 */ -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts index d714840bc..29e8e710b 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts @@ -10,7 +10,7 @@ import axios from 'axios' * Node's Readable instead, which is completely incompatible. */ import { Response as UndiciResponse } from 'undici' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' const request = axios.create({ baseURL: 'http://localhost', diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.browser.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.browser.test.ts index 91838d839..cb0f22c92 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.browser.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.browser.test.ts @@ -3,7 +3,7 @@ */ import { HttpServer } from '@open-draft/test-server/http' import zlib from 'zlib' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' import { test, expect } from '../../../playwright.extend' const httpServer = new HttpServer((app) => { diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts index 0fc894c7f..e997c8176 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts @@ -4,8 +4,8 @@ */ import { HttpServer } from '@open-draft/test-server/http' import zlib from 'zlib' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest, useCors } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts index 6c4676007..f53bfcfaa 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts @@ -1,6 +1,6 @@ // @vitest-environment react-native-like -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts index ae6e28026..43a8ab56d 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts index 7ec9c4f77..d1829b50e 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts index dcadf32f5..ee7517a6d 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' import { test, expect } from '../../../playwright.extend' declare namespace window { diff --git a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts index 0a419567c..e9e79a984 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' -import type { HttpRequestEventMap } from '../../../../src' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest, useCors } from '#/test/helpers' +import type { HttpRequestEventMap } from '#/src/index' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr.browser.test.ts index a951db6cf..a9db05279 100644 --- a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.browser.test.ts @@ -1,8 +1,8 @@ import { Page } from '@playwright/test' import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' declare namespace window { export const interceptor: XMLHttpRequestInterceptor diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts index cc950f757..6e1e6d926 100644 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest, useCors } from '#/test/helpers' declare namespace window { export const _resourceLoader: { diff --git a/test/modules/fetch/compliance/abort-conrtoller.test.ts b/test/modules/fetch/compliance/abort-conrtoller.test.ts index 79384573b..c83f1c9a6 100644 --- a/test/modules/fetch/compliance/abort-conrtoller.test.ts +++ b/test/modules/fetch/compliance/abort-conrtoller.test.ts @@ -2,8 +2,8 @@ import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { sleep } from '../../../helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch' +import { sleep } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { diff --git a/test/modules/fetch/compliance/fetch-follow-redirects.test.ts b/test/modules/fetch/compliance/fetch-follow-redirects.test.ts index f3bebdda9..86e9d7d3a 100644 --- a/test/modules/fetch/compliance/fetch-follow-redirects.test.ts +++ b/test/modules/fetch/compliance/fetch-follow-redirects.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' const interceptor = new FetchInterceptor() @@ -53,7 +53,10 @@ it('follows a mocked relative redirect to the original server', async () => { interceptor.on('request', ({ request, controller }) => { if (request.url.endsWith('/original')) { return controller.respondWith( - new Response(null, { status: 302, headers: { location: '/redirected' } }) + new Response(null, { + status: 302, + headers: { location: '/redirected' }, + }) ) } }) diff --git a/test/modules/fetch/compliance/fetch-request-body-used.test.ts b/test/modules/fetch/compliance/fetch-request-body-used.test.ts index 1e1884cbc..221613a99 100644 --- a/test/modules/fetch/compliance/fetch-request-body-used.test.ts +++ b/test/modules/fetch/compliance/fetch-request-body-used.test.ts @@ -1,7 +1,7 @@ import * as express from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-error.test.ts b/test/modules/fetch/compliance/fetch-response-error.test.ts index 7a76c18b5..4ee272fd7 100644 --- a/test/modules/fetch/compliance/fetch-response-error.test.ts +++ b/test/modules/fetch/compliance/fetch-response-error.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-headers.test.ts b/test/modules/fetch/compliance/fetch-response-headers.test.ts index b94178bda..8da2e6946 100644 --- a/test/modules/fetch/compliance/fetch-response-headers.test.ts +++ b/test/modules/fetch/compliance/fetch-response-headers.test.ts @@ -1,4 +1,4 @@ -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts index 714a02a9c..04bba159a 100644 --- a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts +++ b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { FetchResponse } from '../../../../src/utils/fetchUtils' +import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchResponse } from '#/src/utils/fetchUtils' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-url.test.ts b/test/modules/fetch/compliance/fetch-response-url.test.ts index 93f6693c4..7875637c3 100644 --- a/test/modules/fetch/compliance/fetch-response-url.test.ts +++ b/test/modules/fetch/compliance/fetch-response-url.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/response-content-encoding.browser.test.ts b/test/modules/fetch/compliance/response-content-encoding.browser.test.ts index 4c0f3c03e..63df081e1 100644 --- a/test/modules/fetch/compliance/response-content-encoding.browser.test.ts +++ b/test/modules/fetch/compliance/response-content-encoding.browser.test.ts @@ -1,8 +1,8 @@ import { HttpServer } from '@open-draft/test-server/http' import { test, expect } from '../../../playwright.extend' -import { compressResponse, useCors } from '../../../helpers' -import { parseContentEncoding } from '../../../../src/interceptors/fetch/utils/decompression' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { compressResponse, useCors } from '#/test/helpers' +import { parseContentEncoding } from '#/src/interceptors/fetch/utils/decompression' +import { FetchInterceptor } from '#/src/interceptors/fetch' declare namespace window { const interceptor: FetchInterceptor diff --git a/test/modules/fetch/compliance/response-content-encoding.test.ts b/test/modules/fetch/compliance/response-content-encoding.test.ts index edfc6d93a..720f51e12 100644 --- a/test/modules/fetch/compliance/response-content-encoding.test.ts +++ b/test/modules/fetch/compliance/response-content-encoding.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' -import { compressResponse } from '../../../helpers' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { parseContentEncoding } from '../../../../src/interceptors/fetch/utils/decompression' +import { compressResponse } from '#/test/helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch' +import { parseContentEncoding } from '#/src/interceptors/fetch/utils/decompression' const httpServer = new HttpServer((app) => { app.get('/compressed', (req, res) => { diff --git a/test/modules/fetch/compliance/undici.test.ts b/test/modules/fetch/compliance/undici.test.ts index a248abf95..5e08644b8 100644 --- a/test/modules/fetch/compliance/undici.test.ts +++ b/test/modules/fetch/compliance/undici.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { fetch, request } from 'undici' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() beforeAll(() => { diff --git a/test/modules/fetch/fetch-exception.browser.test.ts b/test/modules/fetch/fetch-exception.browser.test.ts index a94eef0a4..d6a53c48e 100644 --- a/test/modules/fetch/fetch-exception.browser.test.ts +++ b/test/modules/fetch/fetch-exception.browser.test.ts @@ -1,4 +1,4 @@ -import { FetchInterceptor } from '../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' import { test, expect } from '../../playwright.extend' declare global { diff --git a/test/modules/fetch/fetch-exception.test.ts b/test/modules/fetch/fetch-exception.test.ts index 73dda0520..f1ef6c149 100644 --- a/test/modules/fetch/fetch-exception.test.ts +++ b/test/modules/fetch/fetch-exception.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { FetchInterceptor } from '../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/fetch-modify-request.browser.test.ts b/test/modules/fetch/fetch-modify-request.browser.test.ts index 2369933e6..b438433f7 100644 --- a/test/modules/fetch/fetch-modify-request.browser.test.ts +++ b/test/modules/fetch/fetch-modify-request.browser.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { useCors } from '../../helpers' +import { useCors } from '#/test/helpers' import { test, expect } from '../../playwright.extend' const server = new HttpServer((app) => { diff --git a/test/modules/fetch/fetch-request-controller.test.ts b/test/modules/fetch/fetch-request-controller.test.ts index 546802c55..dda4fecc2 100644 --- a/test/modules/fetch/fetch-request-controller.test.ts +++ b/test/modules/fetch/fetch-request-controller.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { DeferredPromise } from '@open-draft/deferred-promise' -import { InterceptorError } from '../../../src/InterceptorError' -import { FetchInterceptor } from '../../../src/interceptors/fetch' +import { InterceptorError } from '#/src/InterceptorError' +import { FetchInterceptor } from '#/src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/intercept/fetch-relative-url.test.ts b/test/modules/fetch/intercept/fetch-relative-url.test.ts index 0e9b07e83..21a7ceec4 100644 --- a/test/modules/fetch/intercept/fetch-relative-url.test.ts +++ b/test/modules/fetch/intercept/fetch-relative-url.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/intercept/fetch.browser.test.ts b/test/modules/fetch/intercept/fetch.browser.test.ts index 3c8dd3019..cf387250c 100644 --- a/test/modules/fetch/intercept/fetch.browser.test.ts +++ b/test/modules/fetch/intercept/fetch.browser.test.ts @@ -2,7 +2,7 @@ import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { Page, Response } from '@playwright/test' import { test, expect } from '../../../playwright.extend' -import { extractRequestFromPage, useCors } from '../../../helpers' +import { extractRequestFromPage, useCors } from '#/test/helpers' const EXAMPLE_PATH = require.resolve('./fetch.browser.runtime.js') diff --git a/test/modules/fetch/intercept/fetch.clone.browser.test.ts b/test/modules/fetch/intercept/fetch.clone.browser.test.ts index 064a68c17..cb3332974 100644 --- a/test/modules/fetch/intercept/fetch.clone.browser.test.ts +++ b/test/modules/fetch/intercept/fetch.clone.browser.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' import { test, expect } from '../../../playwright.extend' declare namespace window { diff --git a/test/modules/fetch/intercept/fetch.request.browser.test.ts b/test/modules/fetch/intercept/fetch.request.browser.test.ts index 053b16587..3c28eabce 100644 --- a/test/modules/fetch/intercept/fetch.request.browser.test.ts +++ b/test/modules/fetch/intercept/fetch.request.browser.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { extractRequestFromPage, useCors } from '../../../helpers' +import { extractRequestFromPage, useCors } from '#/test/helpers' import { test, expect } from '../../../playwright.extend' const httpServer = new HttpServer((app) => { diff --git a/test/modules/fetch/intercept/fetch.request.test.ts b/test/modules/fetch/intercept/fetch.request.test.ts index a165381f2..79df6570e 100644 --- a/test/modules/fetch/intercept/fetch.request.test.ts +++ b/test/modules/fetch/intercept/fetch.request.test.ts @@ -1,9 +1,9 @@ import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '../../../../src' -import { REQUEST_ID_REGEXP } from '../../../helpers' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { RequestController } from '../../../../src/RequestController' +import { HttpRequestEventMap } from '#/src/index' +import { REQUEST_ID_REGEXP } from '#/test/helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch' +import { RequestController } from '#/src/RequestController' const httpServer = new HttpServer((app) => { app.post('/user', (_req, res) => { diff --git a/test/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index 35b2cdff8..3c35e3b01 100644 --- a/test/modules/fetch/intercept/fetch.test.ts +++ b/test/modules/fetch/intercept/fetch.test.ts @@ -1,11 +1,11 @@ import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '../../../../src' -import { REQUEST_ID_REGEXP } from '../../../helpers' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' -import { RequestController } from '../../../../src/RequestController' +import { HttpRequestEventMap } from '#/src/index' +import { REQUEST_ID_REGEXP } from '#/test/helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch' +import { encodeBuffer } from '#/src/utils/bufferUtils' +import { RequestController } from '#/src/RequestController' process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' diff --git a/test/modules/fetch/response/fetch-await-response-event.test.ts b/test/modules/fetch/response/fetch-await-response-event.test.ts index fd7ba5284..18508fead 100644 --- a/test/modules/fetch/response/fetch-await-response-event.test.ts +++ b/test/modules/fetch/response/fetch-await-response-event.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { diff --git a/test/modules/fetch/response/fetch-response-patching.browser.test.ts b/test/modules/fetch/response/fetch-response-patching.browser.test.ts index a7d6d011a..f18fd8a18 100644 --- a/test/modules/fetch/response/fetch-response-patching.browser.test.ts +++ b/test/modules/fetch/response/fetch-response-patching.browser.test.ts @@ -1,8 +1,8 @@ import { HttpServer } from '@open-draft/test-server/http' import { Page } from '@playwright/test' import { test, expect } from '../../../playwright.extend' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { useCors } from '../../../helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch' +import { useCors } from '#/test/helpers' declare namespace window { export const interceptor: FetchInterceptor diff --git a/test/modules/fetch/response/fetch.browser.test.ts b/test/modules/fetch/response/fetch.browser.test.ts index 0a5c266ed..ba6b560b3 100644 --- a/test/modules/fetch/response/fetch.browser.test.ts +++ b/test/modules/fetch/response/fetch.browser.test.ts @@ -1,8 +1,8 @@ import { HttpServer } from '@open-draft/test-server/http' import { Page } from '@playwright/test' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { useCors } from '#/test/helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch' declare namespace window { export const interceptor: FetchInterceptor diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index 0e3ff8b4d..11728f19c 100644 --- a/test/modules/http/compliance/events.test.ts +++ b/test/modules/http/compliance/events.test.ts @@ -1,9 +1,9 @@ // @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { HttpRequestEventMap } from '../../../../src/glossary' -import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { HttpRequestEventMap } from '#/src/glossary' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { diff --git a/test/modules/http/compliance/http-abort-controller.test.ts b/test/modules/http/compliance/http-abort-controller.test.ts index f95b4eaee..8bd007c13 100644 --- a/test/modules/http/compliance/http-abort-controller.test.ts +++ b/test/modules/http/compliance/http-abort-controller.test.ts @@ -3,8 +3,8 @@ import http from 'node:http' import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { sleep, toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { diff --git a/test/modules/http/compliance/http-custom-agent.test.ts b/test/modules/http/compliance/http-custom-agent.test.ts index 66fe63185..7d202bff1 100644 --- a/test/modules/http/compliance/http-custom-agent.test.ts +++ b/test/modules/http/compliance/http-custom-agent.test.ts @@ -2,8 +2,8 @@ import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index 290190ad7..176d7ea62 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { sleep, toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts index 62a921972..41575e00d 100644 --- a/test/modules/http/compliance/http-event-connect.test.ts +++ b/test/modules/http/compliance/http-event-connect.test.ts @@ -2,8 +2,8 @@ import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { 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 7b667a87e..afb2f3630 100644 --- a/test/modules/http/compliance/http-head-response-body.test.ts +++ b/test/modules/http/compliance/http-head-response-body.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-import.test.ts b/test/modules/http/compliance/http-import.test.ts index 71e3b628b..ae5365bac 100644 --- a/test/modules/http/compliance/http-import.test.ts +++ b/test/modules/http/compliance/http-import.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import * as http from 'node:http' import * as https from 'node:https' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() 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 12f1b4aa5..28e50cc75 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,8 +1,8 @@ // @vitest-environment node import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index 978033e57..31be20a38 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const server = new HttpServer((app) => { app.use('/user', (req, res) => { diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index 32bc45951..f9a4f2bac 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -2,7 +2,7 @@ import http from 'node:http' import rateLimit from 'express-rate-limit' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.use( diff --git a/test/modules/http/compliance/http-req-end-after-connect.test.ts b/test/modules/http/compliance/http-req-end-after-connect.test.ts index f2e1cce77..7e9d5ff36 100644 --- a/test/modules/http/compliance/http-req-end-after-connect.test.ts +++ b/test/modules/http/compliance/http-req-end-after-connect.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() 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 1dc9c8020..10e3a4d93 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,8 +1,8 @@ // @see https://github.com/nock/nock/issues/2826 import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { toWebResponse } from '../../../helpers' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-req-method.test.ts b/test/modules/http/compliance/http-req-method.test.ts index 830a797c9..6bd2ccc1a 100644 --- a/test/modules/http/compliance/http-req-method.test.ts +++ b/test/modules/http/compliance/http-req-method.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() 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 0a5dd78ad..0ab894757 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,8 +1,8 @@ // @vitest-environment node import { urlToHttpOptions } from 'node:url' import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } 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 18fe6ed9b..2784af518 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -4,8 +4,8 @@ import http from 'node:http' import express from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { sleep, toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.post('/resource', express.text({ type: '*/*' }), (req, res) => { diff --git a/test/modules/http/compliance/http-request-ipv6.test.ts b/test/modules/http/compliance/http-request-ipv6.test.ts index 7c196a6da..d2482a6d8 100644 --- a/test/modules/http/compliance/http-request-ipv6.test.ts +++ b/test/modules/http/compliance/http-request-ipv6.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { DeferredPromise } from '@open-draft/deferred-promise' -import { httpGet } from '../../../helpers' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { httpGet } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() 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 112bc6b41..e1d4bd364 100644 --- a/test/modules/http/compliance/http-request-without-options.test.ts +++ b/test/modules/http/compliance/http-request-without-options.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-res-callback.test.ts b/test/modules/http/compliance/http-res-callback.test.ts index 4122ebd2f..2f3584f9d 100644 --- a/test/modules/http/compliance/http-res-callback.test.ts +++ b/test/modules/http/compliance/http-res-callback.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/get', (req, res) => { diff --git a/test/modules/http/compliance/http-res-destroy.test.ts b/test/modules/http/compliance/http-res-destroy.test.ts index c6e9b113d..eb7367c6b 100644 --- a/test/modules/http/compliance/http-res-destroy.test.ts +++ b/test/modules/http/compliance/http-res-destroy.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/lib/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => res.sendStatus(200)) 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 310b2830c..597111acf 100644 --- a/test/modules/http/compliance/http-res-non-configurable.test.ts +++ b/test/modules/http/compliance/http-res-non-configurable.test.ts @@ -5,9 +5,9 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { FetchResponse } from '../../../../src/utils/fetchUtils' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { FetchResponse } from '#/src/utils/fetchUtils' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() 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 e1d584339..b7fd70893 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -3,8 +3,8 @@ */ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' // The actual server is here for A/B purpose only. const httpServer = new HttpServer((app) => { 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 5bd2979a5..0580da8c5 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 @@ -6,8 +6,8 @@ */ import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestEventMap } from '../../../../src' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestEventMap } from '#/src/index' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { 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 8d1efc49a..5396d3635 100644 --- a/test/modules/http/compliance/http-res-set-encoding.test.ts +++ b/test/modules/http/compliance/http-res-set-encoding.test.ts @@ -2,7 +2,7 @@ import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/resource', (request, res) => { 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 f8d962c07..8c3ed2a90 100644 --- a/test/modules/http/compliance/http-response-headers-folding.test.ts +++ b/test/modules/http/compliance/http-response-headers-folding.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-socket-listeners.test.ts b/test/modules/http/compliance/http-socket-listeners.test.ts index 9175b50b3..c91829103 100644 --- a/test/modules/http/compliance/http-socket-listeners.test.ts +++ b/test/modules/http/compliance/http-socket-listeners.test.ts @@ -7,8 +7,8 @@ import http from 'node:http' import { Socket } from 'node:net' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { diff --git a/test/modules/http/compliance/http-socket-reuse.test.ts b/test/modules/http/compliance/http-socket-reuse.test.ts index 6d0617ed9..4137f4f99 100644 --- a/test/modules/http/compliance/http-socket-reuse.test.ts +++ b/test/modules/http/compliance/http-socket-reuse.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/get', (req, res) => { diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index a4109cd00..17c2d0506 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -3,7 +3,7 @@ import https from 'node:https' import { TLSSocket } from 'node:tls' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts index abd16a814..03cc9a88c 100644 --- a/test/modules/http/compliance/http-timeout.test.ts +++ b/test/modules/http/compliance/http-timeout.test.ts @@ -2,7 +2,7 @@ import http from 'node:http' import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { diff --git a/test/modules/http/compliance/http-unhandled-exception.test.ts b/test/modules/http/compliance/http-unhandled-exception.test.ts index 7abc082e0..908658136 100644 --- a/test/modules/http/compliance/http-unhandled-exception.test.ts +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-unix-socket.test.ts b/test/modules/http/compliance/http-unix-socket.test.ts index 0543b2630..a3bfeb38c 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -6,8 +6,8 @@ import fs from 'node:fs' import path from 'node:path' import http from 'node:http' import { promisify } from 'node:util' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const HTTP_SOCKET_PATH = path.join(__dirname, './test-http.sock') diff --git a/test/modules/http/compliance/http-upgrade.test.ts b/test/modules/http/compliance/http-upgrade.test.ts index 7b1efd446..8d8f997fb 100644 --- a/test/modules/http/compliance/http-upgrade.test.ts +++ b/test/modules/http/compliance/http-upgrade.test.ts @@ -4,7 +4,7 @@ // @vitest-environment node-with-websocket import { Server } from 'socket.io' import { io } from 'socket.io-client' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() const server = new Server(51678) diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index 7bfbd2a48..366fc8590 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -3,8 +3,8 @@ 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index 9d989e826..7631141d2 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -7,8 +7,8 @@ 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { getIncomingMessageBody } from '#/src/interceptors/ClientRequest/utils/getIncomingMessageBody' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { diff --git a/test/modules/http/compliance/https-custom-agent.test.ts b/test/modules/http/compliance/https-custom-agent.test.ts index fff56a047..ba8669546 100644 --- a/test/modules/http/compliance/https-custom-agent.test.ts +++ b/test/modules/http/compliance/https-custom-agent.test.ts @@ -2,8 +2,8 @@ import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 3448fb70f..1584081e8 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index f21b80902..dce65856b 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' -import { httpGet, PromisifiedResponse, useCors } from '../../helpers' -import { HttpRequestInterceptor } from '../../../src/interceptors/http' +import { httpGet, PromisifiedResponse, useCors } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' function arrayWith(length: number, mapFn: (index: number) => V): V[] { return new Array(length).fill(null).map((_, index) => mapFn(index)) 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 a3986e5f0..312f6803e 100644 --- a/test/modules/http/intercept/http-client-request-agent.test.ts +++ b/test/modules/http/intercept/http-client-request-agent.test.ts @@ -7,7 +7,7 @@ import net from 'node:net' import http from 'node:http' import https from 'node:https' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index 5ac314d80..716c9f3ea 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -1,10 +1,10 @@ // @vitest-environment node import http from 'node:http' import { httpsAgent, HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestEventMap } from '../../../../src/glossary' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { RequestController } from '#/src/RequestController' +import { HttpRequestEventMap } from '#/src/glossary' const httpServer = new HttpServer((app) => { app.get('/user', (_req, res) => { diff --git a/test/modules/http/intercept/http-connect.test.ts b/test/modules/http/intercept/http-connect.test.ts index 3d29a2397..082798da8 100644 --- a/test/modules/http/intercept/http-connect.test.ts +++ b/test/modules/http/intercept/http-connect.test.ts @@ -7,8 +7,8 @@ import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' import { HttpsProxyAgent } from 'https-proxy-agent' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { waitForClientRequest } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 79c6c09c1..b797e35db 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,9 +1,9 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestEventMap } from '../../../../src/glossary' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { RequestController } from '#/src/RequestController' +import { HttpRequestEventMap } from '#/src/glossary' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index fb9346005..7ef55a67a 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -2,10 +2,10 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' -import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src/glossary' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { HttpRequestEventMap } from '#/src/glossary' +import { RequestController } from '#/src/RequestController' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 39c15336d..2447f7ddc 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,9 +1,9 @@ import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src/glossary' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { HttpRequestEventMap } from '#/src/glossary' +import { RequestController } from '#/src/RequestController' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index 701ea0f65..817f97a65 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -2,10 +2,10 @@ import https from 'node:https' import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' -import { REQUEST_ID_REGEXP, toWebResponse } from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { HttpRequestEventMap } from '#/src/index' +import { RequestController } from '#/src/RequestController' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (req, res) => { 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 44528a517..11f6e88ab 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,8 +1,8 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { httpGet } from '../../../helpers' -import { sleep } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { httpGet } from '#/test/helpers' +import { sleep } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', async (req, res) => { 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 5164346e0..d9fd65631 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -3,7 +3,7 @@ * @see https://github.com/mswjs/interceptors/issues/2 */ import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' let requests: Array = [] 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 625c45a21..36d543e32 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,7 +1,7 @@ // @vitest-environment node import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() 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 94979c947..25b059b43 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 @@ -4,8 +4,8 @@ */ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { 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 5e8cd2765..17787eaf2 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 @@ -6,7 +6,7 @@ import http from 'node:http' import path from 'node:path' import { HttpServer } from '@open-draft/test-server/http' import superagent from 'superagent' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/regressions/http-socket-timeout.ts b/test/modules/http/regressions/http-socket-timeout.ts index f0d4413d2..25dcde219 100644 --- a/test/modules/http/regressions/http-socket-timeout.ts +++ b/test/modules/http/regressions/http-socket-timeout.ts @@ -9,7 +9,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { diff --git a/test/modules/http/regressions/https-peer-certificate.test.ts b/test/modules/http/regressions/https-peer-certificate.test.ts index a639ef8cb..fb1f43fb8 100644 --- a/test/modules/http/regressions/https-peer-certificate.test.ts +++ b/test/modules/http/regressions/https-peer-certificate.test.ts @@ -5,8 +5,8 @@ import net from 'node:net' import tls from 'node:tls' import https from 'node:https' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() 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 fc168ac04..545b96083 100644 --- a/test/modules/http/response/http-await-response-event.test.ts +++ b/test/modules/http/response/http-await-response-event.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts index b51349dd3..fb4da7392 100644 --- a/test/modules/http/response/http-empty-response.test.ts +++ b/test/modules/http/response/http-empty-response.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import http from 'node:http' -import { toWebResponse } from '../../../helpers' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 3592e51d1..293ba246a 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -2,8 +2,8 @@ import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index df191b7a9..29d071732 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { sleep, toWebResponse } from '../../../helpers' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { sleep, toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index e2cc2fef7..6c0979a2e 100644 --- a/test/modules/http/response/http-response-error.test.ts +++ b/test/modules/http/response/http-response-error.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import http from 'node:http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 1767c7c1c..04a236f11 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { sleep, toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { sleep, toWebResponse } from '#/test/helpers' const server = new HttpServer((app) => { app.get('/original', async (req, res) => { 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 de5f94a5e..98b616ff3 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -4,8 +4,8 @@ import { Readable } from 'node:stream' import { performance } from 'node:perf_hooks' import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { toWebResponse } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> 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 000f5f6df..dcb40f775 100644 --- a/test/modules/http/response/http-response-transfer-encoding.test.ts +++ b/test/modules/http/response/http-response-transfer-encoding.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import http from 'node:http' -import { toWebResponse } from '../../../helpers' -import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/net/example.test.ts b/test/modules/net/example.test.ts index ea3994866..9cf25dbbd 100644 --- a/test/modules/net/example.test.ts +++ b/test/modules/net/example.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import net from 'node:net' -import { SocketInterceptor } from '../../../src/interceptors/net' +import { SocketInterceptor } from '#/src/interceptors/net' const interceptor = new SocketInterceptor() diff --git a/test/modules/net/socket-connect.test.ts b/test/modules/net/socket-connect.test.ts index 117d704a5..ff465d1c8 100644 --- a/test/modules/net/socket-connect.test.ts +++ b/test/modules/net/socket-connect.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import net from 'node:net' -import { SocketInterceptor } from '../../../src/interceptors/net' -import { createTestServer } from '../../helpers' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer } from '#/test/helpers' const interceptor = new SocketInterceptor() diff --git a/test/modules/net/socket-server-data.test.ts b/test/modules/net/socket-server-data.test.ts index 4443617b9..29c99cb83 100644 --- a/test/modules/net/socket-server-data.test.ts +++ b/test/modules/net/socket-server-data.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import net from 'node:net' -import { SocketInterceptor } from '../../../src/interceptors/net' +import { SocketInterceptor } from '#/src/interceptors/net' const interceptor = new SocketInterceptor() diff --git a/test/playwright.extend.ts b/test/playwright.extend.ts index 0a75cd10a..de0f77a1b 100644 --- a/test/playwright.extend.ts +++ b/test/playwright.extend.ts @@ -9,7 +9,7 @@ import { createBrowserXMLHttpRequest, createRawBrowserXMLHttpRequest, XMLHttpResponse, -} from './helpers' +} from '#/test/helpers' import { getWebpackHttpServer } from './webpackHttpServer' interface TestFixutures { diff --git a/test/third-party/axios.test.ts b/test/third-party/axios.test.ts index 27761f193..8ec7dfa2c 100644 --- a/test/third-party/axios.test.ts +++ b/test/third-party/axios.test.ts @@ -2,8 +2,8 @@ import axios from 'axios' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestInterceptor } from '../../src/interceptors/http' -import { useCors } from '../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { useCors } from '#/test/helpers' function createMockResponse() { return new Response( diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index a352eee98..f7097410e 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -1,8 +1,8 @@ // @vitest-environment node import { https } from 'follow-redirects' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../src/interceptors/http' -import { toWebResponse } from '../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/third-party/got.test.ts b/test/third-party/got.test.ts index f86e2d46a..3e42da9f0 100644 --- a/test/third-party/got.test.ts +++ b/test/third-party/got.test.ts @@ -1,7 +1,7 @@ import got from 'got' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../src/interceptors/http' -import { sleep } from '../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { sleep } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { diff --git a/test/third-party/miniflare-xhr.test.ts b/test/third-party/miniflare-xhr.test.ts index 3cba14e48..ee05c3ae8 100644 --- a/test/third-party/miniflare-xhr.test.ts +++ b/test/third-party/miniflare-xhr.test.ts @@ -1,5 +1,5 @@ // @vitest-environment miniflare -import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' let interceptor: XMLHttpRequestInterceptor diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index 958236a0a..d046beb51 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -1,9 +1,9 @@ // @vitest-environment miniflare -import { BatchInterceptor } from '../../src' -import { HttpRequestInterceptor } from '../../src/interceptors/http' -import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '../../src/interceptors/fetch' -import { httpGet, httpsGet } from '../helpers' +import { BatchInterceptor } from '#/src/index' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { FetchInterceptor } from '#/src/interceptors/fetch' +import { httpGet, httpsGet } from '#/test/helpers' const interceptor = new BatchInterceptor({ name: 'setup-server', diff --git a/test/third-party/node-fetch.test.ts b/test/third-party/node-fetch.test.ts index 14c261cfa..6657e6967 100644 --- a/test/third-party/node-fetch.test.ts +++ b/test/third-party/node-fetch.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import fetch from 'node-fetch' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestInterceptor } from '../../src/interceptors/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' diff --git a/test/third-party/supertest.test.ts b/test/third-party/supertest.test.ts index f4b7d4785..605cb018a 100644 --- a/test/third-party/supertest.test.ts +++ b/test/third-party/supertest.test.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom import express from 'express' import supertest from 'supertest' -import { HttpRequestEventMap } from '../../src' -import { HttpRequestInterceptor } from '../../src/interceptors/http' +import { HttpRequestEventMap } from '#/src/index' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const requestListener = vi.fn<(...args: HttpRequestEventMap['request']) => void>() From e67f97eac97c5415c83b8b876d4348dc2dd36a5c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 18:12:07 +0100 Subject: [PATCH 087/198] fix: delay write/end proxies until socket connects --- src/interceptors/net/socket-controller.ts | 34 ++++++- test/modules/net/socket-write.test.ts | 110 ++++++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 test/modules/net/socket-write.test.ts diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index b15ff7fd2..5f2a7caef 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -151,8 +151,16 @@ function toServerSocket(socket: T): T { // Push data to the client socket when server "socket.write()" is called. if (property === 'write') { return ((chunk, encoding, callback) => { - socket.push(toBuffer(chunk, encoding), encoding) - callback?.() + const translateWrite = () => { + socket.push(toBuffer(chunk, encoding), encoding) + callback?.() + } + + if (socket.connecting) { + socket.once('ready', () => translateWrite()) + } else { + translateWrite() + } }) as net.Socket['write'] } @@ -161,7 +169,22 @@ function toServerSocket(socket: T): T { const realEnd = getRealValue() as net.Socket['end'] return ((...args: Parameters) => { - socket.push(null) + const callback = args[args.length - 1] + + const translateEnd = () => { + socket.push(null) + + if (typeof callback === 'function') { + callback() + } + } + + if (socket.connecting) { + socket.once('ready', () => translateEnd()) + } else { + translateEnd() + } + return realEnd.apply(target, args) }) as net.Socket['end'] } @@ -188,6 +211,11 @@ export abstract class SocketController { this.readyState = SocketController.PENDING } + /** + * Claim this socket. Once claimed, the connection attempt succeeds + * regardless of the requested host and the interceptor becomes the + * mocked server for this connection. + */ public claim(): void { invariant( this.readyState !== SocketController.MOCKED, diff --git a/test/modules/net/socket-write.test.ts b/test/modules/net/socket-write.test.ts new file mode 100644 index 000000000..5654805f1 --- /dev/null +++ b/test/modules/net/socket-write.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('writes to a claimed socket', async () => { + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() + socket.write('hello from server') + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + socket.on('data', () => socket.destroy()) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['data', Buffer.from('hello from server')], + ['close', false], + ]) +}) + +it('writes and immediately ends a claimed socket', async () => { + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() + socket.write('hello from server') + socket.end() + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['data', Buffer.from('hello from server')], + ['end'], + ['close', false], + ]) +}) + +it('writes to a passthrough socket from the interceptor', async () => { + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.write('hello from server') + socket.end() + }) + }) + + interceptor.on('connection', ({ socket, controller }) => { + controller.passthrough() + socket.write('hello from interceptor') + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['data', Buffer.from('hello from interceptor')], + ['data', Buffer.from('hello from server')], + ['end'], + ['close', false], + ]) +}) + +it('writes and immediately ends a passthrough socket', async () => { + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.write('hello from server') + socket.end() + }) + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['data', Buffer.from('hello from server')], + ['end'], + ['close', false], + ]) +}) From f58e8133a78aa5ab66356a65d160402ae8d4a285 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 18:17:51 +0100 Subject: [PATCH 088/198] chore: disable `fail-fast` on ci for better observability --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 826e8d654..32c7d2c21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: build: runs-on: macos-latest strategy: + fail-fast: false matrix: node: [22, 24] steps: From 6a532a8a0b96d294ead78002be90e61195d9e085 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 18:39:15 +0100 Subject: [PATCH 089/198] fix: error on handling already handled connection --- src/interceptors/net/socket-controller.ts | 18 ++-- .../net/socket-controller-claim.test.ts | 91 +++++++++++++++++++ 2 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 test/modules/net/socket-controller-claim.test.ts diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 5f2a7caef..1e3404eb9 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -196,12 +196,12 @@ function toServerSocket(socket: T): T { export abstract class SocketController { static PENDING = 0 as const - static MOCKED = 1 as const + static CLAIMED = 1 as const static PASSTHROUGH = 2 as const protected readyState: | typeof SocketController.PENDING - | typeof SocketController.MOCKED + | typeof SocketController.CLAIMED | typeof SocketController.PASSTHROUGH private [kRawSocket]: net.Socket @@ -218,19 +218,21 @@ export abstract class SocketController { */ public claim(): void { invariant( - this.readyState !== SocketController.MOCKED, - 'Failed to claim a TLS socket: already claimed' + this.readyState === SocketController.PENDING, + 'Failed to claim a socket connection: already handled (%s)', + this.readyState ) - this.readyState = SocketController.MOCKED + this.readyState = SocketController.CLAIMED } public abstract errorWith(reason?: Error): void public passthrough(): void { invariant( - this.readyState !== SocketController.PASSTHROUGH, - 'Failed to passthrough a TLS socket: already passthrough' + this.readyState === SocketController.PENDING, + 'Failed to passthrough a socket connection: already handled (%s)', + this.readyState ) this.readyState = SocketController.PASSTHROUGH @@ -373,7 +375,7 @@ export class TcpSocketController extends SocketController { * past this point will result in "Error: write EBADF". * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 */ - if (this.readyState === SocketController.MOCKED) { + if (this.readyState === SocketController.CLAIMED) { const callback = args[3] // Mock connection still means the socket emits the "connect" event diff --git a/test/modules/net/socket-controller-claim.test.ts b/test/modules/net/socket-controller-claim.test.ts new file mode 100644 index 000000000..f9fe143b2 --- /dev/null +++ b/test/modules/net/socket-controller-claim.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('resolves the connection attempt when the socket is claimed', async () => { + interceptor.on('connection', ({ controller }) => { + controller.claim() + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + socket.on('connect', () => socket.destroy()) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['close', false], + ]) +}) + +it('throws an error claiming an already claimed connection', async () => { + expect.assertions(3) + + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() + + expect(() => controller.claim()).toThrow( + `Failed to claim a socket connection: already handled (1)` + ) + + socket.end() + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['end'], + ['close', false], + ]) +}) + +it('throws an error claiming an already passthrough connection', async () => { + expect.assertions(3) + + interceptor.on('connection', ({ controller }) => { + controller.passthrough() + + expect(() => controller.claim()).toThrow( + `Failed to claim a socket connection: already handled (2)` + ) + }) + + await using server = await createTestServer(() => { + return new net.Server((socket) => socket.end()) + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['end'], + ['close', false], + ]) +}) From 971185a9f8b4fd9079723548dbc31e3b5cf5e3d0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 18:39:23 +0100 Subject: [PATCH 090/198] test: add server `socket.end()` test --- test/modules/net/socket-server-end.test.ts | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 test/modules/net/socket-server-end.test.ts diff --git a/test/modules/net/socket-server-end.test.ts b/test/modules/net/socket-server-end.test.ts new file mode 100644 index 000000000..402a4d2f9 --- /dev/null +++ b/test/modules/net/socket-server-end.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('ends the connection the same way the actual server does', async () => { + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() + socket.end() + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['end'], + ['close', false], + ]) +}) From 0513339069e6ccad657303827298509849a93151 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 28 Feb 2026 23:35:29 +0100 Subject: [PATCH 091/198] test: tidy up net tests --- .../net/compliance/socket-events.test.ts | 49 +++++ test/modules/net/example.test.ts | 115 ------------ test/modules/net/socket-connect.test.ts | 175 ------------------ test/modules/net/socket-server-data.test.ts | 30 +-- 4 files changed, 64 insertions(+), 305 deletions(-) create mode 100644 test/modules/net/compliance/socket-events.test.ts delete mode 100644 test/modules/net/example.test.ts delete mode 100644 test/modules/net/socket-connect.test.ts diff --git a/test/modules/net/compliance/socket-events.test.ts b/test/modules/net/compliance/socket-events.test.ts new file mode 100644 index 000000000..4a6935f75 --- /dev/null +++ b/test/modules/net/compliance/socket-events.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('emits correct events for a passthrough connection', async () => { + const serverConnectionListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + serverConnectionListener(socket) + socket.end() + }) + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners, events } = spyOnSocket(socket) + + socket.resume() + + expect(socket.connecting).toBe(true) + + await expect.poll(() => listeners.connect).toHaveBeenCalledOnce() + expect.soft(socket.connecting).toBe(false) + expect + .soft(events) + .toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['end'], + ['close', false], + ]) + + expect(socket.connecting).toBe(false) +}) diff --git a/test/modules/net/example.test.ts b/test/modules/net/example.test.ts deleted file mode 100644 index 9cf25dbbd..000000000 --- a/test/modules/net/example.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// @vitest-environment node -import net from 'node:net' -import { SocketInterceptor } from '#/src/interceptors/net' - -const interceptor = new SocketInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('mocks the intercepted connection', async () => { - const serverDataListener = vi.fn() - - interceptor.on('connection', ({ socket, controller }) => { - controller.claim() - - socket.write('hello from server') - socket.on('data', serverDataListener) - }) - - const connectionListener = vi.fn() - const socket = net.connect(3000, '127.0.0.1', connectionListener) - - const errorListener = vi.fn() - socket.on('error', errorListener) - - const clientDataListener = vi.fn() - socket.on('data', clientDataListener) - - socket.on('connect', () => { - socket.write('hello from client') - }) - - await expect.poll(() => connectionListener).toHaveBeenCalled() - expect(errorListener).not.toHaveBeenCalled() - expect(serverDataListener).toHaveBeenCalledExactlyOnceWith( - Buffer.from('hello from client') - ) - expect(clientDataListener).toHaveBeenCalledExactlyOnceWith( - Buffer.from('hello from server') - ) -}) - -it('errors the intercepted socket before it connects', async () => { - const reason = new Error('Custom reason') - interceptor.on('connection', ({ controller }) => { - controller.errorWith(reason) - }) - - const connectionCallback = vi.fn() - const socket = net.connect(3000, '127.0.0.1', connectionCallback) - - const connectListener = vi.fn() - const errorListener = vi.fn() - const closeListener = vi.fn() - socket.on('connect', connectListener) - socket.on('error', errorListener) - socket.on('close', closeListener) - - await expect.poll(() => errorListener).toHaveBeenCalled() - expect.soft(errorListener).toHaveBeenCalledExactlyOnceWith(reason) - expect.soft(closeListener).toHaveBeenCalledExactlyOnceWith(true) - expect.soft(connectListener).not.toHaveBeenCalled() - expect.soft(connectionCallback).not.toHaveBeenCalled() -}) - -it('supports bypassing the intercepted connection to a non-existing host', async () => { - const realSocketConnectListener = vi.fn() - const realSocketErrorListener = vi.fn() - const realSocketCloseListener = vi.fn() - - interceptor.on('connection', ({ socket, controller }) => { - const realSocket = controller.passthrough() - realSocket.on('connect', realSocketConnectListener) - realSocket.on('error', realSocketErrorListener) - realSocket.on('close', realSocketCloseListener) - }) - - const connectionCallback = vi.fn() - const socket = net.connect(3000, '127.0.0.1', connectionCallback) - - const clientErrorListener = vi.fn() - socket.on('error', clientErrorListener) - - const clientDataListener = vi.fn() - socket.on('data', clientDataListener) - - const clientConnectListener = vi.fn() - socket.on('connect', clientConnectListener) - - await expect - .poll(() => realSocketErrorListener) - .toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - code: 'ECONNREFUSED', - port: 3000, - address: '127.0.0.1', - message: 'connect ECONNREFUSED 127.0.0.1:3000', - }) - ) - expect(realSocketCloseListener).toHaveBeenCalledExactlyOnceWith(true) - expect(realSocketConnectListener).not.toHaveBeenCalled() - - expect(clientErrorListener).toHaveBeenCalled() - expect(connectionCallback).not.toHaveBeenCalled() - expect(clientConnectListener).not.toHaveBeenCalled() -}) diff --git a/test/modules/net/socket-connect.test.ts b/test/modules/net/socket-connect.test.ts deleted file mode 100644 index ff465d1c8..000000000 --- a/test/modules/net/socket-connect.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -// @vitest-environment node -import net from 'node:net' -import { SocketInterceptor } from '#/src/interceptors/net' -import { createTestServer } from '#/test/helpers' - -const interceptor = new SocketInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('emits the "connect" event for a mocked socket', async () => { - const interceptorConnectionListener = vi.fn() - interceptor.on('connection', ({ socket, controller }) => { - interceptorConnectionListener(socket) - controller.claim() - socket.end() - }) - - const socket = net.connect(80, '127.0.0.1') - - const connectListener = vi.fn() - const errorListener = vi.fn() - const endListener = vi.fn() - const closeListener = vi.fn() - socket - .on('connect', connectListener) - .on('error', errorListener) - .on('end', endListener) - .on('close', closeListener) - - socket.resume() - - expect(socket.connecting).toBe(true) - - await expect.poll(() => connectListener).toHaveBeenCalledOnce() - expect.soft(socket.connecting).toBe(false) - expect.soft(errorListener).not.toHaveBeenCalled() - expect - .soft(interceptorConnectionListener) - .toHaveBeenCalledExactlyOnceWith(expect.any(net.Socket)) - - await expect.poll(() => endListener).toHaveBeenCalledOnce() - await expect.poll(() => closeListener).toHaveBeenCalledOnce() - expect(socket.connecting).toBe(false) -}) - -it('emits the "connect" event for a passthrough socket', async () => { - const serverConnectionListener = vi.fn() - await using server = await createTestServer(() => { - return new net.Server((socket) => { - serverConnectionListener(socket) - socket.end() - }) - }) - - const socket = net.connect(server.port, server.hostname) - - const connectListener = vi.fn() - const errorListener = vi.fn() - const endListener = vi.fn() - const closeListener = vi.fn() - socket - .on('connect', connectListener) - .on('error', errorListener) - .on('end', endListener) - .on('close', closeListener) - - socket.resume() - - expect(socket.connecting).toBe(true) - - await expect.poll(() => connectListener).toHaveBeenCalledOnce() - expect.soft(socket.connecting).toBe(false) - expect.soft(errorListener).not.toHaveBeenCalled() - expect - .soft(serverConnectionListener) - .toHaveBeenCalledExactlyOnceWith(expect.any(net.Socket)) - - await expect.poll(() => endListener).toHaveBeenCalledOnce() - await expect.poll(() => closeListener).toHaveBeenCalledOnce() - expect(socket.connecting).toBe(false) -}) - -it('does not emit the "connect" event for a mocked socket if controller errors the connection', async () => { - const interceptorConnectionListener = vi.fn() - interceptor.on('connection', ({ socket, controller }) => { - interceptorConnectionListener(socket) - controller.errorWith(new Error('Custom reason')) - }) - - const socket = net.connect(80, '127.0.0.1') - - const connectListener = vi.fn() - const errorListener = vi.fn() - const endListener = vi.fn() - const closeListener = vi.fn() - socket - .on('connect', connectListener) - .on('error', errorListener) - .on('end', endListener) - .on('close', closeListener) - - socket.resume() - - expect(socket.connecting).toBe(true) - - await expect - .poll(() => errorListener) - .toHaveBeenCalledExactlyOnceWith(new Error('Custom reason')) - expect.soft(socket.connecting).toBe(false) - expect.soft(connectListener).not.toHaveBeenCalled() - expect.soft(endListener).not.toHaveBeenCalled() - expect - .soft(interceptorConnectionListener) - .toHaveBeenCalledExactlyOnceWith(expect.any(net.Socket)) - - await expect.poll(() => closeListener).toHaveBeenCalledOnce() - expect(socket.connecting).toBe(false) -}) - -it('does not emit the "connect" event for a passthrough socket if controller errors the connection', async () => { - const interceptorConnectionListener = vi.fn() - interceptor.on('connection', ({ socket, controller }) => { - interceptorConnectionListener(socket) - controller.errorWith(new Error('Custom reason')) - }) - - const serverConnectionListener = vi.fn() - await using server = await createTestServer(() => { - return new net.Server((socket) => { - serverConnectionListener(socket) - socket.end() - }) - }) - - const socket = net.connect(server.port, server.hostname) - - const connectListener = vi.fn() - const errorListener = vi.fn() - const endListener = vi.fn() - const closeListener = vi.fn() - socket - .on('connect', connectListener) - .on('error', errorListener) - .on('end', endListener) - .on('close', closeListener) - - socket.resume() - - expect(socket.connecting).toBe(true) - - await expect - .poll(() => errorListener) - .toHaveBeenCalledExactlyOnceWith(new Error('Custom reason')) - - expect.soft(socket.connecting).toBe(false) - expect.soft(connectListener).not.toHaveBeenCalled() - expect.soft(serverConnectionListener).not.toHaveBeenCalled() - expect.soft(endListener).not.toHaveBeenCalled() - expect - .soft(interceptorConnectionListener) - .toHaveBeenCalledExactlyOnceWith(expect.any(net.Socket)) - - await expect.poll(() => closeListener).toHaveBeenCalledOnce() - expect(socket.connecting).toBe(false) -}) diff --git a/test/modules/net/socket-server-data.test.ts b/test/modules/net/socket-server-data.test.ts index 29c99cb83..449c3dff7 100644 --- a/test/modules/net/socket-server-data.test.ts +++ b/test/modules/net/socket-server-data.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node import net from 'node:net' import { SocketInterceptor } from '#/src/interceptors/net' +import { spyOnSocket } from '#/test/helpers' const interceptor = new SocketInterceptor() @@ -24,12 +25,12 @@ it('emits "data" on the server socket for writes before connect', async () => { }) const socket = net.connect(80, 'any.host.com') - const closeListener = vi.fn() + const { listeners, events } = spyOnSocket(socket) - socket.on('close', closeListener) socket.write('hello', () => socket.destroy()) - await expect.poll(() => closeListener).toHaveBeenCalledOnce() + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([['close', false]]) await expect .poll(() => interceptorDataListener) .toHaveBeenCalledExactlyOnceWith(Buffer.from('hello')) @@ -43,16 +44,15 @@ it('emits "data" on the server socket for writes after connect', async () => { }) const socket = net.connect(80, 'any.host.com') - const closeListener = vi.fn() - - socket - .on('connect', () => { - socket.write('hello', () => socket.destroy()) - }) - .on('close', closeListener) - - await expect.poll(() => closeListener).toHaveBeenCalledOnce() - expect(interceptorDataListener).toHaveBeenCalledExactlyOnceWith( - Buffer.from('hello') - ) + const { listeners, events } = spyOnSocket(socket) + + socket.on('connect', () => { + socket.write('hello', () => socket.destroy()) + }) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([['connect'], ['close', false]]) + await expect + .poll(() => interceptorDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('hello')) }) From 89a34996a525847d3583090137a4e2efcf27814b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Mar 2026 12:00:17 +0100 Subject: [PATCH 092/198] chore: remove `controller.errorWith` in favor of `socket.destroy` --- src/interceptors/http/index.ts | 2 +- src/interceptors/net/socket-controller.ts | 6 ------ ...-with.test.ts => socket-server-destroy.test.ts} | 14 +++++++------- 3 files changed, 8 insertions(+), 14 deletions(-) rename test/modules/net/{socket-controller-error-with.test.ts => socket-server-destroy.test.ts} (89%) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 36ffac6ad..76ece3566 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -136,7 +136,7 @@ export class HttpRequestInterceptor extends Interceptor { }, errorWith: (reason) => { if (reason instanceof Error) { - socketController.errorWith(reason) + socket.destroy(reason) } }, passthrough: () => { diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 1e3404eb9..70e232731 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -226,8 +226,6 @@ export abstract class SocketController { this.readyState = SocketController.CLAIMED } - public abstract errorWith(reason?: Error): void - public passthrough(): void { invariant( this.readyState === SocketController.PENDING, @@ -503,10 +501,6 @@ export class TcpSocketController extends SocketController { }) } - public errorWith(reason?: Error): void { - this.socket.destroy(reason) - } - public passthrough( flushPendingData?: ( data: NonNullable, diff --git a/test/modules/net/socket-controller-error-with.test.ts b/test/modules/net/socket-server-destroy.test.ts similarity index 89% rename from test/modules/net/socket-controller-error-with.test.ts rename to test/modules/net/socket-server-destroy.test.ts index 5067da160..e3be5e7a0 100644 --- a/test/modules/net/socket-controller-error-with.test.ts +++ b/test/modules/net/socket-server-destroy.test.ts @@ -19,8 +19,8 @@ afterAll(() => { it('ends the connection before it is open', async () => { const reason = new Error('Custom reason') - interceptor.on('connection', ({ controller }) => { - controller.errorWith(reason) + interceptor.on('connection', ({ socket }) => { + socket.destroy(reason) }) const socket = net.connect(80, '127.0.0.1') @@ -38,7 +38,7 @@ it('ends the connection before it is open', async () => { it('ends a mocked connection after it is open', async () => { const reason = new Error('Custom reason') interceptor.on('connection', ({ socket, controller }) => { - socket.on('connect', () => controller.errorWith(reason)) + socket.on('connect', () => socket.destroy(reason)) controller.claim() }) @@ -58,7 +58,7 @@ it('ends a mocked connection after it is open', async () => { it('ends a passthrough connection after it is open', async () => { const reason = new Error('Custom reason') interceptor.on('connection', ({ socket, controller }) => { - socket.on('connect', () => controller.errorWith(reason)) + socket.on('connect', () => socket.destroy(reason)) controller.passthrough() }) @@ -84,7 +84,7 @@ it('ends a passthrough connection after it is open', async () => { it('ends the connection during a write', async () => { const reason = new Error('Custom reason') interceptor.on('connection', ({ socket, controller }) => { - socket.on('data', () => controller.errorWith(reason)) + socket.on('data', () => socket.destroy(reason)) controller.claim() }) @@ -107,8 +107,8 @@ it('ends the connection during a write', async () => { it('has no effect if the client closed the connection', async () => { const serverReason = new Error('Server reason') - interceptor.on('connection', ({ controller }) => { - controller.errorWith(serverReason) + interceptor.on('connection', ({ socket }) => { + socket.destroy(serverReason) }) const socket = net.connect(80, '127.0.0.1') From f5b83fa8d0331d2960aa255f351371f1b2aaaba4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 1 Mar 2026 13:49:50 +0100 Subject: [PATCH 093/198] fix(wip): forward passthrough writes to the server --- src/interceptors/net/socket-controller.ts | 100 ++++++++++++----- .../http/compliance/http-req-write.test.ts | 10 +- .../net/compliance/socket-write.test.ts | 102 ++++++++++++++++++ ...te.test.ts => socket-server-write.test.ts} | 0 4 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 test/modules/net/compliance/socket-write.test.ts rename test/modules/net/{socket-write.test.ts => socket-server-write.test.ts} (100%) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 70e232731..03f964efb 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -7,6 +7,7 @@ import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' import { NetworkConnectionOptions } from './utils/normalize-net-connect-args' import { getAddressInfoByConnectionOptions } from './utils/address-info' +import { applyPatch } from '#/src/utils/apply-patch' const kListenerWrap = Symbol('kListenerWrap') @@ -237,6 +238,12 @@ export abstract class SocketController { } } +type FlushPendingDataFunction = ( + data: NonNullable, + encoding: BufferEncoding | undefined, + callback: (data: NonNullable) => void +) => void + export class TcpSocketController extends SocketController { public serverSocket: net.Socket @@ -277,10 +284,11 @@ export class TcpSocketController extends SocketController { */ socket .on('free', () => { - log('socket has been freed!') + log('client socket freed!') this.#reset() }) .on('close', () => { + log('client socket closed!') this.#passthroughSocket = null this.#passthroughPausedBuffer = [] }) @@ -341,10 +349,22 @@ export class TcpSocketController extends SocketController { log('socket write:', args, this.readyState) if (this.readyState === SocketController.PENDING) { + log('write while pending...') + // Socket might write immediately, before the "connection" interceptor event is emitted. // In those cases, schedule the emit on the next tick to ensure the server socket emits "data". if (this.socket.listenerCount('internal:write') === 0) { - process.nextTick(() => this.#push(args[1])) + log('no server write listeners, scheduling to the next tick...') + + process.nextTick(() => { + /** + * @note If the socket has been handled in any way, skip this forwarding. + * Both claimed and passthrough scenario are forwarded below. + */ + if (this.readyState === SocketController.PENDING) { + this.#push(args[1]) + } + }) } else { this.#push(args[1]) } @@ -354,6 +374,8 @@ export class TcpSocketController extends SocketController { * This prevents the socket from getting stuck when calling ".end()" in a write callback. */ if (typeof args[3] === 'function') { + log('found a write callback while pending, executing...') + args[3]() /** @@ -374,6 +396,8 @@ export class TcpSocketController extends SocketController { * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 */ if (this.readyState === SocketController.CLAIMED) { + log('write while claimed...') + const callback = args[3] // Mock connection still means the socket emits the "connect" event @@ -392,7 +416,9 @@ export class TcpSocketController extends SocketController { return } - log('writing to passthrough:', args) + log('write while passthrough...') + + this.#push(args[1]) return this.#realWriteGeneric.apply(this.socket, args) } } @@ -415,6 +441,8 @@ export class TcpSocketController extends SocketController { } unwrapPendingData(data, (chunk, encoding) => { + log('emitting "data" on the server socket...', { chunk, encoding }) + this.socket.emit('internal:write', chunk, encoding) }) } @@ -501,13 +529,7 @@ export class TcpSocketController extends SocketController { }) } - public passthrough( - flushPendingData?: ( - data: NonNullable, - encoding: BufferEncoding | undefined, - callback: (data: NonNullable) => void - ) => void - ): net.Socket { + public passthrough(flushPendingData?: FlushPendingDataFunction): net.Socket { super.passthrough() log('passthrough!') @@ -516,24 +538,44 @@ export class TcpSocketController extends SocketController { * @note Modify the pending data to be flushed to the passthrough socket. * In HTTP, this allows sending different request headers (e.g. modified in the listener). */ - if (typeof flushPendingData === 'function') { - // Intentionally grab the latest write method to preserve whatever patches it has. - const realSocketWriteGeneric = this.socket._writeGeneric + if ( + typeof flushPendingData === 'function' && + this.socket._pendingData != null + ) { + log('has pending data and a custom flush function, applying...') + + const revertWriteGenericPatch = applyPatch( + this.socket, + '_writeGeneric', + (clientWriteGeneric) => { + return (writev, data, encoding, callback) => { + if (this.socket._pendingData) { + flushPendingData(data, encoding, (nextData) => { + log('flushing modified pending data...', nextData) + + /** + * @note Call the unpatched "_writeGeneric" to prevent these writes + * from emitting the "data" event on the server socket. These writes + * have already been forwarded to the server socket while pending. + */ + this.#realWriteGeneric.call( + this.socket, + writev, + nextData, + encoding, + callback + ) + }) + + log('restoring the flush pending data patch...') + revertWriteGenericPatch() + return + } - this.socket._writeGeneric = (writev, data, encoding, callback) => { - /** - * @note The scheduled write on "connect" will set "_pendingData" to null. - * @see https://github.com/nodejs/node/blob/6b5178f77b5d1f5d2adef8a1a092febe171cab80/lib/net.js#L1011 - */ - if (this.socket._pendingData) { - flushPendingData(data, encoding, (nextData) => { - realSocketWriteGeneric(writev, nextData, encoding, callback) - }) - return + return clientWriteGeneric(writev, data, encoding, callback) + } } - - realSocketWriteGeneric(writev, data, encoding, callback) - } + ) } const createNewSocket = () => { @@ -652,8 +694,10 @@ export class TlsSocketController extends TcpSocketController { super.claim() } - public passthrough(): tls.TLSSocket { - const realSocket = super.passthrough() as tls.TLSSocket + public passthrough( + flushPendingData?: FlushPendingDataFunction + ): tls.TLSSocket { + const realSocket = super.passthrough(flushPendingData) as tls.TLSSocket /** * @note Remove the internal "connect" listener added by the TLS socket. diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 2784af518..b1ad7328e 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,11 +1,12 @@ // @vitest-environment node import { Readable } from 'node:stream' import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import express from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { sleep, toWebResponse } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.post('/resource', express.text({ type: '*/*' }), (req, res) => { @@ -118,7 +119,7 @@ it('supports Readable as the request body', async () => { const input = ['hello', ' ', 'world', null] const readable = new Readable({ read: async function () { - await sleep(10) + await setTimeout(10) this.push(input.shift()) }, }) @@ -232,8 +233,6 @@ it('supports ending a mocked request in a write callback', async () => { * @see https://github.com/mswjs/interceptors/issues/684 */ it('supports ending a bypassed request in a write callback', async () => { - const requestWriteCallback = vi.fn() - const request = http.request(httpServer.http.url('/resource'), { method: 'POST', headers: { 'content-type': 'text/plain' }, @@ -280,10 +279,7 @@ it('calls the write callbacks when reading request body in the interceptor', asy const [response] = await toWebResponse(request) - // Must call each write callback once. expect(requestWriteCallback).toHaveBeenCalledTimes(3) - // Must be able to read the request stream in the interceptor. expect(requestBodyCallback).toHaveBeenCalledWith('onetwothree') - // Must send the correct request body to the server. await expect(response.text()).resolves.toBe('onetwothree') }) diff --git a/test/modules/net/compliance/socket-write.test.ts b/test/modules/net/compliance/socket-write.test.ts new file mode 100644 index 000000000..5e94c29bd --- /dev/null +++ b/test/modules/net/compliance/socket-write.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' +import { setTimeout } from 'node:timers/promises' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts buffered writes to the original server', async () => { + const serverDataListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', serverDataListener) + }) + }) + + const interceptorDataListener = vi.fn() + interceptor.on('connection', ({ socket, controller }) => { + controller.passthrough() + socket.on('data', interceptorDataListener) + }) + + const socket = net.connect(server.port, server.hostname) + + // Writing multiple chunks before socket connects buffers them into a single write. + socket.write('hello ') + socket.write('from ') + socket.end('client') + + await expect + .poll(() => serverDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('hello from client')) + + // Interceptor "data" events aren't buffered since the connection is pending. + expect.soft(interceptorDataListener).toHaveBeenCalledTimes(3) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) +}) + +it('intercepts separate writes to the original server', async () => { + const serverDataListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', serverDataListener) + }) + }) + + const interceptorDataListener = vi.fn() + interceptor.on('connection', ({ socket, controller }) => { + controller.passthrough() + socket.on('data', interceptorDataListener) + }) + + const socket = net.connect(server.port, server.hostname) + + socket.write('hello ') + await setTimeout(20) + socket.write('from ') + await setTimeout(20) + socket.end('client') + + await expect.poll(() => serverDataListener).toHaveBeenCalledTimes(3) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) + + expect.soft(interceptorDataListener).toHaveBeenCalledTimes(3) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) +}) diff --git a/test/modules/net/socket-write.test.ts b/test/modules/net/socket-server-write.test.ts similarity index 100% rename from test/modules/net/socket-write.test.ts rename to test/modules/net/socket-server-write.test.ts From 4ed4a5b0faffd2d7021e0501b207f30cb271c2b0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 2 Mar 2026 13:09:41 +0100 Subject: [PATCH 094/198] fix(wip): `_writeGeneric` for each readyState, server `data` forwarding --- src/interceptors/http/http-parser.ts | 6 +- src/interceptors/http/index.ts | 180 ++++++----- src/interceptors/net/socket-controller.ts | 292 +++++++++++------- src/utils/bufferUtils.ts | 1 + .../http/compliance/http-req-write.test.ts | 83 +++-- .../http/compliance/http-socket-reuse.test.ts | 72 ++++- .../http/compliance/http-upgrade.test.ts | 5 +- .../net/compliance/socket-write.test.ts | 58 +++- 8 files changed, 453 insertions(+), 244 deletions(-) diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index 36ab3bb50..0e3e5cc29 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -204,11 +204,11 @@ export class HttpResponseParser extends HttpParser { ...(rawHeaders || []), ]) + this.#responseBodyStream = new Readable({ read() {} }) + const response = new FetchResponse( FetchResponse.isResponseWithBody(status) - ? (Readable.toWeb( - (this.#responseBodyStream = new Readable({ read() {} })) - ) as any) + ? (Readable.toWeb(this.#responseBodyStream) as any) : null, { url, diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 76ece3566..c0d041eff 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -1,6 +1,11 @@ import net from 'node:net' import { Readable } from 'node:stream' -import { STATUS_CODES, ServerResponse, IncomingMessage } from 'node:http' +import { + METHODS, + STATUS_CODES, + ServerResponse, + IncomingMessage, +} from 'node:http' import type { ReadableStream } from 'node:stream/web' import { pipeline } from 'node:stream/promises' import { invariant } from 'outvariant' @@ -21,7 +26,11 @@ import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' -import { kRawSocket } from '../net/socket-controller' +import { + kRawSocket, + SocketController, + type FlushPendingDataFunction, +} from '../net/socket-controller' import { unwrapPendingData } from '../net/utils/flush-writes' import { FetchResponse } from '../../utils/fetchUtils' import { requestContext } from '../../request-context' @@ -46,15 +55,18 @@ export class HttpRequestInterceptor extends Interceptor { socketInterceptor.on( 'connection', ({ connectionOptions, socket, controller: socketController }) => { + /** + * @note Only listen to the first sent packet. + * A single socket cannot be used for different protocols. + */ socket.once('data', (chunk) => { const httpMessage = chunk.toString() - const httpMethod = httpMessage.split(' ')[0] + const httpMethod = httpMessage.split(' ')[0] || '' - invariant( - httpMethod != null, - 'Failed to handle an HTTP request: expected a valid HTTP method but got "%s"', - httpMethod - ) + // Ignore non-HTTP packets sent via this socket. + if (!METHODS.includes(httpMethod.toUpperCase())) { + return + } const baseUrl = connectionOptionsToUrl(connectionOptions, socket) @@ -94,8 +106,8 @@ export class HttpRequestInterceptor extends Interceptor { FetchResponse.setUrl(request.url, response) - const respond = async () => { - await this.respondWith({ + const respond = () => { + return this.respondWith({ socket: socketController[kRawSocket], request, response, @@ -103,6 +115,8 @@ export class HttpRequestInterceptor extends Interceptor { } if (socket.connecting) { + // Send a mocked response once the socket connects, just like the real server would. + // This preserves the correct order of events (e.g. connect, then data). socket.once('connect', respond) } else { /** @@ -123,14 +137,12 @@ export class HttpRequestInterceptor extends Interceptor { ) { const responseClone = response.clone() - process.nextTick(async () => { - await emitAsync(this.emitter, 'response', { - initiator, - requestId, - request, - response: responseClone, - isMockedResponse: true, - }) + await emitAsync(this.emitter, 'response', { + initiator, + requestId, + request, + response: responseClone, + isMockedResponse: true, }) } }, @@ -140,74 +152,16 @@ export class HttpRequestInterceptor extends Interceptor { } }, passthrough: () => { - const transformRequestMessage = ( - httpMessage: string | Buffer, - encoding?: BufferEncoding | 'buffer' - ): string | Buffer => { - /** - * @note Socket can write a buffer (e.g. uploaded file) even before - * it writes the HTTP message. Bypass those cases. - */ - if (encoding === 'buffer') { - return httpMessage - } - - const parts = httpMessage.toString(encoding).split('\r\n') - const headersEndIndex = parts.findIndex( - (field) => field === '' - ) - const httpMessageHeaderPairs = parts.slice( - 1, - headersEndIndex - ) - const httpMessageHeaders = FetchResponse.parseRawHeaders( - httpMessageHeaderPairs.flatMap((header) => - header.split(': ') - ) - ) - - const rawHeaders = getRawFetchHeaders(request.headers) - - for (const [name, value] of rawHeaders) { - httpMessageHeaders.set(name, value) - } - - const httpMessageHeadersString = Array.from( - httpMessageHeaders - ) - .map(([name, value]) => `${name}: ${value}`) - .join('\r\n') - parts.splice( - 1, - headersEndIndex - 1, - httpMessageHeadersString - ) - - return parts.join('\r\n') - } - const realSocket = socketController.passthrough( /** * @todo Would be great NOT to run this if request headers weren't modified. */ - (pendingData, encoding, callback) => { - if (Array.isArray(pendingData)) { - pendingData[0].chunk = transformRequestMessage( - pendingData[0].chunk, - pendingData[0].encoding - ) - } else { - pendingData = transformRequestMessage( - pendingData, - encoding - ) - } - - callback(pendingData) - } + this.#modifyHttpHeaders(request) ) if (this.emitter.listenerCount('response')) { + log('found "response" listener, pausing socket...') + const mockSocket = socketController[kRawSocket] // Pause the mock socket to prevent the passthrough 'data' listener @@ -218,13 +172,24 @@ export class HttpRequestInterceptor extends Interceptor { const responseParser = new HttpResponseParser({ onResponse: async (response) => { + log( + 'http response parser parsed a response!', + response.status, + response.statusText + ) + if (isResponseError(response)) { + log( + 'response is an error response, resuming socket...' + ) + mockSocket.resume() return } FetchResponse.setUrl(request.url, response) + log('emitting "response" event...') await emitAsync(this.emitter, 'response', { initiator, requestId, @@ -233,6 +198,7 @@ export class HttpRequestInterceptor extends Interceptor { isMockedResponse: false, }) + log('resuming socket...') mockSocket.resume() }, }) @@ -244,6 +210,14 @@ export class HttpRequestInterceptor extends Interceptor { }, }) + invariant( + socketController['readyState'] === SocketController.PENDING, + 'CANNOT HANDLE ALREADY HANDLED REQUEST', + request.method, + request.url, + socketController['readyState'] + ) + await handleRequest({ initiator, request, @@ -345,4 +319,52 @@ export class HttpRequestInterceptor extends Interceptor { socket.push(null) } } + + #modifyHttpHeaders(request: Request): FlushPendingDataFunction { + const transformRequestMessage = ( + httpMessage: string | Buffer, + encoding?: BufferEncoding | 'buffer' + ): string | Buffer => { + /** + * @note Socket can write a buffer (e.g. uploaded file) even before + * it writes the HTTP message. Bypass those cases. + */ + if (encoding === 'buffer') { + return httpMessage + } + + const parts = httpMessage.toString(encoding).split('\r\n') + const headersEndIndex = parts.findIndex((field) => field === '') + const httpMessageHeaderPairs = parts.slice(1, headersEndIndex) + const httpMessageHeaders = FetchResponse.parseRawHeaders( + httpMessageHeaderPairs.flatMap((header) => header.split(': ')) + ) + + const rawHeaders = getRawFetchHeaders(request.headers) + + for (const [name, value] of rawHeaders) { + httpMessageHeaders.set(name, value) + } + + const httpMessageHeadersString = Array.from(httpMessageHeaders) + .map(([name, value]) => `${name}: ${value}`) + .join('\r\n') + parts.splice(1, headersEndIndex - 1, httpMessageHeadersString) + + return parts.join('\r\n') + } + + return (pendingData, encoding, callback) => { + if (Array.isArray(pendingData)) { + pendingData[0].chunk = transformRequestMessage( + pendingData[0].chunk, + pendingData[0].encoding + ) + } else { + pendingData = transformRequestMessage(pendingData, encoding) + } + + callback(pendingData) + } + } } diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 03f964efb..51109ec6e 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -7,12 +7,9 @@ import { createLogger } from '../../utils/logger' import { unwrapPendingData } from './utils/flush-writes' import { NetworkConnectionOptions } from './utils/normalize-net-connect-args' import { getAddressInfoByConnectionOptions } from './utils/address-info' -import { applyPatch } from '#/src/utils/apply-patch' const kListenerWrap = Symbol('kListenerWrap') - export const kRawSocket = Symbol('kRawSocket') - export const kMockState = Symbol('kMockState') export const kTlsSocket = Symbol('kTlsSocket') @@ -238,7 +235,7 @@ export abstract class SocketController { } } -type FlushPendingDataFunction = ( +export type FlushPendingDataFunction = ( data: NonNullable, encoding: BufferEncoding | undefined, callback: (data: NonNullable) => void @@ -324,7 +321,7 @@ export class TcpSocketController extends SocketController { this.socket.listenerCount('connect') > 0 ) { log('assume connect->write socket, calling "connect" listeners...') - this.onPreemptiveConnect() + this.emulateConnect() } }) }) @@ -346,84 +343,121 @@ export class TcpSocketController extends SocketController { } this.socket._writeGeneric = (...args) => { - log('socket write:', args, this.readyState) - - if (this.readyState === SocketController.PENDING) { - log('write while pending...') - - // Socket might write immediately, before the "connection" interceptor event is emitted. - // In those cases, schedule the emit on the next tick to ensure the server socket emits "data". - if (this.socket.listenerCount('internal:write') === 0) { - log('no server write listeners, scheduling to the next tick...') - - process.nextTick(() => { - /** - * @note If the socket has been handled in any way, skip this forwarding. - * Both claimed and passthrough scenario are forwarded below. - */ - if (this.readyState === SocketController.PENDING) { - this.#push(args[1]) - } - }) - } else { - this.#push(args[1]) - } + const data = args[1] - /** - * @note Execute the write callbacks while the socket is still pending. - * This prevents the socket from getting stuck when calling ".end()" in a write callback. - */ - if (typeof args[3] === 'function') { - log('found a write callback while pending, executing...') + log(this.readyState, 'write:', args) - args[3]() + if (this.socket.listenerCount('internal:write') === 0) { + log('no server data listeners, scheduling to the next tick...') - /** - * @note Replace the original write callback with an empty function. - * This prevents the "TypeError: cb is not a function" error on "Socket.onClose". - */ - args[3] = () => {} - } - - return this.#realWriteGeneric.apply(this.socket, args) + process.nextTick(() => { + log('(scheduled) forwarding write to server socket...', data) + this.#push(data) + }) + } else { + log(this.readyState, 'found server data listeners, pushing...') + this.#push(data) } - /** - * Handle "_writeGeneric" calls scheduled after the "connect" event. - * These are writes performed while connecting, and for the mocked socket - * they must be ignored. There's nowhere to flush them. Calling "_writeGeneric" - * past this point will result in "Error: write EBADF". - * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 - */ - if (this.readyState === SocketController.CLAIMED) { - log('write while claimed...') - - const callback = args[3] - - // Mock connection still means the socket emits the "connect" event - // and tries to flush any buffered writes to the server. Since there's - // nowhere to flush them, skip writing and only invoke the callback - // that will reset pending data/encoding. - if (this.socket._pendingData) { - // this.socket._pendingData = null - // this.socket._pendingEncoding = '' - callback?.() - return - } + // if (typeof callback === 'function') { + // log(this.readyState, 'write with callback, executing...', callback) - this.#push(args[1]) - callback?.() - return - } - - log('write while passthrough...') + // callback() + // args[3] = () => {} + // } - this.#push(args[1]) + log(this.readyState, 'writing to the socket...', args) return this.#realWriteGeneric.apply(this.socket, args) } + + // this.socket._writeGeneric = (...args) => { + // log('socket write:', args, this.readyState) + + // if (this.readyState === SocketController.PENDING) { + // log('write while pending...') + + // console.log('WRITE (PENDING)', args[1]) + + // // Socket might write immediately, before the "connection" interceptor event is emitted. + // // In those cases, schedule the emit on the next tick to ensure the server socket emits "data". + // if (this.socket.listenerCount('internal:write') === 0) { + // log('no server write listeners, scheduling to the next tick...') + + // process.nextTick(() => { + // /** + // * @note If the socket has been handled in any way, skip this forwarding. + // * Both claimed and passthrough scenario are forwarded below. + // */ + // this.#push(args[1]) + // }) + // } else { + // this.#push(args[1]) + // } + + // /** + // * @note Execute the write callbacks while the socket is still pending. + // * This prevents the socket from getting stuck when calling ".end()" in a write callback. + // */ + // if (typeof args[3] === 'function') { + // log('found a write callback while pending, executing...', args[3]) + + // args[3]() + + // /** + // * @note Replace the original write callback with an empty function. + // * This prevents the "TypeError: cb is not a function" error on "Socket.onClose". + // */ + // args[3] = () => {} + // } + + // return this.#realWriteGeneric.apply(this.socket, args) + // } + + // /** + // * Handle "_writeGeneric" calls scheduled after the "connect" event. + // * These are writes performed while connecting, and for the mocked socket + // * they must be ignored. There's nowhere to flush them. Calling "_writeGeneric" + // * past this point will result in "Error: write EBADF". + // * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 + // */ + // if (this.readyState === SocketController.CLAIMED) { + // log('write while claimed...') + + // const callback = args[3] + + // // Mock connection still means the socket emits the "connect" event + // // and tries to flush any buffered writes to the server. Since there's + // // nowhere to flush them, skip writing and only invoke the callback + // // that will reset pending data/encoding. + // if (this.socket._pendingData) { + // // this.socket._pendingData = null + // // this.socket._pendingEncoding = '' + // callback?.() + // return + // } + + // this.#push(args[1]) + // callback?.() + // return + // } + + // log('write while passthrough...') + + // const pendingData = this.socket._pendingData + + // if (pendingData == args[1]) { + // log('deferring passthrough forwarding while connecting...') + // } else { + // this.#push(args[1]) + // } + + // console.log('---\n\n') + + // return this.#realWriteGeneric.apply(this.socket, args) + // } } - protected onPreemptiveConnect() { + protected emulateConnect() { for (const listener of this.socket.listeners('connect')) { listener.apply(this.socket) } @@ -440,8 +474,10 @@ export class TcpSocketController extends SocketController { return } + log('(server) push:', data) + unwrapPendingData(data, (chunk, encoding) => { - log('emitting "data" on the server socket...', { chunk, encoding }) + log('(server) emitting "data"...', { chunk, encoding }) this.socket.emit('internal:write', chunk, encoding) }) @@ -460,19 +496,31 @@ export class TcpSocketController extends SocketController { } #onRealSocketData = (data: Buffer) => { + log('real socket "data" event:\n', data?.toString()) + if (this.socket.isPaused()) { + log('client socket paused, buffering...') this.#passthroughPausedBuffer.push(data) return } + log('pushing real data to the client socket...') + if (!this.socket.push(data)) { + log( + 'client socket forbade more pushes, pausing the passthrough socket...' + ) this.#passthroughSocket?.pause() } } #onRealSocketError = (error: Error) => { + log('real socket "error" event:\n', error) + if (this.socket.destroyed) { - log('real socket errored but mock socket already destroyed, skipping...') + log( + 'real socket errored but client socket already destroyed, skipping...' + ) return } @@ -496,6 +544,7 @@ export class TcpSocketController extends SocketController { } #onMockSocketDrain = () => { + log('client socket drained!') this.#passthroughSocket?.resume() } @@ -507,7 +556,7 @@ export class TcpSocketController extends SocketController { return } - log('claim!') + log('--- claim! ---') /** * Patch the "getsockname" on the handle in case Node.js decides to handle its errors. @@ -519,6 +568,21 @@ export class TcpSocketController extends SocketController { return getAddressInfoByConnectionOptions(this.#connectionOptions) } + /** + * @note Once claimed, there's nowhere to write chunks to. + * Just forward the writes to the server socket and invoke callbacks. + */ + this.socket._writeGeneric = (...args) => { + const data = args[1] + const callback = args[3] + + if (this.socket._pendingData == null) { + this.#push(data) + } + + callback?.() + } + this.pendingConnection.then(([request, handle]) => { log('connection request resolved, mocking the connection...') @@ -532,53 +596,41 @@ export class TcpSocketController extends SocketController { public passthrough(flushPendingData?: FlushPendingDataFunction): net.Socket { super.passthrough() - log('passthrough!') + log('-> passthrough!') - /** - * @note Modify the pending data to be flushed to the passthrough socket. - * In HTTP, this allows sending different request headers (e.g. modified in the listener). - */ - if ( - typeof flushPendingData === 'function' && - this.socket._pendingData != null - ) { - log('has pending data and a custom flush function, applying...') - - const revertWriteGenericPatch = applyPatch( - this.socket, - '_writeGeneric', - (clientWriteGeneric) => { - return (writev, data, encoding, callback) => { - if (this.socket._pendingData) { - flushPendingData(data, encoding, (nextData) => { - log('flushing modified pending data...', nextData) - - /** - * @note Call the unpatched "_writeGeneric" to prevent these writes - * from emitting the "data" event on the server socket. These writes - * have already been forwarded to the server socket while pending. - */ - this.#realWriteGeneric.call( - this.socket, - writev, - nextData, - encoding, - callback - ) - }) - - log('restoring the flush pending data patch...') - revertWriteGenericPatch() - return - } + this.socket._writeGeneric = (...args) => { + log(this.readyState, 'write:', args) - return clientWriteGeneric(writev, data, encoding, callback) - } + const data = args[1] + + if (this.socket._pendingData) { + log('found write scheduled after connect!', this.socket._pendingData) + + /** + * @note Modify the pending data to be flushed to the passthrough socket. + * In HTTP, this allows sending different request headers (e.g. modified in the listener). + */ + if (typeof flushPendingData === 'function') { + log('found a custom flush function, executing...') + + const encoding = args[2] + + return flushPendingData(data, encoding, (nextData) => { + args[1] = nextData + + log('flushing the modified pending chunks...', nextData) + this.#realWriteGeneric.apply(this.socket, args) + }) } - ) + } else { + this.#push(data) + } + + log('writing to the passthrough socket...') + return this.#realWriteGeneric.apply(this.socket, args) } - const createNewSocket = () => { + const createRealSocket = () => { const realSocket = this.createConnection() if (this.socket.timeout != null) { @@ -592,7 +644,7 @@ export class TcpSocketController extends SocketController { const realSocket = this.#passthroughSocket && !this.#passthroughSocket.destroyed ? this.#passthroughSocket - : createNewSocket() + : createRealSocket() if (realSocket !== this.#passthroughSocket) { this.#passthroughSocket = realSocket @@ -650,8 +702,8 @@ export class TlsSocketController extends TcpSocketController { super(socket, createConnection) } - protected onPreemptiveConnect(): void { - super.onPreemptiveConnect() + protected emulateConnect(): void { + super.emulateConnect() // For TLS sockets, also invoke the "secureConnect" callbacks since some consumers, // like Undici, listen to those to start writing to the socket. diff --git a/src/utils/bufferUtils.ts b/src/utils/bufferUtils.ts index 6d2eee581..60dfd067c 100644 --- a/src/utils/bufferUtils.ts +++ b/src/utils/bufferUtils.ts @@ -30,6 +30,7 @@ export function toBuffer( if (Buffer.isBuffer(data)) { return data } + if (data instanceof Uint8Array) { return Buffer.from(data.buffer) } diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index b1ad7328e..9aa31fc3a 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -9,7 +9,7 @@ import { HttpRequestInterceptor } from '#/src/interceptors/http' import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { - app.post('/resource', express.text({ type: '*/*' }), (req, res) => { + app.post('/resource/*', express.text({ type: '*/*' }), (req, res) => { res.send(req.body) }) }) @@ -37,7 +37,7 @@ it('writes string request body', async () => { requestBodyPromise.resolve(await request.clone().text()) }) - const req = http.request(httpServer.http.url('/resource'), { + const req = http.request(httpServer.http.url('/resource/write-string'), { method: 'POST', headers: { 'Content-Type': 'text/plain', @@ -61,7 +61,7 @@ it('writes JSON request body', async () => { requestBodyPromise.resolve(await request.clone().text()) }) - const req = http.request(httpServer.http.url('/resource'), { + const req = http.request(httpServer.http.url('/resource/write-json'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -85,7 +85,7 @@ it('writes Buffer request body', async () => { requestBodyPromise.resolve(await request.clone().text()) }) - const req = http.request(httpServer.http.url('/resource'), { + const req = http.request(httpServer.http.url('/resource/write-buffer'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -109,7 +109,7 @@ it('supports Readable as the request body', async () => { requestBodyPromise.resolve(await request.clone().text()) }) - const request = http.request(httpServer.http.url('/resource'), { + const request = http.request(httpServer.http.url('/resource/readable'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -131,9 +131,12 @@ it('supports Readable as the request body', async () => { }) it('calls the write callback when writing an empty string', async () => { - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - }) + const request = http.request( + httpServer.http.url('/resource/write-empty-cb'), + { + method: 'POST', + } + ) const writeCallback = vi.fn() request.write('', writeCallback) @@ -144,9 +147,12 @@ it('calls the write callback when writing an empty string', async () => { }) it('calls the write callback when writing an empty Buffer', async () => { - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - }) + const request = http.request( + httpServer.http.url('/resource/write-callback'), + { + method: 'POST', + } + ) const writeCallback = vi.fn() request.write(Buffer.from(''), writeCallback) @@ -161,7 +167,7 @@ it('emits "finish" for a passthrough request', async () => { const prefinishListener = vi.fn() const finishListener = vi.fn() - const request = http.request(httpServer.http.url('/resource')) + const request = http.request(httpServer.http.url('/resource/real-finish')) request.on('prefinish', prefinishListener) request.on('finish', finishListener) @@ -181,7 +187,7 @@ it('emits "finish" for a mocked request', async () => { const prefinishListener = vi.fn() const finishListener = vi.fn() - const request = http.request(httpServer.http.url('/resource')) + const request = http.request(httpServer.http.url('/resource/mocked-finish')) request.on('prefinish', prefinishListener) request.on('finish', finishListener) @@ -201,9 +207,12 @@ it('supports ending a mocked request in a write callback', async () => { controller.respondWith(new Response('hello world')) }) - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - }) + const request = http.request( + httpServer.http.url('/resource/mocked-end-after-write'), + { + method: 'POST', + } + ) const firstWriteCallback = vi.fn() const secondWriteCallback = vi.fn() @@ -233,19 +242,22 @@ it('supports ending a mocked request in a write callback', async () => { * @see https://github.com/mswjs/interceptors/issues/684 */ it('supports ending a bypassed request in a write callback', async () => { - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - }) + const request = http.request( + httpServer.http.url('/resource/real-end-after-write'), + { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + } + ) const firstWriteCallback = vi.fn() const secondWriteCallback = vi.fn() const requestEndCallback = vi.fn() - request.write('hello', () => { + request.write('hello ', () => { firstWriteCallback() - request.write(' world', () => { + request.write('world', () => { secondWriteCallback() request.end(requestEndCallback) @@ -269,17 +281,24 @@ it('calls the write callbacks when reading request body in the interceptor', asy requestBodyCallback(await request.text()) }) - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - }) - request.write('one', requestWriteCallback) - request.write('two', requestWriteCallback) - request.end('three', requestWriteCallback) + const request = http.request( + httpServer.http.url('/resource/write-callback'), + { + method: 'POST', + headers: { + 'content-type': 'text/plain', + }, + } + ) + request.write('ash', requestWriteCallback) + request.write('amber', requestWriteCallback) + request.end('fire', requestWriteCallback) const [response] = await toWebResponse(request) - expect(requestWriteCallback).toHaveBeenCalledTimes(3) - expect(requestBodyCallback).toHaveBeenCalledWith('onetwothree') - await expect(response.text()).resolves.toBe('onetwothree') + expect.soft(requestWriteCallback).toHaveBeenCalledTimes(3) + expect + .soft(requestBodyCallback) + .toHaveBeenCalledExactlyOnceWith('ashamberfire') + await expect.soft(response.text()).resolves.toBe('ashamberfire') }) diff --git a/test/modules/http/compliance/http-socket-reuse.test.ts b/test/modules/http/compliance/http-socket-reuse.test.ts index 4137f4f99..955ca3b6b 100644 --- a/test/modules/http/compliance/http-socket-reuse.test.ts +++ b/test/modules/http/compliance/http-socket-reuse.test.ts @@ -5,9 +5,13 @@ import { HttpRequestInterceptor } from '#/src/interceptors/http' import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { - app.get('/get', (req, res) => { + app.get('/resource/*', (req, res) => { res.status(200).send('original') }) + app.post('/resource/*', (req, res) => { + res.status(200) + req.pipe(res) + }) }) const interceptor = new HttpRequestInterceptor() @@ -39,7 +43,7 @@ it('allows reusing the same socket for mixed mocked/bypassed requests', async () }) { - const request = https.get(httpServer.https.url('/get'), { + const request = https.get(httpServer.https.url('/resource/one'), { rejectUnauthorized: false, }) const [response] = await toWebResponse(request) @@ -90,23 +94,83 @@ it('allows reusing the same socket for multiple mocked requests', async () => { }) it('allows reusing the same socket for multiple bypassed requests', async () => { + const requestListener = vi.fn() + + interceptor.on('request', ({ request }) => { + requestListener(request) + }) + { - const request = https.get(httpServer.https.url('/get'), { + const request = https.get(httpServer.https.url('/resource/one'), { rejectUnauthorized: false, }) const [response] = await toWebResponse(request) expect.soft(response.status).toBe(200) await expect.soft(response.text()).resolves.toBe('original') + expect(requestListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + method: 'GET', + url: httpServer.https.url('/resource/one'), + }) + ) } { - const request = https.get(httpServer.https.url('/get'), { + const request = https.request(httpServer.https.url('/resource/two'), { rejectUnauthorized: false, }) + request.write('second') + request.end() const [response] = await toWebResponse(request) expect.soft(response.status).toBe(200) await expect.soft(response.text()).resolves.toBe('original') + expect(requestListener).toHaveBeenCalledTimes(2) + expect(requestListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'GET', + url: httpServer.https.url('/resource/two'), + }) + ) + } +}) + +it('allows reusing the same socket for multiple bypassed requests with a body', async () => { + const requestListener = vi.fn() + + interceptor.on('request', async ({ request }) => { + requestListener(await request.text()) + }) + + { + const request = https.request(httpServer.https.url('/resource/one'), { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + rejectUnauthorized: false, + }) + request.write('first') + request.end() + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('first') + expect(requestListener).toHaveBeenCalledExactlyOnceWith('first') + } + + { + const request = https.request(httpServer.https.url('/resource/two'), { + method: 'POST', + rejectUnauthorized: false, + }) + request.write('second') + request.end() + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('second') + expect(requestListener).toHaveBeenCalledTimes(2) + expect(requestListener).toHaveBeenNthCalledWith(2, 'second') } }) diff --git a/test/modules/http/compliance/http-upgrade.test.ts b/test/modules/http/compliance/http-upgrade.test.ts index 8d8f997fb..56ef857cb 100644 --- a/test/modules/http/compliance/http-upgrade.test.ts +++ b/test/modules/http/compliance/http-upgrade.test.ts @@ -19,6 +19,7 @@ afterEach(() => { afterAll(async () => { interceptor.dispose() + await new Promise((resolve, reject) => { server.disconnectSockets() server.close((error) => { @@ -33,7 +34,5 @@ it('bypasses a WebSocket upgrade request', async () => { transports: ['websocket'], }) - await vi.waitFor(async () => { - expect(client.connected).toBe(true) - }) + await expect.poll(() => client.connected).toBe(true) }) diff --git a/test/modules/net/compliance/socket-write.test.ts b/test/modules/net/compliance/socket-write.test.ts index 5e94c29bd..970a53bde 100644 --- a/test/modules/net/compliance/socket-write.test.ts +++ b/test/modules/net/compliance/socket-write.test.ts @@ -18,7 +18,7 @@ afterAll(() => { interceptor.dispose() }) -it('intercepts buffered writes to the original server', async () => { +it('intercepts buffered writes for passthrough socket', async () => { const serverDataListener = vi.fn() await using server = await createTestServer(() => { return new net.Server((socket) => { @@ -56,7 +56,7 @@ it('intercepts buffered writes to the original server', async () => { .toHaveBeenNthCalledWith(3, Buffer.from('client')) }) -it('intercepts separate writes to the original server', async () => { +it('intercepts separate writes for passthrough socket', async () => { const serverDataListener = vi.fn() await using server = await createTestServer(() => { return new net.Server((socket) => { @@ -65,9 +65,61 @@ it('intercepts separate writes to the original server', async () => { }) const interceptorDataListener = vi.fn() - interceptor.on('connection', ({ socket, controller }) => { + interceptor.on('connection', async ({ socket, controller }) => { + socket.on('data', interceptorDataListener) + + setTimeout(30) controller.passthrough() + }) + + const socket = net.connect(server.port, server.hostname) + + socket.write('hello ') + await setTimeout(20) + socket.write('from ') + await setTimeout(20) + socket.end('client') + + await expect.poll(() => serverDataListener).toHaveBeenCalledTimes(3) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) + + expect.soft(interceptorDataListener).toHaveBeenCalledTimes(3) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) +}) + +it('does not duplicate writes while the socket is pending', async () => { + const serverDataListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', serverDataListener) + }) + }) + + const interceptorDataListener = vi.fn() + interceptor.on('connection', async ({ socket, controller }) => { socket.on('data', interceptorDataListener) + + socket.on('data', (chunk) => { + if (chunk.toString() === 'hello ') { + controller.passthrough() + } + }) }) const socket = net.connect(server.port, server.hostname) From 83ef4bef1de6d907e1a8e4bafa7c90093e4e791a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 2 Mar 2026 20:29:19 +0100 Subject: [PATCH 095/198] fix: opt out from write buffering, buffer in-memory --- src/interceptors/net/socket-controller.ts | 207 +++++++----------- .../net/compliance/socket-write.test.ts | 63 ++++++ test/modules/net/socket-server-data.test.ts | 35 +++ 3 files changed, 175 insertions(+), 130 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 51109ec6e..3d3fe7663 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -250,6 +250,7 @@ export class TcpSocketController extends SocketController { #realWriteGeneric: net.Socket['_writeGeneric'] #passthroughSocket: net.Socket | null = null #passthroughPausedBuffer: Array = [] + #pendingWrites: Array> = [] constructor( protected readonly socket: net.Socket, @@ -262,6 +263,7 @@ export class TcpSocketController extends SocketController { // Store the unpatched write method once so we have access to it between socket state resets. this.#realWriteGeneric = this.socket._writeGeneric + this.#pendingWrites = [] this.socket.connect = new Proxy(this.socket.connect, { apply: (target, thisArg, args) => { @@ -284,10 +286,11 @@ export class TcpSocketController extends SocketController { log('client socket freed!') this.#reset() }) - .on('close', () => { + .on('close', (hadError) => { log('client socket closed!') this.#passthroughSocket = null this.#passthroughPausedBuffer = [] + this.#pendingWrites = [] }) this.serverSocket = toServerSocket(this.socket) @@ -301,6 +304,7 @@ export class TcpSocketController extends SocketController { this.readyState = SocketController.PENDING this.pendingConnection = new DeferredPromise() + this.#pendingWrites = [] const wrapHandle = (handle: TcpHandle) => { this.pendingConnection.then(() => { @@ -344,14 +348,21 @@ export class TcpSocketController extends SocketController { this.socket._writeGeneric = (...args) => { const data = args[1] + const callback = args[3] log(this.readyState, 'write:', args) + // The server socket will NEVER have any "data" listeners attached + // becuase the "connection" interceptor event emits on the next tick. if (this.socket.listenerCount('internal:write') === 0) { log('no server data listeners, scheduling to the next tick...') process.nextTick(() => { - log('(scheduled) forwarding write to server socket...', data) + log( + this.readyState, + '(scheduled) forwarding write to server socket...', + data + ) this.#push(data) }) } else { @@ -359,102 +370,25 @@ export class TcpSocketController extends SocketController { this.#push(data) } - // if (typeof callback === 'function') { - // log(this.readyState, 'write with callback, executing...', callback) + if (typeof callback === 'function') { + log(this.readyState, 'write with callback, executing...', callback) - // callback() - // args[3] = () => {} - // } + callback() + args[3] = function mockNoop() {} + } - log(this.readyState, 'writing to the socket...', args) - return this.#realWriteGeneric.apply(this.socket, args) + /** + * @note Do NOT tap into Node.js internal buffering for three reasons: + * 1. Delaying writes to "connect" is problematic as you cannot tell such writes from + * regular writes after claim/passthrough connects. + * 2. "_pendingData" does NOT accumulate writes. It always points to the last buffered + * chunk so we cannot tell if we're writing a scheduled chunk or not in case multiple + * chunks were buffered. + * 3. Node.js logic here is extremely simple anyway. No harm in buffering writes ourselves + * if that gives us more control. + */ + this.#pendingWrites.push(args) } - - // this.socket._writeGeneric = (...args) => { - // log('socket write:', args, this.readyState) - - // if (this.readyState === SocketController.PENDING) { - // log('write while pending...') - - // console.log('WRITE (PENDING)', args[1]) - - // // Socket might write immediately, before the "connection" interceptor event is emitted. - // // In those cases, schedule the emit on the next tick to ensure the server socket emits "data". - // if (this.socket.listenerCount('internal:write') === 0) { - // log('no server write listeners, scheduling to the next tick...') - - // process.nextTick(() => { - // /** - // * @note If the socket has been handled in any way, skip this forwarding. - // * Both claimed and passthrough scenario are forwarded below. - // */ - // this.#push(args[1]) - // }) - // } else { - // this.#push(args[1]) - // } - - // /** - // * @note Execute the write callbacks while the socket is still pending. - // * This prevents the socket from getting stuck when calling ".end()" in a write callback. - // */ - // if (typeof args[3] === 'function') { - // log('found a write callback while pending, executing...', args[3]) - - // args[3]() - - // /** - // * @note Replace the original write callback with an empty function. - // * This prevents the "TypeError: cb is not a function" error on "Socket.onClose". - // */ - // args[3] = () => {} - // } - - // return this.#realWriteGeneric.apply(this.socket, args) - // } - - // /** - // * Handle "_writeGeneric" calls scheduled after the "connect" event. - // * These are writes performed while connecting, and for the mocked socket - // * they must be ignored. There's nowhere to flush them. Calling "_writeGeneric" - // * past this point will result in "Error: write EBADF". - // * @see https://github.com/nodejs/node/blob/main/deps/uv/src/unix/stream.c#L1304-L1305 - // */ - // if (this.readyState === SocketController.CLAIMED) { - // log('write while claimed...') - - // const callback = args[3] - - // // Mock connection still means the socket emits the "connect" event - // // and tries to flush any buffered writes to the server. Since there's - // // nowhere to flush them, skip writing and only invoke the callback - // // that will reset pending data/encoding. - // if (this.socket._pendingData) { - // // this.socket._pendingData = null - // // this.socket._pendingEncoding = '' - // callback?.() - // return - // } - - // this.#push(args[1]) - // callback?.() - // return - // } - - // log('write while passthrough...') - - // const pendingData = this.socket._pendingData - - // if (pendingData == args[1]) { - // log('deferring passthrough forwarding while connecting...') - // } else { - // this.#push(args[1]) - // } - - // console.log('---\n\n') - - // return this.#realWriteGeneric.apply(this.socket, args) - // } } protected emulateConnect() { @@ -556,7 +490,7 @@ export class TcpSocketController extends SocketController { return } - log('--- claim! ---') + log('-> claim!') /** * Patch the "getsockname" on the handle in case Node.js decides to handle its errors. @@ -568,19 +502,34 @@ export class TcpSocketController extends SocketController { return getAddressInfoByConnectionOptions(this.#connectionOptions) } + this.#pendingWrites = [] + /** * @note Once claimed, there's nowhere to write chunks to. * Just forward the writes to the server socket and invoke callbacks. + * Attempting to write past this point will result in the "Error: write EBADF". */ this.socket._writeGeneric = (...args) => { + log(this.readyState, 'write:', args) + const data = args[1] const callback = args[3] - if (this.socket._pendingData == null) { + if (this.socket._pendingData != null) { + log('clearing pending data...') + + this.socket._pendingData = null + this.socket._pendingEncoding = '' + } else { + log('not writing pending data, forwarding to the server...') + this.#push(data) } - callback?.() + if (typeof callback === 'function') { + log(this.readyState, 'invoking callback for write:', data, callback) + callback() + } } this.pendingConnection.then(([request, handle]) => { @@ -598,38 +547,6 @@ export class TcpSocketController extends SocketController { log('-> passthrough!') - this.socket._writeGeneric = (...args) => { - log(this.readyState, 'write:', args) - - const data = args[1] - - if (this.socket._pendingData) { - log('found write scheduled after connect!', this.socket._pendingData) - - /** - * @note Modify the pending data to be flushed to the passthrough socket. - * In HTTP, this allows sending different request headers (e.g. modified in the listener). - */ - if (typeof flushPendingData === 'function') { - log('found a custom flush function, executing...') - - const encoding = args[2] - - return flushPendingData(data, encoding, (nextData) => { - args[1] = nextData - - log('flushing the modified pending chunks...', nextData) - this.#realWriteGeneric.apply(this.socket, args) - }) - } - } else { - this.#push(data) - } - - log('writing to the passthrough socket...') - return this.#realWriteGeneric.apply(this.socket, args) - } - const createRealSocket = () => { const realSocket = this.createConnection() @@ -650,6 +567,36 @@ export class TcpSocketController extends SocketController { this.#passthroughSocket = realSocket } + /** + * Flush any writes during the pending phase to the passthrough socket. + * @note These are written directly on the passthrough socket to prevent + * them from being forwarded as "data" events on the server (already emitted). + */ + for (let i = 0; i < this.#pendingWrites.length; i++) { + const pendingWrite = this.#pendingWrites[i] + + if (i === 0 && typeof flushPendingData === 'function') { + const data = pendingWrite[1] + const encoding = pendingWrite[2] + flushPendingData(data, encoding, (nextData) => { + pendingWrite[1] = nextData + }) + } + + realSocket._writeGeneric.apply(realSocket, pendingWrite) + } + + this.#pendingWrites = [] + this.socket._pendingData = null + this.socket._pendingEncoding = '' + + this.socket._writeGeneric = (...args) => { + log(this.readyState, 'write:', args) + + this.#push(args[1]) + return this.#realWriteGeneric.apply(this.socket, args) + } + // Buffer to hold data chunks while the mock socket is paused. // This allows async response event listeners to complete before // data flows to the mock socket and triggers ClientRequest events. diff --git a/test/modules/net/compliance/socket-write.test.ts b/test/modules/net/compliance/socket-write.test.ts index 970a53bde..dcf46f95d 100644 --- a/test/modules/net/compliance/socket-write.test.ts +++ b/test/modules/net/compliance/socket-write.test.ts @@ -152,3 +152,66 @@ it('does not duplicate writes while the socket is pending', async () => { .soft(interceptorDataListener) .toHaveBeenNthCalledWith(3, Buffer.from('client')) }) + +it('invokes the write callbacks for a passthrough socket', async () => { + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', (data) => { + if (data.toString() === 'two') { + socket.end() + } + }) + }) + }) + + interceptor.on('connection', ({ controller }) => { + controller.passthrough() + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners } = spyOnSocket(socket) + + const writeOneCallback = vi.fn() + const writeTwoCallback = vi.fn() + const endCallback = vi.fn() + + socket.write('one', writeOneCallback) + socket.write('two', writeTwoCallback) + socket.end(endCallback) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect.soft(writeOneCallback).toHaveBeenCalledOnce() + expect.soft(writeTwoCallback).toHaveBeenCalledOnce() + expect.soft(endCallback).toHaveBeenCalledOnce() + + expect(writeOneCallback).toHaveBeenCalledBefore(writeTwoCallback) + expect(writeTwoCallback).toHaveBeenCalledBefore(endCallback) +}) + +it('invokes callbacks for nested writes for a passthrough socket', async () => { + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', (data) => { + if (data.toString() === 'two') { + socket.end() + } + }) + }) + }) + + interceptor.on('connection', ({ controller }) => { + controller.passthrough() + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners } = spyOnSocket(socket) + + const writeCallback = vi.fn(() => socket.end()) + + socket.write('one', () => { + socket.write('two', writeCallback) + }) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect.soft(writeCallback).toHaveBeenCalledOnce() +}) diff --git a/test/modules/net/socket-server-data.test.ts b/test/modules/net/socket-server-data.test.ts index 449c3dff7..bf140dd4a 100644 --- a/test/modules/net/socket-server-data.test.ts +++ b/test/modules/net/socket-server-data.test.ts @@ -56,3 +56,38 @@ it('emits "data" on the server socket for writes after connect', async () => { .poll(() => interceptorDataListener) .toHaveBeenCalledExactlyOnceWith(Buffer.from('hello')) }) + +it('emits "data" on the server for nested writes', async () => { + const interceptorDataListener = vi.fn() + + interceptor.on('connection', ({ socket, controller }) => { + socket.on('data', interceptorDataListener) + + socket.on('data', (chunk) => { + if (chunk.toString() === 'three') { + socket.destroy() + } + }) + }) + + const socket = net.connect(80, 'any.host.com') + const { listeners } = spyOnSocket(socket) + + socket.write('one', () => { + socket.write('two', () => { + socket.end('three') + }) + }) + + await expect + .poll(() => interceptorDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('one')) + await expect + .poll(() => interceptorDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('two')) + await expect + .poll(() => interceptorDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('three')) + + expect(listeners.close).toHaveBeenCalledOnce() +}) From 98d0e7458ddede077e322e608c1b191cbc73f2b6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 2 Mar 2026 20:33:29 +0100 Subject: [PATCH 096/198] fix: rely on `#bufferedWrites` for write after connect check --- src/interceptors/net/socket-controller.ts | 31 ++++++++--------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 3d3fe7663..3ed1a6040 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -250,7 +250,7 @@ export class TcpSocketController extends SocketController { #realWriteGeneric: net.Socket['_writeGeneric'] #passthroughSocket: net.Socket | null = null #passthroughPausedBuffer: Array = [] - #pendingWrites: Array> = [] + #bufferedWrites: Array> = [] constructor( protected readonly socket: net.Socket, @@ -263,7 +263,7 @@ export class TcpSocketController extends SocketController { // Store the unpatched write method once so we have access to it between socket state resets. this.#realWriteGeneric = this.socket._writeGeneric - this.#pendingWrites = [] + this.#bufferedWrites = [] this.socket.connect = new Proxy(this.socket.connect, { apply: (target, thisArg, args) => { @@ -290,7 +290,7 @@ export class TcpSocketController extends SocketController { log('client socket closed!') this.#passthroughSocket = null this.#passthroughPausedBuffer = [] - this.#pendingWrites = [] + this.#bufferedWrites = [] }) this.serverSocket = toServerSocket(this.socket) @@ -304,7 +304,7 @@ export class TcpSocketController extends SocketController { this.readyState = SocketController.PENDING this.pendingConnection = new DeferredPromise() - this.#pendingWrites = [] + this.#bufferedWrites = [] const wrapHandle = (handle: TcpHandle) => { this.pendingConnection.then(() => { @@ -321,7 +321,7 @@ export class TcpSocketController extends SocketController { if ( this.readyState === SocketController.PENDING && this.socket.connecting && - this.socket._pendingData == null && + this.#bufferedWrites.length === 0 && this.socket.listenerCount('connect') > 0 ) { log('assume connect->write socket, calling "connect" listeners...') @@ -387,7 +387,7 @@ export class TcpSocketController extends SocketController { * 3. Node.js logic here is extremely simple anyway. No harm in buffering writes ourselves * if that gives us more control. */ - this.#pendingWrites.push(args) + this.#bufferedWrites.push(args) } } @@ -502,7 +502,7 @@ export class TcpSocketController extends SocketController { return getAddressInfoByConnectionOptions(this.#connectionOptions) } - this.#pendingWrites = [] + this.#bufferedWrites = [] /** * @note Once claimed, there's nowhere to write chunks to. @@ -515,16 +515,7 @@ export class TcpSocketController extends SocketController { const data = args[1] const callback = args[3] - if (this.socket._pendingData != null) { - log('clearing pending data...') - - this.socket._pendingData = null - this.socket._pendingEncoding = '' - } else { - log('not writing pending data, forwarding to the server...') - - this.#push(data) - } + this.#push(data) if (typeof callback === 'function') { log(this.readyState, 'invoking callback for write:', data, callback) @@ -572,8 +563,8 @@ export class TcpSocketController extends SocketController { * @note These are written directly on the passthrough socket to prevent * them from being forwarded as "data" events on the server (already emitted). */ - for (let i = 0; i < this.#pendingWrites.length; i++) { - const pendingWrite = this.#pendingWrites[i] + for (let i = 0; i < this.#bufferedWrites.length; i++) { + const pendingWrite = this.#bufferedWrites[i] if (i === 0 && typeof flushPendingData === 'function') { const data = pendingWrite[1] @@ -586,7 +577,7 @@ export class TcpSocketController extends SocketController { realSocket._writeGeneric.apply(realSocket, pendingWrite) } - this.#pendingWrites = [] + this.#bufferedWrites = [] this.socket._pendingData = null this.socket._pendingEncoding = '' From ce10f145a5692cab4ee8d31e5e2329c0864bd4b8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 2 Mar 2026 20:35:20 +0100 Subject: [PATCH 097/198] fix(applyPatch): use `Symbol.for` to account for cross-environment --- src/utils/apply-patch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/apply-patch.ts b/src/utils/apply-patch.ts index 29505c9b4..ebb44c4d9 100644 --- a/src/utils/apply-patch.ts +++ b/src/utils/apply-patch.ts @@ -1,6 +1,6 @@ import { invariant } from 'outvariant' -export const IS_PATCHED_MODULE: unique symbol = Symbol('isPatchedModule') +export const IS_PATCHED_MODULE: unique symbol = Symbol.for('kIsPatchedModule') /** * Apply a patch for the given property on the owner object. From 51065668fb7d59236c7487c4711bd5d9a7cc1d46 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 13:13:27 +0100 Subject: [PATCH 098/198] chore: remove unused close argument --- src/interceptors/net/socket-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 3ed1a6040..827af9332 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -286,7 +286,7 @@ export class TcpSocketController extends SocketController { log('client socket freed!') this.#reset() }) - .on('close', (hadError) => { + .on('close', () => { log('client socket closed!') this.#passthroughSocket = null this.#passthroughPausedBuffer = [] From fda4d8abcde161addbcf15949203c164b014948d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 13:33:59 +0100 Subject: [PATCH 099/198] fix(fetchUtils): patch `response.clone` to survive `setUrl` changes --- src/utils/fetchUtils.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index da4393015..83510eba5 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -69,6 +69,18 @@ export class FetchResponse extends Response { writable: false, }) } + + /** + * Since Node.js v24, Undici stores the Response state in an inaccessible field "#state". + * While reassigning the "url" property on this response is enough, its clones won't have that change. + * Patch the clone method to always produce clean clones and replay URL change on them. + * @see https://github.com/nodejs/undici/blob/f734c87280e626c75f59aad55b65eb6a89cef392/lib/web/fetch/response.js#L242 + */ + response.clone = () => { + const clonedResponse = Response.prototype.clone.call(response) + FetchResponse.setUrl(url, clonedResponse) + return clonedResponse + } } /** From fa092cd5be6612c074296c542370d0f63e500ea8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 14:27:27 +0100 Subject: [PATCH 100/198] fix(FetchResponse): copy raw headers from the source --- .../ClientRequest/utils/recordRawHeaders.ts | 25 +++++ src/interceptors/fetch/index.ts | 15 +-- src/interceptors/http/index.ts | 12 ++- src/utils/fetchUtils.ts | 101 ++++++++++++------ test/features/events/response.test.ts | 2 + .../fetch-response-non-configurable.test.ts | 10 +- .../compliance/fetch-response-url.test.ts | 1 + test/modules/fetch/compliance/undici.test.ts | 12 ++- 8 files changed, 131 insertions(+), 47 deletions(-) diff --git a/src/interceptors/ClientRequest/utils/recordRawHeaders.ts b/src/interceptors/ClientRequest/utils/recordRawHeaders.ts index cfa1c9d8b..278974f71 100644 --- a/src/interceptors/ClientRequest/utils/recordRawHeaders.ts +++ b/src/interceptors/ClientRequest/utils/recordRawHeaders.ts @@ -260,3 +260,28 @@ function inferRawHeaders(headers: HeadersInit): RawHeaders { return Reflect.get(new Headers(headers), kRawHeaders) } + +export function copyRawHeaders(source: Headers, destination: Headers): void { + const rawHeaders = [...getRawFetchHeaders(source)] + + if (rawHeaders.length === 0) { + return + } + + /** + * @note Add headers from trhe destination that raw headers from the source + * don't have. Undici automatically appends a "Content-Type" header for responses + * and, for some reason, that change is not recorded. This preserves it. + */ + for (const [name, value] of destination) { + if ( + rawHeaders.every( + (header) => header[0].toLowerCase() !== name.toLowerCase() + ) + ) { + rawHeaders.push([name, value]) + } + } + + defineRawHeadersSymbol(destination, rawHeaders) +} diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index de3bd9bb1..dc41222b1 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -111,12 +111,15 @@ export class FetchInterceptor extends Interceptor { // Decompress the mocked response body, if applicable. const decompressedStream = decompressResponse(rawResponse) - const response = - decompressedStream === null - ? rawResponse - : new FetchResponse(decompressedStream, rawResponse) - - FetchResponse.setUrl(request.url, response) + const response = new FetchResponse( + decompressedStream || rawResponse.body, + { + url: request.url, + status: rawResponse.status, + statusText: rawResponse.statusText, + headers: rawResponse.headers, + } + ) /** * Undici's handling of following redirect responses. diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index c0d041eff..4c8e4b2b2 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -95,16 +95,18 @@ export class HttpRequestInterceptor extends Interceptor { }) const requestController = new RequestController(request, { - respondWith: async (response) => { + respondWith: async (rawResponse) => { log('respondWith() %o', { - status: response.status, - statusText: response.statusText, - hasBody: response.body != null, + status: rawResponse.status, + statusText: rawResponse.statusText, + hasBody: rawResponse.body != null, }) socketController.claim() - FetchResponse.setUrl(request.url, response) + const response = FetchResponse.from(rawResponse, { + url: request.url, + }) const respond = () => { return this.respondWith({ diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index 83510eba5..94240683d 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -1,3 +1,4 @@ +import { copyRawHeaders } from '../interceptors/ClientRequest/utils/recordRawHeaders' import { canParseUrl } from './canParseUrl' import { getValueBySymbol } from './getValueBySymbol' @@ -25,6 +26,23 @@ interface UndiciFetchInternalState { } export class FetchResponse extends Response { + static from(response: Response, init?: FetchResponseInit): FetchResponse { + if (response instanceof FetchResponse) { + return response + } + + const fetchResponse = new FetchResponse(response.body, { + url: init?.url ?? response.url, + status: init?.status || response.status, + statusText: init?.statusText ?? response.statusText, + headers: init?.headers ?? response.headers, + }) + + copyRawHeaders(response.headers, fetchResponse.headers) + + return fetchResponse + } + /** * Response status codes for responses that cannot have body. * @see https://fetch.spec.whatwg.org/#statuses @@ -49,9 +67,31 @@ export class FetchResponse extends Response { return !FetchResponse.STATUS_CODES_WITHOUT_BODY.includes(status) } - static setUrl(url: string | undefined, response: Response): void { + static setStatus(status: number, response: Response): void { + /** + * @note Undici keeps an internal "Symbol(state)" that holds + * the actual value of response status. Update that in Node.js. + */ + const internalState = getValueBySymbol( + 'state', + response + ) + + if (internalState) { + internalState.status = status + } else { + Object.defineProperty(response, 'status', { + value: status, + enumerable: true, + configurable: true, + writable: false, + }) + } + } + + static setUrl(url: string | undefined, response: Response): boolean { if (!url || url === 'about:' || !canParseUrl(url)) { - return + return false } const state = getValueBySymbol('state', response) @@ -70,17 +110,7 @@ export class FetchResponse extends Response { }) } - /** - * Since Node.js v24, Undici stores the Response state in an inaccessible field "#state". - * While reassigning the "url" property on this response is enough, its clones won't have that change. - * Patch the clone method to always produce clean clones and replay URL change on them. - * @see https://github.com/nodejs/undici/blob/f734c87280e626c75f59aad55b65eb6a89cef392/lib/web/fetch/response.js#L242 - */ - response.clone = () => { - const clonedResponse = Response.prototype.clone.call(response) - FetchResponse.setUrl(url, clonedResponse) - return clonedResponse - } + return true } /** @@ -94,6 +124,9 @@ export class FetchResponse extends Response { return headers } + #status?: number + #url?: string + constructor(body?: BodyInit | null, init: FetchResponseInit = {}) { const status = init.status ?? 200 const safeStatus = FetchResponse.isConfigurableStatusCode(status) @@ -107,29 +140,33 @@ export class FetchResponse extends Response { headers: init.headers, }) + /** + * Since Node.js v24, Undici stores the Response state in an inaccessible field "#state". + * Forward the modified status/URL to the cloned response manually. + * @see https://github.com/nodejs/undici/blob/f734c87280e626c75f59aad55b65eb6a89cef392/lib/web/fetch/response.js#L242 + */ if (status !== safeStatus) { - /** - * @note Undici keeps an internal "Symbol(state)" that holds - * the actual value of response status. Update that in Node.js. - */ - const internalState = getValueBySymbol( - 'state', - this - ) + this.#status = status + FetchResponse.setStatus(status, this) + } - if (internalState) { - internalState.status = status - } else { - Object.defineProperty(this, 'status', { - value: status, - enumerable: true, - configurable: true, - writable: false, - }) - } + if (init.url && FetchResponse.setUrl(init.url, this)) { + this.#url = init.url + } + } + + public clone() { + const clonedResponse = super.clone() + + if (this.#status) { + FetchResponse.setStatus(this.#status, clonedResponse) + } + + if (this.#url) { + FetchResponse.setUrl(this.#url, clonedResponse) } - FetchResponse.setUrl(init.url, this) + return clonedResponse } } diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 54a020d7d..bededf796 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -274,6 +274,8 @@ it('fetch: emits the "response" event upon a mocked response', async () => { HttpRequestEventMap['response'][0] >() interceptor.on('response', (args) => { + console.log('RESPONSE!', args.response) + responseListenerArgs.resolve({ ...args, request: args.request.clone(), diff --git a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts index 04bba159a..054bab99a 100644 --- a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts +++ b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts @@ -48,7 +48,12 @@ it('supports mocking non-configurable responses', async () => { * @note The Fetch API `Response` will still error on * non-configurable status codes. Instead, use this helper class. */ - controller.respondWith(new FetchResponse(null, { status: 101 })) + controller.respondWith( + new FetchResponse(null, { + status: 101, + statusText: 'Switching Protocols', + }) + ) }) const responsePromise = new DeferredPromise() @@ -58,7 +63,8 @@ it('supports mocking non-configurable responses', async () => { const response = await fetch('http://localhost/irrelevant') - expect(response.status).toBe(101) + expect.soft(response.status).toBe(101) + expect.soft(response.statusText).toBe('Switching Protocols') // Must expose the exact response in the listener. await expect(responsePromise).resolves.toHaveProperty('status', 101) diff --git a/test/modules/fetch/compliance/fetch-response-url.test.ts b/test/modules/fetch/compliance/fetch-response-url.test.ts index 7875637c3..def391b58 100644 --- a/test/modules/fetch/compliance/fetch-response-url.test.ts +++ b/test/modules/fetch/compliance/fetch-response-url.test.ts @@ -83,6 +83,7 @@ it('returns the last response url in case of redirects', async () => { }) const response = await fetch('http://localhost/target') + expect(response.url).toBe('http://localhost/destination') await expect(response.text()).resolves.toBe('hello world') }) diff --git a/test/modules/fetch/compliance/undici.test.ts b/test/modules/fetch/compliance/undici.test.ts index 5e08644b8..548e39d13 100644 --- a/test/modules/fetch/compliance/undici.test.ts +++ b/test/modules/fetch/compliance/undici.test.ts @@ -28,6 +28,7 @@ it('mocks an HTTP request made with "fetch"', async () => { expect.soft(response.status).toBe(200) expect.soft(Object.fromEntries(response.headers)).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', 'x-custom-header': 'yes', }) await expect.soft(response.text()).resolves.toBe('hello world') @@ -46,6 +47,7 @@ it('mocks an HTTPS request made with "fetch"', async () => { expect.soft(response.status).toBe(200) expect.soft(Object.fromEntries(response.headers)).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', 'x-custom-header': 'yes', }) await expect.soft(response.text()).resolves.toBe('hello world') @@ -63,7 +65,10 @@ it('mocks an HTTP request made with "request"', async () => { const response = await request('http://any.host.here/api') expect.soft(response.statusCode).toBe(200) - expect.soft(response.headers).toEqual({ 'x-custom-header': 'yes' }) + expect.soft(response.headers).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', + 'x-custom-header': 'yes', + }) await expect.soft(response.body.text()).resolves.toBe('hello world') }) @@ -79,6 +84,9 @@ it('mocks an HTTPS request made with "request"', async () => { const response = await request('https://any.host.here/api') expect.soft(response.statusCode).toBe(200) - expect.soft(response.headers).toEqual({ 'x-custom-header': 'yes' }) + expect.soft(response.headers).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', + 'x-custom-header': 'yes', + }) await expect.soft(response.body.text()).resolves.toBe('hello world') }) From 3bf7d9cad7c2e82b12230d50915684e07f5ccfef Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 14:29:08 +0100 Subject: [PATCH 101/198] fix(FetchResponse): return `Response.error()` as-is --- src/utils/fetchUtils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index 94240683d..9fb1983c5 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -1,6 +1,7 @@ import { copyRawHeaders } from '../interceptors/ClientRequest/utils/recordRawHeaders' import { canParseUrl } from './canParseUrl' import { getValueBySymbol } from './getValueBySymbol' +import { isResponseError } from './responseUtils' export interface FetchResponseInit extends ResponseInit { url?: string @@ -31,6 +32,10 @@ export class FetchResponse extends Response { return response } + if (isResponseError(response)) { + return response + } + const fetchResponse = new FetchResponse(response.body, { url: init?.url ?? response.url, status: init?.status || response.status, From e1fc64d8b182b7809d327c6e77acbc138280fad7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 14:32:19 +0100 Subject: [PATCH 102/198] fix(fetch): copy raw headers --- src/interceptors/fetch/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index dc41222b1..bab0e3c06 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -14,6 +14,7 @@ import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { FetchResponse } from '../../utils/fetchUtils' import { isResponseError } from '../../utils/responseUtils' import { applyPatch } from '../../utils/apply-patch' +import { copyRawHeaders } from '../ClientRequest/utils/recordRawHeaders' export class FetchInterceptor extends Interceptor { static symbol = Symbol('fetch') @@ -121,6 +122,8 @@ export class FetchInterceptor extends Interceptor { } ) + copyRawHeaders(rawResponse.headers, response.headers) + /** * Undici's handling of following redirect responses. * Treat the "manual" redirect mode as a regular mocked response. From 44131a38dd5b468a2a88da625254be81173c9739 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 14:39:13 +0100 Subject: [PATCH 103/198] fix: use looser `Symbol.for` for interceptor symbols --- src/interceptors/WebSocket/index.ts | 2 +- src/interceptors/XMLHttpRequest/index.ts | 2 +- src/interceptors/XMLHttpRequest/node.ts | 2 +- src/interceptors/fetch/index.ts | 2 +- src/interceptors/fetch/node.ts | 2 +- src/interceptors/http/index.ts | 2 +- src/interceptors/net/index.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/interceptors/WebSocket/index.ts b/src/interceptors/WebSocket/index.ts index d6fd70575..eecfa65bd 100644 --- a/src/interceptors/WebSocket/index.ts +++ b/src/interceptors/WebSocket/index.ts @@ -70,7 +70,7 @@ export type WebSocketConnectionData = { * the global `WebSocket` class. */ export class WebSocketInterceptor extends Interceptor { - static symbol = Symbol('websocket-interceptor') + static symbol = Symbol.for('websocket-interceptor') constructor() { super(WebSocketInterceptor.symbol) diff --git a/src/interceptors/XMLHttpRequest/index.ts b/src/interceptors/XMLHttpRequest/index.ts index 155ad38b5..3bdd12b73 100644 --- a/src/interceptors/XMLHttpRequest/index.ts +++ b/src/interceptors/XMLHttpRequest/index.ts @@ -8,7 +8,7 @@ import { applyPatch } from '../../utils/apply-patch' export type XMLHttpRequestEmitter = Emitter export class XMLHttpRequestInterceptor extends Interceptor { - static interceptorSymbol = Symbol('xhr') + static interceptorSymbol = Symbol.for('xhr-interceptor') constructor() { super(XMLHttpRequestInterceptor.interceptorSymbol) diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 48c33ba16..f15375f5d 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -5,7 +5,7 @@ import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { applyPatch } from '../../utils/apply-patch' export class XMLHttpRequestInterceptor extends Interceptor { - static interceptorSymbol = Symbol('xhr-interceptor') + static interceptorSymbol = Symbol.for('xhr-interceptor') constructor() { super(XMLHttpRequestInterceptor.interceptorSymbol) diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index bab0e3c06..dc9fa9168 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -17,7 +17,7 @@ import { applyPatch } from '../../utils/apply-patch' import { copyRawHeaders } from '../ClientRequest/utils/recordRawHeaders' export class FetchInterceptor extends Interceptor { - static symbol = Symbol('fetch') + static symbol = Symbol.for('fetch-interceptor') constructor() { super(FetchInterceptor.symbol) diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 505944f53..c4659e926 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -6,7 +6,7 @@ import { requestContext } from '../../request-context' import { applyPatch } from '../../utils/apply-patch' export class FetchInterceptor extends Interceptor { - static symbol = Symbol('fetch') + static symbol = Symbol.for('fetch-interceptor') constructor() { super(FetchInterceptor.symbol) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 4c8e4b2b2..d48fe9435 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -38,7 +38,7 @@ import { requestContext } from '../../request-context' const log = createLogger('HttpRequestInterceptor') export class HttpRequestInterceptor extends Interceptor { - static symbol = Symbol('http-request-interceptor') + static symbol = Symbol.for('http-request-interceptor') constructor() { super(HttpRequestInterceptor.symbol) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 4d3160903..6936391f0 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -23,7 +23,7 @@ interface SocketEventMap { const log = createLogger('SocketInterceptor') export class SocketInterceptor extends Interceptor { - static symbol = Symbol('socket-interceptor') + static symbol = Symbol.for('socket-interceptor') constructor() { super(SocketInterceptor.symbol) From ba9eea0f08fefd796cdd38317a29c88b13932bde Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 14:40:24 +0100 Subject: [PATCH 104/198] fix(RemoteHttpInterceptor): set `initiator` to `null` --- src/RemoteHttpInterceptor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/RemoteHttpInterceptor.ts b/src/RemoteHttpInterceptor.ts index 784608529..b40c09a2a 100644 --- a/src/RemoteHttpInterceptor.ts +++ b/src/RemoteHttpInterceptor.ts @@ -210,6 +210,7 @@ export class RemoteHttpResolver extends Interceptor { // Emit an optimistic "response" event at this point, // not to rely on the back-and-forth signaling for the sake of the event. this.emitter.emit('response', { + initiator: null, request, requestId: requestJson.id, response: responseClone, @@ -230,6 +231,7 @@ export class RemoteHttpResolver extends Interceptor { }) await handleRequest({ + initiator: null, request, requestId: requestJson.id, controller, From c3a4ccc8ff2f74e85b48e7c2d710b13f494b6ba2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 14:44:44 +0100 Subject: [PATCH 105/198] chore: remove `sleep` in favor of `setTimeout` --- README.md | 4 +++- src/interceptors/ClientRequest/index.test.ts | 9 +++------ test/features/events/response.test.ts | 2 -- test/helpers.ts | 6 ------ .../WebSocket/compliance/websocket.events.test.ts | 4 ++-- .../XMLHttpRequest/compliance/xhr-timeout.test.ts | 4 ++-- .../modules/fetch/compliance/abort-conrtoller.test.ts | 5 ++--- .../http/compliance/http-abort-controller.test.ts | 4 ++-- test/modules/http/compliance/http-errors.test.ts | 11 ++++++----- .../http-concurrent-different-response-source.test.ts | 6 +++--- .../modules/http/response/http-response-delay.test.ts | 7 ++++--- .../http/response/http-response-patching.test.ts | 5 +++-- test/third-party/got.test.ts | 5 +++-- 13 files changed, 33 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 3f343feaa..455eecfa6 100644 --- a/README.md +++ b/README.md @@ -282,10 +282,12 @@ Note that a single request _can only be handled once_. You may want to introduce Requests must be responded to within the same tick as the request listener. This means you cannot respond to a request using `setTimeout`, as this will delegate the callback to the next tick. If you wish to introduce asynchronous side-effects in the listener, consider making it an `async` function, awaiting any side-effects you need. ```js +import { setTimeout } from 'node:timers/promises' + // Respond to all requests with a 500 response // delayed by 500ms. interceptor.on('request', async ({ controller }) => { - await sleep(500) + await setTimeout(500) controller.respondWith(new Response(null, { status: 500 })) }) ``` diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 12a25094a..4c96e5359 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -1,12 +1,9 @@ import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { ClientRequestInterceptor } from '.' -import { - sleep, - toWebResponse, - waitForClientRequest, -} from '../../../test/helpers' +import { toWebResponse } from '../../../test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -37,7 +34,7 @@ it('abort the request if the abort signal is emitted', async () => { const requestUrl = httpServer.http.url('/') interceptor.on('request', async function delayedResponse({ controller }) { - await sleep(1_000) + await setTimeout(1000) controller.respondWith(new Response()) }) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index bededf796..54a020d7d 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -274,8 +274,6 @@ it('fetch: emits the "response" event upon a mocked response', async () => { HttpRequestEventMap['response'][0] >() interceptor.on('response', (args) => { - console.log('RESPONSE!', args.response) - responseListenerArgs.resolve({ ...args, request: args.request.clone(), diff --git a/test/helpers.ts b/test/helpers.ts index f3e2dd52a..31c0c014a 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -317,12 +317,6 @@ export async function toWebResponse( return pendingResponse } -export function sleep(duration: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, duration) - }) -} - export const useCors: RequestHandler = (_req, res, next) => { res.set({ 'access-control-allow-origin': '*', diff --git a/test/modules/WebSocket/compliance/websocket.events.test.ts b/test/modules/WebSocket/compliance/websocket.events.test.ts index 54d19c5ce..8ca0f54d4 100644 --- a/test/modules/WebSocket/compliance/websocket.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.events.test.ts @@ -3,11 +3,11 @@ * This test suite asserts that the intercepted WebSocket client * still dispatches the correct events in mocked/bypassed scenarios. */ +import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketServer } from 'ws' import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' -import { sleep } from '#/test/helpers' const wsServer = new WebSocketServer({ host: '127.0.0.1', @@ -287,7 +287,7 @@ it('allows erroring the connection from an asynchronous listener', async ({ vi.spyOn(console, 'error').mockImplementation(() => {}) interceptor.once('connection', async () => { - await sleep(200) + await setTimeout(200) throw new Error('mock error') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts index 6dc4a056f..0e0543433 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts @@ -2,15 +2,15 @@ /** * @see https://github.com/mswjs/interceptors/issues/7 */ +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { sleep } from '#/test/helpers' import { createXMLHttpRequest } from '#/test/helpers' import { DeferredPromise } from '@open-draft/deferred-promise' const httpServer = new HttpServer((app) => { app.get('/', async (_req, res) => { - await sleep(50) + await setTimeout(50) res.send('ok') }) }) diff --git a/test/modules/fetch/compliance/abort-conrtoller.test.ts b/test/modules/fetch/compliance/abort-conrtoller.test.ts index c83f1c9a6..95925849c 100644 --- a/test/modules/fetch/compliance/abort-conrtoller.test.ts +++ b/test/modules/fetch/compliance/abort-conrtoller.test.ts @@ -3,7 +3,6 @@ import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' import { FetchInterceptor } from '#/src/interceptors/fetch' -import { sleep } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -56,7 +55,7 @@ it('aborts a pending request when the original request is aborted', async () => interceptor.on('request', async ({ controller }) => { requestListenerCalled.resolve() - await sleep(1000) + await setTimeout(1000) controller.respondWith(new Response()) }) @@ -103,7 +102,7 @@ it('forwards custom abort reason to the request if pending', async () => { interceptor.once('request', async ({ controller }) => { requestListenerCalled.resolve() - await sleep(1000) + await setTimeout(1000) controller.respondWith(new Response()) }) diff --git a/test/modules/http/compliance/http-abort-controller.test.ts b/test/modules/http/compliance/http-abort-controller.test.ts index 8bd007c13..c223b7d50 100644 --- a/test/modules/http/compliance/http-abort-controller.test.ts +++ b/test/modules/http/compliance/http-abort-controller.test.ts @@ -4,11 +4,11 @@ import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { sleep, toWebResponse } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { - await sleep(200) + await setTimeout(200) res.status(500).end() }) }) diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index 176d7ea62..ca22a843f 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -1,8 +1,9 @@ // @vitest-environment node import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { sleep, toWebResponse } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' const interceptor = new HttpRequestInterceptor() @@ -29,7 +30,7 @@ afterAll(() => { it('suppresses ECONNREFUSED error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { - await sleep(250) + await setTimeout(250) controller.respondWith(new Response('mocked')) }) @@ -74,7 +75,7 @@ it('forwards ECONNREFUSED error given a bypassed request', async () => { it('suppresses ENOTFOUND error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { - await sleep(250) + await setTimeout(250) controller.respondWith(new Response('mocked')) }) @@ -107,7 +108,7 @@ it('forwards ENOTFOUND error for a bypassed request', async () => { it('suppresses EHOSTUNREACH error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { - await sleep(250) + await setTimeout(250) controller.respondWith(new Response('mocked')) }) @@ -162,7 +163,7 @@ it('allows throwing connection errors in the request listener', async () => { } interceptor.on('request', async () => { - await sleep(250) + await setTimeout(250) // A connection error thrown in the request listener // will not be suppressed, and will forward to the consumer. 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 11f6e88ab..f8afb4e6f 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,12 +1,12 @@ // @vitest-environment node +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { httpGet } from '#/test/helpers' -import { sleep } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', async (req, res) => { - await sleep(300) + await setTimeout(300) res.status(200).send('original-response') }) }) @@ -33,7 +33,7 @@ it('handles concurrent requests with different response sources', async () => { return } - await sleep(250) + await setTimeout(250) controller.respondWith(new Response('mocked-response', { status: 201 })) }) diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 29d071732..9d9581ccc 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -1,7 +1,8 @@ // @vitest-environment node import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { sleep, toWebResponse } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() @@ -24,7 +25,7 @@ afterAll(async () => { it('supports custom delay before responding with a mock', async () => { interceptor.once('request', async ({ controller }) => { - await sleep(750) + await setTimeout(750) controller.respondWith(new Response('mocked response')) }) @@ -42,7 +43,7 @@ it('supports custom delay before receiving the original response', async () => { interceptor.once('request', async () => { // This will simply delay the request execution before // it receives the original response. - await sleep(750) + await setTimeout(750) }) const requestStart = Date.now() diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 04a236f11..cbae946ec 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,8 +1,9 @@ // @vitest-environment node import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { sleep, toWebResponse } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' const server = new HttpServer((app) => { app.get('/original', async (req, res) => { @@ -20,7 +21,7 @@ async function getResponse(request: Request): Promise { return new Promise(async (resolve) => { // Defer the resolution of the promise to the next tick. // Request handlers in MSW resolve on the next tick. - await sleep(0) + await setTimeout(0) const originalRequest = http.get(server.http.url('/original')) const [response, rawResponse] = await toWebResponse(originalRequest) diff --git a/test/third-party/got.test.ts b/test/third-party/got.test.ts index 3e42da9f0..d72fef2c1 100644 --- a/test/third-party/got.test.ts +++ b/test/third-party/got.test.ts @@ -1,7 +1,8 @@ +// @vitest-environment node +import { setTimeout } from 'node:timers/promises' import got from 'got' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { sleep } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -45,7 +46,7 @@ it('bypasses an unhandled request made with "got"', async () => { it('supports timeout before resolving request as-is', async () => { interceptor.on('request', async ({ controller }) => { - await sleep(750) + await setTimeout(750) controller.respondWith(new Response('mocked response')) }) From 496de4aca7e5c698d929b8c12d4c60f6bd48af64 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 15:03:41 +0100 Subject: [PATCH 106/198] chore: remove old `ClientRequestInterceptor` --- .../ClientRequest/MockHttpSocket.ts | 722 ------------------ src/interceptors/ClientRequest/agents.ts | 110 --- src/interceptors/ClientRequest/index.test.ts | 20 - src/interceptors/ClientRequest/index.ts | 217 +----- src/interceptors/ClientRequest/new.ts | 66 -- .../utils/getIncomingMessageBody.test.ts | 53 -- .../utils/getIncomingMessageBody.ts | 45 -- .../utils/normalizeClientRequestArgs.test.ts | 429 ----------- .../utils/normalizeClientRequestArgs.ts | 268 ------- .../ClientRequest/utils/parserUtils.ts | 48 -- .../ClientRequest/utils/recordRawHeaders.ts | 4 +- src/interceptors/fetch/node.ts | 2 + src/interceptors/http/index.ts | 9 +- test/features/request-initiator.test.ts | 2 +- test/helpers.ts | 107 +-- .../http/compliance/http-request-ipv6.test.ts | 11 +- .../http/compliance/https-constructor.test.ts | 28 +- test/modules/http/http-performance.test.ts | 31 +- ...ncurrent-different-response-source.test.ts | 25 +- test/third-party/miniflare.test.ts | 22 +- 20 files changed, 116 insertions(+), 2103 deletions(-) delete mode 100644 src/interceptors/ClientRequest/MockHttpSocket.ts delete mode 100644 src/interceptors/ClientRequest/agents.ts delete mode 100644 src/interceptors/ClientRequest/new.ts delete mode 100644 src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts delete mode 100644 src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts delete mode 100644 src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts delete mode 100644 src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts delete mode 100644 src/interceptors/ClientRequest/utils/parserUtils.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts deleted file mode 100644 index a945c1912..000000000 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ /dev/null @@ -1,722 +0,0 @@ -import net from 'node:net' -import { - type HeadersCallback, - HTTPParser, - type RequestHeadersCompleteCallback, - type ResponseHeadersCompleteCallback, -} from '_http_common' -import { STATUS_CODES, IncomingMessage, ServerResponse } from 'node:http' -import { Readable } from 'node:stream' -import { invariant } from 'outvariant' -import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' -import { MockSocket } from '../Socket/MockSocket' -import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs' -import { isPropertyAccessible } from '../../utils/isPropertyAccessible' -import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' -import { createRequestId } from '../../createRequestId' -import { getRawFetchHeaders } from './utils/recordRawHeaders' -import { FetchResponse } from '../../utils/fetchUtils' -import { freeParser } from './utils/parserUtils' - -type HttpConnectionOptions = any - -export type MockHttpSocketRequestCallback = (args: { - requestId: string - request: Request - socket: MockHttpSocket -}) => void - -export type MockHttpSocketResponseCallback = (args: { - requestId: string - request: Request - response: Response - isMockedResponse: boolean - socket: MockHttpSocket -}) => Promise - -interface MockHttpSocketOptions { - connectionOptions: HttpConnectionOptions - createConnection: () => net.Socket - onRequest: MockHttpSocketRequestCallback - onResponse: MockHttpSocketResponseCallback -} - -export const kRequestId = Symbol('kRequestId') - -export class MockHttpSocket extends MockSocket { - private connectionOptions: HttpConnectionOptions - private createConnection: () => net.Socket - private baseUrl: URL - - private onRequest: MockHttpSocketRequestCallback - private onResponse: MockHttpSocketResponseCallback - private responseListenersPromise?: Promise - - private requestRawHeadersBuffer: Array = [] - private responseRawHeadersBuffer: Array = [] - private writeBuffer: Array = [] - private request?: Request - private requestParser: HTTPParser<0> - private requestStream?: Readable - private shouldKeepAlive?: boolean - - private socketState: 'unknown' | 'mock' | 'passthrough' = 'unknown' - private responseParser: HTTPParser<1> - private responseStream?: Readable - private originalSocket?: net.Socket - - constructor(options: MockHttpSocketOptions) { - super({ - write: (chunk, encoding, callback) => { - // Buffer the writes so they can be flushed in case of the original connection - // and when reading the request body in the interceptor. If the connection has - // been established, no need to buffer the chunks anymore, they will be forwarded. - if (this.socketState !== 'passthrough') { - this.writeBuffer.push([chunk, encoding, callback]) - } - - if (chunk) { - /** - * Forward any writes to the mock socket to the underlying original socket. - * This ensures functional duplex connections, like WebSocket. - * @see https://github.com/mswjs/interceptors/issues/682 - */ - if (this.socketState === 'passthrough') { - this.originalSocket?.write(chunk, encoding, callback) - } - - this.requestParser.execute( - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) - ) - } - }, - read: (chunk) => { - if (chunk !== null) { - /** - * @todo We need to free the parser if the connection has been - * upgraded to a non-HTTP protocol. It won't be able to parse data - * from that point onward anyway. No need to keep it in memory. - */ - this.responseParser.execute( - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - ) - } - }, - }) - - this.connectionOptions = options.connectionOptions - this.createConnection = options.createConnection - this.onRequest = options.onRequest - this.onResponse = options.onResponse - - this.baseUrl = baseUrlFromConnectionOptions(this.connectionOptions) - - // Request parser. - this.requestParser = new HTTPParser() - this.requestParser.initialize(HTTPParser.REQUEST, {}) - this.requestParser[HTTPParser.kOnHeaders] = this.onRequestHeaders.bind(this) - this.requestParser[HTTPParser.kOnHeadersComplete] = - this.onRequestStart.bind(this) - this.requestParser[HTTPParser.kOnBody] = this.onRequestBody.bind(this) - this.requestParser[HTTPParser.kOnMessageComplete] = - this.onRequestEnd.bind(this) - - // Response parser. - this.responseParser = new HTTPParser() - this.responseParser.initialize(HTTPParser.RESPONSE, {}) - this.responseParser[HTTPParser.kOnHeaders] = - this.onResponseHeaders.bind(this) - this.responseParser[HTTPParser.kOnHeadersComplete] = - this.onResponseStart.bind(this) - this.responseParser[HTTPParser.kOnBody] = this.onResponseBody.bind(this) - this.responseParser[HTTPParser.kOnMessageComplete] = - this.onResponseEnd.bind(this) - - // Once the socket is finished, nothing can write to it - // anymore. It has also flushed any buffered chunks. - this.once('finish', () => freeParser(this.requestParser, this)) - - if (this.baseUrl.protocol === 'https:') { - Reflect.set(this, 'encrypted', true) - // The server certificate is not the same as a CA - // passed to the TLS socket connection options. - Reflect.set(this, 'authorized', false) - Reflect.set(this, 'getProtocol', () => 'TLSv1.3') - Reflect.set(this, 'getSession', () => undefined) - Reflect.set(this, 'isSessionReused', () => false) - Reflect.set(this, 'getCipher', () => ({ - name: 'AES256-SHA', - standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', - version: 'TLSv1.3', - })) - } - } - - public emit(event: string | symbol, ...args: any[]): boolean { - const emitEvent = super.emit.bind(this, event as any, ...args) - - if (this.responseListenersPromise) { - this.responseListenersPromise.finally(emitEvent) - return this.listenerCount(event) > 0 - } - - return emitEvent() - } - - public destroy(error?: Error | undefined): this { - // Destroy the response parser when the socket gets destroyed. - // Normally, we should listen to the "close" event but it - // can be suppressed by using the "emitClose: false" option. - freeParser(this.responseParser, this) - - if (error) { - this.emit('error', error) - } - - return super.destroy(error) - } - - /** - * Establish this Socket connection as-is and pipe - * its data/events through this Socket. - */ - public passthrough(): void { - this.socketState = 'passthrough' - - if (this.destroyed) { - return - } - - const socket = this.createConnection() - this.originalSocket = socket - - /** - * @note Inherit the original socket's connection handle. - * Without this, each push to the mock socket results in a - * new "connection" listener being added (i.e. buffering pushes). - * @see https://github.com/nodejs/node/blob/b18153598b25485ce4f54d0c5cb830a9457691ee/lib/net.js#L734 - */ - if ('_handle' in socket) { - Object.defineProperty(this, '_handle', { - value: socket._handle, - enumerable: true, - writable: true, - }) - } - - // The client-facing socket can be destroyed in two ways: - // 1. The developer destroys the socket. - // 2. The passthrough socket "close" is forwarded to the socket. - this.once('close', () => { - socket.removeAllListeners() - - // If the closure didn't originate from the passthrough socket, destroy it. - if (!socket.destroyed) { - socket.destroy() - } - - this.originalSocket = undefined - }) - - this.address = socket.address.bind(socket) - - // Flush the buffered "socket.write()" calls onto - // the original socket instance (i.e. write request body). - // Exhaust the "requestBuffer" in case this Socket - // gets reused for different requests. - let writeArgs: NormalizedSocketWriteArgs | undefined - let headersWritten = false - - while ((writeArgs = this.writeBuffer.shift())) { - if (writeArgs !== undefined) { - if (!headersWritten) { - const [chunk, encoding, callback] = writeArgs - const chunkString = chunk.toString() - const chunkBeforeRequestHeaders = chunkString.slice( - 0, - chunkString.indexOf('\r\n') + 2 - ) - const chunkAfterRequestHeaders = chunkString.slice( - chunk.indexOf('\r\n\r\n') - ) - const rawRequestHeaders = getRawFetchHeaders(this.request!.headers) - const requestHeadersString = rawRequestHeaders - // Skip the internal request ID deduplication header. - .filter(([name]) => { - return name.toLowerCase() !== INTERNAL_REQUEST_ID_HEADER_NAME - }) - .map(([name, value]) => `${name}: ${value}`) - .join('\r\n') - - // Modify the HTTP request message headers - // to reflect any changes to the request headers - // from the "request" event listener. - const headersChunk = `${chunkBeforeRequestHeaders}${requestHeadersString}${chunkAfterRequestHeaders}` - socket.write(headersChunk, encoding, callback) - headersWritten = true - continue - } - - socket.write(...writeArgs) - } - } - - // Forward TLS Socket properties onto this Socket instance - // in the case of a TLS/SSL connection. - if (Reflect.get(socket, 'encrypted')) { - const tlsProperties = [ - 'encrypted', - 'authorized', - 'getProtocol', - 'getSession', - 'isSessionReused', - 'getCipher', - ] - - tlsProperties.forEach((propertyName) => { - Object.defineProperty(this, propertyName, { - enumerable: true, - get: () => { - const value = Reflect.get(socket, propertyName) - return typeof value === 'function' ? value.bind(socket) : value - }, - }) - }) - } - - socket - .on('lookup', (...args) => this.emit('lookup', ...args)) - .on('connect', () => { - this.connecting = socket.connecting - this.emit('connect') - }) - .on('secureConnect', () => this.emit('secureConnect')) - .on('secure', () => this.emit('secure')) - .on('session', (session) => this.emit('session', session)) - .on('ready', () => this.emit('ready')) - .on('drain', () => this.emit('drain')) - .on('data', (chunk) => { - // Push the original response to this socket - // so it triggers the HTTP response parser. This unifies - // the handling pipeline for original and mocked response. - this.push(chunk) - }) - .on('error', (error) => { - Reflect.set(this, '_hadError', Reflect.get(socket, '_hadError')) - this.emit('error', error) - }) - .on('resume', () => this.emit('resume')) - .on('timeout', () => this.emit('timeout')) - .on('prefinish', () => this.emit('prefinish')) - .on('finish', () => this.emit('finish')) - .on('close', (hadError) => this.emit('close', hadError)) - .on('end', () => this.emit('end')) - } - - /** - * Convert the given Fetch API `Response` instance to an - * HTTP message and push it to the socket. - */ - public async respondWith(response: Response): Promise { - // Ignore the mocked response if the socket has been destroyed - // (e.g. aborted or timed out), - if (this.destroyed) { - return - } - - // Prevent recursive calls. - invariant( - this.socketState !== 'mock', - '[MockHttpSocket] Failed to respond to the "%s %s" request with "%s %s": the request has already been handled', - this.request?.method, - this.request?.url, - response.status, - response.statusText - ) - - // Handle "type: error" responses. - if (isPropertyAccessible(response, 'type') && response.type === 'error') { - this.errorWith(new TypeError('Network error')) - return - } - - // First, emit all the connection events - // to emulate a successful connection. - this.mockConnect() - this.socketState = 'mock' - - // Flush the write buffer to trigger write callbacks - // if it hasn't been flushed already (e.g. someone started reading request stream). - this.flushWriteBuffer() - - // Create a `ServerResponse` instance to delegate HTTP message parsing, - // Transfer-Encoding, and other things to Node.js internals. - const serverResponse = new ServerResponse(new IncomingMessage(this)) - - /** - * Assign a mock socket instance to the server response to - * spy on the response chunk writes. Push the transformed response chunks - * to this `MockHttpSocket` instance to trigger the "data" event. - * @note Providing the same `MockSocket` instance when creating `ServerResponse` - * does not have the same effect. - * @see https://github.com/nodejs/node/blob/10099bb3f7fd97bb9dd9667188426866b3098e07/test/parallel/test-http-server-response-standalone.js#L32 - */ - serverResponse.assignSocket( - new MockSocket({ - write: (chunk, encoding, callback) => { - this.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') - - const rawResponseHeaders = getRawFetchHeaders(response.headers) - - /** - * @note Call `.writeHead` in order to set the raw response headers - * in the same case as they were provided by the developer. Using - * `.setHeader()`/`.appendHeader()` normalizes header names. - */ - serverResponse.writeHead( - response.status, - response.statusText || STATUS_CODES[response.status], - rawResponseHeaders - ) - - // If the developer destroy the socket, gracefully destroy the response. - this.once('error', () => { - serverResponse.destroy() - }) - - 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) { - if (error instanceof Error) { - serverResponse.destroy() - /** - * @note Destroy the request socket gracefully. - * Response stream errors do NOT produce request errors. - */ - this.destroy() - return - } - - serverResponse.destroy() - throw error - } - } else { - serverResponse.end() - } - - // Close the socket if the connection wasn't marked as keep-alive. - if (!this.shouldKeepAlive) { - this.emit('readable') - - /** - * @todo @fixme This is likely a hack. - * Since we push null to the socket, it never propagates to the - * parser, and the parser never calls "onResponseEnd" to close - * the response stream. We are closing the stream here manually - * but that shouldn't be the case. - */ - this.responseStream?.push(null) - this.push(null) - } - } - - /** - * Close this socket connection with the given error. - */ - public errorWith(error?: Error): void { - this.destroy(error) - } - - private mockConnect(): void { - // Calling this method immediately puts the socket - // into the connected state. - this.connecting = false - - const isIPv6 = - net.isIPv6(this.connectionOptions.hostname) || - this.connectionOptions.family === 6 - const addressInfo = { - address: isIPv6 ? '::1' : '127.0.0.1', - family: isIPv6 ? 'IPv6' : 'IPv4', - port: this.connectionOptions.port, - } - // Return fake address information for the socket. - this.address = () => addressInfo - this.emit( - 'lookup', - null, - addressInfo.address, - addressInfo.family === 'IPv6' ? 6 : 4, - this.connectionOptions.host - ) - this.emit('connect') - this.emit('ready') - - if (this.baseUrl.protocol === 'https:') { - this.emit('secure') - this.emit('secureConnect') - - // A single TLS connection is represented by two "session" events. - this.emit( - 'session', - this.connectionOptions.session || - Buffer.from('mock-session-renegotiate') - ) - this.emit('session', Buffer.from('mock-session-resume')) - } - } - - private flushWriteBuffer(): void { - for (const writeCall of this.writeBuffer) { - if (typeof writeCall[2] === 'function') { - writeCall[2]() - /** - * @note Remove the callback from the write call - * so it doesn't get called twice on passthrough - * if `request.end()` was called within `request.write()`. - * @see https://github.com/mswjs/interceptors/issues/684 - */ - writeCall[2] = undefined - } - } - } - - /** - * This callback might be called when the request is "slow": - * - Request headers were fragmented across multiple TCP packages; - * - Request headers were too large to be processed in a single run - * (e.g. more than 30 request headers). - * @note This is called before request start. - */ - private onRequestHeaders: HeadersCallback = (rawHeaders) => { - this.requestRawHeadersBuffer.push(...rawHeaders) - } - - private onRequestStart: RequestHeadersCompleteCallback = ( - versionMajor, - versionMinor, - rawHeaders, - _, - path, - __, - ___, - ____, - shouldKeepAlive - ) => { - this.shouldKeepAlive = shouldKeepAlive - - const url = new URL(path || '', this.baseUrl) - const method = this.connectionOptions.method?.toUpperCase() || 'GET' - const headers = FetchResponse.parseRawHeaders([ - ...this.requestRawHeadersBuffer, - ...(rawHeaders || []), - ]) - this.requestRawHeadersBuffer.length = 0 - - const canHaveBody = method !== 'GET' && method !== 'HEAD' - - // Translate the basic authorization in the URL to the request header. - // 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 = '' - } - - // Create a new stream for each request. - // If this Socket is reused for multiple requests, - // this ensures that each request gets its own stream. - // One Socket instance can only handle one request at a time. - this.requestStream = new Readable({ - /** - * @note Provide the `read()` method so a `Readable` could be - * used as the actual request body (the stream calls "read()"). - * We control the queue in the onRequestBody/End functions. - */ - 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. - this.flushWriteBuffer() - }, - }) - - const requestId = createRequestId() - this.request = new Request(url, { - method, - headers, - credentials: 'same-origin', - // @ts-expect-error Undocumented Fetch property. - duplex: canHaveBody ? 'half' : undefined, - body: canHaveBody ? (Readable.toWeb(this.requestStream!) as any) : null, - }) - - Reflect.set(this.request, kRequestId, requestId) - - // Set the raw `http.ClientRequest` instance on the request instance. - // This is useful for cases like getting the raw headers of the request. - // setRawRequest(this.request, Reflect.get(this, '_httpMessage')) - - // Create a copy of the request body stream and store it on the request. - // This is only needed for the consumers who wish to read the request body stream - // of requests that cannot have a body per Fetch API specification (i.e. GET, HEAD). - // setRawRequestBodyStream(this.request, this.requestStream) - - // Skip handling the request that's already being handled - // by another (parent) interceptor. For example, XMLHttpRequest - // is often implemented via ClientRequest in Node.js (e.g. JSDOM). - // In that case, XHR interceptor will bubble down to the ClientRequest - // interceptor. No need to try to handle that request again. - /** - * @fixme Stop relying on the "X-Request-Id" request header - * to figure out if one interceptor has been invoked within another. - * @see https://github.com/mswjs/interceptors/issues/378 - */ - if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) { - this.passthrough() - return - } - - this.onRequest({ - requestId, - request: this.request, - socket: this, - }) - } - - private onRequestBody(chunk: Buffer): void { - invariant( - this.requestStream, - 'Failed to write to a request stream: stream does not exist' - ) - - this.requestStream.push(chunk) - } - - private onRequestEnd(): void { - // Request end can be called for requests without body. - if (this.requestStream) { - this.requestStream.push(null) - } - } - - /** - * This callback might be called when the response is "slow": - * - Response headers were fragmented across multiple TCP packages; - * - Response headers were too large to be processed in a single run - * (e.g. more than 30 response headers). - * @note This is called before response start. - */ - private onResponseHeaders: HeadersCallback = (rawHeaders) => { - this.responseRawHeadersBuffer.push(...rawHeaders) - } - - private onResponseStart: ResponseHeadersCompleteCallback = ( - versionMajor, - versionMinor, - rawHeaders, - method, - url, - status, - statusText - ) => { - const headers = FetchResponse.parseRawHeaders([ - ...this.responseRawHeadersBuffer, - ...(rawHeaders || []), - ]) - this.responseRawHeadersBuffer.length = 0 - - const response = new FetchResponse( - /** - * @note The Fetch API response instance exposed to the consumer - * is created over the response stream of the HTTP parser. It is NOT - * related to the Socket instance. This way, you can read response body - * in response listener while the Socket instance delays the emission - * of "end" and other events until those response listeners are finished. - */ - FetchResponse.isResponseWithBody(status) - ? (Readable.toWeb( - (this.responseStream = new Readable({ read() {} })) - ) as any) - : null, - { - url, - status, - statusText, - headers, - } - ) - - invariant( - this.request, - 'Failed to handle a response: request does not exist' - ) - - FetchResponse.setUrl(this.request.url, response) - - /** - * @fixme Stop relying on the "X-Request-Id" request header - * to figure out if one interceptor has been invoked within another. - * @see https://github.com/mswjs/interceptors/issues/378 - */ - if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) { - return - } - - this.responseListenersPromise = this.onResponse({ - response, - isMockedResponse: this.socketState === 'mock', - requestId: Reflect.get(this.request, kRequestId), - request: this.request, - socket: this, - }) - } - - private onResponseBody(chunk: Buffer) { - invariant( - this.responseStream, - 'Failed to write to a response stream: stream does not exist' - ) - - this.responseStream.push(chunk) - } - - private onResponseEnd(): void { - // Response end can be called for responses without body. - if (this.responseStream) { - this.responseStream.push(null) - } - } -} diff --git a/src/interceptors/ClientRequest/agents.ts b/src/interceptors/ClientRequest/agents.ts deleted file mode 100644 index ec8ddd23c..000000000 --- a/src/interceptors/ClientRequest/agents.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Here's how requests are handled in Node.js: - * - * 1. http.ClientRequest instance calls `agent.addRequest(request, options, cb)`. - * 2. Agent creates a new socket: `agent.createSocket(options, cb)`. - * 3. Agent creates a new connection: `agent.createConnection(options, cb)`. - */ -import net from 'node:net' -import http from 'node:http' -import https from 'node:https' -import { - MockHttpSocket, - type MockHttpSocketRequestCallback, - type MockHttpSocketResponseCallback, -} from './MockHttpSocket' - -declare module 'node:http' { - interface Agent { - options?: http.AgentOptions - createConnection(options: any, callback: any): net.Socket - } -} - -interface MockAgentOptions { - customAgent?: http.RequestOptions['agent'] - onRequest: MockHttpSocketRequestCallback - onResponse: MockHttpSocketResponseCallback -} - -export class MockAgent extends http.Agent { - private customAgent?: http.RequestOptions['agent'] - private onRequest: MockHttpSocketRequestCallback - private onResponse: MockHttpSocketResponseCallback - - constructor(options: MockAgentOptions) { - super() - this.customAgent = options.customAgent - this.onRequest = options.onRequest - this.onResponse = options.onResponse - } - - public createConnection(options: any, callback: any): net.Socket { - const createConnection = - this.customAgent instanceof http.Agent - ? this.customAgent.createConnection - : super.createConnection - - const createConnectionOptions = - this.customAgent instanceof http.Agent - ? { - ...options, - ...this.customAgent.options, - } - : options - - const socket = new MockHttpSocket({ - connectionOptions: options, - createConnection: createConnection.bind( - this.customAgent || this, - createConnectionOptions, - callback - ), - onRequest: this.onRequest.bind(this), - onResponse: this.onResponse.bind(this), - }) - - return socket - } -} - -export class MockHttpsAgent extends https.Agent { - private customAgent?: https.RequestOptions['agent'] - private onRequest: MockHttpSocketRequestCallback - private onResponse: MockHttpSocketResponseCallback - - constructor(options: MockAgentOptions) { - super() - this.customAgent = options.customAgent - this.onRequest = options.onRequest - this.onResponse = options.onResponse - } - - public createConnection(options: any, callback: any): net.Socket { - const createConnection = - this.customAgent instanceof http.Agent - ? this.customAgent.createConnection - : super.createConnection - - const createConnectionOptions = - this.customAgent instanceof http.Agent - ? { - ...options, - ...this.customAgent.options, - } - : options - - const socket = new MockHttpSocket({ - connectionOptions: options, - createConnection: createConnection.bind( - this.customAgent || this, - createConnectionOptions, - callback - ), - onRequest: this.onRequest.bind(this), - onResponse: this.onResponse.bind(this), - }) - - return socket - } -} diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 4c96e5359..40b19f565 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -53,23 +53,3 @@ it('abort the request if the abort signal is emitted', async () => { expect(request.destroyed).toBe(true) }) - -it('patch the Headers object correctly after dispose and reapply', async () => { - interceptor.dispose() - interceptor.apply() - - interceptor.on('request', ({ controller }) => { - const headers = new Headers({ - 'X-CustoM-HeadeR': 'Yes', - }) - controller.respondWith(new Response(null, { headers })) - }) - - const request = http.get(httpServer.http.url('/')) - const [response, rawResponse] = await toWebResponse(request) - - expect(rawResponse.rawHeaders).toEqual( - expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) - ) - expect(response.headers.get('x-custom-header')).toBe('Yes') -}) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 471233614..a01031936 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,21 +1,9 @@ import http from 'node:http' import https from 'node:https' +import { HttpRequestEventMap } from '../../glossary' import { Interceptor } from '../../Interceptor' -import type { HttpRequestEventMap } from '../../glossary' -import { - kRequestId, - MockHttpSocketRequestCallback, - MockHttpSocketResponseCallback, -} from './MockHttpSocket' -import { MockAgent, MockHttpsAgent } from './agents' -import { RequestController } from '../../RequestController' -import { emitAsync } from '../../utils/emitAsync' -import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' -import { handleRequest } from '../../utils/handleRequest' -import { - recordRawFetchHeaders, - restoreHeadersPrototype, -} from './utils/recordRawHeaders' +import { runInRequestContext } from '../../request-context' +import { applyPatch } from '#/src/utils/apply-patch' export class ClientRequestInterceptor extends Interceptor { static symbol = Symbol('client-request-interceptor') @@ -25,169 +13,44 @@ export class ClientRequestInterceptor extends Interceptor { } protected setup(): void { - const { - ClientRequest: OriginalClientRequest, - get: originalGet, - request: originalRequest, - } = http - const { get: originalHttpsGet, request: originalHttpsRequest } = https - - const onRequest = this.onRequest.bind(this) - const onResponse = this.onResponse.bind(this) - - // Support requests performed via the `ClientRequest` constructor directly. - http.ClientRequest = new Proxy(http.ClientRequest, { - construct: (target, args: Parameters) => { - const [url, options, callback] = normalizeClientRequestArgs( - 'http:', - args - ) - - // Create a mock agent instance appropriate for the request protocol. - const Agent = options.protocol === 'https:' ? MockHttpsAgent : MockAgent - const mockAgent = new Agent({ - customAgent: options.agent, - onRequest, - onResponse, + this.subscriptions.push( + applyPatch(http, 'ClientRequest', (ClientRequest) => { + return new Proxy(ClientRequest, { + construct(target, args, newTarget) { + return runInRequestContext(() => { + return Reflect.construct(target, args, newTarget) + }) + }, }) - options.agent = mockAgent - - return Reflect.construct(target, [url, options, callback]) - }, - }) - - http.request = new Proxy(http.request, { - apply: (target, thisArg, args: Parameters) => { - const [url, options, callback] = normalizeClientRequestArgs( - 'http:', - args - ) - const mockAgent = new MockAgent({ - customAgent: options.agent, - onRequest, - onResponse, - }) - options.agent = mockAgent - - return Reflect.apply(target, thisArg, [url, options, callback]) - }, - }) - - http.get = new Proxy(http.get, { - apply: (target, thisArg, args: Parameters) => { - const [url, options, callback] = normalizeClientRequestArgs( - 'http:', - args - ) - - const mockAgent = new MockAgent({ - customAgent: options.agent, - onRequest, - onResponse, - }) - options.agent = mockAgent - - return Reflect.apply(target, thisArg, [url, options, callback]) - }, - }) - - // - // HTTPS. - // - - https.request = new Proxy(https.request, { - apply: (target, thisArg, args: Parameters) => { - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', - args - ) - - const mockAgent = new MockHttpsAgent({ - customAgent: options.agent, - onRequest, - onResponse, - }) - options.agent = mockAgent - - return Reflect.apply(target, thisArg, [url, options, callback]) - }, - }) - - https.get = new Proxy(https.get, { - apply: (target, thisArg, args: Parameters) => { - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', - args - ) - - const mockAgent = new MockHttpsAgent({ - customAgent: options.agent, - onRequest, - onResponse, - }) - options.agent = mockAgent - - return Reflect.apply(target, thisArg, [url, options, callback]) - }, - }) - - // Spy on `Header.prototype.set` and `Header.prototype.append` calls - // and record the raw header names provided. This is to support - // `IncomingMessage.prototype.rawHeaders`. - recordRawFetchHeaders() - - this.subscriptions.push(() => { - http.ClientRequest = OriginalClientRequest - - http.get = originalGet - http.request = originalRequest - - https.get = originalHttpsGet - https.request = originalHttpsRequest - - restoreHeadersPrototype() - }) - } - - private onRequest: MockHttpSocketRequestCallback = async ({ - request, - socket, - }) => { - const controller = new RequestController(request, { - passthrough() { - socket.passthrough() - }, - async respondWith(response) { - await socket.respondWith(response) - }, - errorWith(reason) { - if (reason instanceof Error) { - socket.errorWith(reason) + }), + applyPatch(http, 'get', (httpGet) => { + return function mockHttpGet(...args) { + return runInRequestContext(() => { + return httpGet(...(args as [any, any])) + }) } - }, - }) - - await handleRequest({ - request, - requestId: Reflect.get(request, kRequestId), - controller, - emitter: this.emitter, - }) - } - - public onResponse: MockHttpSocketResponseCallback = async ({ - requestId, - request, - response, - isMockedResponse, - }) => { - // Return the promise to when all the response event listeners - // are finished. - return emitAsync(this.emitter, 'response', { - requestId, - request, - response, - isMockedResponse, - }) + }), + applyPatch(http, 'request', (httpRequest) => { + return function mockHttpRequest(...args) { + return runInRequestContext(() => { + return httpRequest(...(args as [any, any])) + }) + } + }), + applyPatch(https, 'get', (httpsGet) => { + return function mockHttpsGet(...args) { + return runInRequestContext(() => { + return httpsGet(...(args as [any, any])) + }) + } + }), + applyPatch(https, 'request', (httpsRequest) => { + return function mockHttpsGet(...args) { + return runInRequestContext(() => { + return httpsRequest(...(args as [any, any])) + }) + } + }) + ) } } diff --git a/src/interceptors/ClientRequest/new.ts b/src/interceptors/ClientRequest/new.ts deleted file mode 100644 index f3377e28c..000000000 --- a/src/interceptors/ClientRequest/new.ts +++ /dev/null @@ -1,66 +0,0 @@ -import http from 'node:http' -import https from 'node:https' -import { HttpRequestEventMap } from '../../glossary' -import { Interceptor } from '../../Interceptor' -import { runInRequestContext } from '../../request-context' - -export class ClientRequestInterceptor extends Interceptor { - static symbol = Symbol('client-request-interceptor') - - constructor() { - super(ClientRequestInterceptor.symbol) - } - - protected setup(): void { - const RealClientRequest = http.ClientRequest - - http.ClientRequest = new Proxy(http.ClientRequest, { - construct(target, args, newTarget) { - return runInRequestContext(() => { - return Reflect.construct(target, args, newTarget) - }) - }, - }) - - const { get: realHttpGet, request: realHttpRequest } = http - http.get = new Proxy(http.get, { - apply(target, thisArg, argArray) { - return runInRequestContext(() => { - return Reflect.apply(target, thisArg, argArray) - }) - }, - }) - http.request = new Proxy(http.request, { - apply(target, thisArg, argArray) { - return runInRequestContext(() => { - return Reflect.apply(target, thisArg, argArray) - }) - }, - }) - - const { get: realHttpsGet, request: realHttpsRequest } = https - https.get = new Proxy(http.get, { - apply(target, thisArg, argArray) { - return runInRequestContext(() => { - return Reflect.apply(target, thisArg, argArray) - }) - }, - }) - https.request = new Proxy(http.request, { - apply(target, thisArg, argArray) { - return runInRequestContext(() => { - return Reflect.apply(target, thisArg, argArray) - }) - }, - }) - - this.subscriptions.push(() => { - http.ClientRequest = RealClientRequest - - http.get = realHttpGet - http.request = realHttpRequest - https.get = realHttpsGet - https.request = realHttpsRequest - }) - } -} diff --git a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts deleted file mode 100644 index 0ad3f16eb..000000000 --- a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { IncomingMessage } from 'node:http' -import { Socket } from 'net' -import * as zlib from 'zlib' -import { getIncomingMessageBody } from './getIncomingMessageBody' - -it('returns utf8 string given a utf8 response body', async () => { - const utfBuffer = Buffer.from('one') - const message = new IncomingMessage(new Socket()) - - const pendingResponseBody = getIncomingMessageBody(message) - message.emit('data', utfBuffer) - message.emit('end') - - expect(await pendingResponseBody).toEqual('one') -}) - -it('returns utf8 string given a gzipped response body', async () => { - const utfBuffer = zlib.gzipSync(Buffer.from('two')) - const message = new IncomingMessage(new Socket()) - message.headers = { - 'content-encoding': 'gzip', - } - - const pendingResponseBody = getIncomingMessageBody(message) - message.emit('data', utfBuffer) - message.emit('end') - - expect(await pendingResponseBody).toEqual('two') -}) - -it('returns utf8 string given a gzipped response body with incorrect "content-length"', async () => { - const utfBuffer = zlib.gzipSync(Buffer.from('three')) - const message = new IncomingMessage(new Socket()) - message.headers = { - 'content-encoding': 'gzip', - 'content-length': '500', - } - - const pendingResponseBody = getIncomingMessageBody(message) - message.emit('data', utfBuffer) - message.emit('end') - - expect(await pendingResponseBody).toEqual('three') -}) - -it('returns empty string given an empty body', async () => { - const message = new IncomingMessage(new Socket()) - - const pendingResponseBody = getIncomingMessageBody(message) - message.emit('end') - - expect(await pendingResponseBody).toEqual('') -}) diff --git a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts deleted file mode 100644 index 9a8f14f56..000000000 --- a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IncomingMessage } from 'node:http' -import { PassThrough } from 'stream' -import * as zlib from 'zlib' -import { Logger } from '@open-draft/logger' - -const logger = new Logger('http getIncomingMessageBody') - -export function getIncomingMessageBody( - response: IncomingMessage -): Promise { - return new Promise((resolve, reject) => { - logger.info('cloning the original response...') - - // Pipe the original response to support non-clone - // "response" input. No need to clone the response, - // as we always have access to the full "response" input, - // either a clone or an original one (in tests). - const responseClone = response.pipe(new PassThrough()) - const stream = - response.headers['content-encoding'] === 'gzip' - ? responseClone.pipe(zlib.createGunzip()) - : responseClone - - const encoding = response.readableEncoding || 'utf8' - stream.setEncoding(encoding) - logger.info('using encoding:', encoding) - - let body = '' - - stream.on('data', (responseBody) => { - logger.info('response body read:', responseBody) - body += responseBody - }) - - stream.once('end', () => { - logger.info('response body end') - resolve(body) - }) - - stream.once('error', (error) => { - logger.info('error while reading response body:', error) - reject(error) - }) - }) -} diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts deleted file mode 100644 index 45ac78fa0..000000000 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { parse } from 'url' -import { globalAgent as httpGlobalAgent, RequestOptions } from 'node:http' -import { - Agent as HttpsAgent, - globalAgent as httpsGlobalAgent, -} from 'node:https' -import { getUrlByRequestOptions } from '../../../utils/getUrlByRequestOptions' -import { normalizeClientRequestArgs } from './normalizeClientRequestArgs' - -it('handles [string, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - 'https://mswjs.io/resource', - function cb() {}, - ]) - - // URL string must be converted to a URL instance. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Request options must be derived from the URL instance. - expect(options).toHaveProperty('method', 'GET') - expect(options).toHaveProperty('protocol', 'https:') - expect(options).toHaveProperty('hostname', 'mswjs.io') - expect(options).toHaveProperty('path', '/resource') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [string, RequestOptions, callback] input', () => { - const initialOptions = { - headers: { - 'Content-Type': 'text/plain', - }, - } - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - 'https://mswjs.io/resource', - initialOptions, - function cb() {}, - ]) - - // URL must be created from the string. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Request options must be preserved. - expect(options).toHaveProperty('headers', initialOptions.headers) - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - new URL('https://mswjs.io/resource'), - function cb() {}, - ]) - - // URL must be preserved. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Request options must be derived from the URL instance. - expect(options.method).toEqual('GET') - expect(options.protocol).toEqual('https:') - expect(options.hostname).toEqual('mswjs.io') - expect(options.path).toEqual('/resource') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [Absolute Legacy URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - parse('https://cherry:durian@mswjs.io:12345/resource?apple=banana'), - function cb() {}, - ]) - - // URL must be preserved. - expect(url.toJSON()).toEqual( - new URL( - 'https://cherry:durian@mswjs.io:12345/resource?apple=banana' - ).toJSON() - ) - - // Request options must be derived from the URL instance. - expect(options.method).toEqual('GET') - expect(options.protocol).toEqual('https:') - expect(options.hostname).toEqual('mswjs.io') - expect(options.path).toEqual('/resource?apple=banana') - expect(options.port).toEqual(12345) - expect(options.auth).toEqual('cherry:durian') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [Relative Legacy URL, RequestOptions without path set, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('http:', [ - parse('/resource?apple=banana'), - { host: 'mswjs.io' }, - function cb() {}, - ]) - - // Correct WHATWG URL generated. - expect(url.toJSON()).toEqual( - new URL('http://mswjs.io/resource?apple=banana').toJSON() - ) - - // No path in request options, so legacy url path is copied-in. - expect(options.protocol).toEqual('http:') - expect(options.host).toEqual('mswjs.io') - expect(options.path).toEqual('/resource?apple=banana') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [Relative Legacy URL, RequestOptions with path set, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('http:', [ - parse('/resource?apple=banana'), - { host: 'mswjs.io', path: '/other?cherry=durian' }, - function cb() {}, - ]) - - // Correct WHATWG URL generated. - expect(url.toJSON()).toEqual( - new URL('http://mswjs.io/other?cherry=durian').toJSON() - ) - - // Path in request options, so that path is preferred. - expect(options.protocol).toEqual('http:') - expect(options.host).toEqual('mswjs.io') - expect(options.path).toEqual('/other?cherry=durian') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [Relative Legacy URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('http:', [ - parse('/resource?apple=banana'), - function cb() {}, - ]) - - // Correct WHATWG URL generated. - expect(url.toJSON()).toMatch( - getUrlByRequestOptions({ path: '/resource?apple=banana' }).toJSON() - ) - - // Check path is in options. - expect(options.protocol).toEqual('http:') - expect(options.path).toEqual('/resource?apple=banana') - - // Callback must be preserved. - expect(callback).toBeTypeOf('function') - expect(callback?.name).toEqual('cb') -}) - -it('handles [Relative Legacy URL] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('http:', [ - parse('/resource?apple=banana'), - ]) - - // Correct WHATWG URL generated. - expect(url.toJSON()).toMatch( - getUrlByRequestOptions({ path: '/resource?apple=banana' }).toJSON() - ) - - // Check path is in options. - expect(options.protocol).toEqual('http:') - expect(options.path).toEqual('/resource?apple=banana') - - // Callback must be preserved. - expect(callback).toBeUndefined() -}) - -it('handles [URL, RequestOptions, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - new URL('https://mswjs.io/resource'), - { - agent: false, - headers: { - 'Content-Type': 'text/plain', - }, - }, - function cb() {}, - ]) - - // URL must be preserved. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Options must be preserved. - // `urlToHttpOptions` from `node:url` generates additional - // ClientRequest options, some of which are not legally allowed. - expect(options).toMatchObject({ - agent: false, - _defaultAgent: httpsGlobalAgent, - protocol: url.protocol, - method: 'GET', - headers: { - 'Content-Type': 'text/plain', - }, - hostname: url.hostname, - path: url.pathname, - }) - - // Callback must be preserved. - expect(callback).toBeTypeOf('function') - expect(callback?.name).toEqual('cb') -}) - -it('handles [URL, RequestOptions] where options have custom "hostname"', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url'), - { - hostname: 'host-from-options.com', - }, - ]) - expect(url.href).toBe('http://host-from-options.com/path-from-url') - expect(options).toMatchObject({ - hostname: 'host-from-options.com', - path: '/path-from-url', - }) -}) - -it('handles [URL, RequestOptions] where options contain "host" and "path" and "port"', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url?a=b&c=d'), - { - hostname: 'host-from-options.com', - path: '/path-from-options', - port: 1234, - }, - ]) - // Must remove the query string since it's not specified in "options.path" - expect(url.href).toBe('http://host-from-options.com:1234/path-from-options') - expect(options).toMatchObject({ - hostname: 'host-from-options.com', - path: '/path-from-options', - port: 1234, - }) -}) - -it('handles [URL, RequestOptions] where options contain "path" with query string', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url?a=b&c=d'), - { - path: '/path-from-options?foo=bar&baz=xyz', - }, - ]) - expect(url.href).toBe('http://example.com/path-from-options?foo=bar&baz=xyz') - expect(options).toMatchObject({ - hostname: 'example.com', - path: '/path-from-options?foo=bar&baz=xyz', - }) -}) - -it('handles [RequestOptions, callback] input', () => { - const initialOptions = { - method: 'POST', - protocol: 'https:', - host: 'mswjs.io', - /** - * @see https://github.com/mswjs/msw/issues/705 - */ - origin: 'https://mswjs.io', - path: '/resource', - headers: { - 'Content-Type': 'text/plain', - }, - } - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - initialOptions, - function cb() {}, - ]) - - // URL must be derived from request options. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Request options must be preserved. - expect(options).toMatchObject(initialOptions) - - // Callback must be preserved. - expect(callback).toBeTypeOf('function') - expect(callback?.name).toEqual('cb') -}) - -it('handles [Empty RequestOptions, callback] input', () => { - const [_, options, callback] = normalizeClientRequestArgs('https:', [ - {}, - function cb() {}, - ]) - - expect(options.protocol).toEqual('https:') - - // Callback must be preserved - expect(callback?.name).toEqual('cb') -}) - -/** - * @see https://github.com/mswjs/interceptors/issues/19 - */ -it('handles [PartialRequestOptions, callback] input', () => { - const initialOptions = { - method: 'GET', - port: '50176', - path: '/resource', - host: '127.0.0.1', - ca: undefined, - key: undefined, - pfx: undefined, - cert: undefined, - passphrase: undefined, - agent: false, - } - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - initialOptions, - function cb() {}, - ]) - - // URL must be derived from request options. - expect(url.toJSON()).toEqual( - new URL('https://127.0.0.1:50176/resource').toJSON() - ) - - // Request options must be preserved. - expect(options).toMatchObject(initialOptions) - - // Options protocol must be inferred from the request issuing module. - expect(options.protocol).toEqual('https:') - - // Callback must be preserved. - expect(callback).toBeTypeOf('function') - expect(callback?.name).toEqual('cb') -}) - -it('sets the default Agent for HTTP request', () => { - const [, options] = normalizeClientRequestArgs('http:', [ - 'http://github.com', - {}, - ]) - - expect(options._defaultAgent).toEqual(httpGlobalAgent) -}) - -it('sets the default Agent for HTTPS request', () => { - const [, options] = normalizeClientRequestArgs('https:', [ - 'https://github.com', - {}, - ]) - - expect(options._defaultAgent).toEqual(httpsGlobalAgent) -}) - -it('preserves a custom default Agent when set', () => { - const [, options] = normalizeClientRequestArgs('https:', [ - 'https://github.com', - { - /** - * @note Intentionally incorrect Agent for HTTPS request. - */ - _defaultAgent: httpGlobalAgent, - }, - ]) - - expect(options._defaultAgent).toEqual(httpGlobalAgent) -}) - -it('merges URL-based RequestOptions with the custom RequestOptions', () => { - const [url, options] = normalizeClientRequestArgs('https:', [ - 'https://github.com/graphql', - { - method: 'GET', - pfx: 'PFX_KEY', - }, - ]) - - expect(url.href).toEqual('https://github.com/graphql') - - // Original request options must be preserved. - expect(options.method).toEqual('GET') - expect(options.pfx).toEqual('PFX_KEY') - - // Other options must be inferred from the URL. - expect(options.protocol).toEqual(url.protocol) - expect(options.hostname).toEqual(url.hostname) - expect(options.path).toEqual(url.pathname) -}) - -it('respects custom "options.path" over URL path', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url'), - { - path: '/path-from-options', - }, - ]) - - expect(url.href).toBe('http://example.com/path-from-options') - expect(options.protocol).toBe('http:') - expect(options.hostname).toBe('example.com') - expect(options.path).toBe('/path-from-options') -}) - -it('respects custom "options.path" over URL path with query string', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url?a=b&c=d'), - { - path: '/path-from-options', - }, - ]) - - // Must replace both the path and the query string. - expect(url.href).toBe('http://example.com/path-from-options') - expect(options.protocol).toBe('http:') - expect(options.hostname).toBe('example.com') - expect(options.path).toBe('/path-from-options') -}) - -it('preserves URL query string', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com:8080/resource?a=b&c=d'), - ]) - - expect(url.href).toBe('http://example.com:8080/resource?a=b&c=d') - expect(options.protocol).toBe('http:') - // expect(options.host).toBe('example.com:8080') - expect(options.hostname).toBe('example.com') - // Query string is a part of the options path. - expect(options.path).toBe('/resource?a=b&c=d') - expect(options.port).toBe(8080) -}) diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts deleted file mode 100644 index 324f865fb..000000000 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { urlToHttpOptions } from 'node:url' -import { - Agent as HttpAgent, - globalAgent as httpGlobalAgent, - IncomingMessage, -} from 'node:http' -import { - RequestOptions, - Agent as HttpsAgent, - globalAgent as httpsGlobalAgent, -} from 'node:https' -import { - /** - * @note Use the Node.js URL instead of the global URL - * because environments like JSDOM may override the global, - * breaking the compatibility with Node.js. - * @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555 - */ - URL, - Url as LegacyURL, - parse as parseUrl, -} from 'node:url' -import { Logger } from '@open-draft/logger' -import { - ResolvedRequestOptions, - getUrlByRequestOptions, -} from '../../../utils/getUrlByRequestOptions' -import { cloneObject } from '../../../utils/cloneObject' -import { isObject } from '../../../utils/isObject' - -const logger = new Logger('http normalizeClientRequestArgs') - -export type HttpRequestCallback = (response: IncomingMessage) => void - -export type ClientRequestArgs = - // Request without any arguments is also possible. - | [] - | [string | URL | LegacyURL, HttpRequestCallback?] - | [string | URL | LegacyURL, RequestOptions, HttpRequestCallback?] - | [RequestOptions, HttpRequestCallback?] - -function resolveRequestOptions( - args: ClientRequestArgs, - url: URL -): RequestOptions { - // Calling `fetch` provides only URL to `ClientRequest` - // without any `RequestOptions` or callback. - if (typeof args[1] === 'undefined' || typeof args[1] === 'function') { - logger.info('request options not provided, deriving from the url', url) - return urlToHttpOptions(url) - } - - if (args[1]) { - logger.info('has custom RequestOptions!', args[1]) - const requestOptionsFromUrl = urlToHttpOptions(url) - - logger.info('derived RequestOptions from the URL:', requestOptionsFromUrl) - - /** - * Clone the request options to lock their state - * at the moment they are provided to `ClientRequest`. - * @see https://github.com/mswjs/interceptors/issues/86 - */ - logger.info('cloning RequestOptions...') - const clonedRequestOptions = cloneObject(args[1]) - logger.info('successfully cloned RequestOptions!', clonedRequestOptions) - - return { - ...requestOptionsFromUrl, - ...clonedRequestOptions, - } - } - - logger.info('using an empty object as request options') - return {} as RequestOptions -} - -/** - * Overrides the given `URL` instance with the explicit properties provided - * on the `RequestOptions` object. The options object takes precedence, - * and will replace URL properties like "host", "path", and "port", if specified. - */ -function overrideUrlByRequestOptions(url: URL, options: RequestOptions): URL { - url.host = options.host || url.host - url.hostname = options.hostname || url.hostname - url.port = options.port ? options.port.toString() : url.port - - if (options.path) { - const parsedOptionsPath = parseUrl(options.path, false) - url.pathname = parsedOptionsPath.pathname || '' - url.search = parsedOptionsPath.search || '' - } - - return url -} - -function resolveCallback( - args: ClientRequestArgs -): HttpRequestCallback | undefined { - return typeof args[1] === 'function' ? args[1] : args[2] -} - -export type NormalizedClientRequestArgs = [ - url: URL, - options: ResolvedRequestOptions, - callback?: HttpRequestCallback -] - -/** - * Normalizes parameters given to a `http.request` call - * so it always has a `URL` and `RequestOptions`. - */ -export function normalizeClientRequestArgs( - defaultProtocol: string, - args: ClientRequestArgs -): NormalizedClientRequestArgs { - let url: URL - let options: ResolvedRequestOptions - let callback: HttpRequestCallback | undefined - - logger.info('arguments', args) - logger.info('using default protocol:', defaultProtocol) - - // Support "http.request()" calls without any arguments. - // That call results in a "GET http://localhost" request. - if (args.length === 0) { - const url = new URL('http://localhost') - const options = resolveRequestOptions(args, url) - return [url, options] - } - - // Convert a url string into a URL instance - // and derive request options from it. - if (typeof args[0] === 'string') { - logger.info('first argument is a location string:', args[0]) - - url = new URL(args[0]) - logger.info('created a url:', url) - - const requestOptionsFromUrl = urlToHttpOptions(url) - logger.info('request options from url:', requestOptionsFromUrl) - - options = resolveRequestOptions(args, url) - logger.info('resolved request options:', options) - - callback = resolveCallback(args) - } - // Handle a given URL instance as-is - // and derive request options from it. - else if (args[0] instanceof URL) { - url = args[0] - logger.info('first argument is a URL:', url) - - // Check if the second provided argument is RequestOptions. - // If it is, check if "options.path" was set and rewrite it - // on the input URL. - // Do this before resolving options from the URL below - // to prevent query string from being duplicated in the path. - if (typeof args[1] !== 'undefined' && isObject(args[1])) { - url = overrideUrlByRequestOptions(url, args[1]) - } - - options = resolveRequestOptions(args, url) - logger.info('derived request options:', options) - - callback = resolveCallback(args) - } - // Handle a legacy URL instance and re-normalize from either a RequestOptions object - // or a WHATWG URL. - else if ('hash' in args[0] && !('method' in args[0])) { - const [legacyUrl] = args - logger.info('first argument is a legacy URL:', legacyUrl) - - if (legacyUrl.hostname === null) { - /** - * We are dealing with a relative url, so use the path as an "option" and - * merge in any existing options, giving priority to existing options -- i.e. a path in any - * existing options will take precedence over the one contained in the url. This is consistent - * with the behaviour in ClientRequest. - * @see https://github.com/nodejs/node/blob/d84f1312915fe45fe0febe888db692c74894c382/lib/_http_client.js#L122 - */ - logger.info('given legacy URL is relative (no hostname)') - - return isObject(args[1]) - ? normalizeClientRequestArgs(defaultProtocol, [ - { path: legacyUrl.path, ...args[1] }, - args[2], - ]) - : normalizeClientRequestArgs(defaultProtocol, [ - { path: legacyUrl.path }, - args[1] as HttpRequestCallback, - ]) - } - - logger.info('given legacy url is absolute') - - // We are dealing with an absolute URL, so convert to WHATWG and try again. - const resolvedUrl = new URL(legacyUrl.href) - - return args[1] === undefined - ? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl]) - : typeof args[1] === 'function' - ? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl, args[1]]) - : normalizeClientRequestArgs(defaultProtocol, [ - resolvedUrl, - args[1], - args[2], - ]) - } - // Handle a given "RequestOptions" object as-is - // and derive the URL instance from it. - else if (isObject(args[0])) { - options = { ...(args[0] as any) } - logger.info('first argument is RequestOptions:', options) - - // When handling a "RequestOptions" object without an explicit "protocol", - // infer the protocol from the request issuing module (http/https). - options.protocol = options.protocol || defaultProtocol - logger.info('normalized request options:', options) - - url = getUrlByRequestOptions(options) - logger.info('created a URL from RequestOptions:', url.href) - - callback = resolveCallback(args) - } else { - throw new Error( - `Failed to construct ClientRequest with these parameters: ${args}` - ) - } - - options.protocol = options.protocol || url.protocol - options.method = options.method || 'GET' - - /** - * Ensure that the default Agent is always set. - * This prevents the protocol mismatch for requests with { agent: false }, - * where the global Agent is inferred. - * @see https://github.com/mswjs/msw/issues/1150 - * @see https://github.com/nodejs/node/blob/418ff70b810f0e7112d48baaa72932a56cfa213b/lib/_http_client.js#L130 - * @see https://github.com/nodejs/node/blob/418ff70b810f0e7112d48baaa72932a56cfa213b/lib/_http_client.js#L157-L159 - */ - if (!options._defaultAgent) { - logger.info( - 'has no default agent, setting the default agent for "%s"', - options.protocol - ) - - options._defaultAgent = - options.protocol === 'https:' ? httpsGlobalAgent : httpGlobalAgent - } - - logger.info('successfully resolved url:', url.href) - logger.info('successfully resolved options:', options) - logger.info('successfully resolved callback:', callback) - - /** - * @note If the user-provided URL is not a valid URL in Node.js, - * (e.g. the one provided by the JSDOM polyfills), case it to - * string. Otherwise, this throws on Node.js incompatibility - * (`ERR_INVALID_ARG_TYPE` on the connection listener) - * @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555 - */ - if (!(url instanceof URL)) { - url = (url as any).toString() - } - - return [url, options, callback] -} diff --git a/src/interceptors/ClientRequest/utils/parserUtils.ts b/src/interceptors/ClientRequest/utils/parserUtils.ts deleted file mode 100644 index 6c9c9585a..000000000 --- a/src/interceptors/ClientRequest/utils/parserUtils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Socket } from 'node:net' -import { HTTPParser } from '_http_common' - -/** - * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_common.js#L180 - */ -export function freeParser(parser: HTTPParser, socket?: Socket): void { - if (parser._consumed) { - parser.unconsume() - } - - parser._headers = [] - parser._url = '' - parser.socket = null - parser.incoming = null - parser.outgoing = null - parser.maxHeaderPairs = 2000 - parser._consumed = false - parser.onIncoming = null - - parser[HTTPParser.kOnHeaders] = null - parser[HTTPParser.kOnHeadersComplete] = null - parser[HTTPParser.kOnMessageBegin] = null - parser[HTTPParser.kOnMessageComplete] = null - parser[HTTPParser.kOnBody] = null - parser[HTTPParser.kOnExecute] = null - parser[HTTPParser.kOnTimeout] = null - - parser.remove() - parser.free() - - if (socket) { - /** - * @note Unassigning the socket's parser will fail this assertion - * if there's still some data being processed on the socket: - * @see https://github.com/nodejs/node/blob/4e1f39b678b37017ac9baa0971e3aeecd3b67b51/lib/_http_client.js#L613 - */ - if (socket.destroyed) { - // @ts-expect-error Node.js internals. - socket.parser = null - } else { - socket.once('end', () => { - // @ts-expect-error Node.js internals. - socket.parser = null - }) - } - } -} diff --git a/src/interceptors/ClientRequest/utils/recordRawHeaders.ts b/src/interceptors/ClientRequest/utils/recordRawHeaders.ts index 278974f71..7a75a82b0 100644 --- a/src/interceptors/ClientRequest/utils/recordRawHeaders.ts +++ b/src/interceptors/ClientRequest/utils/recordRawHeaders.ts @@ -69,7 +69,7 @@ function defineRawHeadersSymbol(headers: Headers, rawHeaders: RawHeaders) { * h.append('x-custom', 'two') * h[Symbol('headers map')] // Map { 'X-Custom' => 'one, two' } */ -export function recordRawFetchHeaders() { +export function recordRawFetchHeaders(): () => void { // Prevent patching the Headers prototype multiple times. if (Reflect.get(Headers, kRestorePatches)) { return Reflect.get(Headers, kRestorePatches) @@ -222,6 +222,8 @@ export function recordRawFetchHeaders() { }, }), }) + + return restoreHeadersPrototype } export function restoreHeadersPrototype() { diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index c4659e926..3192e53e6 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -4,6 +4,7 @@ import { Interceptor } from '../../Interceptor' import { canParseUrl } from '../../utils/canParseUrl' import { requestContext } from '../../request-context' import { applyPatch } from '../../utils/apply-patch' +import { recordRawFetchHeaders } from '../ClientRequest/utils/recordRawHeaders' export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') @@ -18,6 +19,7 @@ export class FetchInterceptor extends Interceptor { protected setup(): void { this.subscriptions.push( + recordRawFetchHeaders(), applyPatch(globalThis, 'fetch', (realFetch) => { return (input, init) => { /** diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index d48fe9435..0a6363310 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -15,7 +15,6 @@ import { RequestController } from '../../RequestController' import { getRawFetchHeaders, recordRawFetchHeaders, - restoreHeadersPrototype, } from '../ClientRequest/utils/recordRawHeaders' import { SocketInterceptor } from '../net' import { connectionOptionsToUrl } from '../net/utils/connection-options-to-url' @@ -49,8 +48,12 @@ export class HttpRequestInterceptor extends Interceptor { socketInterceptor.apply() this.subscriptions.push(() => socketInterceptor.dispose()) - recordRawFetchHeaders() - this.subscriptions.push(() => restoreHeadersPrototype()) + /** + * @note Record the raw values provided to Headers set/append + * in order to support "IncomingMessage.prototype.rawHeaders". + * This is meant for the headers in mocked responses. + */ + this.subscriptions.push(recordRawFetchHeaders()) socketInterceptor.on( 'connection', diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index 52b69dbc4..d9d9d0151 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -2,7 +2,7 @@ import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { BatchInterceptor } from '#/src/BatchInterceptor' -import { ClientRequestInterceptor } from '#/src/interceptors/ClientRequest/new' +import { ClientRequestInterceptor } from '#/src/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' import { FetchInterceptor } from '#/src/interceptors/fetch/node' import { HttpRequestInterceptor } from '#/src/interceptors/http' diff --git a/test/helpers.ts b/test/helpers.ts index 31c0c014a..265beb6bd 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,120 +1,17 @@ import { invariant } from 'outvariant' -import zlib from 'node:zlib' import net from 'node:net' +import zlib from 'node:zlib' import { Readable } from 'node:stream' -import { urlToHttpOptions } from 'node:url' -import http, { ClientRequest, IncomingMessage, RequestOptions } from 'node:http' -import https from 'node:https' +import http from 'node:http' import { RequestHandler } from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { Page } from '@playwright/test' import { MockedFunction } from 'node_modules/vitest/dist' -import { getIncomingMessageBody } from '#/src/interceptors/ClientRequest/utils/getIncomingMessageBody' import { SerializedRequest } from '#/src/RemoteHttpInterceptor' import { FetchResponse } from '#/src/utils/fetchUtils' export const REQUEST_ID_REGEXP = /^\w{9,}$/ -export interface PromisifiedResponse { - req: ClientRequest - res: IncomingMessage - resBody: string - url: string - options: RequestOptions -} - -export function httpGet( - url: string, - options: RequestOptions = {} -): Promise { - const parsedUrl = new URL(url) - return new Promise((resolve, reject) => { - const req = http.get(parsedUrl, options, async (res) => { - res.setEncoding('utf8') - const resBody = await getIncomingMessageBody(res) - resolve({ req, res, resBody, url, options }) - }) - - req.on('error', reject) - }) -} - -export function httpsGet( - url: string, - options?: RequestOptions -): Promise { - const parsedUrl = new URL(url) - const resolvedOptions = Object.assign( - {}, - urlToHttpOptions(parsedUrl), - options - ) - - return new Promise((resolve, reject) => { - const req = https.get(resolvedOptions, async (res) => { - res.setEncoding('utf8') - const resBody = await getIncomingMessageBody(res) - resolve({ req, res, resBody, url, options: resolvedOptions }) - }) - - req.on('error', reject) - }) -} - -export function httpRequest( - url: string, - options?: RequestOptions, - body?: string -): Promise { - const parsedUrl = new URL(url) - const resolvedOptions = Object.assign( - {}, - urlToHttpOptions(parsedUrl), - options - ) - - return new Promise((resolve) => { - const req = http.request(resolvedOptions, async (res) => { - res.setEncoding('utf8') - const resBody = await getIncomingMessageBody(res) - resolve({ req, res, resBody, url, options: resolvedOptions }) - }) - - if (body) { - req.write(body) - } - - req.end() - }) -} - -export function httpsRequest( - url: string, - options?: RequestOptions, - body?: string -): Promise { - const parsedUrl = new URL(url) - const resolvedOptions = Object.assign( - {}, - urlToHttpOptions(parsedUrl), - options - ) - - return new Promise((resolve) => { - const req = https.request(resolvedOptions, async (res) => { - res.setEncoding('utf8') - const resBody = await getIncomingMessageBody(res) - resolve({ req, res, resBody, url, options: resolvedOptions }) - }) - - if (body) { - req.write(body) - } - - req.end() - }) -} - interface PromisifiedFetchPayload { res: Response url: string diff --git a/test/modules/http/compliance/http-request-ipv6.test.ts b/test/modules/http/compliance/http-request-ipv6.test.ts index d2482a6d8..0694d7dce 100644 --- a/test/modules/http/compliance/http-request-ipv6.test.ts +++ b/test/modules/http/compliance/http-request-ipv6.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node +import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { httpGet } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new HttpRequestInterceptor() @@ -22,8 +23,10 @@ it('supports requests with IPv6 request url', async () => { controller.respondWith(new Response('test')) }) - const { resBody } = await httpGet(url) + const request = http.get(url) + const [response] = await toWebResponse(request) + const requestUrl = await listenerUrlPromise - expect(resBody).toBe('test') - expect(requestUrl).toBe(url) + expect.soft(requestUrl).toBe(url) + await expect.soft(response.text()).resolves.toBe('test') }) diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index 7631141d2..1df8e38a2 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -2,13 +2,11 @@ * @vitest-environment node * @see https://github.com/mswjs/interceptors/issues/131 */ -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 { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { @@ -28,23 +26,15 @@ afterAll(async () => { }) it('performs the original HTTPS request', async () => { - const responseReceived = new DeferredPromise() - https - .request( - new URL(httpServer.https.url('/resource')), - { - method: 'GET', - rejectUnauthorized: false, - }, - async (response) => { - responseReceived.resolve(response) - } - ) + const request = https + .request(new URL(httpServer.https.url('/resource')), { + method: 'GET', + rejectUnauthorized: false, + }) .end() - const response = await responseReceived - expect(response.statusCode).toBe(200) + const [response] = await toWebResponse(request) - const responseText = await getIncomingMessageBody(response) - expect(responseText).toEqual('hello') + expect.soft(response.status).toBe(200) + await expect(response.text()).resolves.toEqual('hello') }) diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index dce65856b..0f7f69581 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { httpGet, PromisifiedResponse, useCors } from '#/test/helpers' +import { PromisifiedResponse, toWebResponse, useCors } from '#/test/helpers' import { HttpRequestInterceptor } from '#/src/interceptors/http' function arrayWith(length: number, mapFn: (index: number) => V): V[] { @@ -11,11 +12,9 @@ function randomBetween(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1) + min) } -function parallelRequests( - makeRequest: (index: number) => Promise -) { +function parallelRequests(makeRequest: (index: number) => Promise) { return (index: number) => { - return new Promise((resolve) => { + return new Promise((resolve) => { setTimeout(() => resolve(makeRequest(index)), randomBetween(100, 500)) }) } @@ -53,24 +52,34 @@ it.skip('returns responses for 500 matching parallel requests', async () => { const responses = await Promise.all( arrayWith( 500, - parallelRequests((i) => httpGet(httpServer.http.url(`/user?id=${i + 1}`))) + parallelRequests(async (i) => { + const [response] = await toWebResponse( + http.get(httpServer.http.url(`/user?id=${i + 1}`)) + ) + return response + }) ) ) - const bodies = responses.map((response) => response.resBody) + const bodies = responses.map((response) => response.text()) const expectedBodies = arrayWith(500, (i) => `mocked ${i + 1}`) - expect(bodies).toEqual(expectedBodies) + await expect(Promise.all(bodies)).resolves.toEqual(expectedBodies) }) it.skip('returns responses for 500 bypassed parallel requests', async () => { const responses = await Promise.all( arrayWith( 500, - parallelRequests((i) => httpGet(httpServer.http.url(`/number/${i + 1}`))) + parallelRequests(async (i) => { + const [response] = await toWebResponse( + http.get(httpServer.http.url(`/number/${i + 1}`)) + ) + return response + }) ) ) - const bodies = responses.map((response) => response.resBody) + const bodies = responses.map((response) => response.text()) const expectedBodies = arrayWith(500, (i) => `real ${i + 1}`) - expect(bodies).toEqual(expectedBodies) + await expect(Promise.all(bodies)).resolves.toEqual(expectedBodies) }) 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 f8afb4e6f..19580c647 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,8 +1,9 @@ // @vitest-environment node +import http from 'node:http' import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { httpGet } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', async (req, res) => { @@ -39,17 +40,19 @@ it('handles concurrent requests with different response sources', async () => { }) const requests = await Promise.all([ - httpGet(httpServer.http.url('/')), - httpGet(httpServer.http.url('/'), { - headers: { - 'x-ignore-request': 'yes', - }, - }), + toWebResponse(http.get(httpServer.http.url('/'))), + toWebResponse( + http.get(httpServer.http.url('/'), { + headers: { + 'x-ignore-request': 'yes', + }, + }) + ), ]) - expect(requests[0].res.statusCode).toEqual(201) - expect(requests[0].resBody).toEqual('mocked-response') + expect(requests[0][0].status).toEqual(201) + await expect(requests[0][0].text()).resolves.toBe('mocked-response') - expect(requests[1].res.statusCode).toEqual(200) - expect(requests[1].resBody).toEqual('original-response') + expect(requests[1][0].status).toEqual(200) + await expect(requests[1][0].text()).resolves.toBe('original-response') }) diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index d046beb51..96d64e8e8 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -1,9 +1,11 @@ // @vitest-environment miniflare +import http from 'node:http' +import https from 'node:https' import { BatchInterceptor } from '#/src/index' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { FetchInterceptor } from '#/src/interceptors/fetch' -import { httpGet, httpsGet } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' const interceptor = new BatchInterceptor({ name: 'setup-server', @@ -32,7 +34,7 @@ test('responds to fetch', async () => { controller.respondWith(new Response('mocked-body')) }) - const response = await fetch('https://example.com') + const response = await fetch('https://any.host.here/') expect(response.status).toEqual(200) expect(await response.text()).toEqual('mocked-body') }) @@ -42,8 +44,8 @@ test('responds to http.get', async () => { controller.respondWith(new Response('mocked-body')) }) - const { resBody } = await httpGet('http://example.com') - expect(resBody).toEqual('mocked-body') + const [response] = await toWebResponse(http.get('http://any.host.here/')) + await expect(response.text()).resolves.toEqual('mocked-body') }) test('responds to https.get', async () => { @@ -51,8 +53,8 @@ test('responds to https.get', async () => { controller.respondWith(new Response('mocked-body')) }) - const { resBody } = await httpsGet('https://example.com') - expect(resBody).toEqual('mocked-body') + const [response] = await toWebResponse(https.get('https://any.host.here/')) + await expect(response.text()).resolves.toEqual('mocked-body') }) test('throws when responding with a network error', async () => { @@ -64,13 +66,13 @@ test('throws when responding with a network error', async () => { controller.respondWith(Response.error()) }) - const { res, resBody } = await httpGet('http://example.com') + const [response] = await toWebResponse(http.get('http://any.host.here/')) // Unhandled exceptions in the interceptor are coerced // to 500 error responses. - expect(res.statusCode).toEqual(500) - expect(res.statusMessage).toEqual('Unhandled Exception') - expect(JSON.parse(resBody)).toEqual({ + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + await expect(response.json()).resolves.toEqual({ name: 'TypeError', message: 'Response.error is not a function', stack: expect.any(String), From 8844c399b30ff38f59b72884a25de398cd4ded72 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 15:05:33 +0100 Subject: [PATCH 107/198] chore: remove unused `fetch` test utility --- test/helpers.ts | 46 ++++++++++------------------------------------ 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/test/helpers.ts b/test/helpers.ts index 265beb6bd..f9b42ddb8 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -12,46 +12,20 @@ import { FetchResponse } from '#/src/utils/fetchUtils' export const REQUEST_ID_REGEXP = /^\w{9,}$/ -interface PromisifiedFetchPayload { - res: Response - url: string - init?: RequestInit -} - -export async function fetch( - info: RequestInfo | URL, - init?: RequestInit -): Promise { - let url: string = '' - const res = await globalThis.fetch(info, init) - - if (typeof info === 'string') { - url = info - } else if ('href' in info) { - url = info.href - } else if ('url' in info) { - url = info.url - } - - return { - res, - url, - init, - } -} - export async function readBlob( blob: Blob ): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.addEventListener('loadend', () => { - resolve(reader.result) - }) - reader.addEventListener('abort', reject) - reader.addEventListener('error', reject) - reader.readAsText(blob) + const pendingResult = new DeferredPromise() + + const reader = new FileReader() + reader.addEventListener('loadend', () => { + pendingResult.resolve(reader.result) }) + reader.addEventListener('abort', () => pendingResult.reject()) + reader.addEventListener('error', () => pendingResult.reject()) + reader.readAsText(blob) + + return pendingResult } export function createXMLHttpRequest( From d3ef92351c23ecba50d8c668ec455b4554a570a0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 15:59:01 +0100 Subject: [PATCH 108/198] chore: remove `createXMLHttpRequest` test helper --- src/utils/fetchUtils.ts | 2 + test/features/events/request.test.ts | 71 ++-- test/features/events/response.test.ts | 90 ++-- test/features/presets/node-preset.test.ts | 11 +- test/features/request-initiator.test.ts | 11 +- test/helpers.ts | 35 +- .../compliance/xhr-add-event-listener.test.ts | 24 +- .../xhr-event-callback-null.test.ts | 94 ++--- .../compliance/xhr-event-handlers.test.ts | 136 +++--- .../compliance/xhr-events-order.test.ts | 54 +-- .../xhr-middleware-exception.test.ts | 87 ++-- .../compliance/xhr-modify-request.test.ts | 21 +- .../xhr-no-response-headers.test.ts | 13 +- .../compliance/xhr-request-headers.test.ts | 19 +- .../compliance/xhr-request-method.test.ts | 11 +- .../xhr-response-body-empty.test.ts | 15 +- .../xhr-response-body-json-invalid.test.ts | 34 +- .../compliance/xhr-response-body-xml.test.ts | 20 +- ...-response-headers-case-sensitivity.test.ts | 17 +- .../compliance/xhr-response-headers.test.ts | 34 +- .../xhr-response-non-configurable.test.ts | 22 +- .../compliance/xhr-response-type.test.ts | 43 +- .../compliance/xhr-status.test.ts | 29 +- .../compliance/xhr-timeout.test.ts | 40 +- .../XMLHttpRequest/features/events.test.ts | 20 +- .../intercept/XMLHttpRequest.test.ts | 392 ++++++++++-------- .../regressions/xhr-0-status-code.test.ts | 26 +- .../xhr-compressed-response.test.ts | 11 +- .../xhr-location-undefined.test.ts | 37 +- .../xhr-request-body-length.test.ts | 13 +- .../response/xhr-response-error.test.ts | 13 +- .../xhr-response-without-body.test.ts | 29 +- .../XMLHttpRequest/response/xhr.test.ts | 129 +++--- 33 files changed, 849 insertions(+), 754 deletions(-) diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index 9fb1983c5..07f003308 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -123,9 +123,11 @@ export class FetchResponse extends Response { */ static parseRawHeaders(rawHeaders: Array): Headers { const headers = new Headers() + for (let line = 0; line < rawHeaders.length; line += 2) { headers.append(rawHeaders[line], rawHeaders[line + 1]) } + return headers } diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 8e3390d3c..dbe135e35 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -2,10 +2,11 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { - createXMLHttpRequest, useCors, REQUEST_ID_REGEXP, toWebResponse, + WebResponse, + waitForXMLHttpRequest, } from '#/test/helpers' import { BatchInterceptor } from '#/src/BatchInterceptor' import { HttpRequestInterceptor } from '#/src/interceptors/http' @@ -72,28 +73,31 @@ it('XMLHttpRequest: emits the "request" event upon the request (no CORS)', async interceptor.on('request', requestListener) const url = httpServer.http.url('/user') - await createXMLHttpRequest((req) => { - req.open('POST', url) - req.setRequestHeader('Content-Type', 'application/json') - req.send(JSON.stringify({ userId: 'abc-123' })) - }) + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('Content-Type', 'application/json') + request.send(JSON.stringify({ userId: 'abc-123' })) + + await waitForXMLHttpRequest(request) /** - * @note This XHR request, while cross-origin, doesn't have any other criteria + * @note This XHR request, while cross-origin, doesn't have enough criteria * to trigger the OPTIONS preflight request. */ expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = requestListener.mock.calls[0] + { + const [{ request, requestId, controller }] = requestListener.mock.calls[0] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.headers.get('content-type')).toBe('application/json') - expect(request.credentials).toBe('same-origin') - await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers.get('content-type')).toBe('application/json') + expect(request.credentials).toBe('same-origin') + await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)', async () => { @@ -101,15 +105,16 @@ it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)' interceptor.on('request', requestListener) const url = httpServer.http.url('/user') - await createXMLHttpRequest((req) => { - req.open('POST', url) - req.setRequestHeader('Content-Type', 'application/json') - /** - * @note The addition of this custom header triggers the OPTIONS request in XHR. - */ - req.setRequestHeader('X-Custom-Header', 'yes') - req.send(JSON.stringify({ userId: 'abc-123' })) - }) + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('Content-Type', 'application/json') + /** + * @note The addition of this custom header triggers the OPTIONS request in XHR. + */ + request.setRequestHeader('X-Custom-Header', 'yes') + request.send(JSON.stringify({ userId: 'abc-123' })) + + await waitForXMLHttpRequest(request) expect(requestListener).toHaveBeenCalledTimes(2) @@ -132,14 +137,16 @@ it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)' }) ) - const [{ request, requestId, controller }] = requestListener.mock.calls[1] + { + const [{ request, requestId, controller }] = requestListener.mock.calls[1] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.headers.get('content-type')).toBe('application/json') - expect(request.credentials).toBe('same-origin') - await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers.get('content-type')).toBe('application/json') + expect(request.credentials).toBe('same-origin') + await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 54a020d7d..42821a22d 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -6,7 +6,7 @@ import { HttpRequestEventMap } from '#/src/index' import { BatchInterceptor } from '#/src/BatchInterceptor' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { useCors, createXMLHttpRequest, toWebResponse } from '#/test/helpers' +import { useCors, toWebResponse, waitForXMLHttpRequest } from '#/test/helpers' declare namespace window { export const _resourceLoader: { @@ -170,11 +170,12 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () interceptor.on('response', responseListener) const url = 'http://any.host.here/resource' - const originalRequest = await createXMLHttpRequest((req) => { - req.open('GET', url) - req.setRequestHeader('x-request-custom', 'yes') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', url) + request.setRequestHeader('x-request-custom', 'yes') + request.send() + + await waitForXMLHttpRequest(request) expect(responseListener).toHaveBeenCalledTimes(2) expect(responseListener).toHaveBeenNthCalledWith( @@ -189,24 +190,25 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () request: expect.objectContaining({ method: 'GET', url }), }) ) + expect(request.responseText).toBe('mocked-response-text') - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[1] - - expect.soft(request.method).toBe('GET') - expect.soft(request.url).toBe(url) - expect.soft(request.headers.get('x-request-custom')).toBe('yes') - expect.soft(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + { + const [{ response, request, isMockedResponse }] = + responseListener.mock.calls[1] - expect.soft(response.status).toBe(200) - expect.soft(response.statusText).toBe('OK') - expect.soft(response.url).toBe(request.url) - expect.soft(response.headers.get('x-response-type')).toBe('mocked') - await expect(response.text()).resolves.toBe('mocked-response-text') - expect(isMockedResponse).toBe(true) + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + expect.soft(request.headers.get('x-request-custom')).toBe('yes') + expect.soft(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) - expect(originalRequest.responseText).toBe('mocked-response-text') + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect.soft(response.url).toBe(request.url) + expect.soft(response.headers.get('x-response-type')).toBe('mocked') + await expect(response.text()).resolves.toBe('mocked-response-text') + expect(isMockedResponse).toBe(true) + } }) it('XMLHttpRequest: emits the "response" event upon the original response', async () => { @@ -215,11 +217,12 @@ it('XMLHttpRequest: emits the "response" event upon the original response', asyn interceptor.on('response', responseListener) const url = httpServer.https.url('/account') - const originalRequest = await createXMLHttpRequest((req) => { - req.open('POST', url) - req.setRequestHeader('x-request-custom', 'yes') - req.send('request-body') - }) + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('x-request-custom', 'yes') + request.send('request-body') + + await waitForXMLHttpRequest(request) expect(responseListener).toHaveBeenCalledTimes(2) expect(responseListener).toHaveBeenNthCalledWith( @@ -234,28 +237,29 @@ it('XMLHttpRequest: emits the "response" event upon the original response', asyn request: expect.objectContaining({ method: 'POST', url }), }) ) + expect(request.responseText).toBe('original-response-text') - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[1] + { + const [{ response, request, isMockedResponse }] = + responseListener.mock.calls[1] - expect(request).toBeDefined() - expect(response).toBeDefined() + expect(request).toBeDefined() + expect(response).toBeDefined() - expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.https.url('/account')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('request-body') - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('original') - await expect(response.text()).resolves.toBe('original-response-text') + expect(request.method).toBe('POST') + expect(request.url).toBe(httpServer.https.url('/account')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('request-body') - expect(isMockedResponse).toBe(false) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-response-type')).toBe('original') + await expect(response.text()).resolves.toBe('original-response-text') - expect(originalRequest.responseText).toBe('original-response-text') + expect(isMockedResponse).toBe(false) + } }) it('fetch: emits the "response" event upon a mocked response', async () => { diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index 8872b70e8..aa8278339 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -2,7 +2,7 @@ import http from 'node:http' import { BatchInterceptor } from '../../../lib/node/index.mjs' import nodeInterceptors from '../../../lib/node/presets/node.mjs' -import { createXMLHttpRequest, toWebResponse } from '#/test/helpers' +import { toWebResponse, waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new BatchInterceptor({ name: 'node-preset-interceptor', @@ -45,10 +45,11 @@ it('intercepts and mocks a ClientRequest', async () => { }) it('intercepts and mocks an XMLHttpRequest (jsdom)', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost:3001/resource') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', 'http://localhost:3001/resource') + request.send() + + await waitForXMLHttpRequest(request) expect(requestListener).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index d9d9d0151..a41fa486b 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -6,7 +6,7 @@ import { ClientRequestInterceptor } from '#/src/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' import { FetchInterceptor } from '#/src/interceptors/fetch/node' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { createXMLHttpRequest, toWebResponse } from '#/test/helpers' +import { toWebResponse, waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new BatchInterceptor({ name: 'interceptor', @@ -58,10 +58,11 @@ it('exposes the initiator of a mocked XMLHttpRequest request', async () => { ) }) - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost/api') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', 'http://localhost/api') + request.send() + + await waitForXMLHttpRequest(request) await expect(initiatorPromise).resolves.toEqual(request) expect(request.responseText).toBe('mocked') diff --git a/test/helpers.ts b/test/helpers.ts index f9b42ddb8..e7503b863 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,4 +1,4 @@ -import { invariant } from 'outvariant' +import { invariant, InvariantError } from 'outvariant' import net from 'node:net' import zlib from 'node:zlib' import { Readable } from 'node:stream' @@ -28,28 +28,6 @@ export async function readBlob( return pendingResult } -export function createXMLHttpRequest( - middleware: (req: XMLHttpRequest) => void -): Promise { - const request = new XMLHttpRequest() - middleware(request) - - if (request.readyState < 1) { - throw new Error( - 'Failed to create an XMLHttpRequest. Did you forget to call `.open()` in the middleware function?' - ) - } - - return new Promise((resolve, reject) => { - request.addEventListener('loadend', () => { - resolve(request) - }) - request.addEventListener('abort', (error) => { - reject(error) - }) - }) -} - export interface XMLHttpResponse { status: number statusText: string @@ -161,6 +139,17 @@ export function createBrowserXMLHttpRequest(page: Page) { } } +export function waitForXMLHttpRequest(request: XMLHttpRequest): Promise { + const pendingResponse = new DeferredPromise() + + request.addEventListener('loadend', () => pendingResponse.resolve()) + request.addEventListener('abort', () => + pendingResponse.reject(new Error('Request aborted')) + ) + + return pendingResponse +} + export async function toWebResponse( request: http.ClientRequest ): Promise<[Response, http.IncomingMessage]> { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts index 06818906e..85a3d62d6 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts @@ -3,7 +3,7 @@ * @see https://github.com/mswjs/msw/issues/273 */ import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { @@ -30,18 +30,16 @@ afterAll(() => { }) it('calls the "load" event attached via "addEventListener" with a mocked response', async () => { - await createXMLHttpRequest((req) => { - req.open('GET', 'https://test.mswjs.io/user') - req.responseType = 'json' + const request = new XMLHttpRequest() + request.open('GET', 'https://test.mswjs.io/user') + request.responseType = 'json' + request.send() - req.addEventListener('load', function () { - const { status, response } = this - const headers = this.getAllResponseHeaders() + await waitForXMLHttpRequest(request) - expect(status).toBe(200) - expect(headers).toContain('x-header: yes') - expect(response).toEqual({ mocked: true }) - }) - req.send() - }) + expect(request.status).toBe(200) + expect(request.getAllResponseHeaders()).toEqual( + `content-type: application/json\r\nx-header: yes` + ) + expect(request.response).toEqual({ mocked: true }) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts index b87a2f701..f55355242 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts @@ -1,10 +1,10 @@ +// @vitest-environment jsdom /** - * @note https://xhr.spec.whatwg.org/#event-handlers + * @see https://xhr.spec.whatwg.org/#event-handlers */ -// @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -59,18 +59,17 @@ it.each<[name: string, getUrl: () => string]>([ async (_, getUrl) => { const url = getUrl() - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = null - request.onloadstart = null - request.onprogress = null - request.onload = null - request.onloadend = null - request.ontimeout = null + const request = new XMLHttpRequest() + request.open('GET', url) + request.onreadystatechange = null + request.onloadstart = null + request.onprogress = null + request.onload = null + request.onloadend = null + request.ontimeout = null + request.send() - request.send() - }) + await waitForXMLHttpRequest(request) expect(request.readyState).toBe(4) expect(request.status).toBe(200) @@ -86,18 +85,17 @@ it.each<[name: string, getUrl: () => string]>([ async (_, getUrl) => { const url = getUrl() - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = null - request.onloadstart = null - request.onprogress = null - request.onload = null - request.onloadend = null - request.ontimeout = null + const request = new XMLHttpRequest() + request.open('GET', url) + request.onreadystatechange = null + request.onloadstart = null + request.onprogress = null + request.onload = null + request.onloadend = null + request.ontimeout = null + request.send() - request.send() - }) + await waitForXMLHttpRequest(request) expect(request.readyState).toBe(4) expect(request.status).toBe(500) @@ -113,18 +111,17 @@ it.each<[name: string, getUrl: () => string]>([ async (_, getUrl) => { const url = getUrl() - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = null - request.onloadstart = null - request.onprogress = null - request.onload = null - request.onloadend = null - request.ontimeout = null + const request = new XMLHttpRequest() + request.open('GET', url) + request.onreadystatechange = null + request.onloadstart = null + request.onprogress = null + request.onload = null + request.onloadend = null + request.ontimeout = null + request.send() - request.send() - }) + await waitForXMLHttpRequest(request) expect(request.readyState).toBe(4) expect(request.status).toBe(0) @@ -133,19 +130,18 @@ it.each<[name: string, getUrl: () => string]>([ ) it('does not fail when unsetting event handlers during unhandled exception in the interceptor', async () => { - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', httpServer.https.url('/exception')) - - request.onreadystatechange = null - request.onloadstart = null - request.onprogress = null - request.onload = null - request.onloadend = null - request.ontimeout = null - - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', httpServer.https.url('/exception')) + request.onreadystatechange = null + request.onloadstart = null + request.onprogress = null + request.onload = null + request.onloadend = null + request.ontimeout = null + request.send() + + await waitForXMLHttpRequest(request) expect(request.readyState).toBe(4) expect(request.status).toBe(500) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts index 06fce6dee..78982317b 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts @@ -4,7 +4,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -75,23 +75,24 @@ it.each<[name: string, getUrl: () => string]>([ const loadListener = vi.fn() const loadEndListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) + const request = new XMLHttpRequest() + request.open('GET', url) - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler + request.onreadystatechange = onReadyStateChangeHandler + request.onloadstart = onLoadStartHandler + request.onprogress = onProgressHandler + request.onload = onLoadHandler + request.onloadend = onLoadEndHandler - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) + request.addEventListener('readystatechange', onReadyStateChangeListener) + request.addEventListener('loadstart', loadStartListener) + request.addEventListener('progress', progressListener) + request.addEventListener('load', loadListener) + request.addEventListener('loadend', loadEndListener) - request.send() - }) + request.send() + + await waitForXMLHttpRequest(request) expect(request.readyState).toBe(4) expect(request.status).toBe(200) @@ -139,23 +140,24 @@ it.each<[name: string, getUrl: () => string]>([ const loadListener = vi.fn() const loadEndListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) + const request = new XMLHttpRequest() + request.open('GET', url) - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler + request.onreadystatechange = onReadyStateChangeHandler + request.onloadstart = onLoadStartHandler + request.onprogress = onProgressHandler + request.onload = onLoadHandler + request.onloadend = onLoadEndHandler - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) + request.addEventListener('readystatechange', onReadyStateChangeListener) + request.addEventListener('loadstart', loadStartListener) + request.addEventListener('progress', progressListener) + request.addEventListener('load', loadListener) + request.addEventListener('loadend', loadEndListener) - request.send() - }) + request.send() + + await waitForXMLHttpRequest(request) expect(request.readyState).toBe(4) expect(request.status).toBe(500) @@ -202,23 +204,24 @@ it.each<[name: string, getUrl: () => string]>([ const loadListener = vi.fn() const loadEndListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) + const request = new XMLHttpRequest() + request.open('GET', url) - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler + request.onreadystatechange = onReadyStateChangeHandler + request.onloadstart = onLoadStartHandler + request.onprogress = onProgressHandler + request.onload = onLoadHandler + request.onloadend = onLoadEndHandler - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) + request.addEventListener('readystatechange', onReadyStateChangeListener) + request.addEventListener('loadstart', loadStartListener) + request.addEventListener('progress', progressListener) + request.addEventListener('load', loadListener) + request.addEventListener('loadend', loadEndListener) - request.send() - }) + request.send() + + await waitForXMLHttpRequest(request) expect(request.readyState).toBe(4) expect(request.status).toBe(0) @@ -258,29 +261,30 @@ it('dispatched relevant events upon an unhandled exception in the interceptor', const loadEndListener = vi.fn() const errorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - request.onerror = onErrorHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - request.addEventListener('error', errorListener) - - // Open the connection after the callbacks/listeners have been added. - // Some XHR interactions, like file uploads, require you to add the - // progress listeners BEFORE the request is open. - request.open('GET', httpServer.https.url('/exception')) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'json' + + request.onreadystatechange = onReadyStateChangeHandler + request.onloadstart = onLoadStartHandler + request.onprogress = onProgressHandler + request.onload = onLoadHandler + request.onloadend = onLoadEndHandler + request.onerror = onErrorHandler + + request.addEventListener('readystatechange', onReadyStateChangeListener) + request.addEventListener('loadstart', loadStartListener) + request.addEventListener('progress', progressListener) + request.addEventListener('load', loadListener) + request.addEventListener('loadend', loadEndListener) + request.addEventListener('error', errorListener) + + // Open the connection after the callbacks/listeners have been added. + // Some XHR interactions, like file uploads, require you to add the + // progress listeners BEFORE the request is open. + request.open('GET', httpServer.https.url('/exception')) + request.send() + + await waitForXMLHttpRequest(request) expect(request.readyState).toBe(4) expect(request.status).toBe(500) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts index 9a307de93..1a9c82cdc 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts @@ -4,7 +4,7 @@ */ import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors, waitForXMLHttpRequest } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.use(useCors) @@ -60,11 +60,12 @@ afterAll(async () => { it('emits correct events sequence for an unhandled request with no response body', async () => { const listener = vi.fn() - const req = await createXMLHttpRequest((req) => { - spyOnEvents(req, listener) - req.open('GET', httpServer.http.url()) - req.send() - }) + const request = new XMLHttpRequest() + spyOnEvents(request, listener) + request.open('GET', httpServer.http.url()) + request.send() + + await waitForXMLHttpRequest(request) expect(listener.mock.calls).toEqual([ ['readystatechange', 1], // OPEN @@ -75,16 +76,17 @@ it('emits correct events sequence for an unhandled request with no response body ['load', 4], ['loadend', 4], ]) - expect(req.readyState).toEqual(4) + expect(request.readyState).toEqual(4) }) it('emits correct events sequence for a handled request with no response body', async () => { const listener = vi.fn() - const req = await createXMLHttpRequest((req) => { - spyOnEvents(req, listener) - req.open('GET', httpServer.http.url('/user')) - req.send() - }) + const request = new XMLHttpRequest() + spyOnEvents(request, listener) + request.open('GET', httpServer.http.url('/user')) + request.send() + + await waitForXMLHttpRequest(request) expect(listener.mock.calls).toEqual([ ['readystatechange', 1], // OPEN @@ -95,16 +97,17 @@ it('emits correct events sequence for a handled request with no response body', ['load', 4], ['loadend', 4], ]) - expect(req.readyState).toBe(4) + expect(request.readyState).toBe(4) }) it('emits correct events sequence for an unhandled request with a response body', async () => { const listener = vi.fn() - const req = await createXMLHttpRequest((req) => { - spyOnEvents(req, listener) - req.open('GET', httpServer.http.url('/numbers')) - req.send() - }) + const request = new XMLHttpRequest() + spyOnEvents(request, listener) + request.open('GET', httpServer.http.url('/numbers')) + request.send() + + await waitForXMLHttpRequest(request) expect(listener.mock.calls).toEqual([ ['readystatechange', 1], // OPEN @@ -116,16 +119,17 @@ it('emits correct events sequence for an unhandled request with a response body' ['load', 4], ['loadend', 4], ]) - expect(req.readyState).toBe(4) + expect(request.readyState).toBe(4) }) it('emits correct events sequence for a handled request with a response body', async () => { const listener = vi.fn() - const req = await createXMLHttpRequest((req) => { - spyOnEvents(req, listener) - req.open('GET', httpServer.http.url('/numbers-mock')) - req.send() - }) + const request = new XMLHttpRequest() + spyOnEvents(request, listener) + request.open('GET', httpServer.http.url('/numbers-mock')) + request.send() + + await waitForXMLHttpRequest(request) expect(listener.mock.calls).toEqual([ ['readystatechange', 1], // OPEN @@ -137,5 +141,5 @@ it('emits correct events sequence for a handled request with a response body', a ['load', 4], ['loadend', 4], ]) - expect(req.readyState).toBe(4) + expect(request.readyState).toBe(4) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index 5d8e8bf89..298d5a988 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -4,7 +4,7 @@ */ import axios from 'axios' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -25,11 +25,12 @@ it('XMLHttpRequest: treats unhandled interceptor exceptions as 500 responses', a throw new Error('Custom error') }) - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', 'http://localhost/api') - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://localhost/api') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(500) expect(request.statusText).toBe('Unhandled Exception') @@ -66,11 +67,12 @@ it('treats a thrown Response instance as a mocked response', async () => { throw new Response('hello world') }) - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(200) expect(request.response).toBe('hello world') @@ -83,12 +85,13 @@ it('treats a Response.error() as a network error', async () => { }) const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.addEventListener('error', requestErrorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(0) expect(requestErrorListener).toHaveBeenCalledTimes(1) @@ -100,12 +103,13 @@ it('treats a thrown Response.error() as a network error', async () => { }) const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.addEventListener('error', requestErrorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(0) expect(requestErrorListener).toHaveBeenCalledTimes(1) @@ -119,11 +123,12 @@ it('handles exceptions by default if "unhandledException" listener is provided b }) interceptor.on('unhandledException', unhandledExceptionListener) - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', 'http://localhost/api') - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://localhost/api') + request.send() + + await waitForXMLHttpRequest(request) // Must emit the "unhandledException" interceptor event. expect(unhandledExceptionListener).toHaveBeenCalledWith( @@ -157,12 +162,13 @@ it('handles exceptions as instructed in "unhandledException" listener (mock resp }) const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.addEventListener('error', requestErrorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(200) expect(request.response).toBe('fallback response') @@ -191,12 +197,13 @@ it('handles exceptions as instructed in "unhandledException" listener (request e }) const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.addEventListener('error', requestErrorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(requestErrorListener).toHaveBeenCalledOnce() expect(request.readyState).toBe(request.DONE) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts index 4e6425279..a88144d95 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors, waitForXMLHttpRequest } from '#/test/helpers' const server = new HttpServer((app) => { app.use(useCors) @@ -44,15 +44,16 @@ it('allows modifying outgoing request headers', async () => { request.headers.set('X-Set-Header', 'new-value') }) - const req = await createXMLHttpRequest((req) => { - req.open('GET', server.http.url('/user')) - req.setRequestHeader('X-Delete-Header', 'a') - req.setRequestHeader('X-Append-Header', '1') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', server.http.url('/user')) + request.setRequestHeader('X-Delete-Header', 'a') + request.setRequestHeader('X-Append-Header', '1') + request.send() + + await waitForXMLHttpRequest(request) // Cannot delete XMLHttpRequest headers. - expect(req.getResponseHeader('x-delete-header')).toBe('a') + expect(request.getResponseHeader('x-delete-header')).toBe('a') expect(console.warn).toHaveBeenCalledWith( expect.stringMatching( `XMLHttpRequest: Cannot remove a "X-Delete-Header" header from the Fetch API representation of the "GET http://127.0.0.1:\\d+/user" request. XMLHttpRequest headers cannot be removed.` @@ -60,6 +61,6 @@ it('allows modifying outgoing request headers', async () => { ) // Adding and modifying XMLHttpRequest headers is allowed. - expect(req.getResponseHeader('x-append-header')).toBe('1, 2') - expect(req.getResponseHeader('x-set-header')).toBe('new-value') + expect(request.getResponseHeader('x-append-header')).toBe('1, 2') + expect(request.getResponseHeader('x-set-header')).toBe('new-value') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts index 38618a526..3d84717d8 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -24,11 +24,12 @@ afterAll(async () => { }) it('handles original response without any headers', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/user')) - request.setRequestHeader('accept', 'plain/text') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/user')) + request.setRequestHeader('accept', 'plain/text') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toEqual(200) expect(request.statusText).toEqual('OK') diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts index ab712aa09..56bd3d824 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors, waitForXMLHttpRequest } from '#/test/helpers' interface ResponseType { requestRawHeaders: Array @@ -36,14 +36,15 @@ afterAll(async () => { }) it('sends the request headers to the server', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/')) - req.setRequestHeader('X-ClienT-HeadeR', 'abc-123') - req.setRequestHeader('X-Multi-Value', 'value1; value2') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/')) + request.setRequestHeader('X-ClienT-HeadeR', 'abc-123') + request.setRequestHeader('X-Multi-Value', 'value1; value2') + request.send() + + await waitForXMLHttpRequest(request) // Normalized request headers list all headers in lower-case. - expect(req.getResponseHeader('x-client-header')).toEqual('abc-123') - expect(req.getResponseHeader('x-multi-value')).toEqual('value1; value2') + expect(request.getResponseHeader('x-client-header')).toEqual('abc-123') + expect(request.getResponseHeader('x-multi-value')).toEqual('value1; value2') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts index b74de0dd0..43e4afa35 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -23,10 +23,11 @@ it('supports lowercase HTTP request method', async () => { } }) - const request = await createXMLHttpRequest((request) => { - request.open('post', 'http://localhost/resource') - request.send() - }) + const request = new XMLHttpRequest() + request.open('post', 'http://localhost/resource') + request.send() + + await waitForXMLHttpRequest(request) expect(request.responseText).toBe('hello world') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts index 4ddb62a34..2e4f5fb4a 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { @@ -21,11 +21,12 @@ afterAll(() => { }) it('sends a mocked response with an empty response body', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/arbitrary-url') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.send() - expect(req.status).toEqual(401) - expect(req.response).toEqual('') + await waitForXMLHttpRequest(request) + + expect(request.status).toEqual(401) + expect(request.response).toEqual('') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts index b292a35d4..c27fde767 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -35,25 +35,27 @@ afterAll(() => { }) it('handles response of type "json" and missing response JSON body', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('PUT', '/no-body') - req.responseType = 'json' - req.send() - }) + const request = new XMLHttpRequest() + request.open('PUT', '/no-body') + request.responseType = 'json' + request.send() + + await waitForXMLHttpRequest(request) // When XHR fails to parse a given response JSON body, // fall back to null, as the failed JSON parsing result. - expect(req.response).toBe(null) - expect(req.responseType).toBe('json') + expect(request.response).toBe(null) + expect(request.responseType).toBe('json') }) it('handles response of type "json" and invalid response JSON body', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/invalid-json') - req.responseType = 'json' - req.send() - }) - - expect(req.response).toBe(null) - expect(req.responseType).toEqual('json') + const request = new XMLHttpRequest() + request.open('GET', '/invalid-json') + request.responseType = 'json' + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.response).toBe(null) + expect(request.responseType).toEqual('json') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts index a2eecdba5..b4151bb9f 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const XML_STRING = 'Content' @@ -24,10 +24,11 @@ describe('Content-Type: application/xml', () => { }) it('supports a mocked response with an XML response body', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '/arbitrary-url') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.send() + + await waitForXMLHttpRequest(request) expect(request.responseXML).toStrictEqual( new DOMParser().parseFromString(XML_STRING, 'application/xml') @@ -55,10 +56,11 @@ describe('Content-Type: text/xml', () => { }) it('supports a mocked response with an XML response body', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '/arbitrary-url') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.send() + + await waitForXMLHttpRequest(request) expect(request.responseXML).toStrictEqual( new DOMParser().parseFromString(XML_STRING, 'text/xml') diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts index 416a57684..b4578c361 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors, waitForXMLHttpRequest } from '#/test/helpers' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' const httpServer = new HttpServer((app) => { @@ -27,12 +27,13 @@ afterAll(async () => { }) it('ignores casing when retrieving response headers via "getResponseHeader"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/account')) - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/account')) + request.send() + + await waitForXMLHttpRequest(request) - expect(req.getResponseHeader('x-response-type')).toEqual('bypass') - expect(req.getResponseHeader('X-response-Type')).toEqual('bypass') - expect(req.getResponseHeader('X-RESPONSE-TYPE')).toEqual('bypass') + expect(request.getResponseHeader('x-response-type')).toEqual('bypass') + expect(request.getResponseHeader('X-response-Type')).toEqual('bypass') + expect(request.getResponseHeader('X-RESPONSE-TYPE')).toEqual('bypass') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts index 818fdc0df..96bc5c26a 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors, waitForXMLHttpRequest } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.use(useCors) @@ -46,23 +46,27 @@ afterAll(async () => { }) it('retrieves the mocked response headers when called ".getAllResponseHeaders()"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/?mock=true') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/?mock=true') + request.send() + + await waitForXMLHttpRequest(request) - const responseHeaders = req.getAllResponseHeaders() - expect(responseHeaders).toEqual('etag: 123\r\nx-response-type: mock') + expect(request.getAllResponseHeaders()).toBe( + 'etag: 123\r\nx-response-type: mock' + ) }) it('returns the bypass response headers when called ".getAllResponseHeaders()"', async () => { - const req = await createXMLHttpRequest((req) => { - // Perform a HEAD request so that the response has no "Content-Type" header - // always appended by Express. - req.open('HEAD', httpServer.http.url('/')) - req.send() - }) + const request = new XMLHttpRequest() + // Perform a HEAD request so that the response has no "Content-Type" header + // always appended by Express. + request.open('HEAD', httpServer.http.url('/')) + request.send() + + await waitForXMLHttpRequest(request) - const responseHeaders = req.getAllResponseHeaders() - expect(responseHeaders).toEqual('etag: 456\r\nx-response-type: bypass') + expect(request.getAllResponseHeaders()).toBe( + 'etag: 456\r\nx-response-type: bypass' + ) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts index 22b6d0505..80905fe44 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts @@ -3,10 +3,10 @@ * @see https://github.com/mswjs/msw/issues/2307 */ import { HttpServer } from '@open-draft/test-server/http' +import { DeferredPromise } from '@open-draft/deferred-promise' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { FetchResponse } from '#/src/utils/fetchUtils' -import { createXMLHttpRequest, useCors } from '#/test/helpers' -import { DeferredPromise } from '@open-draft/deferred-promise' +import { waitForXMLHttpRequest, useCors } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -38,10 +38,11 @@ it('handles non-configurable responses from the actual server', async () => { responsePromise.resolve(response) }) - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/resource')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/resource')) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(101) expect(request.statusText).toBe('Switching Protocols') @@ -65,10 +66,11 @@ it('supports mocking non-configurable responses', async () => { responsePromise.resolve(response) }) - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/resource')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/resource')) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(101) expect(request.responseText).toBe('') diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts index 7978d6804..eede923d8 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts @@ -2,7 +2,7 @@ import { encodeBuffer } from '#/src/index' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { toArrayBuffer } from '#/src/utils/bufferUtils' -import { createXMLHttpRequest, readBlob } from '#/test/helpers' +import { readBlob, waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { @@ -30,25 +30,27 @@ afterAll(() => { }) it('responds with an object when "responseType" equals "json"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/arbitrary-url') - req.responseType = 'json' - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.responseType = 'json' + request.send() + + await waitForXMLHttpRequest(request) - expect(typeof req.response).toBe('object') - expect(req.response).toEqual({ + expect(typeof request.response).toBe('object') + expect(request.response).toEqual({ firstName: 'John', lastName: 'Maverick', }) }) it('responds with a Blob when "responseType" equals "blob"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/arbitrary-url') - req.responseType = 'blob' - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.responseType = 'blob' + request.send() + + await waitForXMLHttpRequest(request) const expectedBlob = new Blob( [ @@ -62,7 +64,7 @@ it('responds with a Blob when "responseType" equals "blob"', async () => { } ) - const responseBlob: Blob = req.response + const responseBlob: Blob = request.response const expectedBlobContents = await readBlob(responseBlob) expect(responseBlob).toBeInstanceOf(Blob) @@ -78,11 +80,12 @@ it('responds with a Blob when "responseType" equals "blob"', async () => { }) it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/arbitrary-url') - req.responseType = 'arraybuffer' - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.responseType = 'arraybuffer' + request.send() + + await waitForXMLHttpRequest(request) const expectedArrayBuffer = toArrayBuffer( encodeBuffer( @@ -93,7 +96,7 @@ it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', asyn ) ) - const responseBuffer = req.response as ArrayBuffer + const responseBuffer = request.response as ArrayBuffer const isBufferEqual = (left: ArrayBuffer, right: ArrayBuffer): boolean => { const first = new Uint8Array(left) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts index f2f4b17a9..948dc7fd0 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts @@ -3,7 +3,7 @@ * @see https://github.com/mswjs/interceptors/issues/281 */ import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -32,28 +32,31 @@ afterAll(() => { }) it('keeps "status" as 0 if the request fails', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '/cors') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/cors') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(0) }) it('respects error response status', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '?status=500') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '?status=500') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(500) }) it('respects a custom "status" from the response', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '/?status=201') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/?status=201') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(201) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts index 0e0543433..1f9243538 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts @@ -4,9 +4,9 @@ */ import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' import { DeferredPromise } from '@open-draft/deferred-promise' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', async (_req, res) => { @@ -30,14 +30,15 @@ afterAll(async () => { it('handles request timeout via the "ontimeout" callback', async () => { const timeoutCalled = new DeferredPromise() - createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/')) - req.timeout = 1 - req.ontimeout = function customTimeoutCallback() { - timeoutCalled.resolve(this.readyState) - } - req.send() - }).catch(console.error) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/')) + request.timeout = 1 + request.ontimeout = function customTimeoutCallback() { + timeoutCalled.resolve(this.readyState) + } + request.send() + + await waitForXMLHttpRequest(request) const nextReadyState = await timeoutCalled expect(nextReadyState).toBe(4) @@ -46,15 +47,16 @@ it('handles request timeout via the "ontimeout" callback', async () => { it('handles request timeout via the "timeout" event listener', async () => { const timeoutCalled = new DeferredPromise() - createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/')) - req.timeout = 1 - req.addEventListener('timeout', function customTimeoutListener() { - expect(this.readyState).toBe(4) - timeoutCalled.resolve(this.readyState) - }) - req.send() - }).catch(console.error) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/')) + request.timeout = 1 + request.addEventListener('timeout', function customTimeoutListener() { + expect(this.readyState).toBe(4) + timeoutCalled.resolve(this.readyState) + }) + request.send() + + await waitForXMLHttpRequest(request) const nextReadyState = await timeoutCalled expect(nextReadyState).toBe(4) diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index 439d04499..1ed8e6578 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -2,9 +2,9 @@ import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { - createXMLHttpRequest, useCors, REQUEST_ID_REGEXP, + waitForXMLHttpRequest, } from '#/test/helpers' import { HttpRequestEventMap } from '#/src/index' import { RequestController } from '#/src/RequestController' @@ -47,10 +47,11 @@ it('emits events for a handled request', async () => { interceptor.on('request', requestListener) interceptor.on('response', responseListener) - await createXMLHttpRequest((request) => { - request.open('GET', server.http.url('/user')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', server.http.url('/user')) + request.send() + + await waitForXMLHttpRequest(request) // Must call the "request" event listener. expect(requestListener).toHaveBeenCalledTimes(1) @@ -90,10 +91,11 @@ it('emits events for a bypassed request', async () => { interceptor.on('request', requestListener) interceptor.on('response', responseListener) - await createXMLHttpRequest((request) => { - request.open('GET', server.http.url('/bypassed')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', server.http.url('/bypassed')) + request.send() + + await waitForXMLHttpRequest(request) // Must call the "request" event listener. expect(requestListener).toHaveBeenCalledTimes(1) diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts index 044ceea7a..64f7640fc 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts @@ -3,9 +3,9 @@ import type { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { - createXMLHttpRequest, useCors, REQUEST_ID_REGEXP, + waitForXMLHttpRequest, } from '#/test/helpers' import { toArrayBuffer, encodeBuffer } from '#/src/utils/bufferUtils' import { RequestController } from '#/src/RequestController' @@ -62,286 +62,326 @@ afterAll(async () => { it('intercepts an HTTP HEAD request', async () => { const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('HEAD', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) + const request = new XMLHttpRequest() + request.open('HEAD', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('HEAD') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('HEAD') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTP GET request', async () => { const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('GET', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTP POST request', async () => { const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('POST', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send('post-payload') - }) + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send('post-payload') + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('post-payload') + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTP PUT request', async () => { const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('PUT', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send('put-payload') - }) + const request = new XMLHttpRequest() + request.open('PUT', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send('put-payload') + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('PUT') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('PUT') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('put-payload') + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTP DELETE request', async () => { const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('DELETE', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) + const request = new XMLHttpRequest() + request.open('DELETE', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('DELETE') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('DELETE') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTPS HEAD request', async () => { const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('HEAD', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) + const request = new XMLHttpRequest() + request.open('HEAD', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('HEAD') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('HEAD') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTPS GET request', async () => { const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('GET', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTPS POST request', async () => { const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('POST', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send('post-payload') - }) + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send('post-payload') + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('post-payload') + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTPS PUT request', async () => { const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('PUT', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send('put-payload') - }) + const request = new XMLHttpRequest() + request.open('PUT', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send('put-payload') + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('PUT') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('PUT') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('put-payload') + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('intercepts an HTTPS DELETE request', async () => { const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('DELETE', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) + const request = new XMLHttpRequest() + request.open('DELETE', url) + request.setRequestHeader('x-custom-header', 'yes') + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + { + const [{ request, requestId, controller }] = resolver.mock.calls[0] - expect(request.method).toBe('DELETE') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) + expect(request.method).toBe('DELETE') + expect(request.url).toBe(url) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('sets "credentials" to "include" on isomorphic request when "withCredentials" is true', async () => { - await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/user')) - req.withCredentials = true - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.https.url('/user')) + request.withCredentials = true + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('include') + { + const [{ request }] = resolver.mock.calls[0] + expect(request.credentials).toBe('include') + } }) it('sets "credentials" to "omit" on isomorphic request when "withCredentials" is not set', async () => { - await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/user')) - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.https.url('/user')) + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('same-origin') + { + const [{ request }] = resolver.mock.calls[0] + expect(request.credentials).toBe('same-origin') + } }) it('sets "credentials" to "omit" on isomorphic request when "withCredentials" is false', async () => { - await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/user')) - req.withCredentials = false - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.https.url('/user')) + request.withCredentials = false + request.send() + + await waitForXMLHttpRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('same-origin') + { + const [{ request }] = resolver.mock.calls[0] + expect(request.credentials).toBe('same-origin') + } }) it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.https.url('/user')) - request.responseType = 'arraybuffer' - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.https.url('/user')) + request.responseType = 'arraybuffer' + request.send() + + await waitForXMLHttpRequest(request) const expectedArrayBuffer = toArrayBuffer(encodeBuffer('user-body')) const responseBuffer = request.response as ArrayBuffer diff --git a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts index 382d558b8..62e5c5df0 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts @@ -3,7 +3,7 @@ * @see https://github.com/mswjs/interceptors/issues/335 */ import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -22,12 +22,13 @@ it('handles Response.error() as a request error', async () => { const loadListener = vi.fn() const errorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost') - request.addEventListener('load', loadListener) - request.addEventListener('error', errorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', 'http://localhost') + request.addEventListener('load', loadListener) + request.addEventListener('error', errorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(0) expect(request.readyState).toBe(4) @@ -41,11 +42,12 @@ it('handles interceptor exceptions as 500 error responses', async () => { throw new Error('Network error') }) - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', 'http://localhost') - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://localhost') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(500) expect(request.statusText).toBe('Unhandled Exception') diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts index e997c8176..72ed9c7ba 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts @@ -5,7 +5,7 @@ import { HttpServer } from '@open-draft/test-server/http' import zlib from 'zlib' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { waitForXMLHttpRequest, useCors } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.use(useCors) @@ -30,10 +30,11 @@ afterAll(async () => { }) it('bypasses a compressed HTTP request', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/compressed')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/compressed')) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toEqual(200) expect(request.response).toEqual('compressed-body') diff --git a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts index f53bfcfaa..def45ec38 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts @@ -1,6 +1,6 @@ // @vitest-environment react-native-like import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -17,28 +17,27 @@ it('responds to a request with an absolute URL', async () => { controller.respondWith(new Response('Hello world')) }) - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'https://example.com/resource') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', 'https://example.com/resource') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(200) expect(request.response).toBe('Hello world') }) it('throws on a request with a relative URL', async () => { - const createRequest = () => { - return createXMLHttpRequest((request) => { - /** - * @note Since the "location" is not present in React Native, - * relative requests will throw (nothing to be relative to). - * This is the correct behavior in React Native, where relative - * requests are a no-op. - */ - request.open('GET', '/relative/url') - request.send() - }) - } - - expect(createRequest).toThrow('Invalid URL') + expect(() => { + const request = new XMLHttpRequest() + + /** + * @note Since the "location" is not present in React Native, + * relative requests will throw (nothing to be relative to). + * This is the correct behavior in React Native, where relative + * requests are a no-op. + */ + request.open('GET', '/relative/url') + request.send() + }).toThrow('Invalid URL') }) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts index 43a8ab56d..78891f06e 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() @@ -24,11 +24,12 @@ it('does not lock the request body stream when calculating the body size', async }) const uploadLoadStartListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.upload.addEventListener('loadstart', uploadLoadStartListener) - request.open('POST', '/resource') - request.send('request-body') - }) + const request = new XMLHttpRequest() + request.upload.addEventListener('loadstart', uploadLoadStartListener) + request.open('POST', '/resource') + request.send('request-body') + + await waitForXMLHttpRequest(request) // Must calculate the total request body size for the upload event. const progressEvent = uploadLoadStartListener.mock.calls[0][0] diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts index d1829b50e..9cded4c64 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { @@ -18,11 +18,12 @@ afterAll(async () => { it('treats "Response.error()" as request error', async () => { const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost:3001/resource') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', 'http://localhost:3001/resource') + request.addEventListener('error', requestErrorListener) + request.send() + + await waitForXMLHttpRequest(request) // Request must reflect the request error state. expect(request.readyState).toBe(request.DONE) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts index e9e79a984..3d5fc18d6 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors, waitForXMLHttpRequest } from '#/test/helpers' import type { HttpRequestEventMap } from '#/src/index' const httpServer = new HttpServer((app) => { @@ -32,10 +32,11 @@ afterAll(async () => { }) it('represents a 204 response without body using fetch api response', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/204')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/204')) + request.send() + + await waitForXMLHttpRequest(request) expect(request.response).toBe('') expect(responseListener).toHaveBeenNthCalledWith( @@ -51,10 +52,11 @@ it('represents a 204 response without body using fetch api response', async () = }) it('represents a 205 response without body using fetch api response', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/205')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/205')) + request.send() + + await waitForXMLHttpRequest(request) expect(request.response).toBe('') expect(responseListener).toHaveBeenNthCalledWith( @@ -70,10 +72,11 @@ it('represents a 205 response without body using fetch api response', async () = }) it('represents a 304 response without body using fetch api response', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/304')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/304')) + request.send() + + await waitForXMLHttpRequest(request) expect(request.response).toBe('') expect(responseListener).toHaveBeenNthCalledWith( diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts index 6e1e6d926..32f9e90e8 100644 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors, waitForXMLHttpRequest } from '#/test/helpers' declare namespace window { export const _resourceLoader: { @@ -71,71 +71,77 @@ afterAll(async () => { }) it('responds to an HTTP request handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/')) - req.send() - }) - const responseHeaders = req.getAllResponseHeaders() + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/')) + request.send() + + await waitForXMLHttpRequest(request) + const responseHeaders = request.getAllResponseHeaders() - expect(req.status).toEqual(301) + expect(request.status).toEqual(301) expect(responseHeaders).toContain('content-type: application/hal+json') - expect(req.response).toEqual('foo') + expect(request.response).toEqual('foo') }) it('bypasses an HTTP request not handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/get')) - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/get')) + request.send() - expect(req.status).toEqual(200) - expect(req.response).toEqual('/get') + await waitForXMLHttpRequest(request) + + expect(request.status).toEqual(200) + expect(request.response).toEqual('/get') }) it('responds to an HTTPS request handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/')) - req.send() - }) - const responseHeaders = req.getAllResponseHeaders() + const request = new XMLHttpRequest() + request.open('GET', httpServer.https.url('/')) + request.send() - expect(req.status).toEqual(301) + await waitForXMLHttpRequest(request) + const responseHeaders = request.getAllResponseHeaders() + + expect(request.status).toEqual(301) expect(responseHeaders).toContain('content-type: application/hal+json') - expect(req.response).toEqual('foo') - expect(req.responseURL).toEqual(httpServer.https.url('/')) + expect(request.response).toEqual('foo') + expect(request.responseURL).toEqual(httpServer.https.url('/')) }) it('bypasses an HTTPS request not handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/get')) - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.https.url('/get')) + request.send() - expect(req.status).toEqual(200) - expect(req.response).toEqual('/get') - expect(req.responseURL).toEqual(httpServer.https.url('/get')) + await waitForXMLHttpRequest(request) + + expect(request.status).toEqual(200) + expect(request.response).toEqual('/get') + expect(request.responseURL).toEqual(httpServer.https.url('/get')) }) it('responds to an HTTP request to a relative URL that is handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('POST', httpServer.https.url('/login')) - req.send() - }) - const responseHeaders = req.getAllResponseHeaders() + const request = new XMLHttpRequest() + request.open('POST', httpServer.https.url('/login')) + request.send() - expect(req.status).toEqual(301) + await waitForXMLHttpRequest(request) + const responseHeaders = request.getAllResponseHeaders() + + expect(request.status).toEqual(301) expect(responseHeaders).toContain('content-type: application/hal+json') - expect(req.response).toEqual('foo') - expect(req.responseURL).toEqual(httpServer.https.url('/login')) + expect(request.response).toEqual('foo') + expect(request.responseURL).toEqual(httpServer.https.url('/login')) }) it('produces a request error for a mocked Response.error() response', async () => { const errorListener = vi.fn() - const req = await createXMLHttpRequest((req) => { - req.open('GET', 'http://localhost/network-error') - req.addEventListener('error', errorListener) - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', 'http://localhost/network-error') + request.addEventListener('error', errorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(errorListener).toHaveBeenCalledTimes(1) @@ -144,15 +150,16 @@ it('produces a request error for a mocked Response.error() response', async () = expect(progressEvent).toBeInstanceOf(ProgressEvent) // Request must still exist. - expect(req.status).toBe(0) + expect(request.status).toBe(0) }) it('produces a 500 response for an unhandled exception in the interceptor', async () => { - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', 'http://localhost/exception') - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://localhost/exception') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(500) expect(request.statusText).toBe('Unhandled Exception') @@ -164,23 +171,25 @@ it('produces a 500 response for an unhandled exception in the interceptor', asyn }) it('does not propagate the forbidden "cookie" header on the bypassed response', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('POST', httpServer.https.url('/cookies')) - req.send() - }) - const responseHeaders = req.getAllResponseHeaders() + const request = new XMLHttpRequest() + request.open('POST', httpServer.https.url('/cookies')) + request.send() + + await waitForXMLHttpRequest(request) + const responseHeaders = request.getAllResponseHeaders() expect(responseHeaders).not.toMatch(/cookie/) }) it('bypasses any request when the interceptor is restored', async () => { interceptor.dispose() - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/')) - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.https.url('/')) + request.send() + + await waitForXMLHttpRequest(request) - expect(req.status).toEqual(200) - expect(req.response).toEqual('/') - expect(req.responseURL).toEqual(httpServer.https.url('/')) + expect(request.status).toEqual(200) + expect(request.response).toEqual('/') + expect(request.responseURL).toEqual(httpServer.https.url('/')) }) From d5ab8f006ba62a861cfc1eddc6ceb7d82b9671a6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 16:03:38 +0100 Subject: [PATCH 109/198] chore: clean up with knip --- package.json | 1 - pnpm-lock.yaml | 12 --------- .../utils/baseUrlFromConnectionOptions.ts | 26 ------------------- src/interceptors/http/http-parser.ts | 2 +- src/interceptors/net/socket-controller.ts | 2 -- src/utils/apply-patch.ts | 2 +- src/utils/getUrlByRequestOptions.ts | 2 +- src/utils/nextTick.ts | 4 --- 8 files changed, 3 insertions(+), 48 deletions(-) delete mode 100644 src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts diff --git a/package.json b/package.json index 107bdeed5..9fa3b64b7 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,6 @@ "body-parser": "^1.19.0", "commitizen": "^4.2.4", "cors": "^2.8.5", - "cross-env": "^7.0.3", "cz-conventional-changelog": "3.3.0", "engine.io-parser": "^5.2.1", "express": "^4.21.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a28f8a55..81d9d4292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,9 +93,6 @@ importers: cors: specifier: ^2.8.5 version: 2.8.5 - cross-env: - specifier: ^7.0.3 - version: 7.0.3 cz-conventional-changelog: specifier: 3.3.0 version: 3.3.0(@types/node@22.13.9)(typescript@5.8.2) @@ -1416,11 +1413,6 @@ packages: typescript: optional: true - cross-env@7.0.3: - resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} - engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4566,10 +4558,6 @@ snapshots: optionalDependencies: typescript: 5.8.2 - cross-env@7.0.3: - dependencies: - cross-spawn: 7.0.6 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts deleted file mode 100644 index 6a4f33adb..000000000 --- a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts +++ /dev/null @@ -1,26 +0,0 @@ -export function baseUrlFromConnectionOptions(options: any): URL { - if ('href' in options) { - return new URL(options.href) - } - - const protocol = options.port === 443 ? 'https:' : 'http:' - const host = options.host - - const url = new URL(`${protocol}//${host}`) - - if (options.port) { - url.port = options.port.toString() - } - - if (options.path) { - url.pathname = options.path - } - - if (options.auth) { - const [username, password] = options.auth.split(':') - url.username = username - url.password = password - } - - return url -} diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index 0e3e5cc29..6a0059b01 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -24,7 +24,7 @@ interface ParserHooks { onTimeout?: () => void } -export class HttpParser { +class HttpParser { static REQUEST = HTTPParser.REQUEST static RESPONSE = HTTPParser.RESPONSE diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 827af9332..8bd532f92 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -10,8 +10,6 @@ import { getAddressInfoByConnectionOptions } from './utils/address-info' const kListenerWrap = Symbol('kListenerWrap') export const kRawSocket = Symbol('kRawSocket') -export const kMockState = Symbol('kMockState') -export const kTlsSocket = Symbol('kTlsSocket') const log = createLogger('SocketController') diff --git a/src/utils/apply-patch.ts b/src/utils/apply-patch.ts index ebb44c4d9..8f08a69a8 100644 --- a/src/utils/apply-patch.ts +++ b/src/utils/apply-patch.ts @@ -1,6 +1,6 @@ import { invariant } from 'outvariant' -export const IS_PATCHED_MODULE: unique symbol = Symbol.for('kIsPatchedModule') +const IS_PATCHED_MODULE: unique symbol = Symbol.for('kIsPatchedModule') /** * Apply a patch for the given property on the owner object. diff --git a/src/utils/getUrlByRequestOptions.ts b/src/utils/getUrlByRequestOptions.ts index 0ff6ddac8..79309dfcb 100644 --- a/src/utils/getUrlByRequestOptions.ts +++ b/src/utils/getUrlByRequestOptions.ts @@ -13,7 +13,7 @@ export interface RequestSelf { export type ResolvedRequestOptions = RequestOptions & RequestSelf -export const DEFAULT_PATH = '/' +const DEFAULT_PATH = '/' const DEFAULT_PROTOCOL = 'http:' const DEFAULT_HOSTNAME = 'localhost' const SSL_PORT = 443 diff --git a/src/utils/nextTick.ts b/src/utils/nextTick.ts index a080ca1e1..31e31cee5 100644 --- a/src/utils/nextTick.ts +++ b/src/utils/nextTick.ts @@ -1,7 +1,3 @@ -export function nextTick(callback: () => void) { - setTimeout(callback, 0) -} - export function nextTickAsync(callback: () => void) { return new Promise((resolve) => { setTimeout(() => { From e2162313f02b95a33610a4eaeb303c2a12e511ac Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 16:37:44 +0100 Subject: [PATCH 110/198] fix: use `HttpRequestInterceptor` in granular interceptors --- src/interceptors/ClientRequest/index.ts | 24 ++++++++++++++--- src/interceptors/XMLHttpRequest/node.ts | 32 ++++++++++++++++++----- src/interceptors/fetch/node.ts | 32 +++++++++++++++++------ src/presets/node.ts | 4 +-- test/features/presets/node-preset.test.ts | 10 +++++-- 5 files changed, 80 insertions(+), 22 deletions(-) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index a01031936..48a791e07 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,9 +1,10 @@ import http from 'node:http' import https from 'node:https' -import { HttpRequestEventMap } from '../../glossary' -import { Interceptor } from '../../Interceptor' -import { runInRequestContext } from '../../request-context' +import { HttpRequestEventMap } from '#/src/glossary' +import { Interceptor } from '#/src/Interceptor' +import { runInRequestContext } from '#/src/request-context' import { applyPatch } from '#/src/utils/apply-patch' +import { HttpRequestInterceptor } from '#/src/interceptors/http' export class ClientRequestInterceptor extends Interceptor { static symbol = Symbol('client-request-interceptor') @@ -13,6 +14,23 @@ export class ClientRequestInterceptor extends Interceptor { } protected setup(): void { + const httpInterceptor = new HttpRequestInterceptor() + + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + httpInterceptor + .on('request', (args) => { + if (args.initiator instanceof http.ClientRequest) { + this.emitter.emit('request', args) + } + }) + .on('response', (args) => { + if (args.initiator instanceof http.ClientRequest) { + this.emitter.emit('response', args) + } + }) + this.subscriptions.push( applyPatch(http, 'ClientRequest', (ClientRequest) => { return new Proxy(ClientRequest, { diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index f15375f5d..d58026584 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -1,14 +1,15 @@ -import { requestContext } from '../../request-context' -import { Interceptor } from '../../Interceptor' -import { HttpRequestEventMap } from '../../glossary' -import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { applyPatch } from '../../utils/apply-patch' +import { requestContext } from '#/src/request-context' +import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' +import { applyPatch } from '#/src/utils/apply-patch' +import { Interceptor } from '#/src/Interceptor' +import { HttpRequestEventMap } from '#/src/glossary' +import { HttpRequestInterceptor } from '#/src/interceptors/http' export class XMLHttpRequestInterceptor extends Interceptor { - static interceptorSymbol = Symbol.for('xhr-interceptor') + static symbol = Symbol.for('xhr-interceptor') constructor() { - super(XMLHttpRequestInterceptor.interceptorSymbol) + super(XMLHttpRequestInterceptor.symbol) } protected checkEnvironment() { @@ -16,6 +17,23 @@ export class XMLHttpRequestInterceptor extends Interceptor } protected setup(): void { + const httpInterceptor = new HttpRequestInterceptor() + + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + httpInterceptor + .on('request', (args) => { + if (args.initiator instanceof XMLHttpRequest) { + this.emitter.emit('request', args) + } + }) + .on('response', (args) => { + if (args.initiator instanceof XMLHttpRequest) { + this.emitter.emit('response', args) + } + }) + this.logger.info('patching global "XMLHttpRequest"...') this.subscriptions.push( diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 3192e53e6..35e665389 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -1,10 +1,10 @@ -import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { HttpRequestEventMap } from '../../glossary' -import { Interceptor } from '../../Interceptor' -import { canParseUrl } from '../../utils/canParseUrl' -import { requestContext } from '../../request-context' -import { applyPatch } from '../../utils/apply-patch' -import { recordRawFetchHeaders } from '../ClientRequest/utils/recordRawHeaders' +import { Interceptor } from '#/src/Interceptor' +import { HttpRequestEventMap } from '#/src/glossary' +import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' +import { canParseUrl } from '#/src/utils/canParseUrl' +import { requestContext } from '#/src/request-context' +import { applyPatch } from '#/src/utils/apply-patch' +import { HttpRequestInterceptor } from '#/src/interceptors/http' export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') @@ -18,8 +18,24 @@ export class FetchInterceptor extends Interceptor { } protected setup(): void { + const httpInterceptor = new HttpRequestInterceptor() + + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + httpInterceptor + .on('request', (args) => { + if (args.initiator instanceof Request) { + this.emitter.emit('request', args) + } + }) + .on('response', (args) => { + if (args.initiator instanceof Request) { + this.emitter.emit('response', args) + } + }) + this.subscriptions.push( - recordRawFetchHeaders(), applyPatch(globalThis, 'fetch', (realFetch) => { return (input, init) => { /** diff --git a/src/presets/node.ts b/src/presets/node.ts index 63fbe0f45..332c80518 100644 --- a/src/presets/node.ts +++ b/src/presets/node.ts @@ -1,6 +1,6 @@ import { ClientRequestInterceptor } from '../interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest' -import { FetchInterceptor } from '../interceptors/fetch' +import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest/node' +import { FetchInterceptor } from '../interceptors/fetch/node' /** * The default preset provisions the interception of requests diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index aa8278339..04c6980c0 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -15,12 +15,18 @@ beforeAll(() => { interceptor.apply() interceptor.on('request', ({ request, controller }) => { requestListener(request) - controller.respondWith(new Response('mocked')) + controller.respondWith( + new Response('mocked', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) }) }) afterEach(() => { - vi.resetAllMocks() + vi.clearAllMocks() }) afterAll(() => { From 74a0072e55657f30a8d3da911773bd5cf0371ae5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 17:57:54 +0100 Subject: [PATCH 111/198] chore: remove unused `MockSocket` --- src/interceptors/Socket/MockSocket.test.ts | 263 ------------------ src/interceptors/Socket/MockSocket.ts | 58 ---- .../utils/normalizeSocketWriteArgs.test.ts | 51 ---- .../Socket/utils/normalizeSocketWriteArgs.ts | 33 --- 4 files changed, 405 deletions(-) delete mode 100644 src/interceptors/Socket/MockSocket.test.ts delete mode 100644 src/interceptors/Socket/MockSocket.ts delete mode 100644 src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts delete mode 100644 src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts diff --git a/src/interceptors/Socket/MockSocket.test.ts b/src/interceptors/Socket/MockSocket.test.ts deleted file mode 100644 index ee7adb10c..000000000 --- a/src/interceptors/Socket/MockSocket.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * @vitest-environment node - */ -import { Socket } from 'node:net' -import { MockSocket } from './MockSocket' - -it(`keeps the socket connecting until it's destroyed`, () => { - const socket = new MockSocket({ - write: vi.fn(), - read: vi.fn(), - }) - - expect(socket.connecting).toBe(true) - - socket.destroy() - expect(socket.connecting).toBe(false) -}) - -it('calls the "write" on "socket.write()"', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - socket.write() - expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined) -}) - -it('calls the "write" on "socket.write(chunk)"', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - socket.write('hello') - expect(writeCallback).toHaveBeenCalledWith('hello', undefined, undefined) -}) - -it('calls the "write" on "socket.write(chunk, encoding)"', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - socket.write('hello', 'utf8') - expect(writeCallback).toHaveBeenCalledWith('hello', 'utf8', undefined) -}) - -it('calls the "write" on "socket.write(chunk, encoding, callback)"', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - const callback = vi.fn() - socket.write('hello', 'utf8', callback) - expect(writeCallback).toHaveBeenCalledWith('hello', 'utf8', callback) -}) - -it('calls the "write" on "socket.end()"', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - socket.end() - expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined) -}) - -it('calls the "write" on "socket.end(chunk)"', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - socket.end('final') - expect(writeCallback).toHaveBeenCalledWith('final', undefined, undefined) -}) - -it('calls the "write" on "socket.end(chunk, encoding)"', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - socket.end('final', 'utf8') - expect(writeCallback).toHaveBeenCalledWith('final', 'utf8', undefined) -}) - -it('calls the "write" on "socket.end(chunk, encoding, callback)"', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - const callback = vi.fn() - socket.end('final', 'utf8', callback) - expect(writeCallback).toHaveBeenCalledWith('final', 'utf8', callback) -}) - -it('calls the "write" on "socket.end()" without any arguments', () => { - const writeCallback = vi.fn() - const socket = new MockSocket({ - write: writeCallback, - read: vi.fn(), - }) - - socket.end() - expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined) -}) - -it('emits "finished" on .end() without any arguments', async () => { - const finishListener = vi.fn() - const socket = new MockSocket({ - write: vi.fn(), - read: vi.fn(), - }) - socket.on('finish', finishListener) - socket.end() - - await vi.waitFor(() => { - expect(finishListener).toHaveBeenCalledTimes(1) - }) -}) - -it('calls the "read" on "socket.read(chunk)"', () => { - const readCallback = vi.fn() - const socket = new MockSocket({ - write: vi.fn(), - read: readCallback, - }) - - socket.push('hello') - expect(readCallback).toHaveBeenCalledWith('hello', undefined) -}) - -it('calls the "read" on "socket.read(chunk, encoding)"', () => { - const readCallback = vi.fn() - const socket = new MockSocket({ - write: vi.fn(), - read: readCallback, - }) - - socket.push('world', 'utf8') - expect(readCallback).toHaveBeenCalledWith('world', 'utf8') -}) - -it('calls the "read" on "socket.read(null)"', () => { - const readCallback = vi.fn() - const socket = new MockSocket({ - write: vi.fn(), - read: readCallback, - }) - - socket.push(null) - expect(readCallback).toHaveBeenCalledWith(null, undefined) -}) - -it('updates the writable state on "socket.end()"', async () => { - const finishListener = vi.fn() - const endListener = vi.fn() - const socket = new MockSocket({ - write: vi.fn(), - read: vi.fn(), - }) - socket.on('finish', finishListener) - socket.on('end', endListener) - - expect(socket.writable).toBe(true) - expect(socket.writableEnded).toBe(false) - expect(socket.writableFinished).toBe(false) - - socket.write('hello') - // Finish the writable stream. - socket.end() - - expect(socket.writable).toBe(false) - expect(socket.writableEnded).toBe(true) - - // The "finish" event is emitted when writable is done. - // I.e. "socket.end()" is called. - await vi.waitFor(() => { - expect(finishListener).toHaveBeenCalledTimes(1) - }) - expect(socket.writableFinished).toBe(true) -}) - -it('updates the readable state on "socket.push(null)"', async () => { - const endListener = vi.fn() - const socket = new MockSocket({ - write: vi.fn(), - read: vi.fn(), - }) - socket.on('end', endListener) - - expect(socket.readable).toBe(true) - expect(socket.readableEnded).toBe(false) - - socket.push('hello') - socket.push(null) - - expect(socket.readable).toBe(true) - expect(socket.readableEnded).toBe(false) - - // Read the data to free the buffer and - // make Socket emit "end". - socket.read() - - await vi.waitFor(() => { - expect(endListener).toHaveBeenCalledTimes(1) - }) - expect(socket.readable).toBe(false) - expect(socket.readableEnded).toBe(true) -}) - -it('updates the readable/writable state on "socket.destroy()"', async () => { - const finishListener = vi.fn() - const endListener = vi.fn() - const closeListener = vi.fn() - const socket = new MockSocket({ - write: vi.fn(), - read: vi.fn(), - }) - socket.on('finish', finishListener) - socket.on('end', endListener) - socket.on('close', closeListener) - - expect(socket.writable).toBe(true) - expect(socket.writableEnded).toBe(false) - expect(socket.writableFinished).toBe(false) - expect(socket.readable).toBe(true) - - socket.destroy() - - expect(socket.writable).toBe(false) - // The ".end()" wasn't called. - expect(socket.writableEnded).toBe(false) - expect(socket.writableFinished).toBe(false) - expect(socket.readable).toBe(false) - - await vi.waitFor(() => { - expect(closeListener).toHaveBeenCalledTimes(1) - }) - - // Neither "finish" nor "end" events are emitted - // when you destroy the stream. If you want those, - // call ".end()", then destroy the stream. - expect(finishListener).not.toHaveBeenCalled() - expect(endListener).not.toHaveBeenCalled() - expect(socket.writableFinished).toBe(false) - - // The "end" event was never emitted so "readableEnded" - // remains false. - expect(socket.readableEnded).toBe(false) -}) diff --git a/src/interceptors/Socket/MockSocket.ts b/src/interceptors/Socket/MockSocket.ts deleted file mode 100644 index 5bcc42eea..000000000 --- a/src/interceptors/Socket/MockSocket.ts +++ /dev/null @@ -1,58 +0,0 @@ -import net from 'node:net' -import { - normalizeSocketWriteArgs, - type WriteArgs, - type WriteCallback, -} from './utils/normalizeSocketWriteArgs' - -export interface MockSocketOptions { - write: ( - chunk: Buffer | string, - encoding: BufferEncoding | undefined, - callback?: WriteCallback - ) => void - - read: (chunk: Buffer, encoding: BufferEncoding | undefined) => void -} - -export class MockSocket extends net.Socket { - public connecting: boolean - - constructor(protected readonly options: MockSocketOptions) { - super() - this.connecting = false - this.connect() - - this._final = (callback) => { - callback(null) - } - } - - public connect() { - // The connection will remain pending until - // the consumer decides to handle it. - this.connecting = true - return this - } - - public write(...args: Array): boolean { - const [chunk, encoding, callback] = normalizeSocketWriteArgs( - args as WriteArgs - ) - this.options.write(chunk, encoding, callback) - return true - } - - public end(...args: Array) { - const [chunk, encoding, callback] = normalizeSocketWriteArgs( - args as WriteArgs - ) - this.options.write(chunk, encoding, callback) - return super.end.apply(this, args as any) - } - - public push(chunk: any, encoding?: BufferEncoding): boolean { - this.options.read(chunk, encoding) - return super.push(chunk, encoding) - } -} diff --git a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts deleted file mode 100644 index 97d3287e7..000000000 --- a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @vitest-environment node - */ -import { normalizeSocketWriteArgs } from './normalizeSocketWriteArgs' - -it('normalizes .write()', () => { - expect(normalizeSocketWriteArgs([undefined])).toEqual([ - undefined, - undefined, - undefined, - ]) - expect(normalizeSocketWriteArgs([null])).toEqual([null, undefined, undefined]) -}) - -it('normalizes .write(chunk)', () => { - expect(normalizeSocketWriteArgs([Buffer.from('hello')])).toEqual([ - Buffer.from('hello'), - undefined, - undefined, - ]) - expect(normalizeSocketWriteArgs(['hello'])).toEqual([ - 'hello', - undefined, - undefined, - ]) - expect(normalizeSocketWriteArgs([null])).toEqual([null, undefined, undefined]) -}) - -it('normalizes .write(chunk, encoding)', () => { - expect(normalizeSocketWriteArgs([Buffer.from('hello'), 'utf8'])).toEqual([ - Buffer.from('hello'), - 'utf8', - undefined, - ]) -}) - -it('normalizes .write(chunk, callback)', () => { - const callback = () => {} - expect(normalizeSocketWriteArgs([Buffer.from('hello'), callback])).toEqual([ - Buffer.from('hello'), - undefined, - callback, - ]) -}) - -it('normalizes .write(chunk, encoding, callback)', () => { - const callback = () => {} - expect( - normalizeSocketWriteArgs([Buffer.from('hello'), 'utf8', callback]) - ).toEqual([Buffer.from('hello'), 'utf8', callback]) -}) diff --git a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts deleted file mode 100644 index 03a3e9c05..000000000 --- a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type WriteCallback = (error?: Error | null) => void - -export type WriteArgs = - | [chunk: unknown, callback?: WriteCallback] - | [chunk: unknown, encoding: BufferEncoding, callback?: WriteCallback] - -export type NormalizedSocketWriteArgs = [ - chunk: any, - encoding?: BufferEncoding, - callback?: WriteCallback, -] - -/** - * Normalizes the arguments provided to the `Writable.prototype.write()` - * and `Writable.prototype.end()`. - */ -export function normalizeSocketWriteArgs( - args: WriteArgs -): NormalizedSocketWriteArgs { - const normalized: NormalizedSocketWriteArgs = [args[0], undefined, undefined] - - if (typeof args[1] === 'string') { - normalized[1] = args[1] - } else if (typeof args[1] === 'function') { - normalized[2] = args[1] - } - - if (typeof args[2] === 'function') { - normalized[2] = args[2] - } - - return normalized -} From 28fd3daa0ce9551dd05dc6ce4fc28d50195fac0e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 18:40:12 +0100 Subject: [PATCH 112/198] fix: set `initiator` on `XMLHttpRequest`, vitest browser mode --- .github/workflows/ci.yml | 6 +- .gitignore | 1 + package.json | 8 +- pnpm-lock.yaml | 117 +++++++++++++++++- .../XMLHttpRequest/XMLHttpRequestProxy.ts | 2 + src/interceptors/fetch/index.ts | 7 -- test/features/events/request.test.ts | 9 +- test/features/events/response.test.ts | 3 +- test/features/presets/node-preset.test.ts | 3 +- test/features/request-initiator.test.ts | 3 +- test/helpers.ts | 13 +- .../compliance/xhr-add-event-listener.test.ts | 2 +- .../xhr-event-callback-null.test.ts | 2 +- .../compliance/xhr-event-handlers.test.ts | 2 +- .../compliance/xhr-events-order.test.ts | 5 +- .../xhr-middleware-exception.test.ts | 2 +- .../compliance/xhr-modify-request.test.ts | 3 +- .../xhr-no-response-headers.test.ts | 2 +- .../compliance/xhr-request-headers.test.ts | 3 +- .../compliance/xhr-request-method.test.ts | 2 +- .../xhr-response-body-empty.test.ts | 2 +- .../xhr-response-body-json-invalid.test.ts | 2 +- .../compliance/xhr-response-body-xml.test.ts | 2 +- ...-response-headers-case-sensitivity.test.ts | 3 +- .../compliance/xhr-response-headers.test.ts | 3 +- .../xhr-response-non-configurable.test.ts | 3 +- .../compliance/xhr-response-type.test.ts | 3 +- .../compliance/xhr-status.test.ts | 2 +- .../compliance/xhr-timeout.test.ts | 2 +- .../XMLHttpRequest/features/events.test.ts | 7 +- .../features/xhr-initiator.v-browser.test.ts | 35 ++++++ .../intercept/XMLHttpRequest.test.ts | 7 +- .../regressions/xhr-0-status-code.test.ts | 2 +- .../xhr-compressed-response.test.ts | 3 +- .../xhr-location-undefined.test.ts | 2 +- .../xhr-request-body-length.test.ts | 2 +- .../response/xhr-response-error.test.ts | 2 +- .../xhr-response-patching.browser.test.ts | 2 +- .../xhr-response-without-body.test.ts | 3 +- .../XMLHttpRequest/response/xhr.test.ts | 3 +- .../fetch-initiator.v-browser.test.ts | 35 ++++++ test/setup/helpers-neutral.ts | 12 ++ test/vitest.browser.config.js | 8 -- test/vitest.config.js | 22 ---- vitest.config.mjs | 60 ++++++++- 45 files changed, 309 insertions(+), 113 deletions(-) create mode 100644 test/modules/XMLHttpRequest/features/xhr-initiator.v-browser.test.ts create mode 100644 test/modules/fetch/features/fetch-initiator.v-browser.test.ts create mode 100644 test/setup/helpers-neutral.ts delete mode 100644 test/vitest.browser.config.js delete mode 100644 test/vitest.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32c7d2c21..20bd56c65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,16 +32,16 @@ jobs: run: pnpm install - name: Unit tests - run: pnpm test:unit + run: pnpm test -- --project=unit - name: Build run: pnpm build - name: Node.js tests - run: pnpm test:node + run: pnpm test -- --project=node - name: Install Playwright browsers run: npx playwright install chromium --with-deps --only-shell - name: Browser tests - run: pnpm test:browser + run: pnpm test -- --project=browser diff --git a/.gitignore b/.gitignore index c2671178c..21997906b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ package-lock.json .idea /test-results /tmp +__screenshots__ diff --git a/package.json b/package.json index 9fa3b64b7..582624423 100644 --- a/package.json +++ b/package.json @@ -78,11 +78,7 @@ }, "scripts": { "start": "tsdown --watch", - "test": "pnpm test:unit && pnpm test:integration", - "test:unit": "vitest", - "test:integration": "pnpm test:node && pnpm test:browser", - "test:node": "vitest -c test/vitest.config.js", - "test:browser": "pnpm playwright test -c test/playwright.config.ts", + "test": "vitest", "test:nock": "./test/third-party/nock.sh", "build": "tsdown", "prepare": "pnpm simple-git-hooks init", @@ -117,6 +113,7 @@ "@types/superagent": "^8.1.9", "@types/supertest": "^6.0.2", "@types/ws": "^8.18.0", + "@vitest/browser-playwright": "^4.0.18", "axios": "^1.8.2", "body-parser": "^1.19.0", "commitizen": "^4.2.4", @@ -132,6 +129,7 @@ "https-proxy-agent": "^7.0.6", "jsdom": "^26.1.0", "node-fetch": "3.3.2", + "playwright": "^1.58.2", "simple-git-hooks": "^2.7.0", "socket.io": "^4.7.4", "socket.io-client": "^4.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81d9d4292..8402dddc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: '@types/ws': specifier: ^8.18.0 version: 8.18.0 + '@vitest/browser-playwright': + specifier: ^4.0.18 + version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18) axios: specifier: ^1.8.2 version: 1.8.2(debug@4.4.3) @@ -126,6 +129,9 @@ importers: node-fetch: specifier: 3.3.2 version: 3.3.2 + playwright: + specifier: ^1.58.2 + version: 1.58.2 simple-git-hooks: specifier: ^2.7.0 version: 2.11.1 @@ -155,10 +161,10 @@ importers: version: 7.22.0 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) + version: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) vitest-environment-miniflare: specifier: ^2.14.1 - version: 2.14.4(vitest@4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0)) + version: 2.14.4(vitest@4.0.18) web-encoding: specifier: ^1.1.5 version: 1.1.5 @@ -605,6 +611,9 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} engines: {node: '>=12'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@publint/pack@0.1.2': resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} engines: {node: '>=18'} @@ -972,6 +981,17 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@vitest/browser-playwright@4.0.18': + resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + peerDependencies: + playwright: '*' + vitest: 4.0.18 + + '@vitest/browser@4.0.18': + resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + peerDependencies: + vitest: 4.0.18 + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -2277,6 +2297,10 @@ packages: resolution: {integrity: sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -2451,16 +2475,34 @@ packages: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + playwright-core@1.51.0: resolution: {integrity: sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==} engines: {node: '>=18'} hasBin: true + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + playwright@1.51.0: resolution: {integrity: sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==} engines: {node: '>=18'} hasBin: true + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} @@ -2702,6 +2744,10 @@ packages: resolution: {integrity: sha512-tgqwPUMDcNDhuf1Xf6KTUsyeqGdgKMhzaH4PAZZuzguOgTl5uuyeYe/8mWgAr6IBxB5V06uqEf6Dy37gIWDtDg==} hasBin: true + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + socket.io-adapter@2.5.5: resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} @@ -2904,6 +2950,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -3770,6 +3820,8 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@polka/url@1.0.0-next.29': {} + '@publint/pack@0.1.2': {} '@quansync/fs@1.0.0': @@ -4082,6 +4134,36 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18)': + dependencies: + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) + playwright: 1.58.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18)': + dependencies: + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) + '@vitest/utils': 4.0.18 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -5423,6 +5505,8 @@ snapshots: mri@1.1.4: {} + mrmime@2.0.1: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -5590,14 +5674,28 @@ snapshots: sonic-boom: 2.8.0 thread-stream: 0.15.2 + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + playwright-core@1.51.0: {} + playwright-core@1.58.2: {} + playwright@1.51.0: dependencies: playwright-core: 1.51.0 optionalDependencies: fsevents: 2.3.2 + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + possible-typed-array-names@1.0.0: {} postcss@8.5.6: @@ -5883,6 +5981,12 @@ snapshots: simple-git-hooks@2.11.1: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + socket.io-adapter@2.5.5: dependencies: debug: 4.3.7 @@ -6107,6 +6211,8 @@ snapshots: toidentifier@1.0.1: {} + totalist@3.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.83 @@ -6229,19 +6335,19 @@ snapshots: jiti: 2.4.2 terser: 5.36.0 - vitest-environment-miniflare@2.14.4(vitest@4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0)): + vitest-environment-miniflare@2.14.4(vitest@4.0.18): dependencies: '@miniflare/queues': 2.14.4 '@miniflare/runner-vm': 2.14.4 '@miniflare/shared': 2.14.4 '@miniflare/shared-test-environment': 2.14.4 undici: 5.28.4 - vitest: 4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) transitivePeerDependencies: - bufferutil - utf-8-validate - vitest@4.0.18(@types/node@22.13.9)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0): + vitest@4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) @@ -6265,6 +6371,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.13.9 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18) happy-dom: 20.7.0 jsdom: 26.1.0 transitivePeerDependencies: diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts index 4cfe8b89b..03f8de31e 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts @@ -84,6 +84,7 @@ export function createXMLHttpRequestProxy({ ) await handleRequest({ + initiator: this.request, request, requestId, controller, @@ -103,6 +104,7 @@ export function createXMLHttpRequestProxy({ ) emitter.emit('response', { + initiator: this.request, response, isMockedResponse, request, diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index dc9fa9168..c704c1f2a 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -48,13 +48,6 @@ export class FetchInterceptor extends Interceptor { const request = new Request(resolvedInput, init) - /** - * @note Set the raw request only if a Request instance was provided to fetch. - */ - if (input instanceof Request) { - // setRawRequest(request, input) - } - const responsePromise = new DeferredPromise() const controller = new RequestController(request, { diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index dbe135e35..7d9452553 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -1,17 +1,12 @@ // @vitest-environment jsdom import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { - useCors, - REQUEST_ID_REGEXP, - toWebResponse, - WebResponse, - waitForXMLHttpRequest, -} from '#/test/helpers' +import { useCors, REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' import { BatchInterceptor } from '#/src/BatchInterceptor' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' import { RequestController } from '#/src/RequestController' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 42821a22d..3b440c990 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -6,7 +6,8 @@ import { HttpRequestEventMap } from '#/src/index' import { BatchInterceptor } from '#/src/BatchInterceptor' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { useCors, toWebResponse, waitForXMLHttpRequest } from '#/test/helpers' +import { useCors, toWebResponse } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' declare namespace window { export const _resourceLoader: { diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index 04c6980c0..0260d5a71 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -2,7 +2,8 @@ import http from 'node:http' import { BatchInterceptor } from '../../../lib/node/index.mjs' import nodeInterceptors from '../../../lib/node/presets/node.mjs' -import { toWebResponse, waitForXMLHttpRequest } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new BatchInterceptor({ name: 'node-preset-interceptor', diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index a41fa486b..c37fcb7da 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -6,7 +6,8 @@ import { ClientRequestInterceptor } from '#/src/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' import { FetchInterceptor } from '#/src/interceptors/fetch/node' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { toWebResponse, waitForXMLHttpRequest } from '#/test/helpers' +import { toWebResponse } from '#/test/helpers' +import { waitForXMLHttpRequest } from '../setup/helpers-neutral' const interceptor = new BatchInterceptor({ name: 'interceptor', diff --git a/test/helpers.ts b/test/helpers.ts index e7503b863..78f4f4cd1 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,4 +1,4 @@ -import { invariant, InvariantError } from 'outvariant' +import { invariant } from 'outvariant' import net from 'node:net' import zlib from 'node:zlib' import { Readable } from 'node:stream' @@ -139,17 +139,6 @@ export function createBrowserXMLHttpRequest(page: Page) { } } -export function waitForXMLHttpRequest(request: XMLHttpRequest): Promise { - const pendingResponse = new DeferredPromise() - - request.addEventListener('loadend', () => pendingResponse.resolve()) - request.addEventListener('abort', () => - pendingResponse.reject(new Error('Request aborted')) - ) - - return pendingResponse -} - export async function toWebResponse( request: http.ClientRequest ): Promise<[Response, http.IncomingMessage]> { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts index 85a3d62d6..4009e56d1 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts @@ -3,7 +3,7 @@ * @see https://github.com/mswjs/msw/issues/273 */ import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts index f55355242..7d8aaa670 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts @@ -4,7 +4,7 @@ */ import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts index 78982317b..94fdc292f 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts @@ -4,7 +4,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts index 1a9c82cdc..cae9b5aaa 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts @@ -4,7 +4,8 @@ */ import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors, waitForXMLHttpRequest } from '#/test/helpers' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) @@ -33,7 +34,7 @@ interceptor.on('request', ({ request, controller }) => { } }) -function spyOnEvents(req: XMLHttpRequest, listener: Mock) { +function spyOnEvents(req: XMLHttpRequest, listener: any) { function wrapListener(this: XMLHttpRequest, event: Event) { listener(event.type, this.readyState) } diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index 298d5a988..58359b370 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -4,7 +4,7 @@ */ import axios from 'axios' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts index a88144d95..fa57c4c74 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors, waitForXMLHttpRequest } from '#/test/helpers' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const server = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts index 3d84717d8..bbfaf8890 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts index 56bd3d824..789b48385 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors, waitForXMLHttpRequest } from '#/test/helpers' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' interface ResponseType { requestRawHeaders: Array diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts index 43e4afa35..e67e2b970 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts index 2e4f5fb4a..b17bfebb2 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts index c27fde767..73f6ab615 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts index b4151bb9f..c5b422a0e 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const XML_STRING = 'Content' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts index b4578c361..e219fb5ca 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' -import { useCors, waitForXMLHttpRequest } from '#/test/helpers' +import { useCors } from '#/test/helpers' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts index 96bc5c26a..fc18cd271 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors, waitForXMLHttpRequest } from '#/test/helpers' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts index 80905fe44..9ce3342e3 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts @@ -6,7 +6,8 @@ import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { FetchResponse } from '#/src/utils/fetchUtils' -import { waitForXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts index eede923d8..042316703 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts @@ -2,7 +2,8 @@ import { encodeBuffer } from '#/src/index' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { toArrayBuffer } from '#/src/utils/bufferUtils' -import { readBlob, waitForXMLHttpRequest } from '#/test/helpers' +import { readBlob } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts index 948dc7fd0..7c07d7caa 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts @@ -3,7 +3,7 @@ * @see https://github.com/mswjs/interceptors/issues/281 */ import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts index 1f9243538..7bbf24f98 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts @@ -6,7 +6,7 @@ import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.get('/', async (_req, res) => { diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index 1ed8e6578..ae66e32a3 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -1,13 +1,10 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { - useCors, - REQUEST_ID_REGEXP, - waitForXMLHttpRequest, -} from '#/test/helpers' +import { useCors, REQUEST_ID_REGEXP } from '#/test/helpers' import { HttpRequestEventMap } from '#/src/index' import { RequestController } from '#/src/RequestController' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const server = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/features/xhr-initiator.v-browser.test.ts b/test/modules/XMLHttpRequest/features/xhr-initiator.v-browser.test.ts new file mode 100644 index 000000000..2dd9bdc5e --- /dev/null +++ b/test/modules/XMLHttpRequest/features/xhr-initiator.v-browser.test.ts @@ -0,0 +1,35 @@ +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral.js' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('exposes the initiator for a mocked XMLHttpRequest', async () => { + const pendingInitiator = new DeferredPromise() + + interceptor.on('request', ({ initiator, controller }) => { + pendingInitiator.resolve(initiator as XMLHttpRequest) + controller.respondWith(new Response('hello world')) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/resource') + request.send() + + await waitForXMLHttpRequest(request) + + await expect.soft(pendingInitiator).resolves.toEqual(request) + expect.soft(request.responseText).toBe('hello world') +}) diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts index 64f7640fc..28e773532 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts @@ -2,14 +2,11 @@ import type { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { - useCors, - REQUEST_ID_REGEXP, - waitForXMLHttpRequest, -} from '#/test/helpers' +import { useCors, REQUEST_ID_REGEXP } from '#/test/helpers' import { toArrayBuffer, encodeBuffer } from '#/src/utils/bufferUtils' import { RequestController } from '#/src/RequestController' import { HttpRequestEventMap } from '#/src/glossary' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' declare namespace window { export const _resourceLoader: { diff --git a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts index 62e5c5df0..1c84c3aca 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts @@ -3,7 +3,7 @@ * @see https://github.com/mswjs/interceptors/issues/335 */ import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts index 72ed9c7ba..3a44d0652 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts @@ -5,7 +5,8 @@ import { HttpServer } from '@open-draft/test-server/http' import zlib from 'zlib' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest, useCors } from '#/test/helpers' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts index def45ec38..4648c72ac 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts @@ -1,6 +1,6 @@ // @vitest-environment react-native-like import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts index 78891f06e..3a4aab50d 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts index 9cded4c64..6eb2c820c 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts index ee7517a6d..2124b8c67 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts @@ -1,6 +1,6 @@ import { HttpServer } from '@open-draft/test-server/http' import { useCors } from '#/test/helpers' -import { test, expect } from '../../../playwright.extend' +import { test, expect } from '#/test/playwright.extend' declare namespace window { export let originalUrl: string diff --git a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts index 3d5fc18d6..459520c34 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts @@ -1,8 +1,9 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors, waitForXMLHttpRequest } from '#/test/helpers' +import { useCors } from '#/test/helpers' import type { HttpRequestEventMap } from '#/src/index' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts index 32f9e90e8..953c2606e 100644 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.test.ts @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors, waitForXMLHttpRequest } from '#/test/helpers' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' declare namespace window { export const _resourceLoader: { diff --git a/test/modules/fetch/features/fetch-initiator.v-browser.test.ts b/test/modules/fetch/features/fetch-initiator.v-browser.test.ts new file mode 100644 index 000000000..48d5a57b1 --- /dev/null +++ b/test/modules/fetch/features/fetch-initiator.v-browser.test.ts @@ -0,0 +1,35 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { DeferredPromise } from '@open-draft/deferred-promise' + +const interceptor = new FetchInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('exposes the initiator for a mocked fetch request', async () => { + const pendingInitiator = new DeferredPromise() + + interceptor.on('request', ({ initiator, controller }) => { + pendingInitiator.resolve(initiator) + controller.respondWith(new Response('hello world')) + }) + + const response = await fetch('http://any.host.here/resource') + + const initiator = await pendingInitiator + expect.soft(initiator).toBeInstanceOf(Request) + expect.soft(initiator).toMatchObject({ + method: 'GET', + url: 'http://any.host.here/resource', + }) + await expect.soft(response.text()).resolves.toBe('hello world') +}) diff --git a/test/setup/helpers-neutral.ts b/test/setup/helpers-neutral.ts new file mode 100644 index 000000000..69e12d89f --- /dev/null +++ b/test/setup/helpers-neutral.ts @@ -0,0 +1,12 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' + +export function waitForXMLHttpRequest(request: XMLHttpRequest): Promise { + const pendingResponse = new DeferredPromise() + + request.addEventListener('loadend', () => pendingResponse.resolve()) + request.addEventListener('abort', () => + pendingResponse.reject(new Error('Request aborted')) + ) + + return pendingResponse +} diff --git a/test/vitest.browser.config.js b/test/vitest.browser.config.js deleted file mode 100644 index daa262971..000000000 --- a/test/vitest.browser.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - root: __dirname, - include: ['**/*.browser.test.ts'], - }, -}) diff --git a/test/vitest.config.js b/test/vitest.config.js deleted file mode 100644 index 91b354263..000000000 --- a/test/vitest.config.js +++ /dev/null @@ -1,22 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - root: __dirname, - include: ['**/*.test.ts'], - exclude: ['**/*.browser.test.ts'], - alias: { - 'vitest-environment-node-with-websocket': './envs/node-with-websocket', - 'vitest-environment-react-native-like': './envs/react-native-like', - }, - globals: true, - }, - resolve: { - alias: { - // Create a manual alias for Vitest so it could resolve this - // internal environment-dependent module in tests. - 'internal:brotli-decompress': - '../../../../src/interceptors/fetch/utils/brotli-decompress.ts', - }, - }, -}) diff --git a/vitest.config.mjs b/vitest.config.mjs index a1e1eea4e..3a49d488e 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -1,11 +1,63 @@ -import { defineConfig } from 'vitest/config' +import { playwright } from '@vitest/browser-playwright' +import { defineConfig, defaultExclude } from 'vitest/config' export default defineConfig({ test: { - include: ['./src/**/*.test.ts'], globals: true, + projects: [ + { + extends: true, + test: { + name: 'unit', + root: './src', + include: ['**/*.test.ts'], + }, + esbuild: { + target: 'es2022', + }, + }, + { + extends: true, + test: { + name: 'node', + environment: 'node', + root: './test', + include: ['**/*.test.ts'], + exclude: [ + ...defaultExclude, + '**/*.browser.test.ts', + '**/*.v-browser.test.ts', + ], + alias: { + 'vitest-environment-node-with-websocket': + './envs/node-with-websocket', + 'vitest-environment-react-native-like': './envs/react-native-like', + }, + }, + }, + { + extends: true, + test: { + name: 'browser', + root: './test', + include: ['**/*.v-browser.test.ts'], + browser: { + enabled: true, + provider: playwright(), + instances: [{ name: '', browser: 'chromium' }], + headless: true, + }, + testTimeout: 5000, + }, + }, + ], }, - esbuild: { - target: 'es2022', + resolve: { + alias: { + // Create a manual alias for Vitest so it could resolve this + // internal environment-dependent module in tests. + 'internal:brotli-decompress': + '../../../../src/interceptors/fetch/utils/brotli-decompress.ts', + }, }, }) From 602f940ba144f46986e62d567d64957fb051a62b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 21:50:40 +0100 Subject: [PATCH 113/198] chore: use vitest browser mode for neutral tests --- .../XMLHttpRequestController.ts | 19 +------ .../xhr-response-error.browser.runtime.js | 9 ---- .../xhr-response-error.browser.test.ts | 33 ------------ .../xhr-response-error.neutral.test.ts | 45 +++++++++++++++++ .../response/xhr-response-error.test.ts | 36 ------------- .../xhr-response-patching.browser.runtime.js | 46 ----------------- .../xhr-response-patching.browser.test.ts | 50 ------------------- .../xhr-response-patching.neutral.test.ts | 49 ++++++++++++++++++ test/setup/helpers-neutral.ts | 31 ++++++++++++ test/setup/helpers-node.ts | 18 +++++++ test/setup/vitest.ts | 31 ++++++++++++ test/tsconfig.json | 5 +- tsconfig.base.json | 21 ++++++++ tsconfig.json | 29 ++--------- tsconfig.src.json | 21 ++++++++ tsconfig.vitest.json | 8 +++ tsdown.config.mts | 1 + vitest.config.mjs => vitest.config.ts | 15 ++++-- vitest.setup.ts | 25 ++++++++++ 19 files changed, 268 insertions(+), 224 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/response/xhr-response-error.browser.runtime.js delete mode 100644 test/modules/XMLHttpRequest/response/xhr-response-error.browser.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts delete mode 100644 test/modules/XMLHttpRequest/response/xhr-response-error.test.ts delete mode 100644 test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js delete mode 100644 test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts create mode 100644 test/setup/helpers-node.ts create mode 100644 test/setup/vitest.ts create mode 100644 tsconfig.base.json create mode 100644 tsconfig.src.json create mode 100644 tsconfig.vitest.json rename vitest.config.mjs => vitest.config.ts (81%) create mode 100644 vitest.setup.ts diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index c7f7d70c0..735a9365f 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -12,7 +12,6 @@ import { createProxy } from '../../utils/createProxy' import { isDomParserSupportedType } from './utils/isDomParserSupportedType' import { parseJson } from '../../utils/parseJson' import { createResponse } from './utils/createResponse' -import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { createRequestId } from '../../createRequestId' import { getBodyByteLength } from './utils/getBodyByteLength' @@ -187,22 +186,6 @@ export class XMLHttpRequestController { this.request.readyState ) - /** - * @note Set the intercepted request ID on the original request in Node.js - * so that if it triggers any other interceptors, they don't attempt - * to process it once again. - * - * For instance, XMLHttpRequest is often implemented via "http.ClientRequest" - * and we don't want for both XHR and ClientRequest interceptors to - * handle the same request at the same time (e.g. emit the "response" event twice). - */ - if (IS_NODE) { - this.request.setRequestHeader( - INTERNAL_REQUEST_ID_HEADER_NAME, - this.requestId! - ) - } - return invoke() } }) @@ -372,7 +355,7 @@ export class XMLHttpRequestController { return '' } - const headersList = Array.from(response.headers.entries()) + const headersList = Array.from(response.headers) const allHeaders = headersList .map(([headerName, headerValue]) => { return `${headerName}: ${headerValue}` diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr-response-error.browser.runtime.js deleted file mode 100644 index b90c6a452..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.browser.runtime.js +++ /dev/null @@ -1,9 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() - -interceptor.on('request', ({ controller }) => { - controller.respondWith(Response.error()) -}) - -interceptor.apply() diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.browser.test.ts deleted file mode 100644 index 75b38e4f0..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.browser.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { test, expect } from '../../../playwright.extend' - -test('treats "Response.error()" as request error', async ({ - loadExample, - page, -}) => { - await loadExample(require.resolve('./xhr-response-error.browser.runtime.js')) - - const requestAfterError = await page.evaluate(() => { - const request = new XMLHttpRequest() - request.open('GET', 'http://localhost/resource') - - return new Promise<{ - status: number - statusText: string - response: unknown - }>((resolve) => { - request.addEventListener('error', () => { - resolve({ - status: request.status, - statusText: request.statusText, - response: request.response, - }) - }) - - request.send() - }) - }) - - expect(requestAfterError.status).toBe(0) - expect(requestAfterError.statusText).toBe('') - expect(requestAfterError.response).toBe('') -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts new file mode 100644 index 000000000..2b02ce23c --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() +}) + +it('treats "Response.error()" as a request error', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.error()) + }) + + const errorListener = vi.fn() + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + + request.open('GET', 'http://localhost/resource') + request.addEventListener('error', errorListener) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.statusText).toBe('') + expect.soft(request.response).toBe('') + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ]) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts deleted file mode 100644 index 6eb2c820c..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ controller }) => { - controller.respondWith(Response.error()) -}) - -beforeAll(async () => { - interceptor.apply() -}) - -afterAll(async () => { - interceptor.dispose() -}) - -it('treats "Response.error()" as request error', async () => { - const requestErrorListener = vi.fn() - - const request = new XMLHttpRequest() - request.open('GET', 'http://localhost:3001/resource') - request.addEventListener('error', requestErrorListener) - request.send() - - await waitForXMLHttpRequest(request) - - // Request must reflect the request error state. - expect(request.readyState).toBe(request.DONE) - expect(request.status).toBe(0) - expect(request.statusText).toBe('') - expect(request.response).toBe('') - - // Network error must propagate to the "error" request event. - expect(requestErrorListener).toHaveBeenCalledTimes(1) -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js deleted file mode 100644 index b835d21d3..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js +++ /dev/null @@ -1,46 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() - -interceptor.on('request', async ({ request, requestId, controller }) => { - window.dispatchEvent( - new CustomEvent('resolver', { - detail: { - id: requestId, - method: request.method, - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - credentials: request.credentials, - body: await request.clone().text(), - }, - }) - ) - - const url = new URL(request.url) - - if (url.pathname === '/mocked') { - await new Promise((resolve) => setTimeout(resolve, 0)) - - const req = new XMLHttpRequest() - req.open('GET', window.originalUrl, true) - req.send() - await new Promise((resolve, reject) => { - req.addEventListener('loadend', resolve) - req.addEventListener('error', reject) - }) - - controller.respondWith( - new Response(`${req.responseText} world`, { - status: req.status, - statusText: req.statusText, - headers: { - 'X-Custom-Header': req.getResponseHeader('X-Custom-Header'), - }, - }) - ) - } -}) - -interceptor.apply() - -window.interceptor = interceptor diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts deleted file mode 100644 index 2124b8c67..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { HttpServer } from '@open-draft/test-server/http' -import { useCors } from '#/test/helpers' -import { test, expect } from '#/test/playwright.extend' - -declare namespace window { - export let originalUrl: string -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/original', (req, res) => { - res - .set('access-control-expose-headers', 'x-custom-header') - .set('x-custom-header', 'yes') - .send('hello') - }) -}) - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - -test('responds to an HTTP request handled in the resolver', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample( - require.resolve('./xhr-response-patching.browser.runtime.js') - ) - await page.evaluate((url) => { - window.originalUrl = url - }, httpServer.http.url('/original')) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: 'http://localhost/mocked', - }) - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.headers).toBe( - 'content-type: text/plain;charset=UTF-8\r\nx-custom-header: yes' - ) - expect(response.body).toBe('hello world') -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts new file mode 100644 index 000000000..1ed7766ef --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() +}) + +it('patches the original XMLHttpRequest response', async () => { + interceptor.on('request', async ({ request, controller }) => { + const url = new URL(request.url) + + if (url.searchParams.get('type') === 'passthrough') { + return controller.passthrough() + } + + const originalRequest = new XMLHttpRequest() + url.searchParams.set('type', 'passthrough') + originalRequest.open(request.method, url.href) + originalRequest.send(await request.text()) + await waitForXMLHttpRequest(originalRequest) + + controller.respondWith( + new Response(`${originalRequest.responseText}-patched`) + ) + }) + + const request = new XMLHttpRequest() + request.open('POST', server.http.url('/')) + request.send('payload') + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.responseText).toBe('payload-patched') +}) diff --git a/test/setup/helpers-neutral.ts b/test/setup/helpers-neutral.ts index 69e12d89f..3a56b56b1 100644 --- a/test/setup/helpers-neutral.ts +++ b/test/setup/helpers-neutral.ts @@ -10,3 +10,34 @@ export function waitForXMLHttpRequest(request: XMLHttpRequest): Promise { return pendingResponse } + +export function spyOnXMLHttpRequest(request: XMLHttpRequest) { + const events: Array<[string, number] | [string, number, any]> = [] + + const addEvent = (name: string) => { + return (event: unknown) => { + if (event instanceof ProgressEvent) { + events.push([ + name, + request.readyState, + { loaded: event.loaded, total: event.total }, + ]) + } else { + events.push([name, request.readyState]) + } + } + } + + request.onreadystatechange = addEvent('readystatechange') + request.onprogress = addEvent('progress') + request.onloadstart = addEvent('loadstart') + request.onload = addEvent('load') + request.onload = addEvent('loadend') + request.ontimeout = addEvent('timeout') + request.onerror = addEvent('error') + request.onabort = addEvent('abort') + + return { + events, + } +} diff --git a/test/setup/helpers-node.ts b/test/setup/helpers-node.ts new file mode 100644 index 000000000..a491960ed --- /dev/null +++ b/test/setup/helpers-node.ts @@ -0,0 +1,18 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { Worker } from 'node:worker_threads' + +export async function runServeSnippet(snippet: string) { + const worker = new Worker(snippet, { + eval: true, + }) + const pendingResult = new DeferredPromise() + + worker + .once('message', (message) => { + pendingResult.resolve(message) + worker.terminate() + }) + .on('error', (error) => pendingResult.reject(error)) + + return pendingResult +} diff --git a/test/setup/vitest.ts b/test/setup/vitest.ts new file mode 100644 index 000000000..d997f46b9 --- /dev/null +++ b/test/setup/vitest.ts @@ -0,0 +1,31 @@ +import { inject } from 'vitest' + +declare module 'vitest' { + export interface ProvidedContext { + server: { + http: string + https: string + } + } +} + +export function getTestServer() { + const server = inject('server') + + const createUrlBuilder = (protocol: 'http' | 'https') => { + return (path = '/'): URL => { + return new URL(path, server[protocol]) + } + } + + return { + http: { + href: server.http, + url: createUrlBuilder('http'), + }, + https: { + href: server.https, + url: createUrlBuilder('https'), + }, + } +} diff --git a/test/tsconfig.json b/test/tsconfig.json index e1b948d00..6d175bc41 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,9 +1,8 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { "target": "es6", "types": ["node", "vitest/globals"], }, - "include": ["**/*.test.ts"], - "exclude": ["node_modules"], + "include": ["setup/vitest.ts", "**/*.test.ts"], } diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 000000000..8aecc48ff --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,21 @@ +{ + "exclude": ["node_modules"], + "compilerOptions": { + "strict": true, + "target": "es2018", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "downlevelIteration": true, + "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], + "baseUrl": ".", + "paths": { + "#/src/*": ["./src/*"], + "#/test/*": ["./test/*"], + "_http_common": ["./_http_common.d.ts"], + "internal:brotli-decompress": [ + "./src/interceptors/fetch/utils/brotli-decompress.ts", + ], + }, + }, +} diff --git a/tsconfig.json b/tsconfig.json index 8e3eb7bed..5c6b4327c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,6 @@ { - "compilerOptions": { - "strict": true, - "target": "es2018", - "module": "NodeNext", - "sourceMap": true, - "outDir": "lib", - "declaration": true, - "moduleResolution": "NodeNext", - "removeComments": false, - "esModuleInterop": true, - "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], - "types": ["@types/node"], - "baseUrl": ".", - "paths": { - "#/src/*": ["./src/*"], - "#/test/*": ["./test/*"], - "_http_common": ["./_http_common.d.ts"], - "internal:brotli-decompress": [ - "./src/interceptors/fetch/utils/brotli-decompress.ts", - ], - }, - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "**/*.test.*"], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.vitest.json" }, + ], } diff --git a/tsconfig.src.json b/tsconfig.src.json new file mode 100644 index 000000000..2d5ae4bdf --- /dev/null +++ b/tsconfig.src.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": true, + "sourceMap": true, + "outDir": "lib", + "declaration": true, + "removeComments": false, + "baseUrl": ".", + "paths": { + "#/src/*": ["./src/*"], + "#/test/*": ["./test/*"], + "_http_common": ["./_http_common.d.ts"], + "internal:brotli-decompress": [ + "./src/interceptors/fetch/utils/brotli-decompress.ts", + ], + }, + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "**/*.test.*"], +} diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json new file mode 100644 index 000000000..9fb8949b2 --- /dev/null +++ b/tsconfig.vitest.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "include": ["./vitest.config", "./vitest.setup"], + "compilerOptions": { + "composite": true, + "types": ["node", "./test/setup/vitest.d.ts"], + }, +} diff --git a/tsdown.config.mts b/tsdown.config.mts index 2e945c622..570ed63c0 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -22,6 +22,7 @@ export default defineConfig([ format: ['cjs', 'esm'], sourcemap: true, dts: true, + tsconfig: './tsconfig.src.json', }, { name: 'browser', diff --git a/vitest.config.mjs b/vitest.config.ts similarity index 81% rename from vitest.config.mjs rename to vitest.config.ts index 3a49d488e..e099cf545 100644 --- a/vitest.config.mjs +++ b/vitest.config.ts @@ -1,9 +1,16 @@ import { playwright } from '@vitest/browser-playwright' import { defineConfig, defaultExclude } from 'vitest/config' +declare module 'vitest' { + export interface ProvidedContext { + serverUrl: string + } +} + export default defineConfig({ test: { globals: true, + globalSetup: './vitest.setup.ts', projects: [ { extends: true, @@ -38,16 +45,16 @@ export default defineConfig({ { extends: true, test: { - name: 'browser', root: './test', - include: ['**/*.v-browser.test.ts'], + include: ['**/*.v-browser.test.ts', '**/*.neutral.test.ts'], browser: { enabled: true, provider: playwright(), - instances: [{ name: '', browser: 'chromium' }], + instances: [{ name: 'browser', browser: 'chromium' }], headless: true, + screenshotFailures: false, }, - testTimeout: 5000, + testTimeout: 4000, }, }, ], diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..578ade408 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,25 @@ +import { TestProject } from 'vitest/node' +import { HttpServer } from '@open-draft/test-server/http' +import { useCors } from './test/helpers' + +const server = new HttpServer((app) => { + app.use(useCors) + + app.all('*', (req, res) => { + res.status(200) + req.pipe(res) + }) +}) + +export async function setup(project: TestProject) { + await server.listen() + + project.provide('server', { + http: server.http.address.href, + https: server.http.address.href, + }) +} + +export async function teardown() { + await server.close() +} From 6779532ff25ceae3755000a6b328cd7292464ea6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 3 Mar 2026 22:04:17 +0100 Subject: [PATCH 114/198] test(xhr): add granular mocked response body tests --- .../response/xhr-array-buffer.neutral.test.ts | 58 +++++++++++++++++++ .../response/xhr-blob.neutral.test.ts | 57 ++++++++++++++++++ .../response/xhr-json.neutral.test.ts | 55 ++++++++++++++++++ .../response/xhr-text.neutral.test.ts | 55 ++++++++++++++++++ vitest.setup.ts | 2 +- 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 test/modules/XMLHttpRequest/response/xhr-array-buffer.neutral.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-blob.neutral.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-json.neutral.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-text.neutral.test.ts diff --git a/test/modules/XMLHttpRequest/response/xhr-array-buffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-array-buffer.neutral.test.ts new file mode 100644 index 000000000..bff6c52b9 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-array-buffer.neutral.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment jsdom +import { toArrayBuffer } from '#/src/utils/bufferUtils' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() +}) + +it('responds with a mocked ArrayBuffer response to an HTTP request', async () => { + const buffer = new TextEncoder().encode('hello world') + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(buffer, { headers: { 'content-type': 'text/plain' } }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.getAllResponseHeaders()).toBe('content-type: text/plain') + expect.soft(request.response).toEqual(buffer.buffer) +}) + +it('responds with a mocked ArrayBuffer response to an HTTPS request', async () => { + const buffer = new TextEncoder().encode('hello world') + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(buffer, { headers: { 'content-type': 'text/plain' } }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.getAllResponseHeaders()).toBe('content-type: text/plain') + expect.soft(request.response).toEqual(buffer.buffer) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-blob.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-blob.neutral.test.ts new file mode 100644 index 000000000..3f06903b3 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-blob.neutral.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() +}) + +it('responds with a mocked Blob response to an HTTP request', async () => { + const blob = new Blob(['hello world'], { type: 'text/plain' }) + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response(blob)) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders()) + .toContain('content-type: text/plain') + expect.soft(request.response).toEqual(blob) +}) + +it('responds with a mocked Blob response to an HTTP request', async () => { + const blob = new Blob(['hello world'], { type: 'text/plain' }) + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response(blob)) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders()) + .toContain('content-type: text/plain') + expect.soft(request.response).toEqual(blob) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-json.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-json.neutral.test.ts new file mode 100644 index 000000000..3aa1b9a72 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-json.neutral.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() +}) + +it('responds with a mocked text response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.json({ name: 'John Maverick' })) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders()) + .toBe('content-type: application/json') + expect.soft(request.response).toEqual({ name: 'John Maverick' }) +}) + +it('responds with a mocked text response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.json({ name: 'John Maverick' })) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders()) + .toBe('content-type: application/json') + expect.soft(request.response).toEqual({ name: 'John Maverick' }) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-text.neutral.test.ts new file mode 100644 index 000000000..82e8575a6 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-text.neutral.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() +}) + +it('responds with a mocked text response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders()) + .toBe('content-type: text/plain;charset=UTF-8') + expect.soft(request.response).toBe('hello world') + expect.soft(request.responseText).toBe('hello world') +}) + +it('responds with a mocked text response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders()) + .toBe('content-type: text/plain;charset=UTF-8') + expect.soft(request.response).toBe('hello world') + expect.soft(request.responseText).toBe('hello world') +}) diff --git a/vitest.setup.ts b/vitest.setup.ts index 578ade408..2d4d0d7dc 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -6,7 +6,7 @@ const server = new HttpServer((app) => { app.use(useCors) app.all('*', (req, res) => { - res.status(200) + res.status(200).set(req.headers) req.pipe(res) }) }) From 5f997ef725c4ebe7b25a5b5d5aef5e677d9e8769 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 4 Mar 2026 19:27:26 +0100 Subject: [PATCH 115/198] fix(xhr): follow redirects --- .../XMLHttpRequestController.ts | 62 +++++++++++++++++ .../xhr-response-redirect.neutral.test.ts | 67 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 735a9365f..6cd5552ae 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -1,3 +1,4 @@ +import { until } from '@open-draft/until' import { invariant } from 'outvariant' import { isNodeProcess } from 'is-node-process' import type { Logger } from '@open-draft/logger' @@ -14,10 +15,12 @@ import { parseJson } from '../../utils/parseJson' import { createResponse } from './utils/createResponse' import { createRequestId } from '../../createRequestId' import { getBodyByteLength } from './utils/getBodyByteLength' +import { FetchResponse } from '../../utils/fetchUtils' const kIsRequestHandled = Symbol('kIsRequestHandled') const IS_NODE = isNodeProcess() const kFetchRequest = Symbol('kFetchRequest') +const MAX_REDIRECTS = 20 /** * An `XMLHttpRequest` instance controller that allows us @@ -49,6 +52,7 @@ export class XMLHttpRequestController { private url: URL = null as any private requestHeaders: Headers private responseBuffer: Uint8Array + private redirectCount: number private events: Map> private uploadEvents: Map< keyof XMLHttpRequestEventTargetEventMap, @@ -61,6 +65,7 @@ export class XMLHttpRequestController { ) { this[kIsRequestHandled] = false + this.redirectCount = 0 this.events = new Map() this.uploadEvents = new Map() this.requestId = createRequestId() @@ -282,6 +287,30 @@ export class XMLHttpRequestController { */ this[kIsRequestHandled] = true + // Follow redirect responses to maintain parity with browser XHR behavior. + // Browsers follow redirects transparently so XHR never sees 3xx responses. + const redirectLocation = response.headers.get('location') + if ( + redirectLocation && + FetchResponse.isRedirectResponse(response.status) + ) { + const redirectUrl = new URL(redirectLocation, location.href) + const redirectMethod = FetchResponse.isResponseWithBody(response.status) + ? this.method + : 'GET' + + const redirectResult = await until(() => + this.followRedirect(redirectMethod, redirectUrl) + ) + + if (redirectResult.error) { + return + } + + this.url = new URL(redirectResult.data.responseURL) + return this.respondWith(redirectResult.data.response) + } + /** * Dispatch request upload events for requests with a body. * @see https://github.com/mswjs/interceptors/issues/573 @@ -561,6 +590,39 @@ export class XMLHttpRequestController { return null } + private followRedirect( + method: string, + url: URL + ): Promise<{ response: Response; responseURL: string }> { + this.redirectCount++ + + if (this.redirectCount > MAX_REDIRECTS) { + const reason = new Error('Too many redirects') + this.errorWith(reason) + return Promise.reject(reason) + } + + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest() + request.responseType = this.request.responseType + + request.addEventListener('load', () => { + resolve({ + response: createResponse(request, request.response), + responseURL: request.responseURL, + }) + }) + + request.addEventListener('error', () => { + this.errorWith() + reject(new Error('Redirect request failed')) + }) + + request.open(method, url.href) + request.send() + }) + } + public errorWith(error?: Error): void { /** * @note Mark this request as handled even if it received a mock error. diff --git a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts new file mode 100644 index 000000000..7bed652b9 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a redirect response', async () => { + const request = new XMLHttpRequest() + request.open('GET', server.http.url('/redirect')) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getResponseHeader('content-type')) + .toBe('text/html; charset=utf-8') + expect.soft(request.response).toBe('destination-body') + expect + .soft(request.responseURL) + .toBe(server.http.url('/redirect/destination').href) +}) + +it('responds with a mocked redirect response', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.url.endsWith('/original')) { + return controller.respondWith( + new Response(null, { + status: 301, + headers: { + location: new URL('/destination', request.url).href, + }, + }) + ) + } + + controller.respondWith(new Response('destination-body')) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/original') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getResponseHeader('content-type')) + .toBe('text/plain;charset=UTF-8') + expect.soft(request.response).toBe('destination-body') + expect.soft(request.responseURL).toBe('http://any.host.here/destination') +}) From b8602123968e5904f686c0922e70237181b961bf Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 12:18:57 +0100 Subject: [PATCH 116/198] chore: improve xhr tests --- .../xhr-response-body-empty.test.ts | 32 ------- .../xhr-response-without-body.test.ts | 15 ++-- .../response/xhr-error-with.test.ts | 68 +++++++++++++++ ...xhr-response-array-buffer.neutral.test.ts} | 5 +- ...t.ts => xhr-response-blob.neutral.test.ts} | 4 +- .../xhr-response-empty.neutral.test.ts | 57 ++++++++++++ .../xhr-response-error.neutral.test.ts | 34 +++++--- ...t.ts => xhr-response-json.neutral.test.ts} | 4 +- .../xhr-response-patching.neutral.test.ts | 4 +- ...t.ts => xhr-response-text.neutral.test.ts} | 4 +- .../response/xhr-synchronous.neutral.test.ts | 39 +++++++++ .../xhr-unhandled-exception.neutral.test.ts | 59 +++++++++++++ .../response/xhr.browser.test.ts | 58 ------------- .../XMLHttpRequest/response/xhr.test.ts | 86 ------------------- test/setup/helpers-node.ts | 18 ---- vitest.setup.ts | 14 +++ 16 files changed, 275 insertions(+), 226 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts rename test/modules/XMLHttpRequest/{response => compliance}/xhr-response-without-body.test.ts (82%) create mode 100644 test/modules/XMLHttpRequest/response/xhr-error-with.test.ts rename test/modules/XMLHttpRequest/response/{xhr-array-buffer.neutral.test.ts => xhr-response-array-buffer.neutral.test.ts} (94%) rename test/modules/XMLHttpRequest/response/{xhr-blob.neutral.test.ts => xhr-response-blob.neutral.test.ts} (97%) create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts rename test/modules/XMLHttpRequest/response/{xhr-json.neutral.test.ts => xhr-response-json.neutral.test.ts} (97%) rename test/modules/XMLHttpRequest/response/{xhr-text.neutral.test.ts => xhr-response-text.neutral.test.ts} (97%) create mode 100644 test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts delete mode 100644 test/setup/helpers-node.ts diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts deleted file mode 100644 index b17bfebb2..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @vitest-environment jsdom -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ controller }) => { - controller.respondWith( - new Response(null, { - status: 401, - statusText: 'Unauthorized', - }) - ) -}) - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('sends a mocked response with an empty response body', async () => { - const request = new XMLHttpRequest() - request.open('GET', '/arbitrary-url') - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toEqual(401) - expect(request.response).toEqual('') -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts similarity index 82% rename from test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts rename to test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts index 459520c34..794b2a1a5 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts @@ -16,6 +16,7 @@ const interceptor = new XMLHttpRequestInterceptor() const responseListener = vi.fn<(...args: HttpRequestEventMap['response']) => void>() + interceptor.on('response', responseListener) beforeAll(async () => { @@ -32,7 +33,7 @@ afterAll(async () => { await httpServer.close() }) -it('represents a 204 response without body using fetch api response', async () => { +it('supports a 204 response withouth body for a bypassed request', async () => { const request = new XMLHttpRequest() request.open('GET', httpServer.http.url('/204')) request.send() @@ -49,10 +50,10 @@ it('represents a 204 response without body using fetch api response', async () = } satisfies Partial), }) ) - expect(responseListener).toHaveBeenCalledTimes(1) + expect(responseListener).toHaveBeenCalledOnce() }) -it('represents a 205 response without body using fetch api response', async () => { +it('supports a 202 response withouth body for a bypassed request', async () => { const request = new XMLHttpRequest() request.open('GET', httpServer.http.url('/205')) request.send() @@ -60,8 +61,7 @@ it('represents a 205 response without body using fetch api response', async () = await waitForXMLHttpRequest(request) expect(request.response).toBe('') - expect(responseListener).toHaveBeenNthCalledWith( - 1, + expect(responseListener).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ response: expect.objectContaining({ status: 205, @@ -69,7 +69,6 @@ it('represents a 205 response without body using fetch api response', async () = } satisfies Partial), }) ) - expect(responseListener).toHaveBeenCalledTimes(1) }) it('represents a 304 response without body using fetch api response', async () => { @@ -80,8 +79,7 @@ it('represents a 304 response without body using fetch api response', async () = await waitForXMLHttpRequest(request) expect(request.response).toBe('') - expect(responseListener).toHaveBeenNthCalledWith( - 1, + expect(responseListener).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ response: expect.objectContaining({ status: 304, @@ -89,5 +87,4 @@ it('represents a 304 response without body using fetch api response', async () = } satisfies Partial), }) ) - expect(responseListener).toHaveBeenCalledTimes(1) }) diff --git a/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts b/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts new file mode 100644 index 000000000..2fd4a011f --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('treats "controller.errorWith()" as a request error for an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.errorWith(new Error('Network failure')) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.statusText).toBe('') + expect.soft(request.response).toBe('') + expect.soft(request.readyState).toBe(request.DONE) + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ]) +}) + +it('treats "controller.errorWith()" as a request error for an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.errorWith(new Error('Network failure')) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.statusText).toBe('') + expect.soft(request.response).toBe('') + expect.soft(request.readyState).toBe(request.DONE) + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ]) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-array-buffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts similarity index 94% rename from test/modules/XMLHttpRequest/response/xhr-array-buffer.neutral.test.ts rename to test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts index bff6c52b9..8b5c49e42 100644 --- a/test/modules/XMLHttpRequest/response/xhr-array-buffer.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts @@ -1,11 +1,10 @@ // @vitest-environment jsdom -import { toArrayBuffer } from '#/src/utils/bufferUtils' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() -beforeAll(async () => { +beforeAll(() => { interceptor.apply() }) @@ -13,7 +12,7 @@ afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() }) diff --git a/test/modules/XMLHttpRequest/response/xhr-blob.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts similarity index 97% rename from test/modules/XMLHttpRequest/response/xhr-blob.neutral.test.ts rename to test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts index 3f06903b3..780fba421 100644 --- a/test/modules/XMLHttpRequest/response/xhr-blob.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts @@ -4,7 +4,7 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() -beforeAll(async () => { +beforeAll(() => { interceptor.apply() }) @@ -12,7 +12,7 @@ afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts new file mode 100644 index 000000000..83b6ce578 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('responds with an empty mocked response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + status: 401, + statusText: 'Unauthorized', + }) + ) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(401) + expect.soft(request.response).toEqual('') +}) + +it('responds with an empty mocked response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + status: 401, + statusText: 'Unauthorized', + }) + ) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(401) + expect.soft(request.response).toEqual('') +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts index 2b02ce23c..3208ac4e8 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts @@ -1,8 +1,5 @@ // @vitest-environment jsdom -import { - spyOnXMLHttpRequest, - waitForXMLHttpRequest, -} from '#/test/setup/helpers-neutral' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() @@ -19,16 +16,33 @@ afterAll(async () => { interceptor.dispose() }) -it('treats "Response.error()" as a request error', async () => { +it('treats Response.error() as a request error for an HTTP request', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith(Response.error()) }) const errorListener = vi.fn() const request = new XMLHttpRequest() - const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.addEventListener('error', errorListener) + request.send() + + await waitForXMLHttpRequest(request) - request.open('GET', 'http://localhost/resource') + expect.soft(request.status).toBe(0) + expect.soft(request.statusText).toBe('') + expect.soft(request.response).toBe('') + expect.soft(errorListener).toHaveBeenCalledTimes(1) +}) + +it('treats Response.error() as a request error for an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.error()) + }) + + const errorListener = vi.fn() + const request = new XMLHttpRequest() + request.open('GET', 'https://any.host.here/irrelevant') request.addEventListener('error', errorListener) request.send() @@ -37,9 +51,5 @@ it('treats "Response.error()" as a request error', async () => { expect.soft(request.status).toBe(0) expect.soft(request.statusText).toBe('') expect.soft(request.response).toBe('') - expect.soft(events).toEqual([ - ['readystatechange', 1], - ['readystatechange', 4], - ['error', 4, { loaded: 0, total: 0 }], - ]) + expect.soft(errorListener).toHaveBeenCalledTimes(1) }) diff --git a/test/modules/XMLHttpRequest/response/xhr-json.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts similarity index 97% rename from test/modules/XMLHttpRequest/response/xhr-json.neutral.test.ts rename to test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts index 3aa1b9a72..80769b0d0 100644 --- a/test/modules/XMLHttpRequest/response/xhr-json.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts @@ -4,7 +4,7 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() -beforeAll(async () => { +beforeAll(() => { interceptor.apply() }) @@ -12,7 +12,7 @@ afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts index 1ed7766ef..0e07fcf1c 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts @@ -7,7 +7,7 @@ const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() -beforeAll(async () => { +beforeAll(() => { interceptor.apply() }) @@ -15,7 +15,7 @@ afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() }) diff --git a/test/modules/XMLHttpRequest/response/xhr-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts similarity index 97% rename from test/modules/XMLHttpRequest/response/xhr-text.neutral.test.ts rename to test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts index 82e8575a6..2ea0b1b5f 100644 --- a/test/modules/XMLHttpRequest/response/xhr-text.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts @@ -4,7 +4,7 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() -beforeAll(async () => { +beforeAll(() => { interceptor.apply() }) @@ -12,7 +12,7 @@ afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() }) diff --git a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts new file mode 100644 index 000000000..90d6834de --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks response to a synchronous XMLHttpRequest', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = new XMLHttpRequest() + request.open('GET', server.http.url('/'), false) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders()) + .toBe('content-type: text/plain;charset=UTF-8') + expect.soft(request.response).toBe('hello world') + expect.soft(request.responseText).toBe('hello world') +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts new file mode 100644 index 000000000..d7957c34e --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('treats an unhandled exception as a 500 response for an HTTP request', async () => { + interceptor.on('request', () => { + throw new Error('Custom error message') + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(500) + expect.soft(request.statusText).toBe('Unhandled Exception') + expect.soft(request.response).toEqual({ + name: 'Error', + message: 'Custom error message', + stack: expect.any(String), + }) +}) + +it('treats an unhandled exception as a 500 response for an HTTPS request', async () => { + interceptor.on('request', () => { + throw new Error('Custom error message') + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(500) + expect.soft(request.statusText).toBe('Unhandled Exception') + expect.soft(request.response).toEqual({ + name: 'Error', + message: 'Custom error message', + stack: expect.any(String), + }) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr.browser.test.ts index a9db05279..f363d1594 100644 --- a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.browser.test.ts @@ -38,44 +38,6 @@ test.afterAll(async () => { await httpServer.close() }) -test('responds to an HTTP request handled in the resolver', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/'), - }) - - expect(response.status).toBe(201) - expect(response.statusText).toBe('Created') - expect(response.headers).toBe('content-type: application/hal+json') - expect(response.body).toEqual(JSON.stringify({ mocked: true })) -}) - -test('responds to an HTTPS request handled in the resolver', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: httpServer.https.url('/'), - }) - - expect(response.status).toBe(201) - expect(response.statusText).toBe('Created') - expect(response.headers).toBe('content-type: application/hal+json') - expect(response.body).toEqual(JSON.stringify({ mocked: true })) -}) - test('bypasses a request not handled in the resolver', async ({ loadExample, callXMLHttpRequest, @@ -125,23 +87,3 @@ test('bypasses any request when the interceptor is restored', async ({ expect(secondResponse.statusText).toBe('OK') expect(secondResponse.body).toEqual(JSON.stringify({ route: '/get' })) }) - -test('mocks response to a synchronous XMLHttpRequest', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/'), - async: false, - }) - - expect(response.status).toBe(201) - expect(response.statusText).toBe('Created') - expect(response.headers).toBe('content-type: application/hal+json') - expect(response.body).toEqual(JSON.stringify({ mocked: true })) -}) diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts index 953c2606e..9e90ea728 100644 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.test.ts @@ -71,56 +71,6 @@ afterAll(async () => { vi.restoreAllMocks() }) -it('responds to an HTTP request handled in the middleware', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/')) - request.send() - - await waitForXMLHttpRequest(request) - const responseHeaders = request.getAllResponseHeaders() - - expect(request.status).toEqual(301) - expect(responseHeaders).toContain('content-type: application/hal+json') - expect(request.response).toEqual('foo') -}) - -it('bypasses an HTTP request not handled in the middleware', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/get')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toEqual(200) - expect(request.response).toEqual('/get') -}) - -it('responds to an HTTPS request handled in the middleware', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.https.url('/')) - request.send() - - await waitForXMLHttpRequest(request) - const responseHeaders = request.getAllResponseHeaders() - - expect(request.status).toEqual(301) - expect(responseHeaders).toContain('content-type: application/hal+json') - expect(request.response).toEqual('foo') - expect(request.responseURL).toEqual(httpServer.https.url('/')) -}) - -it('bypasses an HTTPS request not handled in the middleware', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.https.url('/get')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toEqual(200) - expect(request.response).toEqual('/get') - expect(request.responseURL).toEqual(httpServer.https.url('/get')) -}) - it('responds to an HTTP request to a relative URL that is handled in the middleware', async () => { const request = new XMLHttpRequest() request.open('POST', httpServer.https.url('/login')) @@ -135,42 +85,6 @@ it('responds to an HTTP request to a relative URL that is handled in the middlew expect(request.responseURL).toEqual(httpServer.https.url('/login')) }) -it('produces a request error for a mocked Response.error() response', async () => { - const errorListener = vi.fn() - const request = new XMLHttpRequest() - request.open('GET', 'http://localhost/network-error') - request.addEventListener('error', errorListener) - request.send() - - await waitForXMLHttpRequest(request) - - expect(errorListener).toHaveBeenCalledTimes(1) - - // XMLHttpRequest request exception propagates as "ProgressEvent". - const [progressEvent] = errorListener.mock.calls[0] - expect(progressEvent).toBeInstanceOf(ProgressEvent) - - // Request must still exist. - expect(request.status).toBe(0) -}) - -it('produces a 500 response for an unhandled exception in the interceptor', async () => { - const request = new XMLHttpRequest() - request.responseType = 'json' - request.open('GET', 'http://localhost/exception') - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toBe(500) - expect(request.statusText).toBe('Unhandled Exception') - expect(request.response).toEqual({ - name: 'Error', - message: 'Custom message', - stack: expect.any(String), - }) -}) - it('does not propagate the forbidden "cookie" header on the bypassed response', async () => { const request = new XMLHttpRequest() request.open('POST', httpServer.https.url('/cookies')) diff --git a/test/setup/helpers-node.ts b/test/setup/helpers-node.ts deleted file mode 100644 index a491960ed..000000000 --- a/test/setup/helpers-node.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { DeferredPromise } from '@open-draft/deferred-promise' -import { Worker } from 'node:worker_threads' - -export async function runServeSnippet(snippet: string) { - const worker = new Worker(snippet, { - eval: true, - }) - const pendingResult = new DeferredPromise() - - worker - .once('message', (message) => { - pendingResult.resolve(message) - worker.terminate() - }) - .on('error', (error) => pendingResult.reject(error)) - - return pendingResult -} diff --git a/vitest.setup.ts b/vitest.setup.ts index 2d4d0d7dc..6ed2c0ffd 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -5,6 +5,20 @@ import { useCors } from './test/helpers' const server = new HttpServer((app) => { app.use(useCors) + app.get('/redirect', (req, res) => { + const baseUrl = new URL( + `${req.secure ? 'https' : 'http'}://${req.get('host')}/` + ) + + res + .status(301) + .set({ location: new URL('/redirect/destination', baseUrl) }) + .end() + }) + app.get('/redirect/destination', (req, res) => { + res.status(200).send('destination-body') + }) + app.all('*', (req, res) => { res.status(200).set(req.headers) req.pipe(res) From 4d99ad9b9dcc18935d296cff9f1de9e8c275a6a7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 12:25:19 +0100 Subject: [PATCH 117/198] chore(testServer): response for GET requests, fix `https.url` --- vitest.setup.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/vitest.setup.ts b/vitest.setup.ts index 6ed2c0ffd..fe4c75a29 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -21,7 +21,12 @@ const server = new HttpServer((app) => { app.all('*', (req, res) => { res.status(200).set(req.headers) - req.pipe(res) + + if (req.method === 'GET') { + res.send('original-response') + } else { + req.pipe(res) + } }) }) @@ -30,7 +35,7 @@ export async function setup(project: TestProject) { project.provide('server', { http: server.http.address.href, - https: server.http.address.href, + https: server.https.address.href, }) } From e33c55737ffa2f4de8e01ea8faaadadcefe5ba17 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 12:25:30 +0100 Subject: [PATCH 118/198] test(http-https): polish the test suite --- test/modules/http/response/http-https.test.ts | 142 +++++++++--------- 1 file changed, 70 insertions(+), 72 deletions(-) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 293ba246a..9faca1ace 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,134 +1,132 @@ // @vitest-environment node import http from 'node:http' import https from 'node:https' -import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { toWebResponse } from '#/test/helpers' +import { getTestServer } from '#/test/setup/vitest' -const httpServer = new HttpServer((app) => { - app.get('/', (_req, res) => { - res.status(200).send('/') - }) - app.get('/get', (_req, res) => { - res.status(200).send('/get') - }) -}) +const server = getTestServer() const interceptor = new HttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - if (url.pathname === '/non-existing') { - controller.respondWith( - new Response('mocked', { - status: 301, - statusText: 'Moved Permanently', - headers: { - 'Content-Type': 'text/plain', - }, - }) - ) - } - - if (url.href === 'http://error.me/') { - throw new Error('Custom exception message') - } -}) - -beforeAll(async () => { - await httpServer.listen() +beforeAll(() => { interceptor.apply() }) -afterAll(async () => { +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { interceptor.dispose() - await httpServer.close() }) it('responds to a handled request issued by "http.get"', async () => { - const req = http.get('http://any.localhost/non-existing') - const [response] = await toWebResponse(req) + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) + }) + + const request = http.get('http://any.localhost/non-existing') + const [response] = await toWebResponse(request) - expect(response.status).toBe(301) - expect(response.statusText).toBe('Moved Permanently') - expect(response.headers.get('content-type')).toBe('text/plain') + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect + .soft(response.headers.get('content-type')) + .toBe('text/plain;charset=UTF-8') await expect(response.text()).resolves.toEqual('mocked') }) it('responds to a handled request issued by "https.get"', async () => { - const req = https.get('https://any.localhost/non-existing') - const [response] = await toWebResponse(req) + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) + }) - expect(response.status).toBe(301) - expect(response.statusText).toBe('Moved Permanently') - expect(response.headers.get('content-type')).toBe('text/plain') + const request = https.get('https://any.localhost/non-existing') + const [response] = await toWebResponse(request) + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect + .soft(response.headers.get('content-type')) + .toBe('text/plain;charset=UTF-8') await expect(response.text()).resolves.toEqual('mocked') }) it('bypasses an unhandled request issued by "http.get"', async () => { - const req = http.get(httpServer.http.url('/get')) - const [response] = await toWebResponse(req) + const request = http.get(server.http.url('/get')) + const [response] = await toWebResponse(request) expect(response.status).toBe(200) expect(response.statusText).toBe('OK') - await expect(response.text()).resolves.toEqual('/get') + await expect(response.text()).resolves.toBe('original-response') }) it('bypasses an unhandled request issued by "https.get"', async () => { - const req = https.get(httpServer.https.url('/get'), { + const request = https.get(server.https.url('/get'), { rejectUnauthorized: false, }) - const [response] = await toWebResponse(req) + const [response] = await toWebResponse(request) expect(response.status).toBe(200) expect(response.statusText).toBe('OK') - await expect(response.text()).resolves.toEqual('/get') + await expect(response.text()).resolves.toBe('original-response') }) it('responds to a handled request issued by "http.request"', async () => { - const req = http.request('http://any.localhost/non-existing') - req.end() - const [response] = await toWebResponse(req) + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) + }) - expect(response.status).toBe(301) - expect(response.statusText).toEqual('Moved Permanently') - expect(response.headers.get('content-type')).toBe('text/plain') + const request = http.request('http://any.localhost/non-existing') + request.end() + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect + .soft(response.headers.get('content-type')) + .toBe('text/plain;charset=UTF-8') await expect(response.text()).resolves.toEqual('mocked') }) it('responds to a handled request issued by "https.request"', async () => { - const req = https.request('https://any.localhost/non-existing') + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) + }) + + const request = https.request('https://any.localhost/non-existing') + request.end() - req.end() - const [response] = await toWebResponse(req) + const [response] = await toWebResponse(request) - expect(response.status).toBe(301) - expect(response.statusText).toBe('Moved Permanently') - expect(response.headers.get('content-type')).toBe('text/plain') + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect + .soft(response.headers.get('content-type')) + .toBe('text/plain;charset=UTF-8') await expect(response.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 [response] = await toWebResponse(req) + const request = http.request(server.http.url('/get')) + request.end() + const [response] = await toWebResponse(request) expect(response.status).toBe(200) expect(response.statusText).toBe('OK') - await expect(response.text()).resolves.toEqual('/get') + await expect(response.text()).resolves.toBe('original-response') }) it('bypasses an unhandled request issued by "https.request"', async () => { - const req = https.request(httpServer.https.url('/get'), { + const request = https.request(server.https.url('/get'), { rejectUnauthorized: false, }) - req.end() - const [response] = await toWebResponse(req) + request.end() + const [response] = await toWebResponse(request) expect(response.status).toBe(200) expect(response.statusText).toBe('OK') - await expect(response.text()).resolves.toEqual('/get') + await expect(response.text()).resolves.toBe('original-response') }) it('throws a request error when the middleware throws an exception', async () => { @@ -141,10 +139,10 @@ 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 [response] = await toWebResponse(req) + const request = http.get(server.http.url('/')) + const [response] = await toWebResponse(request) expect(response.status).toBe(200) expect(response.statusText).toBe('OK') - await expect(response.text()).resolves.toEqual('/') + await expect(response.text()).resolves.toBe('original-response') }) From 10d3ca8ee191644785a1b7023a084c91050db547 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 13:09:29 +0100 Subject: [PATCH 119/198] chore(wip): use happy-dom --- package.json | 3 +- pnpm-lock.yaml | 414 +++++++++++------- .../XMLHttpRequest/utils/createEvent.test.ts | 2 +- test/features/events/request.test.ts | 2 +- test/features/events/response.test.ts | 2 +- test/features/presets/node-preset.test.ts | 2 +- test/features/request-initiator.test.ts | 2 +- .../compliance/xhr-add-event-listener.test.ts | 2 +- .../xhr-event-callback-null.test.ts | 2 +- .../compliance/xhr-event-handlers.test.ts | 2 +- .../compliance/xhr-events-order.test.ts | 2 +- .../xhr-middleware-exception.test.ts | 2 +- .../compliance/xhr-modify-request.test.ts | 2 +- .../xhr-no-response-headers.test.ts | 2 +- .../compliance/xhr-ready-state-enums.test.ts | 2 +- .../compliance/xhr-request-headers.test.ts | 2 +- .../compliance/xhr-request-method.test.ts | 2 +- .../xhr-response-body-json-invalid.test.ts | 2 +- .../compliance/xhr-response-body-xml.test.ts | 2 +- ...response-forbidden-headers.neutral.test.ts | 32 ++ ...-response-headers-case-sensitivity.test.ts | 2 +- .../compliance/xhr-response-headers.test.ts | 2 +- .../xhr-response-non-configurable.test.ts | 2 +- .../compliance/xhr-response-type.test.ts | 2 +- .../xhr-response-without-body.test.ts | 2 +- .../compliance/xhr-status.test.ts | 2 +- .../compliance/xhr-timeout.test.ts | 2 +- .../XMLHttpRequest/features/events.test.ts | 2 +- .../intercept/XMLHttpRequest.test.ts | 2 +- .../regressions/xhr-0-status-code.test.ts | 2 +- .../xhr-compressed-response.test.ts | 2 +- .../xhr-request-body-length.test.ts | 2 +- .../response/xhr-error-with.test.ts | 8 +- .../xhr-request-relative-url.neutral.test.ts | 31 ++ .../xhr-response-array-buffer.neutral.test.ts | 2 +- .../xhr-response-blob.neutral.test.ts | 19 +- .../xhr-response-empty.neutral.test.ts | 14 +- .../xhr-response-error.neutral.test.ts | 2 +- .../xhr-response-json.neutral.test.ts | 19 +- .../xhr-response-patching.neutral.test.ts | 2 +- .../xhr-response-redirect.neutral.test.ts | 2 +- .../xhr-response-text.neutral.test.ts | 10 +- .../response/xhr-synchronous.neutral.test.ts | 6 +- .../xhr-unhandled-exception.neutral.test.ts | 2 +- .../XMLHttpRequest/response/xhr.test.ts | 26 +- test/setup/helpers-neutral.ts | 2 + test/third-party/axios.test.ts | 2 +- test/third-party/supertest.test.ts | 2 +- vitest.setup.ts | 7 + 49 files changed, 420 insertions(+), 243 deletions(-) create mode 100644 test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-request-relative-url.neutral.test.ts diff --git a/package.json b/package.json index 582624423..620c7038b 100644 --- a/package.json +++ b/package.json @@ -125,9 +125,8 @@ "express-rate-limit": "^7.5.0", "follow-redirects": "^1.15.1", "got": "^14.4.6", - "happy-dom": "^20.7.0", + "happy-dom": "^20.8.3", "https-proxy-agent": "^7.0.6", - "jsdom": "^26.1.0", "node-fetch": "3.3.2", "playwright": "^1.58.2", "simple-git-hooks": "^2.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8402dddc7..8823a8883 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,14 +118,11 @@ importers: specifier: ^14.4.6 version: 14.4.6 happy-dom: - specifier: ^20.7.0 - version: 20.7.0 + specifier: ^20.8.3 + version: 20.8.3 https-proxy-agent: specifier: ^7.0.6 version: 7.0.6 - jsdom: - specifier: ^26.1.0 - version: 26.1.0 node-fetch: specifier: 3.3.2 version: 3.3.2 @@ -161,7 +158,7 @@ importers: version: 7.22.0 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) + version: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) vitest-environment-miniflare: specifier: ^2.14.1 version: 2.14.4(vitest@4.0.18) @@ -180,8 +177,18 @@ importers: packages: - '@asamuzakjp/css-color@2.8.3': - resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} @@ -212,6 +219,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cloudflare/workers-types@4.20241127.0': resolution: {integrity: sha512-UqlvtqV8eI0CdPR7nxlbVlE52+lcjHvGdbYXEPwisy23+39RsFV7OOy0da0moJAhqnL2OhDmWTOaKdsVcPHiJQ==} @@ -288,33 +299,36 @@ packages: resolution: {integrity: sha512-DSHae2obMSMkAtTBSOulg5X7/z+rGLxcXQIkg3OmWvY6wifojge5uVMydfhUvs7yQj+V7jNmRZ2Xzl8GJyqRgg==} engines: {node: '>=v18'} - '@csstools/color-helpers@5.0.2': - resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} - engines: {node: '>=18'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} - '@csstools/css-calc@2.1.2': - resolution: {integrity: sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==} - engines: {node: '>=18'} + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.4 - '@csstools/css-tokenizer': ^3.0.3 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@3.0.8': - resolution: {integrity: sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ==} - engines: {node: '>=18'} + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.4 - '@csstools/css-tokenizer': ^3.0.3 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-parser-algorithms@3.0.4': - resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} - engines: {node: '>=18'} + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-tokenizer': ^3.0.3 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-tokenizer@3.0.3': - resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} - engines: {node: '>=18'} + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -481,6 +495,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -1201,6 +1224,9 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -1437,9 +1463,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - cssstyle@4.2.1: - resolution: {integrity: sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==} - engines: {node: '>=18'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} cz-conventional-changelog@3.3.0: resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==} @@ -1453,9 +1483,9 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -1486,8 +1516,8 @@ packages: supports-color: optional: true - decimal.js@10.5.0: - resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} @@ -1602,8 +1632,8 @@ packages: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} entities@7.0.1: @@ -1881,8 +1911,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - happy-dom@20.7.0: - resolution: {integrity: sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==} + happy-dom@20.8.3: + resolution: {integrity: sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==} engines: {node: '>=20.0.0'} has-flag@3.0.0: @@ -1923,9 +1953,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} html-rewriter-wasm@0.4.1: resolution: {integrity: sha512-lNovG8CMCCmcVB1Q7xggMSf7tqPCijZXaH4gL6iE8BFghdQCbaY5Met9i1x2Ex8m/cZHDUtXK9H6/znKamRP8Q==} @@ -1957,10 +1987,6 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2102,9 +2128,9 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jsdom@26.1.0: - resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} - engines: {node: '>=18'} + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -2211,12 +2237,16 @@ packages: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -2348,9 +2378,6 @@ packages: npx-import@1.1.4: resolution: {integrity: sha512-3ShymTWOgqGyNlh5lMJAejLuIv3W1K3fbI5Ewc6YErZU3Sp0PqsNs8UIU1O8z5+KVl/Du5ag56Gza9vdorGEoA==} - nwsapi@2.2.16: - resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2421,8 +2448,8 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} - parse5@7.2.1: - resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} @@ -2653,9 +2680,6 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -2931,11 +2955,11 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tldts-core@6.1.83: - resolution: {integrity: sha512-I2wb9OJc6rXyh9d4aInhSNWChNI+ra6qDnFEGEwe9OoA68lE4Temw29bOkf1Uvwt8VZS079t1BFZdXVBmmB4dw==} + tldts-core@7.0.24: + resolution: {integrity: sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==} - tldts@6.1.83: - resolution: {integrity: sha512-FHxxNJJ0WNsEBPHyC1oesQb3rRoxpuho/z2g3zIIAhw1WHJeQsUzK1jYK8TI1/iClaa4fS3Z2TCA9mtxXsENSg==} + tldts@7.0.24: + resolution: {integrity: sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==} hasBin: true tmp@0.0.33: @@ -2954,13 +2978,13 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} - tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} - tr46@5.0.0: - resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} - engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} @@ -3185,9 +3209,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} webpack-http-server@0.5.0: resolution: {integrity: sha512-kyewxAnzmDuZxe09fn/Bb0PeEnaDxHChYKFVsMy4oeBUs9Cyv2j1uEgzQJ7ljPFexLU8ongUS4i4O+e22CeBZQ==} @@ -3206,21 +3230,17 @@ packages: webpack-cli: optional: true - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} - whatwg-url@14.1.1: - resolution: {integrity: sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==} - engines: {node: '>=18'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} which-typed-array@1.1.16: resolution: {integrity: sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==} @@ -3332,13 +3352,29 @@ packages: snapshots: - '@asamuzakjp/css-color@2.8.3': + '@acemir/cssom@0.9.31': + optional: true + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + optional: true + + '@asamuzakjp/dom-selector@6.8.1': dependencies: - '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-color-parser': 3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 - lru-cache: 10.4.3 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + optional: true + + '@asamuzakjp/nwsapi@2.3.9': + optional: true '@babel/code-frame@7.26.2': dependencies: @@ -3369,6 +3405,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + optional: true + '@cloudflare/workers-types@4.20241127.0': {} '@commitlint/cli@19.7.1(@types/node@22.13.9)(typescript@5.8.2)': @@ -3498,25 +3539,33 @@ snapshots: '@types/conventional-commits-parser': 5.0.1 chalk: 5.3.0 - '@csstools/color-helpers@5.0.2': {} + '@csstools/color-helpers@6.0.2': + optional: true - '@csstools/css-calc@2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + optional: true - '@csstools/css-color-parser@3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/color-helpers': 5.0.2 - '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + optional: true - '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/css-tokenizer': 3.0.3 + '@csstools/css-tokenizer': 4.0.0 + optional: true + + '@csstools/css-syntax-patches-for-csstree@1.1.0': + optional: true - '@csstools/css-tokenizer@3.0.3': {} + '@csstools/css-tokenizer@4.0.0': + optional: true '@emnapi/core@1.7.1': dependencies: @@ -3612,6 +3661,9 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@exodus/bytes@1.15.0': + optional: true + '@fastify/busboy@2.1.1': {} '@iarna/toml@2.2.5': {} @@ -4140,7 +4192,7 @@ snapshots: '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) playwright: 1.58.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) transitivePeerDependencies: - bufferutil - msw @@ -4156,7 +4208,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) ws: 8.19.0 transitivePeerDependencies: - bufferutil @@ -4390,6 +4442,11 @@ snapshots: baseline-browser-mapping@2.9.19: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + optional: true + birpc@4.0.0: {} bl@4.1.0: @@ -4646,10 +4703,19 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - cssstyle@4.2.1: + css-tree@3.2.1: dependencies: - '@asamuzakjp/css-color': 2.8.3 - rrweb-cssom: 0.8.0 + mdn-data: 2.27.1 + source-map-js: 1.2.1 + optional: true + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + optional: true cz-conventional-changelog@3.3.0(@types/node@22.13.9)(typescript@5.8.2): dependencies: @@ -4669,10 +4735,13 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-urls@5.0.0: + data-urls@7.0.0: dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.1.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true dateformat@4.6.3: {} @@ -4688,7 +4757,8 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js@10.5.0: {} + decimal.js@10.6.0: + optional: true decompress-response@6.0.0: dependencies: @@ -4800,7 +4870,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - entities@4.5.0: {} + entities@6.0.1: + optional: true entities@7.0.1: {} @@ -5141,7 +5212,7 @@ snapshots: graceful-fs@4.2.11: {} - happy-dom@20.7.0: + happy-dom@20.8.3: dependencies: '@types/node': 22.13.9 '@types/whatwg-mimetype': 3.0.2 @@ -5183,9 +5254,12 @@ snapshots: hookable@5.5.3: {} - html-encoding-sniffer@4.0.0: + html-encoding-sniffer@6.0.0: dependencies: - whatwg-encoding: 3.1.1 + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + optional: true html-rewriter-wasm@0.4.1: {} @@ -5205,6 +5279,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true http2-wrapper@2.2.1: dependencies: @@ -5224,10 +5299,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} import-fresh@3.3.0: @@ -5299,7 +5370,8 @@ snapshots: is-obj@2.0.0: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-stream@3.0.0: {} @@ -5350,32 +5422,33 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.1.0: + jsdom@28.1.0: dependencies: - cssstyle: 4.2.1 - data-urls: 5.0.0 - decimal.js: 10.5.0 - html-encoding-sniffer: 4.0.0 + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.16 - parse5: 7.2.1 - rrweb-cssom: 0.8.0 + parse5: 8.0.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 5.1.2 + tough-cookie: 6.0.0 + undici: 7.22.0 w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.1.1 - ws: 8.18.1 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 xml-name-validator: 5.0.0 transitivePeerDependencies: - - bufferutil + - '@noble/hashes' - supports-color - - utf-8-validate + optional: true jsesc@3.1.0: {} @@ -5448,12 +5521,16 @@ snapshots: lowercase-keys@3.0.0: {} - lru-cache@10.4.3: {} + lru-cache@11.2.6: + optional: true magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mdn-data@2.27.1: + optional: true + media-typer@0.3.0: {} memfs@3.5.3: @@ -5544,8 +5621,6 @@ snapshots: semver: 7.6.3 validate-npm-package-name: 4.0.0 - nwsapi@2.2.16: {} - object-assign@4.1.1: {} object-inspect@1.13.3: {} @@ -5613,9 +5688,10 @@ snapshots: parse-passwd@1.0.0: {} - parse5@7.2.1: + parse5@8.0.0: dependencies: - entities: 4.5.0 + entities: 6.0.1 + optional: true parseurl@1.3.3: {} @@ -5729,7 +5805,8 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 - punycode@2.3.1: {} + punycode@2.3.1: + optional: true qs@6.13.0: dependencies: @@ -5881,8 +5958,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - rrweb-cssom@0.8.0: {} - run-async@2.4.1: {} rxjs@7.8.1: @@ -5904,6 +5979,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true schema-utils@4.3.3: dependencies: @@ -6149,7 +6225,8 @@ snapshots: dependencies: has-flag: 4.0.0 - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true tapable@2.3.0: {} @@ -6195,11 +6272,13 @@ snapshots: tinyrainbow@3.0.3: {} - tldts-core@6.1.83: {} + tldts-core@7.0.24: + optional: true - tldts@6.1.83: + tldts@7.0.24: dependencies: - tldts-core: 6.1.83 + tldts-core: 7.0.24 + optional: true tmp@0.0.33: dependencies: @@ -6213,13 +6292,15 @@ snapshots: totalist@3.0.1: {} - tough-cookie@5.1.2: + tough-cookie@6.0.0: dependencies: - tldts: 6.1.83 + tldts: 7.0.24 + optional: true - tr46@5.0.0: + tr46@6.0.0: dependencies: punycode: 2.3.1 + optional: true traverse@0.6.8: {} @@ -6342,12 +6423,12 @@ snapshots: '@miniflare/shared': 2.14.4 '@miniflare/shared-test-environment': 2.14.4 undici: 5.28.4 - vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0) + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) transitivePeerDependencies: - bufferutil - utf-8-validate - vitest@4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.7.0)(jiti@2.4.2)(jsdom@26.1.0)(terser@5.36.0): + vitest@4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) @@ -6372,8 +6453,8 @@ snapshots: optionalDependencies: '@types/node': 22.13.9 '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18) - happy-dom: 20.7.0 - jsdom: 26.1.0 + happy-dom: 20.8.3 + jsdom: 28.1.0 transitivePeerDependencies: - jiti - less @@ -6390,6 +6471,7 @@ snapshots: w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 + optional: true watchpack@2.5.1: dependencies: @@ -6408,7 +6490,8 @@ snapshots: web-streams-polyfill@3.3.3: {} - webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: + optional: true webpack-http-server@0.5.0: dependencies: @@ -6460,18 +6543,19 @@ snapshots: - esbuild - uglify-js - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-mimetype@3.0.0: {} - whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: + optional: true - whatwg-url@14.1.1: + whatwg-url@16.0.1: dependencies: - tr46: 5.0.0 - webidl-conversions: 7.0.0 + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true which-typed-array@1.1.16: dependencies: @@ -6516,9 +6600,11 @@ snapshots: ws@8.19.0: {} - xml-name-validator@5.0.0: {} + xml-name-validator@5.0.0: + optional: true - xmlchars@2.2.0: {} + xmlchars@2.2.0: + optional: true xmlhttprequest-ssl@2.1.2: {} diff --git a/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts b/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts index a2b2a12dc..a2f899d80 100644 --- a/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts +++ b/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { createEvent } from './createEvent' import { EventPolyfill } from '../polyfills/EventPolyfill' diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 7d9452553..99c2ad429 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { useCors, REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 3b440c990..7490904b9 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index 0260d5a71..f055a05bb 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import http from 'node:http' import { BatchInterceptor } from '../../../lib/node/index.mjs' import nodeInterceptors from '../../../lib/node/presets/node.mjs' diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index c37fcb7da..be486e047 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { BatchInterceptor } from '#/src/BatchInterceptor' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts index 4009e56d1..d446662a7 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/msw/issues/273 */ diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts index 7d8aaa670..a69af7d97 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://xhr.spec.whatwg.org/#event-handlers */ diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts index 94fdc292f..b35f43dbe 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts @@ -1,7 +1,7 @@ /** * @note https://xhr.spec.whatwg.org/#event-handlers */ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts index cae9b5aaa..995af6635 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://xhr.spec.whatwg.org/#events */ diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index 58359b370..1f7f6c6b5 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/msw/issues/355 */ diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts index fa57c4c74..814297286 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts index bbfaf8890..3e2548523 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts index 07486fd0b..d39ba0fee 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts index 789b48385..7b0d61eda 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts index e67e2b970..a79b089f8 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts index 73f6ab615..9cc79db41 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts index c5b422a0e..1d7fdad71 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts new file mode 100644 index 000000000..e5782d91e --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('does not propagate the forbidden "cookie" header on the bypassed response', async () => { + const request = new XMLHttpRequest() + request.open('POST', server.https.url('/cookie')) + request.setRequestHeader('Set-Cookie', 'foo=bar') + request.send() + + await waitForXMLHttpRequest(request) + + console.log(request.getAllResponseHeaders()) + expect(request.getAllResponseHeaders()).not.toMatch(/cookie/) +}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts index e219fb5ca..a17826408 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { useCors } from '#/test/helpers' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts index fc18cd271..326f3bf11 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts index 9ce3342e3..b1607c990 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/msw/issues/2307 */ diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts index 042316703..cbfebf22c 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { encodeBuffer } from '#/src/index' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { toArrayBuffer } from '#/src/utils/bufferUtils' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts index 794b2a1a5..d4c7238cf 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts index 7c07d7caa..369e24a90 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/interceptors/issues/281 */ diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts index 7bbf24f98..6ffbf13d8 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/interceptors/issues/7 */ diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index ae66e32a3..17b79c7c4 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { useCors, REQUEST_ID_REGEXP } from '#/test/helpers' diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts index 28e773532..19fbd0eaf 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import type { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts index 1c84c3aca..005add65c 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/interceptors/issues/335 */ diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts index 3a44d0652..4706aab34 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/interceptors/issues/308 */ diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts index 3a4aab50d..0e2a9dfaf 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts b/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts index 2fd4a011f..e16c4d8cf 100644 --- a/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { spyOnXMLHttpRequest, waitForXMLHttpRequest, @@ -43,7 +43,7 @@ it('treats "controller.errorWith()" as a request error for an HTTP request', asy ]) }) -it('treats "controller.errorWith()" as a request error for an HTTPS request', async () => { +it.only('treats "controller.errorWith()" as a request error for an HTTPS request', async () => { interceptor.on('request', ({ controller }) => { controller.errorWith(new Error('Network failure')) }) @@ -61,8 +61,8 @@ it('treats "controller.errorWith()" as a request error for an HTTPS request', as expect.soft(request.response).toBe('') expect.soft(request.readyState).toBe(request.DONE) expect.soft(events).toEqual([ - ['readystatechange', 1], - ['readystatechange', 4], + // ['readystatechange', 1], + // ['readystatechange', 4], ['error', 4, { loaded: 0, total: 0 }], ]) }) diff --git a/test/modules/XMLHttpRequest/response/xhr-request-relative-url.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-request-relative-url.neutral.test.ts new file mode 100644 index 000000000..6bc62b844 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-request-relative-url.neutral.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks a response to a request with a relative url', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = new XMLHttpRequest() + request.open('GET', '/resource') + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.response).toBe('hello world') +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts index 8b5c49e42..0d46d95be 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts index 780fba421..0238010d0 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts @@ -1,7 +1,9 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() beforeAll(() => { @@ -16,6 +18,17 @@ afterAll(() => { interceptor.dispose() }) +it('intercepts a bypassed request with a blob response', async () => { + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('POST', server.http.url('/blob')) + request.send(new Blob(['hello world'])) + + await waitForXMLHttpRequest(request) + + expect(request.response).toEqual(new Blob(['hello world'])) +}) + it('responds with a mocked Blob response to an HTTP request', async () => { const blob = new Blob(['hello world'], { type: 'text/plain' }) interceptor.on('request', ({ controller }) => { @@ -31,7 +44,7 @@ it('responds with a mocked Blob response to an HTTP request', async () => { expect.soft(request.status).toBe(200) expect - .soft(request.getAllResponseHeaders()) + .soft(request.getAllResponseHeaders().toLowerCase()) .toContain('content-type: text/plain') expect.soft(request.response).toEqual(blob) }) @@ -51,7 +64,7 @@ it('responds with a mocked Blob response to an HTTP request', async () => { expect.soft(request.status).toBe(200) expect - .soft(request.getAllResponseHeaders()) + .soft(request.getAllResponseHeaders().toLowerCase()) .toContain('content-type: text/plain') expect.soft(request.response).toEqual(blob) }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts index 83b6ce578..cb06fb77d 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts @@ -1,7 +1,9 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() beforeAll(() => { @@ -16,6 +18,16 @@ afterAll(() => { interceptor.dispose() }) +it('intercepts a bypassed request with an empty response', async () => { + const request = new XMLHttpRequest() + request.open('POST', server.http.url('/empty')) + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.response).toBe('') +}) + it('responds with an empty mocked response to an HTTP request', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith( diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts index 3208ac4e8..cefe10be0 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts index 80769b0d0..902cdf092 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts @@ -1,7 +1,9 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() beforeAll(() => { @@ -16,6 +18,17 @@ afterAll(() => { interceptor.dispose() }) +it('intercepts a bypassed request with a JSON response', async () => { + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('POST', server.http.url('/empty')) + request.send(JSON.stringify({ name: 'John Maverick' })) + + await waitForXMLHttpRequest(request) + + expect(request.response).toEqual({ name: 'John Maverick' }) +}) + it('responds with a mocked text response to an HTTP request', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith(Response.json({ name: 'John Maverick' })) @@ -30,7 +43,7 @@ it('responds with a mocked text response to an HTTP request', async () => { expect.soft(request.status).toBe(200) expect - .soft(request.getAllResponseHeaders()) + .soft(request.getAllResponseHeaders().toLowerCase()) .toBe('content-type: application/json') expect.soft(request.response).toEqual({ name: 'John Maverick' }) }) @@ -49,7 +62,7 @@ it('responds with a mocked text response to an HTTPS request', async () => { expect.soft(request.status).toBe(200) expect - .soft(request.getAllResponseHeaders()) + .soft(request.getAllResponseHeaders().toLowerCase()) .toBe('content-type: application/json') expect.soft(request.response).toEqual({ name: 'John Maverick' }) }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts index 0e07fcf1c..300c567cf 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { getTestServer } from '#/test/setup/vitest' diff --git a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts index 7bed652b9..386b61e2c 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts index 2ea0b1b5f..858ac0942 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' @@ -29,8 +29,8 @@ it('responds with a mocked text response to an HTTP request', async () => { expect.soft(request.status).toBe(200) expect - .soft(request.getAllResponseHeaders()) - .toBe('content-type: text/plain;charset=UTF-8') + .soft(request.getAllResponseHeaders().toLowerCase()) + .toBe('content-type: text/plain;charset=utf-8') expect.soft(request.response).toBe('hello world') expect.soft(request.responseText).toBe('hello world') }) @@ -48,8 +48,8 @@ it('responds with a mocked text response to an HTTPS request', async () => { expect.soft(request.status).toBe(200) expect - .soft(request.getAllResponseHeaders()) - .toBe('content-type: text/plain;charset=UTF-8') + .soft(request.getAllResponseHeaders().toLowerCase()) + .toBe('content-type: text/plain;charset=utf-8') expect.soft(request.response).toBe('hello world') expect.soft(request.responseText).toBe('hello world') }) diff --git a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts index 90d6834de..76f82df7b 100644 --- a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' @@ -32,8 +32,8 @@ it('mocks response to a synchronous XMLHttpRequest', async () => { expect.soft(request.status).toBe(200) expect - .soft(request.getAllResponseHeaders()) - .toBe('content-type: text/plain;charset=UTF-8') + .soft(request.getAllResponseHeaders().toLowerCase()) + .toBe('content-type: text/plain;charset=utf-8') expect.soft(request.response).toBe('hello world') expect.soft(request.responseText).toBe('hello world') }) diff --git a/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts index d7957c34e..a2f0b2a84 100644 --- a/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts index 9e90ea728..c6092714b 100644 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.test.ts @@ -1,15 +1,9 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' -declare namespace window { - export const _resourceLoader: { - _strictSSL: boolean - } -} - const httpServer = new HttpServer((app) => { app.use(useCors) app.get('/', (req, res) => { @@ -58,9 +52,6 @@ interceptor.on('request', ({ request, controller }) => { }) beforeAll(async () => { - // Allow XHR requests to the local HTTPS server with a self-signed certificate. - window._resourceLoader._strictSSL = false - await httpServer.listen() interceptor.apply() }) @@ -77,24 +68,15 @@ it('responds to an HTTP request to a relative URL that is handled in the middlew request.send() await waitForXMLHttpRequest(request) - const responseHeaders = request.getAllResponseHeaders() expect(request.status).toEqual(301) - expect(responseHeaders).toContain('content-type: application/hal+json') + expect(request.getAllResponseHeaders().toLowerCase()).toContain( + 'content-type: application/hal+json' + ) expect(request.response).toEqual('foo') expect(request.responseURL).toEqual(httpServer.https.url('/login')) }) -it('does not propagate the forbidden "cookie" header on the bypassed response', async () => { - const request = new XMLHttpRequest() - request.open('POST', httpServer.https.url('/cookies')) - request.send() - - await waitForXMLHttpRequest(request) - const responseHeaders = request.getAllResponseHeaders() - expect(responseHeaders).not.toMatch(/cookie/) -}) - it('bypasses any request when the interceptor is restored', async () => { interceptor.dispose() diff --git a/test/setup/helpers-neutral.ts b/test/setup/helpers-neutral.ts index 3a56b56b1..5a906092e 100644 --- a/test/setup/helpers-neutral.ts +++ b/test/setup/helpers-neutral.ts @@ -16,6 +16,8 @@ export function spyOnXMLHttpRequest(request: XMLHttpRequest) { const addEvent = (name: string) => { return (event: unknown) => { + console.log('EVENT:', name, event, request.readyState) + if (event instanceof ProgressEvent) { events.push([ name, diff --git a/test/third-party/axios.test.ts b/test/third-party/axios.test.ts index 8ec7dfa2c..45bfac6e8 100644 --- a/test/third-party/axios.test.ts +++ b/test/third-party/axios.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import axios from 'axios' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/third-party/supertest.test.ts b/test/third-party/supertest.test.ts index 605cb018a..ff677702f 100644 --- a/test/third-party/supertest.test.ts +++ b/test/third-party/supertest.test.ts @@ -1,4 +1,4 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom import express from 'express' import supertest from 'supertest' import { HttpRequestEventMap } from '#/src/index' diff --git a/vitest.setup.ts b/vitest.setup.ts index fe4c75a29..a2b983194 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -22,6 +22,13 @@ const server = new HttpServer((app) => { app.all('*', (req, res) => { res.status(200).set(req.headers) + if (req.headers['set-cookie']) { + res.cookie('cookie', 'supersecret', { + secure: true, + expires: new Date(Date.now() + 90000), + }) + } + if (req.method === 'GET') { res.send('original-response') } else { From 3e11d9b8fc4daad54a01389f1ddb3e68f89c71b6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 13:59:35 +0100 Subject: [PATCH 120/198] fix(xhr): set `total` to `0` in `loadstart` --- .../XMLHttpRequest/XMLHttpRequestController.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 6cd5552ae..384485dbf 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -290,10 +290,7 @@ export class XMLHttpRequestController { // Follow redirect responses to maintain parity with browser XHR behavior. // Browsers follow redirects transparently so XHR never sees 3xx responses. const redirectLocation = response.headers.get('location') - if ( - redirectLocation && - FetchResponse.isRedirectResponse(response.status) - ) { + if (redirectLocation && FetchResponse.isRedirectResponse(response.status)) { const redirectUrl = new URL(redirectLocation, location.href) const redirectMethod = FetchResponse.isResponseWithBody(response.status) ? this.method @@ -423,7 +420,7 @@ export class XMLHttpRequestController { this.trigger('loadstart', this.request, { loaded: 0, - total: totalResponseBodyLength, + total: 0, }) this.setReadyState(this.request.HEADERS_RECEIVED) From f17ba561835ad72f7eef3ec7cce208df40ff1488 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 14:26:38 +0100 Subject: [PATCH 121/198] fix(xhr): do not transition to LOADING for empty responses --- .../XMLHttpRequestController.ts | 9 +- .../xhr-response-empty.neutral.test.ts | 96 +++++++++++++++---- 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 384485dbf..e7b8e175c 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -424,7 +424,14 @@ export class XMLHttpRequestController { }) this.setReadyState(this.request.HEADERS_RECEIVED) - this.setReadyState(this.request.LOADING) + + /** + * @note The request never transitions to the loading state if the response body is empty. + * @see https://xhr.spec.whatwg.org/#handle-response-end-of-body + */ + if (totalResponseBodyLength > 0) { + this.setReadyState(this.request.LOADING) + } const finalizeResponse = () => { this.logger.info('finalizing the mocked response...') diff --git a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts index cb06fb77d..22d621674 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts @@ -1,6 +1,9 @@ // @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' const server = getTestServer() @@ -18,52 +21,103 @@ afterAll(() => { interceptor.dispose() }) -it('intercepts a bypassed request with an empty response', async () => { +it('intercepts a bypassed request with an empty response', async ({ task }) => { const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('POST', server.http.url('/empty')) request.send() await waitForXMLHttpRequest(request) - expect(request.response).toBe('') + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 4], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } }) -it('responds with an empty mocked response to an HTTP request', async () => { - interceptor.on('request', ({ controller }) => { - controller.respondWith( - new Response(null, { - status: 401, - statusText: 'Unauthorized', - }) - ) +it('responds with an empty mocked response to an HTTP request', async ({ + task, +}) => { + interceptor.on('request', ({ request, controller }) => { + /** + * @see Browser-likes dispatch an extra preflight request. + */ + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + + controller.respondWith(new Response(null, { status: 204 })) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', 'http://any.host.here/irrelevant') request.send() await waitForXMLHttpRequest(request) - expect.soft(request.status).toBe(401) + expect.soft(request.status).toBe(204) expect.soft(request.response).toEqual('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 4], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } }) -it('responds with an empty mocked response to an HTTPS request', async () => { - interceptor.on('request', ({ controller }) => { - controller.respondWith( - new Response(null, { - status: 401, - statusText: 'Unauthorized', - }) - ) +it('responds with an empty mocked response to an HTTPS request', async ({ + task, +}) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + + controller.respondWith(new Response(null, { status: 204 })) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', 'https://any.host.here/irrelevant') request.send() await waitForXMLHttpRequest(request) - expect.soft(request.status).toBe(401) + expect.soft(request.status).toBe(204) expect.soft(request.response).toEqual('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 4], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } }) From 42152866742bdfc0a8ff5cfb7b98cbe3ac0a6fda Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 15:36:28 +0100 Subject: [PATCH 122/198] fix(xhr): implement `respondWith` as per spec --- .../XMLHttpRequestController.ts | 353 ++++++++++++------ 1 file changed, 232 insertions(+), 121 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index e7b8e175c..0e550f443 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -1,6 +1,5 @@ import { until } from '@open-draft/until' import { invariant } from 'outvariant' -import { isNodeProcess } from 'is-node-process' import type { Logger } from '@open-draft/logger' import { concatArrayBuffer } from './utils/concatArrayBuffer' import { createEvent } from './utils/createEvent' @@ -16,9 +15,9 @@ import { createResponse } from './utils/createResponse' import { createRequestId } from '../../createRequestId' import { getBodyByteLength } from './utils/getBodyByteLength' import { FetchResponse } from '../../utils/fetchUtils' +import { isResponseError } from '../../utils/responseUtils' const kIsRequestHandled = Symbol('kIsRequestHandled') -const IS_NODE = isNodeProcess() const kFetchRequest = Symbol('kFetchRequest') const MAX_REDIRECTS = 20 @@ -48,6 +47,7 @@ export class XMLHttpRequestController { [kIsRequestHandled]: boolean; [kFetchRequest]?: Request + private sync: boolean = false private method: string = 'GET' private url: URL = null as any private requestHeaders: Headers @@ -98,7 +98,12 @@ export class XMLHttpRequestController { methodCall: ([methodName, args], invoke) => { switch (methodName) { case 'open': { - const [method, url] = args as [string, string | undefined] + const [method, url, sync] = args as [ + string, + string | undefined, + boolean | undefined, + ] + this.sync = sync ?? false if (typeof url === 'undefined') { this.method = 'GET' @@ -287,6 +292,97 @@ export class XMLHttpRequestController { */ this[kIsRequestHandled] = true + this.logger.info( + 'responding with a mocked response: %d %s', + response.status, + response.statusText + ) + + define(this.request, 'status', response.status) + define(this.request, 'statusText', response.statusText) + define(this.request, 'responseURL', this.url.href) + + // Update the response getters to resolve against the mocked response. + Object.defineProperties(this.request, { + response: { + enumerable: true, + configurable: false, + get: () => this.response, + }, + responseText: { + enumerable: true, + configurable: false, + get: () => this.responseText, + }, + responseXML: { + enumerable: true, + configurable: false, + get: () => this.responseXML, + }, + }) + + // 1. Fire a progress event named loadstart at this with 0 and 0. + this.trigger('loadstart', this.request, { loaded: 0, total: 0 }) + + // 2. Let requestBodyTransmitted be 0. + let requestBodyTransmitted = 0 + let uploadComplete = false + + if (this[kFetchRequest]) { + const requestBodyLength = await getBodyByteLength(this[kFetchRequest]) + + // 5. If this’s upload complete flag is unset and this’s upload listener flag is set, then fire a progress event named loadstart at this’s upload object with requestBodyTransmitted and requestBodyLength. + if (!uploadComplete) { + this.trigger('loadstart', this.request.upload, { + loaded: 0, + total: requestBodyLength, + }) + } + + const processRequestBodyChunkLength = (bytesLength: number) => { + requestBodyTransmitted += bytesLength + + this.trigger('progress', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + } + + const processRequestEndOfBody = () => { + uploadComplete = true + + this.trigger('progress', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + this.trigger('load', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + this.trigger('loadend', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + } + + if (this[kFetchRequest]?.body != null) { + const reader = this[kFetchRequest].body.getReader() + + while (true) { + const { value, done } = await reader.read() + + if (done) { + processRequestEndOfBody() + break + } + + processRequestBodyChunkLength(value.byteLength) + } + } else { + processRequestEndOfBody() + } + } + // Follow redirect responses to maintain parity with browser XHR behavior. // Browsers follow redirects transparently so XHR never sees 3xx responses. const redirectLocation = response.headers.get('location') @@ -308,43 +404,144 @@ export class XMLHttpRequestController { return this.respondWith(redirectResult.data.response) } - /** - * Dispatch request upload events for requests with a body. - * @see https://github.com/mswjs/interceptors/issues/573 - */ - if (this[kFetchRequest]) { - const totalRequestBodyLength = await getBodyByteLength( - this[kFetchRequest] - ) + let timedOut = false + const responseReadController = new AbortController() - this.trigger('loadstart', this.request.upload, { - loaded: 0, - total: totalRequestBodyLength, - }) - this.trigger('progress', this.request.upload, { - loaded: totalRequestBodyLength, - total: totalRequestBodyLength, - }) - this.trigger('load', this.request.upload, { - loaded: totalRequestBodyLength, - total: totalRequestBodyLength, - }) + const handleErrors = () => { + if (timedOut) { + requestErrorSteps( + 'timeout', + new DOMException('The operation timed out.') + ) + } else if (responseReadController.signal.aborted) { + requestErrorSteps( + 'abort', + new DOMException('The operation was aborted.') + ) + } else if (isResponseError(response)) { + requestErrorSteps('error', new TypeError('A network error occurred.')) + } + } - this.trigger('loadend', this.request.upload, { - loaded: totalRequestBodyLength, - total: totalRequestBodyLength, - }) + const requestErrorSteps = ( + event: keyof XMLHttpRequestEventTargetEventMap, + exception?: Error + ) => { + this.setReadyState(this.request.DONE) + + if (this.sync) { + throw exception + } + + if (!uploadComplete) { + this.trigger(event, this.request.upload, { + loaded: 0, + total: 0, + }) + this.trigger('loadend', this.request.upload, { + loaded: 0, + total: 0, + }) + } + + this.trigger(event, this.request, { loaded: 0, total: 0 }) + this.trigger('loadend', this.request, { loaded: 0, total: 0 }) } - this.logger.info( - 'responding with a mocked response: %d %s', - response.status, - response.statusText - ) + const processResponse = async (response: Response) => { + handleErrors() - define(this.request, 'status', response.status) - define(this.request, 'statusText', response.statusText) - define(this.request, 'responseURL', this.url.href) + if (isResponseError(response)) { + return + } + + this.setReadyState(this.request.HEADERS_RECEIVED) + + let responseBodyLength = + response.body != null ? await getBodyByteLength(response.clone()) : 0 + let receivedBytes = 0 + + const processResponseBodyChunk = (bytesLength: number) => { + receivedBytes += bytesLength + + if (this.request.readyState === this.request.HEADERS_RECEIVED) { + this.setReadyState(this.request.LOADING) + } + + this.trigger('readystatechange', this.request) + this.trigger('progress', this.request, { + loaded: receivedBytes, + total: responseBodyLength, + }) + } + + const processResponseEndOfBody = () => { + handleErrors() + + if (isResponseError(response)) { + return + } + + // 3. Let transmitted be xhr’s received bytes’s length. + let transmitted = receivedBytes + + // 8. If xhr’s synchronous flag is unset, then fire a progress event named progress at xhr with transmitted and length. + if (!this.sync) { + this.trigger('progress', this.request, { + loaded: transmitted, + total: responseBodyLength, + }) + } + + // 9. Fire an event named readystatechange at xhr. + this.setReadyState(this.request.DONE) + // 10. Fire a progress event named load at xhr with transmitted and length. + this.trigger('load', this.request, { + loaded: transmitted, + total: responseBodyLength, + }) + // 11. Fire a progress event named loadend at xhr with transmitted and length. + this.trigger('loadend', this.request, { + loaded: transmitted, + total: responseBodyLength, + }) + } + + // 7. If this’s response’s body is null, then run handle response end-of-body for this and return. + if (response.body == null) { + processResponseEndOfBody() + } else { + const reader = response.body.getReader() + + while (true) { + if (responseReadController.signal.aborted) { + break + } + + const { value, done } = await reader.read() + + if (done) { + processResponseEndOfBody() + return + } + + processResponseBodyChunk(value.byteLength) + this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) + } + } + } + + processResponse(response) + + // 12.1, 12.2. + if (this.request.timeout) { + setTimeout(() => { + if (this.request.readyState !== this.request.DONE) { + timedOut = true + responseReadController.abort() + } + }, this.request.timeout) + } this.request.getResponseHeader = new Proxy(this.request.getResponseHeader, { apply: (_, __, args: [name: string]) => { @@ -394,92 +591,6 @@ export class XMLHttpRequestController { }, } ) - - // Update the response getters to resolve against the mocked response. - Object.defineProperties(this.request, { - response: { - enumerable: true, - configurable: false, - get: () => this.response, - }, - responseText: { - enumerable: true, - configurable: false, - get: () => this.responseText, - }, - responseXML: { - enumerable: true, - configurable: false, - get: () => this.responseXML, - }, - }) - - const totalResponseBodyLength = await getBodyByteLength(response.clone()) - - this.logger.info('calculated response body length', totalResponseBodyLength) - - this.trigger('loadstart', this.request, { - loaded: 0, - total: 0, - }) - - this.setReadyState(this.request.HEADERS_RECEIVED) - - /** - * @note The request never transitions to the loading state if the response body is empty. - * @see https://xhr.spec.whatwg.org/#handle-response-end-of-body - */ - if (totalResponseBodyLength > 0) { - this.setReadyState(this.request.LOADING) - } - - const finalizeResponse = () => { - this.logger.info('finalizing the mocked response...') - - this.setReadyState(this.request.DONE) - - this.trigger('load', this.request, { - loaded: this.responseBuffer.byteLength, - total: totalResponseBodyLength, - }) - - this.trigger('loadend', this.request, { - loaded: this.responseBuffer.byteLength, - total: totalResponseBodyLength, - }) - } - - if (response.body) { - this.logger.info('mocked response has body, streaming...') - - const reader = response.body.getReader() - - const readNextResponseBodyChunk = async () => { - const { value, done } = await reader.read() - - if (done) { - this.logger.info('response body stream done!') - finalizeResponse() - return - } - - if (value) { - this.logger.info('read response body chunk:', value) - this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) - - this.trigger('progress', this.request, { - loaded: this.responseBuffer.byteLength, - total: totalResponseBodyLength, - }) - } - - readNextResponseBodyChunk() - } - - readNextResponseBodyChunk() - } else { - finalizeResponse() - } } private responseBufferToText(): string { From 23fb88d135c7efcfc94f202e9f3c669b1c4a2ebc Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 15:47:37 +0100 Subject: [PATCH 123/198] fix(xhr): skip "progress" even on `processResponseEndOfBody` --- .../XMLHttpRequestController.ts | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 0e550f443..d0962574c 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -464,17 +464,27 @@ export class XMLHttpRequestController { const processResponseBodyChunk = (bytesLength: number) => { receivedBytes += bytesLength + /** + * @note Decouple "readyState" change and "readystatechange" event here. + * This is intentional and per specification. + * @see https://xhr.spec.whatwg.org/#the-send()-method (11.9.10.4). + */ if (this.request.readyState === this.request.HEADERS_RECEIVED) { - this.setReadyState(this.request.LOADING) + this.setReadyState(this.request.LOADING, false) } - this.trigger('readystatechange', this.request) + this.trigger('progress', this.request, { loaded: receivedBytes, total: responseBodyLength, }) } + const processResponseBodyError = () => { + response = Response.error() + handleErrors() + } + const processResponseEndOfBody = () => { handleErrors() @@ -485,14 +495,6 @@ export class XMLHttpRequestController { // 3. Let transmitted be xhr’s received bytes’s length. let transmitted = receivedBytes - // 8. If xhr’s synchronous flag is unset, then fire a progress event named progress at xhr with transmitted and length. - if (!this.sync) { - this.trigger('progress', this.request, { - loaded: transmitted, - total: responseBodyLength, - }) - } - // 9. Fire an event named readystatechange at xhr. this.setReadyState(this.request.DONE) // 10. Fire a progress event named load at xhr with transmitted and length. @@ -518,15 +520,19 @@ export class XMLHttpRequestController { break } - const { value, done } = await reader.read() + try { + const { value, done } = await reader.read() - if (done) { - processResponseEndOfBody() - return - } + if (done) { + processResponseEndOfBody() + return + } - processResponseBodyChunk(value.byteLength) - this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) + processResponseBodyChunk(value.byteLength) + this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) + } catch { + processResponseBodyError() + } } } } @@ -754,7 +760,10 @@ export class XMLHttpRequestController { /** * Transitions this request's `readyState` to the given one. */ - private setReadyState(nextReadyState: number): void { + private setReadyState( + nextReadyState: number, + triggerReadyStateChangeEvent = true + ): void { this.logger.info( 'setReadyState: %d -> %d', this.request.readyState, @@ -770,6 +779,10 @@ export class XMLHttpRequestController { this.logger.info('set readyState to: %d', nextReadyState) + if (!triggerReadyStateChangeEvent) { + return + } + if (nextReadyState !== this.request.UNSENT) { this.logger.info('triggering "readystatechange" event...') From d077c5bb80a3357d481cf1b03b4aacfc701071b4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 18:54:34 +0100 Subject: [PATCH 124/198] fix(http): await forwarded request/response events --- src/interceptors/XMLHttpRequest/node.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index d58026584..29801aa11 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -4,6 +4,7 @@ import { applyPatch } from '#/src/utils/apply-patch' import { Interceptor } from '#/src/Interceptor' import { HttpRequestEventMap } from '#/src/glossary' import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { emitAsync } from '#/src/utils/emitAsync' export class XMLHttpRequestInterceptor extends Interceptor { static symbol = Symbol.for('xhr-interceptor') @@ -23,14 +24,14 @@ export class XMLHttpRequestInterceptor extends Interceptor this.subscriptions.push(() => httpInterceptor.dispose()) httpInterceptor - .on('request', (args) => { + .on('request', async (args) => { if (args.initiator instanceof XMLHttpRequest) { - this.emitter.emit('request', args) + await emitAsync(this.emitter, 'request', args) } }) - .on('response', (args) => { + .on('response', async (args) => { if (args.initiator instanceof XMLHttpRequest) { - this.emitter.emit('response', args) + emitAsync(this.emitter, 'response', args) } }) From 960353298783162d14c35310ee05a3f56f1bd81b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 18:55:22 +0100 Subject: [PATCH 125/198] fix(net): add `readyState` guard to pending `_writeGeneric` --- src/interceptors/net/socket-controller.ts | 42 +++++++++++++++-------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 8bd532f92..169abe359 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -350,6 +350,15 @@ export class TcpSocketController extends SocketController { log(this.readyState, 'write:', args) + /** + * @note Buffer the write BEFORE pushing data to the server socket. + * `#push` triggers the 'data' event on the server socket synchronously, + * which may lead to `passthrough()` being called within the same call stack. + * If we buffer after `#push`, passthrough will read an empty `#bufferedWrites` + * and the request data will never be flushed to the real socket. + */ + this.#bufferedWrites.push(args) + // The server socket will NEVER have any "data" listeners attached // becuase the "connection" interceptor event emits on the next tick. if (this.socket.listenerCount('internal:write') === 0) { @@ -368,24 +377,20 @@ export class TcpSocketController extends SocketController { this.#push(data) } - if (typeof callback === 'function') { - log(this.readyState, 'write with callback, executing...', callback) - + /** + * @note Only call the callback if the socket is still in PENDING state. + * If `#push` triggered `passthrough()` synchronously (e.g. when the handler + * decided to pass through the request), the buffered write was already + * flushed to the real socket with the original callback. Calling it again + * here would result in "Callback called multiple times" error. + */ + if ( + typeof callback === 'function' && + this.readyState === SocketController.PENDING + ) { callback() args[3] = function mockNoop() {} } - - /** - * @note Do NOT tap into Node.js internal buffering for three reasons: - * 1. Delaying writes to "connect" is problematic as you cannot tell such writes from - * regular writes after claim/passthrough connects. - * 2. "_pendingData" does NOT accumulate writes. It always points to the last buffered - * chunk so we cannot tell if we're writing a scheduled chunk or not in case multiple - * chunks were buffered. - * 3. Node.js logic here is extremely simple anyway. No harm in buffering writes ourselves - * if that gives us more control. - */ - this.#bufferedWrites.push(args) } } @@ -556,6 +561,13 @@ export class TcpSocketController extends SocketController { this.#passthroughSocket = realSocket } + if (this.#bufferedWrites.length === 0) { + log( + this.readyState, + 'WARNING: passthrough with empty writes buffer! This likely indicates an issue.' + ) + } + /** * Flush any writes during the pending phase to the passthrough socket. * @note These are written directly on the passthrough socket to prevent From e11cf829f9b3ae31e90fee914f895e632c8bfcb1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 19:31:51 +0100 Subject: [PATCH 126/198] fix(xhr): support synchronous requests --- .../XMLHttpRequestController.ts | 166 ++++++++++-------- 1 file changed, 97 insertions(+), 69 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index d0962574c..1797406e8 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -98,12 +98,12 @@ export class XMLHttpRequestController { methodCall: ([methodName, args], invoke) => { switch (methodName) { case 'open': { - const [method, url, sync] = args as [ + const [method, url, async] = args as [ string, string | undefined, boolean | undefined, ] - this.sync = sync ?? false + this.sync = !(async ?? true) if (typeof url === 'undefined') { this.method = 'GET' @@ -298,9 +298,7 @@ export class XMLHttpRequestController { response.statusText ) - define(this.request, 'status', response.status) - define(this.request, 'statusText', response.statusText) - define(this.request, 'responseURL', this.url.href) + FetchResponse.setUrl(this.url.href, response) // Update the response getters to resolve against the mocked response. Object.defineProperties(this.request, { @@ -321,15 +319,19 @@ export class XMLHttpRequestController { }, }) - // 1. Fire a progress event named loadstart at this with 0 and 0. - this.trigger('loadstart', this.request, { loaded: 0, total: 0 }) + if (!this.sync) { + // 1. Fire a progress event named loadstart at this with 0 and 0. + this.trigger('loadstart', this.request, { loaded: 0, total: 0 }) + } // 2. Let requestBodyTransmitted be 0. let requestBodyTransmitted = 0 let uploadComplete = false if (this[kFetchRequest]) { - const requestBodyLength = await getBodyByteLength(this[kFetchRequest]) + const requestBodyLength = await getBodyByteLength( + this[kFetchRequest].clone() + ) // 5. If this’s upload complete flag is unset and this’s upload listener flag is set, then fire a progress event named loadstart at this’s upload object with requestBodyTransmitted and requestBodyLength. if (!uploadComplete) { @@ -383,46 +385,9 @@ export class XMLHttpRequestController { } } - // Follow redirect responses to maintain parity with browser XHR behavior. - // Browsers follow redirects transparently so XHR never sees 3xx responses. - const redirectLocation = response.headers.get('location') - if (redirectLocation && FetchResponse.isRedirectResponse(response.status)) { - const redirectUrl = new URL(redirectLocation, location.href) - const redirectMethod = FetchResponse.isResponseWithBody(response.status) - ? this.method - : 'GET' - - const redirectResult = await until(() => - this.followRedirect(redirectMethod, redirectUrl) - ) - - if (redirectResult.error) { - return - } - - this.url = new URL(redirectResult.data.responseURL) - return this.respondWith(redirectResult.data.response) - } - let timedOut = false const responseReadController = new AbortController() - const handleErrors = () => { - if (timedOut) { - requestErrorSteps( - 'timeout', - new DOMException('The operation timed out.') - ) - } else if (responseReadController.signal.aborted) { - requestErrorSteps( - 'abort', - new DOMException('The operation was aborted.') - ) - } else if (isResponseError(response)) { - requestErrorSteps('error', new TypeError('A network error occurred.')) - } - } - const requestErrorSteps = ( event: keyof XMLHttpRequestEventTargetEventMap, exception?: Error @@ -449,16 +414,65 @@ export class XMLHttpRequestController { } const processResponse = async (response: Response) => { + const handleErrors = () => { + if (timedOut) { + requestErrorSteps( + 'timeout', + new DOMException('The operation timed out.') + ) + } else if (responseReadController.signal.aborted) { + requestErrorSteps( + 'abort', + new DOMException('The operation was aborted.') + ) + } else if (isResponseError(response)) { + requestErrorSteps('error', new TypeError('A network error occurred.')) + } + } + handleErrors() + define(this.request, 'status', response.status) + define(this.request, 'statusText', response.statusText) + + if (!this.request.responseURL) { + define(this.request, 'responseURL', response.url) + } + if (isResponseError(response)) { return } - this.setReadyState(this.request.HEADERS_RECEIVED) - let responseBodyLength = response.body != null ? await getBodyByteLength(response.clone()) : 0 + + /** + * @note The specification deviates in handling synchronous requests earlier, + * but it's easier for us to keep the logic around consistent by handling them here. + */ + if (this.sync) { + this.responseBuffer = await response + .arrayBuffer() + .then((arrayBuffer) => { + return new Uint8Array(arrayBuffer) + }) + + this.setReadyState(this.request.DONE) + + this.trigger('load', this.request, { + loaded: responseBodyLength, + total: responseBodyLength, + }) + this.trigger('loadend', this.request, { + loaded: responseBodyLength, + total: responseBodyLength, + }) + + return + } + + this.setReadyState(this.request.HEADERS_RECEIVED) + let receivedBytes = 0 const processResponseBodyChunk = (bytesLength: number) => { @@ -481,8 +495,7 @@ export class XMLHttpRequestController { } const processResponseBodyError = () => { - response = Response.error() - handleErrors() + requestErrorSteps('error', new TypeError('A network error occurred.')) } const processResponseEndOfBody = () => { @@ -537,7 +550,17 @@ export class XMLHttpRequestController { } } - processResponse(response) + // Redirects are followed as a part of the fetch controller. Since we don't have one, + // retrieve the final response and then continue with processing it instead of the mocked one. + const { error: redirectError, data: finalResponse } = await until(() => { + return this.followRedirects(response) + }) + + if (redirectError) { + return + } + + processResponse(finalResponse) // 12.1, 12.2. if (this.request.timeout) { @@ -555,12 +578,10 @@ export class XMLHttpRequestController { if (this.request.readyState < this.request.HEADERS_RECEIVED) { this.logger.info('headers not received yet, returning null') - - // Headers not received yet, nothing to return. return null } - const headerValue = response.headers.get(args[0]) + const headerValue = finalResponse.headers.get(args[0]) this.logger.info( 'resolved response header "%s" to', args[0], @@ -579,12 +600,10 @@ export class XMLHttpRequestController { if (this.request.readyState < this.request.HEADERS_RECEIVED) { this.logger.info('headers not received yet, returning empty string') - - // Headers not received yet, nothing to return. return '' } - const headersList = Array.from(response.headers) + const headersList = Array.from(finalResponse.headers) const allHeaders = headersList .map(([headerName, headerValue]) => { return `${headerName}: ${headerValue}` @@ -711,27 +730,34 @@ export class XMLHttpRequestController { return null } - private followRedirect( - method: string, - url: URL - ): Promise<{ response: Response; responseURL: string }> { + private async followRedirects(response: Response): Promise { + const redirectLocation = response.headers.get('location') + + if ( + !redirectLocation || + !FetchResponse.isRedirectResponse(response.status) + ) { + return response + } + this.redirectCount++ if (this.redirectCount > MAX_REDIRECTS) { - const reason = new Error('Too many redirects') - this.errorWith(reason) - return Promise.reject(reason) + throw new Error('Too many redirects') } - return new Promise((resolve, reject) => { + const redirectUrl = new URL(redirectLocation, location.href) + const redirectMethod = FetchResponse.isResponseWithBody(response.status) + ? this.method + : 'GET' + + const redirectResponse = await new Promise((resolve, reject) => { const request = new XMLHttpRequest() request.responseType = this.request.responseType request.addEventListener('load', () => { - resolve({ - response: createResponse(request, request.response), - responseURL: request.responseURL, - }) + this.url = new URL(request.responseURL) + resolve(createResponse(request, request.response)) }) request.addEventListener('error', () => { @@ -739,9 +765,11 @@ export class XMLHttpRequestController { reject(new Error('Redirect request failed')) }) - request.open(method, url.href) + request.open(redirectMethod, redirectUrl.href) request.send() }) + + return this.followRedirects(redirectResponse) } public errorWith(error?: Error): void { From f334a1cb726c582248ab668f765502208e6a3c23 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 6 Mar 2026 19:39:46 +0100 Subject: [PATCH 127/198] chore: huge improvements in xhr testing --- XMLHttpRequest/package.json | 20 ++- package.json | 18 ++- pnpm-lock.yaml | 25 ++-- src/RemoteHttpInterceptor.ts | 2 +- .../XMLHttpRequest/XMLHttpRequestProxy.ts | 8 +- .../XMLHttpRequest/{index.ts => web.ts} | 0 src/presets/browser.ts | 2 +- test/helpers.ts | 1 + ...test.ts => xhr-error-with.neutral.test.ts} | 42 ++++--- .../xhr-response-array-buffer.neutral.test.ts | 117 ++++++++++++++++-- .../xhr-response-blob.neutral.test.ts | 112 +++++++++++++++-- .../xhr-response-empty.neutral.test.ts | 3 + .../xhr-response-error.neutral.test.ts | 72 +++++++++-- .../xhr-response-json.neutral.test.ts | 90 ++++++++++++-- .../xhr-response-patching.neutral.test.ts | 37 +++++- .../xhr-response-redirect.neutral.test.ts | 49 +++++++- .../xhr-response-text.neutral.test.ts | 92 ++++++++++++-- .../response/xhr-synchronous.neutral.test.ts | 65 +++++++++- .../xhr-unhandled-exception.neutral.test.ts | 24 +++- test/setup/helpers-neutral.ts | 32 +++-- tsdown.config.mts | 4 +- vitest.setup.ts | 4 + 22 files changed, 699 insertions(+), 120 deletions(-) rename src/interceptors/XMLHttpRequest/{index.ts => web.ts} (100%) rename test/modules/XMLHttpRequest/response/{xhr-error-with.test.ts => xhr-error-with.neutral.test.ts} (66%) diff --git a/XMLHttpRequest/package.json b/XMLHttpRequest/package.json index bc0cbf043..b11063132 100644 --- a/XMLHttpRequest/package.json +++ b/XMLHttpRequest/package.json @@ -1,12 +1,20 @@ { - "main": "../lib/node/interceptors/XMLHttpRequest/index.cjs", - "module": "../lib/node/interceptors/XMLHttpRequest/index.mjs", - "browser": "../lib/browser/interceptors/XMLHttpRequest/index.mjs", + "main": "../lib/node/interceptors/XMLHttpRequest/node.cjs", + "module": "../lib/node/interceptors/XMLHttpRequest/node.mjs", + "browser": "../lib/browser/interceptors/XMLHttpRequest/web.mjs", "exports": { ".": { - "browser": "./../lib/browser/interceptors/XMLHttpRequest/index.mjs", - "import": "./../lib/node/interceptors/XMLHttpRequest/index.mjs", - "default": "./../lib/node/interceptors/XMLHttpRequest/index.cjs" + "browser": "./../lib/browser/interceptors/XMLHttpRequest/web.mjs", + "import": "./../lib/node/interceptors/XMLHttpRequest/node.mjs", + "default": "./../lib/node/interceptors/XMLHttpRequest/node.cjs" + }, + "./node": { + "import": "./../lib/node/interceptors/XMLHttpRequest/node.mjs", + "default": "./../lib/node/interceptors/XMLHttpRequest/node.cjs" + }, + "./web": { + "import": "./../lib/browser/interceptors/XMLHttpRequest/web.mjs", + "default": "./../lib/browser/interceptors/XMLHttpRequest/web.cjs" } } } diff --git a/package.json b/package.json index 620c7038b..f04a5dcb2 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,18 @@ "default": "./lib/node/interceptors/ClientRequest/index.cjs" }, "./XMLHttpRequest": { - "browser": "./lib/browser/interceptors/XMLHttpRequest/index.mjs", - "require": "./lib/node/interceptors/XMLHttpRequest/index.cjs", - "import": "./lib/node/interceptors/XMLHttpRequest/index.mjs", - "default": "./lib/node/interceptors/XMLHttpRequest/index.cjs" + "browser": "./lib/browser/interceptors/XMLHttpRequest/web.mjs", + "require": "./lib/node/interceptors/XMLHttpRequest/node.cjs", + "import": "./lib/node/interceptors/XMLHttpRequest/node.mjs", + "default": "./lib/node/interceptors/XMLHttpRequest/node.cjs" + }, + "./XMLHttpRequest/node": { + "import": "./lib/node/interceptors/XMLHttpRequest/node.mjs", + "default": "./lib/node/interceptors/XMLHttpRequest/node.cjs" + }, + "./XMLHttpRequest/web": { + "import": "./lib/browser/interceptors/XMLHttpRequest/web.mjs", + "default": "./lib/browser/interceptors/XMLHttpRequest/web.cjs" }, "./fetch": { "browser": "./lib/browser/interceptors/fetch/index.mjs", @@ -80,6 +88,7 @@ "start": "tsdown --watch", "test": "vitest", "test:nock": "./test/third-party/nock.sh", + "lint": "publint", "build": "tsdown", "prepare": "pnpm simple-git-hooks init", "release": "release publish" @@ -129,6 +138,7 @@ "https-proxy-agent": "^7.0.6", "node-fetch": "3.3.2", "playwright": "^1.58.2", + "publint": "^0.3.18", "simple-git-hooks": "^2.7.0", "socket.io": "^4.7.4", "socket.io-client": "^4.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8823a8883..40fed1334 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: playwright: specifier: ^1.58.2 version: 1.58.2 + publint: + specifier: ^0.3.18 + version: 0.3.18 simple-git-hooks: specifier: ^2.7.0 version: 2.11.1 @@ -149,7 +152,7 @@ importers: version: 7.0.0 tsdown: specifier: ^0.18.1 - version: 0.18.1(publint@0.3.16)(typescript@5.8.2) + version: 0.18.1(publint@0.3.18)(typescript@5.8.2) typescript: specifier: ^5.8.2 version: 5.8.2 @@ -637,8 +640,8 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@publint/pack@0.1.2': - resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} + '@publint/pack@0.1.4': + resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} '@quansync/fs@1.0.0': @@ -2554,8 +2557,8 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - publint@0.3.16: - resolution: {integrity: sha512-MFqyfRLAExPVZdTQFwkAQELzA8idyXzROVOytg6nEJ/GEypXBUmMGrVaID8cTuzRS1U5L8yTOdOJtMXgFUJAeA==} + publint@0.3.18: + resolution: {integrity: sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog==} engines: {node: '>=18'} hasBin: true @@ -3847,7 +3850,7 @@ snapshots: outvariant: 1.4.3 pino: 7.11.0 pino-pretty: 7.6.1 - publint: 0.3.16 + publint: 0.3.18 rc: 1.2.8 registry-auth-token: 5.1.0 semver: 7.7.3 @@ -3874,7 +3877,7 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@publint/pack@0.1.2': {} + '@publint/pack@0.1.4': {} '@quansync/fs@1.0.0': dependencies: @@ -5793,9 +5796,9 @@ snapshots: proxy-from-env@1.1.0: {} - publint@0.3.16: + publint@0.3.18: dependencies: - '@publint/pack': 0.1.2 + '@publint/pack': 0.1.4 package-manager-detector: 1.6.0 picocolors: 1.1.1 sade: 1.8.1 @@ -6306,7 +6309,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.18.1(publint@0.3.16)(typescript@5.8.2): + tsdown@0.18.1(publint@0.3.18)(typescript@5.8.2): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -6325,7 +6328,7 @@ snapshots: unconfig-core: 7.4.2 unrun: 0.2.20 optionalDependencies: - publint: 0.3.16 + publint: 0.3.18 typescript: 5.8.2 transitivePeerDependencies: - '@ts-macro/tsc' diff --git a/src/RemoteHttpInterceptor.ts b/src/RemoteHttpInterceptor.ts index b40c09a2a..f5252e9cd 100644 --- a/src/RemoteHttpInterceptor.ts +++ b/src/RemoteHttpInterceptor.ts @@ -3,7 +3,7 @@ import { HttpRequestEventMap } from './glossary' import { Interceptor } from './Interceptor' import { BatchInterceptor } from './BatchInterceptor' import { ClientRequestInterceptor } from './interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest/web' import { FetchInterceptor } from './interceptors/fetch' import { handleRequest } from './utils/handleRequest' import { RequestController } from './RequestController' diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts index 03f8de31e..e13cad3aa 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts @@ -1,9 +1,8 @@ import type { Logger } from '@open-draft/logger' -import { XMLHttpRequestEmitter } from '.' +import { XMLHttpRequestEmitter } from './web' import { RequestController } from '../../RequestController' import { XMLHttpRequestController } from './XMLHttpRequestController' import { handleRequest } from '../../utils/handleRequest' -import { isResponseError } from '../../utils/responseUtils' export interface XMLHttpRequestProxyOptions { emitter: XMLHttpRequestEmitter @@ -60,11 +59,6 @@ export function createXMLHttpRequestProxy({ ) }, respondWith: async (response) => { - if (isResponseError(response)) { - this.errorWith(new TypeError('Network error')) - return - } - await this.respondWith(response) }, errorWith: (reason) => { diff --git a/src/interceptors/XMLHttpRequest/index.ts b/src/interceptors/XMLHttpRequest/web.ts similarity index 100% rename from src/interceptors/XMLHttpRequest/index.ts rename to src/interceptors/XMLHttpRequest/web.ts diff --git a/src/presets/browser.ts b/src/presets/browser.ts index c609adc5e..d727924b7 100644 --- a/src/presets/browser.ts +++ b/src/presets/browser.ts @@ -1,5 +1,5 @@ import { FetchInterceptor } from '../interceptors/fetch' -import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest/web' /** * The default preset provisions the interception of requests diff --git a/test/helpers.ts b/test/helpers.ts index 78f4f4cd1..eaaafcf55 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -169,6 +169,7 @@ export async function toWebResponse( export const useCors: RequestHandler = (_req, res, next) => { res.set({ 'access-control-allow-origin': '*', + 'access-control-allow-headers': '*', }) return next() } diff --git a/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts b/test/modules/XMLHttpRequest/response/xhr-error-with.neutral.test.ts similarity index 66% rename from test/modules/XMLHttpRequest/response/xhr-error-with.test.ts rename to test/modules/XMLHttpRequest/response/xhr-error-with.neutral.test.ts index e16c4d8cf..c79577c16 100644 --- a/test/modules/XMLHttpRequest/response/xhr-error-with.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-error-with.neutral.test.ts @@ -19,50 +19,56 @@ afterAll(() => { interceptor.dispose() }) -it('treats "controller.errorWith()" as a request error for an HTTP request', async () => { +it('treats "controller.errorWith()" as a request error for an HTTP request', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { controller.errorWith(new Error('Network failure')) }) const request = new XMLHttpRequest() const { events } = spyOnXMLHttpRequest(request) - request.open('GET', 'http://any.host.here/irrelevant') request.send() await waitForXMLHttpRequest(request) expect.soft(request.status).toBe(0) - expect.soft(request.statusText).toBe('') expect.soft(request.response).toBe('') - expect.soft(request.readyState).toBe(request.DONE) - expect.soft(events).toEqual([ - ['readystatechange', 1], - ['readystatechange', 4], - ['error', 4, { loaded: 0, total: 0 }], - ]) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } }) -it.only('treats "controller.errorWith()" as a request error for an HTTPS request', async () => { +it('treats "controller.errorWith()" as a request error for an HTTPS request', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { controller.errorWith(new Error('Network failure')) }) const request = new XMLHttpRequest() const { events } = spyOnXMLHttpRequest(request) - request.open('GET', 'https://any.host.here/irrelevant') request.send() await waitForXMLHttpRequest(request) expect.soft(request.status).toBe(0) - expect.soft(request.statusText).toBe('') expect.soft(request.response).toBe('') - expect.soft(request.readyState).toBe(request.DONE) - expect.soft(events).toEqual([ - // ['readystatechange', 1], - // ['readystatechange', 4], - ['error', 4, { loaded: 0, total: 0 }], - ]) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts index 0d46d95be..032877e2a 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts @@ -1,7 +1,12 @@ // @vitest-environment happy-dom -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() beforeAll(() => { @@ -16,15 +21,70 @@ afterAll(() => { interceptor.dispose() }) -it('responds with a mocked ArrayBuffer response to an HTTP request', async () => { +/** + * @note Use this utility because in Node.js (JSDOM), "request.response" + * becomes Uint8Array while in the browser it's correctly ArrayBuffer. + */ +function toArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer { + if (value instanceof Uint8Array) { + return value.buffer + } + + return value +} + +it('intercepts a bypassed request with an ArrayBuffer response', async ({ + task, +}) => { const buffer = new TextEncoder().encode('hello world') + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'arraybuffer' + request.open('POST', server.http.url('/arraybuffer')) + request.setRequestHeader('content-type', 'application/octet-stream') + request.send(buffer) + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/octet-stream') + expect.soft(toArrayBuffer(request.response)).toEqual(buffer.buffer) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +it('responds with a mocked ArrayBuffer response to an HTTP request', async ({ + task, +}) => { + const buffer = new TextEncoder().encode('hello world') + interceptor.on('request', ({ controller }) => { controller.respondWith( - new Response(buffer, { headers: { 'content-type': 'text/plain' } }) + new Response(buffer, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'application/octet-stream', + }, + }) ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.responseType = 'arraybuffer' request.open('GET', 'http://any.host.here/irrelevant') request.send() @@ -32,19 +92,43 @@ it('responds with a mocked ArrayBuffer response to an HTTP request', async () => await waitForXMLHttpRequest(request) expect.soft(request.status).toBe(200) - expect.soft(request.getAllResponseHeaders()).toBe('content-type: text/plain') - expect.soft(request.response).toEqual(buffer.buffer) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/octet-stream') + expect.soft(toArrayBuffer(request.response)).toEqual(buffer.buffer) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } }) -it('responds with a mocked ArrayBuffer response to an HTTPS request', async () => { +it('responds with a mocked ArrayBuffer response to an HTTPS request', async ({ + task, +}) => { const buffer = new TextEncoder().encode('hello world') + interceptor.on('request', ({ controller }) => { controller.respondWith( - new Response(buffer, { headers: { 'content-type': 'text/plain' } }) + new Response(buffer, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'application/octet-stream', + }, + }) ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.responseType = 'arraybuffer' request.open('GET', 'https://any.host.here/irrelevant') request.send() @@ -52,6 +136,21 @@ it('responds with a mocked ArrayBuffer response to an HTTPS request', async () = await waitForXMLHttpRequest(request) expect.soft(request.status).toBe(200) - expect.soft(request.getAllResponseHeaders()).toBe('content-type: text/plain') - expect.soft(request.response).toEqual(buffer.buffer) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/octet-stream') + expect.soft(toArrayBuffer(request.response)).toEqual(buffer.buffer) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts index 0238010d0..1a617a523 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts @@ -1,5 +1,8 @@ // @vitest-environment happy-dom -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' @@ -18,26 +21,63 @@ afterAll(() => { interceptor.dispose() }) -it('intercepts a bypassed request with a blob response', async () => { +it('intercepts a bypassed request with a blob response', async ({ task }) => { + const blob = new Blob(['hello world'], { type: 'text/plain' }) + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.responseType = 'blob' request.open('POST', server.http.url('/blob')) - request.send(new Blob(['hello world'])) + request.send(blob) await waitForXMLHttpRequest(request) - expect(request.response).toEqual(new Blob(['hello world'])) + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + /** + * @note Strict equality won't work here because Undici appends "charset=utf-8" + * to the response Blob. The browser, however, does not do that. + */ + expect(request.response).instanceOf(Blob) + await expect + .soft(request.response.arrayBuffer()) + .resolves.toEqual(await blob.arrayBuffer()) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } }) -it('responds with a mocked Blob response to an HTTP request', async () => { +it('responds with a mocked Blob response to an HTTP request', async ({ + task, +}) => { const blob = new Blob(['hello world'], { type: 'text/plain' }) + interceptor.on('request', ({ controller }) => { - controller.respondWith(new Response(blob)) + controller.respondWith( + new Response(blob, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.responseType = 'blob' - request.open('GET', 'http://any.host.here/irrelevant') + request.open('GET', server.http.url('/blob')) request.send() await waitForXMLHttpRequest(request) @@ -46,16 +86,46 @@ it('responds with a mocked Blob response to an HTTP request', async () => { expect .soft(request.getAllResponseHeaders().toLowerCase()) .toContain('content-type: text/plain') - expect.soft(request.response).toEqual(blob) + /** + * @note Strict equality won't work here because Undici appends "charset=utf-8" + * to the response Blob. The browser, however, does not do that. + */ + expect(request.response).instanceOf(Blob) + await expect + .soft(request.response.arrayBuffer()) + .resolves.toEqual(await blob.arrayBuffer()) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } }) -it('responds with a mocked Blob response to an HTTP request', async () => { +it('responds with a mocked Blob response to an HTTP request', async ({ + task, +}) => { const blob = new Blob(['hello world'], { type: 'text/plain' }) + interceptor.on('request', ({ controller }) => { - controller.respondWith(new Response(blob)) + controller.respondWith( + new Response(blob, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.responseType = 'blob' request.open('GET', 'https://any.host.here/irrelevant') request.send() @@ -66,5 +136,25 @@ it('responds with a mocked Blob response to an HTTP request', async () => { expect .soft(request.getAllResponseHeaders().toLowerCase()) .toContain('content-type: text/plain') - expect.soft(request.response).toEqual(blob) + /** + * @note Strict equality won't work here because Undici appends "charset=utf-8" + * to the response Blob. The browser, however, does not do that. + */ + expect(request.response).instanceOf(Blob) + await expect + .soft(request.response.arrayBuffer()) + .resolves.toEqual(await blob.arrayBuffer()) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts index 22d621674..569e6582f 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts @@ -38,6 +38,7 @@ it('intercepts a bypassed request with an empty response', async ({ task }) => { ['loadstart', 1, { loaded: 0, total: 0 }], ['readystatechange', 2], ['readystatechange', 4], + ['load', 4, { loaded: 0, total: 0 }], ['loadend', 4, { loaded: 0, total: 0 }], ]) } @@ -79,6 +80,7 @@ it('responds with an empty mocked response to an HTTP request', async ({ ['loadstart', 1, { loaded: 0, total: 0 }], ['readystatechange', 2], ['readystatechange', 4], + ['load', 4, { loaded: 0, total: 0 }], ['loadend', 4, { loaded: 0, total: 0 }], ]) } @@ -117,6 +119,7 @@ it('responds with an empty mocked response to an HTTPS request', async ({ ['loadstart', 1, { loaded: 0, total: 0 }], ['readystatechange', 2], ['readystatechange', 4], + ['load', 4, { loaded: 0, total: 0 }], ['loadend', 4, { loaded: 0, total: 0 }], ]) } diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts index cefe10be0..18a092355 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts @@ -1,10 +1,15 @@ // @vitest-environment happy-dom -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() -beforeAll(async () => { +beforeAll(() => { interceptor.apply() }) @@ -12,19 +17,45 @@ afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() }) -it('treats Response.error() as a request error for an HTTP request', async () => { +it('intercepts a bypassed request with a network error response', async ({ + task, +}) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/network-error')) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.statusText).toBe('') + expect.soft(request.response).toBe('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) + +it('treats Response.error() as a request error for an HTTP request', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { controller.respondWith(Response.error()) }) - const errorListener = vi.fn() const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', 'http://any.host.here/irrelevant') - request.addEventListener('error', errorListener) request.send() await waitForXMLHttpRequest(request) @@ -32,18 +63,28 @@ it('treats Response.error() as a request error for an HTTP request', async () => expect.soft(request.status).toBe(0) expect.soft(request.statusText).toBe('') expect.soft(request.response).toBe('') - expect.soft(errorListener).toHaveBeenCalledTimes(1) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } }) -it('treats Response.error() as a request error for an HTTPS request', async () => { +it('treats Response.error() as a request error for an HTTPS request', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { controller.respondWith(Response.error()) }) - const errorListener = vi.fn() const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', 'https://any.host.here/irrelevant') - request.addEventListener('error', errorListener) request.send() await waitForXMLHttpRequest(request) @@ -51,5 +92,14 @@ it('treats Response.error() as a request error for an HTTPS request', async () = expect.soft(request.status).toBe(0) expect.soft(request.statusText).toBe('') expect.soft(request.response).toBe('') - expect.soft(errorListener).toHaveBeenCalledTimes(1) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts index 902cdf092..229608f3a 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts @@ -1,5 +1,8 @@ // @vitest-environment happy-dom -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' @@ -18,23 +21,54 @@ afterAll(() => { interceptor.dispose() }) -it('intercepts a bypassed request with a JSON response', async () => { +it('intercepts a bypassed request with a JSON response', async ({ task }) => { const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.responseType = 'json' request.open('POST', server.http.url('/empty')) + request.setRequestHeader('content-type', 'application/json') request.send(JSON.stringify({ name: 'John Maverick' })) await waitForXMLHttpRequest(request) - expect(request.response).toEqual({ name: 'John Maverick' }) + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/json') + expect.soft(request.response).toEqual({ name: 'John Maverick' }) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 24, total: 24 }], + ['readystatechange', 4], + ['load', 4, { loaded: 24, total: 24 }], + ['loadend', 4, { loaded: 24, total: 24 }], + ]) + } }) -it('responds with a mocked text response to an HTTP request', async () => { +it('responds with a mocked text response to an HTTP request', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { - controller.respondWith(Response.json({ name: 'John Maverick' })) + controller.respondWith( + Response.json( + { name: 'John Maverick' }, + { + headers: { + 'access-control-allow-origin': '*', + }, + } + ) + ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.responseType = 'json' request.open('GET', 'http://any.host.here/irrelevant') request.send() @@ -44,16 +78,41 @@ it('responds with a mocked text response to an HTTP request', async () => { expect.soft(request.status).toBe(200) expect .soft(request.getAllResponseHeaders().toLowerCase()) - .toBe('content-type: application/json') + .toContain('content-type: application/json') expect.soft(request.response).toEqual({ name: 'John Maverick' }) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 24, total: 24 }], + ['readystatechange', 4], + ['load', 4, { loaded: 24, total: 24 }], + ['loadend', 4, { loaded: 24, total: 24 }], + ]) + } }) -it('responds with a mocked text response to an HTTPS request', async () => { +it('responds with a mocked text response to an HTTPS request', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { - controller.respondWith(Response.json({ name: 'John Maverick' })) + controller.respondWith( + Response.json( + { name: 'John Maverick' }, + { + headers: { + 'access-control-allow-origin': '*', + }, + } + ) + ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.responseType = 'json' request.open('GET', 'https://any.host.here/irrelevant') request.send() @@ -63,6 +122,19 @@ it('responds with a mocked text response to an HTTPS request', async () => { expect.soft(request.status).toBe(200) expect .soft(request.getAllResponseHeaders().toLowerCase()) - .toBe('content-type: application/json') + .toContain('content-type: application/json') expect.soft(request.response).toEqual({ name: 'John Maverick' }) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 24, total: 24 }], + ['readystatechange', 4], + ['load', 4, { loaded: 24, total: 24 }], + ['loadend', 4, { loaded: 24, total: 24 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts index 300c567cf..7b06a6ae7 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts @@ -1,10 +1,12 @@ // @vitest-environment happy-dom -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { getTestServer } from '#/test/setup/vitest' const server = getTestServer() - const interceptor = new XMLHttpRequestInterceptor() beforeAll(() => { @@ -19,7 +21,7 @@ afterAll(() => { interceptor.dispose() }) -it('patches the original XMLHttpRequest response', async () => { +it.only('patches the original XMLHttpRequest response', async ({ task }) => { interceptor.on('request', async ({ request, controller }) => { const url = new URL(request.url) @@ -27,10 +29,22 @@ it('patches the original XMLHttpRequest response', async () => { return controller.passthrough() } + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + const originalRequest = new XMLHttpRequest() url.searchParams.set('type', 'passthrough') originalRequest.open(request.method, url.href) originalRequest.send(await request.text()) + originalRequest.onerror = () => console.log('ERROR') + originalRequest.onabort = () => console.trace('ABORT') await waitForXMLHttpRequest(originalRequest) controller.respondWith( @@ -39,11 +53,26 @@ it('patches the original XMLHttpRequest response', async () => { }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('POST', server.http.url('/')) request.send('payload') await waitForXMLHttpRequest(request) expect.soft(request.status).toBe(200) - expect.soft(request.responseText).toBe('payload-patched') + expect.soft(request.response).toBe('payload-patched') + expect.soft(request.responseURL).toBe(server.http.url('/').href) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 15, total: 15 }], + ['readystatechange', 4], + ['load', 4, { loaded: 15, total: 15 }], + ['loadend', 4, { loaded: 15, total: 15 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts index 386b61e2c..680171034 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts @@ -1,5 +1,8 @@ // @vitest-environment happy-dom -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' @@ -19,8 +22,11 @@ afterAll(() => { interceptor.dispose() }) -it('intercepts a bypassed request with a redirect response', async () => { +it('intercepts a bypassed request with a redirect response', async ({ + task, +}) => { const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', server.http.url('/redirect')) request.send() @@ -34,10 +40,33 @@ it('intercepts a bypassed request with a redirect response', async () => { expect .soft(request.responseURL) .toBe(server.http.url('/redirect/destination').href) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 16, total: 16 }], + ['readystatechange', 4], + ['load', 4, { loaded: 16, total: 16 }], + ['loadend', 4, { loaded: 16, total: 16 }], + ]) + } }) -it('responds with a mocked redirect response', async () => { +it('responds with a mocked redirect response', async ({ task }) => { interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + if (request.url.endsWith('/original')) { return controller.respondWith( new Response(null, { @@ -53,6 +82,7 @@ it('responds with a mocked redirect response', async () => { }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', 'http://any.host.here/original') request.send() @@ -64,4 +94,17 @@ it('responds with a mocked redirect response', async () => { .toBe('text/plain;charset=UTF-8') expect.soft(request.response).toBe('destination-body') expect.soft(request.responseURL).toBe('http://any.host.here/destination') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 16, total: 16 }], + ['readystatechange', 4], + ['load', 4, { loaded: 16, total: 16 }], + ['loadend', 4, { loaded: 16, total: 16 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts index 858ac0942..ea35a3246 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts @@ -1,7 +1,12 @@ // @vitest-environment happy-dom -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() beforeAll(() => { @@ -16,12 +21,50 @@ afterAll(() => { interceptor.dispose() }) -it('responds with a mocked text response to an HTTP request', async () => { +it('intercepts a bypassed request with a text response', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/blob')) + request.send('hello world') + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + expect(request.response).toBe('hello world') + expect(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +it('responds with a mocked text response to an HTTP request', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { - controller.respondWith(new Response('hello world')) + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', 'http://any.host.here/irrelevant') request.send() @@ -30,17 +73,39 @@ it('responds with a mocked text response to an HTTP request', async () => { expect.soft(request.status).toBe(200) expect .soft(request.getAllResponseHeaders().toLowerCase()) - .toBe('content-type: text/plain;charset=utf-8') + .toContain('content-type: text/plain;charset=utf-8') expect.soft(request.response).toBe('hello world') expect.soft(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } }) -it('responds with a mocked text response to an HTTPS request', async () => { +it('responds with a mocked text response to an HTTPS request', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { - controller.respondWith(new Response('hello world')) + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', 'https://any.host.here/irrelevant') request.send() @@ -49,7 +114,20 @@ it('responds with a mocked text response to an HTTPS request', async () => { expect.soft(request.status).toBe(200) expect .soft(request.getAllResponseHeaders().toLowerCase()) - .toBe('content-type: text/plain;charset=utf-8') + .toContain('content-type: text/plain;charset=utf-8') expect.soft(request.response).toBe('hello world') expect.soft(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts index 76f82df7b..169d07b34 100644 --- a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts @@ -1,10 +1,12 @@ // @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' const server = getTestServer() - const interceptor = new XMLHttpRequestInterceptor() beforeAll(() => { @@ -19,21 +21,72 @@ afterAll(() => { interceptor.dispose() }) -it('mocks response to a synchronous XMLHttpRequest', async () => { +it('intercepts a synchronous bypassed request', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/'), false) + request.setRequestHeader('content-type', 'text/plain') + request.send('hello world') + + /** + * @note In a regular synchronous XMLHttpRequest, you don't have to + * await anything as ".send()" will block the thread and consume the + * response body synchronously. We cannot do that since ".respondWith()" + * is async by design. + */ + await waitForXMLHttpRequest(request, false) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + expect.soft(request.response).toBe('hello world') + expect.soft(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +/** + * @fixme HappyDOM's sync request is invisible to the HttpRequestInterceptor? + */ +it('mocks response to a synchronous XMLHttpRequest', async ({ task }) => { interceptor.on('request', ({ controller }) => { - controller.respondWith(new Response('hello world')) + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) }) const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) request.open('GET', server.http.url('/'), false) request.send() - await waitForXMLHttpRequest(request) + await waitForXMLHttpRequest(request, false) expect.soft(request.status).toBe(200) expect .soft(request.getAllResponseHeaders().toLowerCase()) - .toBe('content-type: text/plain;charset=utf-8') + .toContain('content-type: text/plain') expect.soft(request.response).toBe('hello world') expect.soft(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts index a2f0b2a84..6532daeef 100644 --- a/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts @@ -17,7 +17,17 @@ afterAll(() => { }) it('treats an unhandled exception as a 500 response for an HTTP request', async () => { - interceptor.on('request', () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + throw new Error('Custom error message') }) @@ -38,7 +48,17 @@ it('treats an unhandled exception as a 500 response for an HTTP request', async }) it('treats an unhandled exception as a 500 response for an HTTPS request', async () => { - interceptor.on('request', () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + throw new Error('Custom error message') }) diff --git a/test/setup/helpers-neutral.ts b/test/setup/helpers-neutral.ts index 5a906092e..25f36e2d8 100644 --- a/test/setup/helpers-neutral.ts +++ b/test/setup/helpers-neutral.ts @@ -1,12 +1,30 @@ import { DeferredPromise } from '@open-draft/deferred-promise' -export function waitForXMLHttpRequest(request: XMLHttpRequest): Promise { +export function waitForXMLHttpRequest( + request: XMLHttpRequest, + async = true +): Promise { const pendingResponse = new DeferredPromise() - request.addEventListener('loadend', () => pendingResponse.resolve()) - request.addEventListener('abort', () => - pendingResponse.reject(new Error('Request aborted')) - ) + if (async) { + request.addEventListener('loadend', () => { + pendingResponse.resolve() + }) + + request.addEventListener('abort', () => { + pendingResponse.reject(new Error('Request aborted')) + }) + } else { + if (request.readyState === XMLHttpRequest.DONE) { + pendingResponse.resolve() + } else { + request.addEventListener('loadend', () => { + if (request.readyState === XMLHttpRequest.DONE) { + pendingResponse.resolve() + } + }) + } + } return pendingResponse } @@ -16,8 +34,6 @@ export function spyOnXMLHttpRequest(request: XMLHttpRequest) { const addEvent = (name: string) => { return (event: unknown) => { - console.log('EVENT:', name, event, request.readyState) - if (event instanceof ProgressEvent) { events.push([ name, @@ -34,7 +50,7 @@ export function spyOnXMLHttpRequest(request: XMLHttpRequest) { request.onprogress = addEvent('progress') request.onloadstart = addEvent('loadstart') request.onload = addEvent('load') - request.onload = addEvent('loadend') + request.onloadend = addEvent('loadend') request.ontimeout = addEvent('timeout') request.onerror = addEvent('error') request.onabort = addEvent('abort') diff --git a/tsdown.config.mts b/tsdown.config.mts index 570ed63c0..7ae87faae 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -8,7 +8,7 @@ export default defineConfig([ './src/presets/node.ts', './src/RemoteHttpInterceptor.ts', './src/interceptors/ClientRequest/index.ts', - './src/interceptors/XMLHttpRequest/index.ts', + './src/interceptors/XMLHttpRequest/node.ts', './src/interceptors/fetch/index.ts', ], external: ['_http_common'], @@ -29,7 +29,7 @@ export default defineConfig([ entry: [ './src/index.ts', './src/presets/browser.ts', - './src/interceptors/XMLHttpRequest/index.ts', + './src/interceptors/XMLHttpRequest/web.ts', './src/interceptors/fetch/index.ts', './src/interceptors/WebSocket/index.ts', ], diff --git a/vitest.setup.ts b/vitest.setup.ts index a2b983194..f284ea897 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -19,6 +19,10 @@ const server = new HttpServer((app) => { res.status(200).send('destination-body') }) + app.get('/network-error', (req, res) => { + res.destroy() + }) + app.all('*', (req, res) => { res.status(200).set(req.headers) From db45069761d5021f02f0a9047dbabd2e4b5fb34f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 7 Mar 2026 14:55:34 +0100 Subject: [PATCH 128/198] fix(xhr): implement chunked response handling --- .../XMLHttpRequestController.ts | 40 ++-- .../xhr-response-progress.neutral.test.ts | 184 ++++++++++++++++++ .../response/xhr-upload.neutral.test.ts | 99 ++++++++++ 3 files changed, 310 insertions(+), 13 deletions(-) create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 1797406e8..12ee0f14b 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -1,4 +1,5 @@ import { until } from '@open-draft/until' +import { debounce } from 'es-toolkit' import { invariant } from 'outvariant' import type { Logger } from '@open-draft/logger' import { concatArrayBuffer } from './utils/concatArrayBuffer' @@ -344,10 +345,12 @@ export class XMLHttpRequestController { const processRequestBodyChunkLength = (bytesLength: number) => { requestBodyTransmitted += bytesLength - this.trigger('progress', this.request.upload, { - loaded: requestBodyTransmitted, - total: requestBodyLength, - }) + if (requestBodyTransmitted < requestBodyLength) { + this.trigger('progress', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + } } const processRequestEndOfBody = () => { @@ -443,8 +446,13 @@ export class XMLHttpRequestController { return } - let responseBodyLength = - response.body != null ? await getBodyByteLength(response.clone()) : 0 + /** + * @note The response body length is derived ONLY from the "content-length" header. + * If that response header is not set, the "total" in all progress events must be 0. + */ + const responseBodyLength = Number( + response.headers.get('content-length') ?? '0' + ) /** * @note The specification deviates in handling synchronous requests earlier, @@ -474,20 +482,26 @@ export class XMLHttpRequestController { this.setReadyState(this.request.HEADERS_RECEIVED) let receivedBytes = 0 + let lastReceivedResponseBytesAt = performance.now() const processResponseBodyChunk = (bytesLength: number) => { receivedBytes += bytesLength - /** - * @note Decouple "readyState" change and "readystatechange" event here. - * This is intentional and per specification. - * @see https://xhr.spec.whatwg.org/#the-send()-method (11.9.10.4). - */ + const now = performance.now() + const shouldBuffer = + now - lastReceivedResponseBytesAt <= 60 && + receivedBytes < responseBodyLength + lastReceivedResponseBytesAt = now + + if (shouldBuffer) { + return + } + if (this.request.readyState === this.request.HEADERS_RECEIVED) { this.setReadyState(this.request.LOADING, false) } - this.trigger('readystatechange', this.request) + this.trigger('readystatechange', this.request) this.trigger('progress', this.request, { loaded: receivedBytes, total: responseBodyLength, @@ -498,7 +512,7 @@ export class XMLHttpRequestController { requestErrorSteps('error', new TypeError('A network error occurred.')) } - const processResponseEndOfBody = () => { + const processResponseEndOfBody = async () => { handleErrors() if (isResponseError(response)) { diff --git a/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts new file mode 100644 index 000000000..6812be454 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + setTimeout, + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const encoder = new TextEncoder() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a stream response', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/stream')) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response.replace(/\s+/g, '')).toBe('helloworld') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 1024, total: 3072 }], + ['readystatechange', 3], + ['progress', 3, { loaded: 2048, total: 3072 }], + ['readystatechange', 3], + ['progress', 3, { loaded: 3072, total: 3072 }], + ['readystatechange', 4], + ['load', 4, { loaded: 3072, total: 3072 }], + ['loadend', 4, { loaded: 3072, total: 3072 }], + ]) + } +}) + +it('responds with a mocked immediate chunked response', async ({ task }) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + + const pad = (value: string) => value + ' '.repeat(1024 - value.length) + const chunks = [pad('hello'), pad(' '), pad('world')] + + const stream = new ReadableStream({ + async pull(controller) { + const chunk = chunks.shift() + + if (chunk) { + return controller.enqueue(encoder.encode(chunk)) + } + + controller.close() + }, + }) + + controller.respondWith( + new Response(stream, { + headers: { + 'content-type': 'text/plain', + 'content-length': chunks.join('').length.toString(), + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response.replace(/\s+/g, '')).toBe('helloworld') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 3072, total: 3072 }], + ['readystatechange', 4], + ['load', 4, { loaded: 3072, total: 3072 }], + ['loadend', 4, { loaded: 3072, total: 3072 }], + ]) + } +}) + +it('responds with a mocked delayed chunked response', async ({ task }) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + + /** + * @note The browser buffers the incoming response chunks unless a chunk exceeds + * 1KB. Simulate three chunks, each 1KB in size to trigger the "progress" event for each chunk. + */ + const pad = (value: string) => value + ' '.repeat(1024 - value.length) + const chunks = [pad('hello'), pad(' '), pad('world')] + + const stream = new ReadableStream({ + async pull(controller) { + const chunk = chunks.shift() + + if (chunk) { + await setTimeout(100) + return controller.enqueue(encoder.encode(chunk)) + } + + controller.close() + }, + }) + + controller.respondWith( + new Response(stream, { + headers: { + 'content-type': 'text/plain', + 'content-length': chunks.join('').length.toString(), + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response.replace(/\s+/g, '')).toBe('helloworld') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 1024, total: 3072 }], + ['readystatechange', 3], + ['progress', 3, { loaded: 2048, total: 3072 }], + ['readystatechange', 3], + ['progress', 3, { loaded: 3072, total: 3072 }], + ['readystatechange', 4], + ['load', 4, { loaded: 3072, total: 3072 }], + ['loadend', 4, { loaded: 3072, total: 3072 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts new file mode 100644 index 000000000..5b631a917 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + spyOnXMLHttpRequestUpload, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request ...', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + const { events: uploadEvents } = spyOnXMLHttpRequestUpload(request.upload) + request.open('POST', server.http.url('/upload')) + request.send(new Blob(['hello', ' ', 'world'])) + + await waitForXMLHttpRequest(request) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + /** + * @note This is the response progress event since the test server + * responds with the same payload that the request sent. + */ + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + expect.soft(uploadEvents).toEqual([ + ['loadstart', { loaded: 0, total: 11 }], + ['progress', { loaded: 11, total: 11 }], + ['load', { loaded: 11, total: 11 }], + ['loadend', { loaded: 11, total: 11 }], + ]) + } +}) + +it('dispatches the "upload" events for a mocked request', async ({ task }) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + + controller.respondWith(new Response('hello world')) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + const { events: uploadEvents } = spyOnXMLHttpRequestUpload(request.upload) + request.open('POST', 'http://any.host.here/irrelevant') + request.send(new Blob(['hello', ' ', 'world'])) + + await waitForXMLHttpRequest(request) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + expect.soft(uploadEvents).toEqual([ + ['loadstart', { loaded: 0, total: 11 }], + ['progress', { loaded: 11, total: 11 }], + ['load', { loaded: 11, total: 11 }], + ['loadend', { loaded: 11, total: 11 }], + ]) + } +}) From 9af13cd2b64dc45dac2348760c911c5a006a4d61 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 7 Mar 2026 15:15:18 +0100 Subject: [PATCH 129/198] chore: improve test setup --- test/setup/helpers-neutral.ts | 39 +++++++++++++++++++++++++++++++---- vitest.config.ts | 1 + vitest.setup.ts | 27 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/test/setup/helpers-neutral.ts b/test/setup/helpers-neutral.ts index 25f36e2d8..53009f456 100644 --- a/test/setup/helpers-neutral.ts +++ b/test/setup/helpers-neutral.ts @@ -1,5 +1,15 @@ import { DeferredPromise } from '@open-draft/deferred-promise' +/** + * Environment-agnostic, Promise-based "setTimeout". + * The one from `node:timers/promises` is awesome but it won't run in the browser. + */ +export function setTimeout(duration: number): Promise { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, duration) + }) +} + export function waitForXMLHttpRequest( request: XMLHttpRequest, async = true @@ -7,13 +17,12 @@ export function waitForXMLHttpRequest( const pendingResponse = new DeferredPromise() if (async) { - request.addEventListener('loadend', () => { - pendingResponse.resolve() - }) - request.addEventListener('abort', () => { pendingResponse.reject(new Error('Request aborted')) }) + request.addEventListener('loadend', () => { + pendingResponse.resolve() + }) } else { if (request.readyState === XMLHttpRequest.DONE) { pendingResponse.resolve() @@ -59,3 +68,25 @@ export function spyOnXMLHttpRequest(request: XMLHttpRequest) { events, } } + +export function spyOnXMLHttpRequestUpload(upload: XMLHttpRequestUpload) { + const events: Array<[string, { loaded: number; total: number }]> = [] + + const addUploadEvent = (name: string) => { + return (event: ProgressEvent) => { + events.push([name, { loaded: event.loaded, total: event.total }]) + } + } + + upload.onloadstart = addUploadEvent('loadstart') + upload.onprogress = addUploadEvent('progress') + upload.onload = addUploadEvent('load') + upload.onloadend = addUploadEvent('loadend') + upload.onabort = addUploadEvent('abort') + upload.onerror = addUploadEvent('error') + upload.ontimeout = addUploadEvent('timeout') + + return { + events, + } +} diff --git a/vitest.config.ts b/vitest.config.ts index e099cf545..1b4a911d9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ test: { globals: true, globalSetup: './vitest.setup.ts', + hookTimeout: 5000, projects: [ { extends: true, diff --git a/vitest.setup.ts b/vitest.setup.ts index f284ea897..08ecac9dd 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,3 +1,5 @@ +import { Readable } from 'node:stream' +import { setTimeout } from 'node:timers/promises' import { TestProject } from 'vitest/node' import { HttpServer } from '@open-draft/test-server/http' import { useCors } from './test/helpers' @@ -19,6 +21,31 @@ const server = new HttpServer((app) => { res.status(200).send('destination-body') }) + app.get('/stream', (req, res) => { + const encoder = new TextEncoder() + const pad = (value: string) => value + ' '.repeat(1024 - value.length) + const chunks = [pad('hello'), pad(' '), pad('world')] + + res.status(200).set({ + 'content-type': 'text/plain', + 'content-length': chunks.join('').length, + }) + + const stream = new ReadableStream({ + async pull(controller) { + const chunk = chunks.shift() + + if (chunk) { + await setTimeout(100) + return controller.enqueue(encoder.encode(chunk)) + } + + controller.close() + }, + }) + Readable.fromWeb(stream as any).pipe(res) + }) + app.get('/network-error', (req, res) => { res.destroy() }) From 1f1d0f3b0af4a9278c110932bca8001ff542f93d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 7 Mar 2026 15:15:29 +0100 Subject: [PATCH 130/198] chore: remove obsolete regression test --- .../regressions/xhr-0-status-code.test.ts | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts diff --git a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts deleted file mode 100644 index 005add65c..000000000 --- a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// @vitest-environment happy-dom -/** - * @see https://github.com/mswjs/interceptors/issues/335 - */ -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('handles Response.error() as a request error', async () => { - interceptor.once('request', ({ controller }) => { - controller.respondWith(Response.error()) - }) - - const loadListener = vi.fn() - const errorListener = vi.fn() - const request = new XMLHttpRequest() - request.open('GET', 'http://localhost') - request.addEventListener('load', loadListener) - request.addEventListener('error', errorListener) - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toBe(0) - expect(request.readyState).toBe(4) - expect(request.response).toBe('') - expect(loadListener).not.toBeCalled() - expect(errorListener).toHaveBeenCalledTimes(1) -}) - -it('handles interceptor exceptions as 500 error responses', async () => { - interceptor.once('request', () => { - throw new Error('Network error') - }) - - const request = new XMLHttpRequest() - request.responseType = 'json' - request.open('GET', 'http://localhost') - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toBe(500) - expect(request.statusText).toBe('Unhandled Exception') - expect(request.readyState).toBe(4) - expect(request.response).toEqual({ - name: 'Error', - message: 'Network error', - stack: expect.any(String), - }) -}) From 20a9ba3eb64e4763e7b90de6b236e9b0061efb16 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 7 Mar 2026 17:08:41 +0100 Subject: [PATCH 131/198] fix(socket): fire write callback if not passthrough --- src/interceptors/net/socket-controller.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 169abe359..81c4b8f13 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -378,15 +378,20 @@ export class TcpSocketController extends SocketController { } /** - * @note Only call the callback if the socket is still in PENDING state. - * If `#push` triggered `passthrough()` synchronously (e.g. when the handler - * decided to pass through the request), the buffered write was already - * flushed to the real socket with the original callback. Calling it again - * here would result in "Callback called multiple times" error. + * @note Only skip the callback if the socket transitioned to PASSTHROUGH. + * In the passthrough case, `#push` triggered `passthrough()` synchronously + * and the buffered write was already flushed to the real socket with the + * original callback. Calling it again would result in "Callback called + * multiple times" error. + * + * For CLAIMED state, `#push` may have triggered `claim()` synchronously + * (e.g. the handler responded with a mocked response). In that case, + * the callback was NOT flushed anywhere and must still be called here + * so the socket's writable state completes properly (enabling "finish"). */ if ( typeof callback === 'function' && - this.readyState === SocketController.PENDING + this.readyState !== SocketController.PASSTHROUGH ) { callback() args[3] = function mockNoop() {} From 97756d92eeb19d88eb1279448b412aee79984051 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 7 Mar 2026 17:08:50 +0100 Subject: [PATCH 132/198] test(xhr): improve the upload test --- .../response/xhr-upload.neutral.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts index 5b631a917..d52fd74d8 100644 --- a/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts @@ -22,7 +22,9 @@ afterAll(() => { interceptor.dispose() }) -it('intercepts a bypassed request ...', async ({ task }) => { +it('intercepts a bypassed request with the upload listeners', async ({ + task, +}) => { const request = new XMLHttpRequest() const { events } = spyOnXMLHttpRequest(request) const { events: uploadEvents } = spyOnXMLHttpRequestUpload(request.upload) @@ -55,7 +57,7 @@ it('intercepts a bypassed request ...', async ({ task }) => { } }) -it('dispatches the "upload" events for a mocked request', async ({ task }) => { +it('fires the upload events for a mocked request', async ({ task }) => { interceptor.on('request', ({ request, controller }) => { if (request.method === 'OPTIONS') { return controller.respondWith( @@ -67,7 +69,14 @@ it('dispatches the "upload" events for a mocked request', async ({ task }) => { ) } - controller.respondWith(new Response('hello world')) + controller.respondWith( + new Response(request.body, { + headers: { + 'content-type': 'text/plain', + // 'content-length': '11', + }, + }) + ) }) const request = new XMLHttpRequest() @@ -78,6 +87,9 @@ it('dispatches the "upload" events for a mocked request', async ({ task }) => { await waitForXMLHttpRequest(request) + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') + if (task.file.projectName === 'browser') { expect.soft(events).toEqual([ ['readystatechange', 1], From c7bf3db14bc2c8b6d12227f19e169547fb404d66 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 7 Mar 2026 17:12:12 +0100 Subject: [PATCH 133/198] test(xhr): set `content-length` in tests for correct `total` --- .../response/xhr-response-array-buffer.neutral.test.ts | 2 ++ .../response/xhr-response-blob.neutral.test.ts | 2 ++ .../response/xhr-response-json.neutral.test.ts | 2 ++ .../response/xhr-response-patching.neutral.test.ts | 6 +++++- .../response/xhr-response-redirect.neutral.test.ts | 8 +++++++- .../response/xhr-response-text.neutral.test.ts | 2 ++ .../response/xhr-synchronous.neutral.test.ts | 5 ++++- .../XMLHttpRequest/response/xhr-upload.neutral.test.ts | 2 +- 8 files changed, 25 insertions(+), 4 deletions(-) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts index 032877e2a..fc98fc846 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts @@ -78,6 +78,7 @@ it('responds with a mocked ArrayBuffer response to an HTTP request', async ({ headers: { 'access-control-allow-origin': '*', 'content-type': 'application/octet-stream', + 'content-length': '11', }, }) ) @@ -122,6 +123,7 @@ it('responds with a mocked ArrayBuffer response to an HTTPS request', async ({ headers: { 'access-control-allow-origin': '*', 'content-type': 'application/octet-stream', + 'content-length': '11', }, }) ) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts index 1a617a523..712a0ae59 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts @@ -69,6 +69,7 @@ it('responds with a mocked Blob response to an HTTP request', async ({ new Response(blob, { headers: { 'access-control-allow-origin': '*', + 'content-length': '11', }, }) ) @@ -119,6 +120,7 @@ it('responds with a mocked Blob response to an HTTP request', async ({ new Response(blob, { headers: { 'access-control-allow-origin': '*', + 'content-length': '11', }, }) ) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts index 229608f3a..fe602d3a1 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts @@ -61,6 +61,7 @@ it('responds with a mocked text response to an HTTP request', async ({ { headers: { 'access-control-allow-origin': '*', + 'content-length': '24', }, } ) @@ -105,6 +106,7 @@ it('responds with a mocked text response to an HTTPS request', async ({ { headers: { 'access-control-allow-origin': '*', + 'content-length': '24', }, } ) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts index 7b06a6ae7..5fd9abfbe 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts @@ -48,7 +48,11 @@ it.only('patches the original XMLHttpRequest response', async ({ task }) => { await waitForXMLHttpRequest(originalRequest) controller.respondWith( - new Response(`${originalRequest.responseText}-patched`) + new Response(`${originalRequest.responseText}-patched`, { + headers: { + 'content-length': '15', + }, + }) ) }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts index 680171034..f0aa6e2ff 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts @@ -78,7 +78,13 @@ it('responds with a mocked redirect response', async ({ task }) => { ) } - controller.respondWith(new Response('destination-body')) + controller.respondWith( + new Response('destination-body', { + headers: { + 'content-length': '16', + }, + }) + ) }) const request = new XMLHttpRequest() diff --git a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts index ea35a3246..04db7d8e9 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts @@ -58,6 +58,7 @@ it('responds with a mocked text response to an HTTP request', async ({ new Response('hello world', { headers: { 'access-control-allow-origin': '*', + 'content-length': '11', }, }) ) @@ -99,6 +100,7 @@ it('responds with a mocked text response to an HTTPS request', async ({ new Response('hello world', { headers: { 'access-control-allow-origin': '*', + 'content-length': '11', }, }) ) diff --git a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts index 169d07b34..2e8068c58 100644 --- a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts @@ -54,7 +54,9 @@ it('intercepts a synchronous bypassed request', async ({ task }) => { }) /** - * @fixme HappyDOM's sync request is invisible to the HttpRequestInterceptor? + * @note The way HappyDOM performs synchronous XMLHttpRequest is via "ChildProcess.execFileSync", + * which bypassed the "net.connect()" and makes such requests invisible to our interceptor. + * We do not support synchronous XMLHttpRequest. */ it('mocks response to a synchronous XMLHttpRequest', async ({ task }) => { interceptor.on('request', ({ controller }) => { @@ -62,6 +64,7 @@ it('mocks response to a synchronous XMLHttpRequest', async ({ task }) => { new Response('hello world', { headers: { 'access-control-allow-origin': '*', + 'content-length': '11', }, }) ) diff --git a/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts index d52fd74d8..ee70eb9a6 100644 --- a/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts @@ -73,7 +73,7 @@ it('fires the upload events for a mocked request', async ({ task }) => { new Response(request.body, { headers: { 'content-type': 'text/plain', - // 'content-length': '11', + 'content-length': '11', }, }) ) From ee5d7bf8c381fd816e6d07e290890eafc3f3082a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 7 Mar 2026 20:02:09 +0100 Subject: [PATCH 134/198] fix(node): await request/response event emission --- src/interceptors/XMLHttpRequest/node.ts | 2 +- src/interceptors/fetch/node.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 29801aa11..5e9cc4a60 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -31,7 +31,7 @@ export class XMLHttpRequestInterceptor extends Interceptor }) .on('response', async (args) => { if (args.initiator instanceof XMLHttpRequest) { - emitAsync(this.emitter, 'response', args) + await emitAsync(this.emitter, 'response', args) } }) diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 35e665389..282cbbc6d 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -5,6 +5,7 @@ import { canParseUrl } from '#/src/utils/canParseUrl' import { requestContext } from '#/src/request-context' import { applyPatch } from '#/src/utils/apply-patch' import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { emitAsync } from '#/src/utils/emitAsync' export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') @@ -24,14 +25,14 @@ export class FetchInterceptor extends Interceptor { this.subscriptions.push(() => httpInterceptor.dispose()) httpInterceptor - .on('request', (args) => { + .on('request', async (args) => { if (args.initiator instanceof Request) { - this.emitter.emit('request', args) + await emitAsync(this.emitter, 'request', args) } }) - .on('response', (args) => { + .on('response', async (args) => { if (args.initiator instanceof Request) { - this.emitter.emit('response', args) + await emitAsync(this.emitter, 'response', args) } }) From c419a5fa2cfd31b2b8dee0dc1de455f55258a53d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 16:11:23 +0100 Subject: [PATCH 135/198] fix(xhr): await the emitted `response` event --- src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts index e13cad3aa..b7bdbc9df 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts @@ -3,6 +3,7 @@ import { XMLHttpRequestEmitter } from './web' import { RequestController } from '../../RequestController' import { XMLHttpRequestController } from './XMLHttpRequestController' import { handleRequest } from '../../utils/handleRequest' +import { emitAsync } from '../../utils/emitAsync' export interface XMLHttpRequestProxyOptions { emitter: XMLHttpRequestEmitter @@ -97,7 +98,7 @@ export function createXMLHttpRequestProxy({ emitter.listenerCount('response') ) - emitter.emit('response', { + await emitAsync(emitter, 'response', { initiator: this.request, response, isMockedResponse, From b569b16cb6460af5e8fc70177bf6008af951a566 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 17:36:26 +0100 Subject: [PATCH 136/198] fix(xhr): warn for sync requests in the browser, ignore everywhere --- .../XMLHttpRequestController.ts | 62 +++++------------- .../response/xhr-synchronous.neutral.test.ts | 65 +++++-------------- 2 files changed, 33 insertions(+), 94 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 12ee0f14b..eb99d629f 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -1,5 +1,4 @@ import { until } from '@open-draft/until' -import { debounce } from 'es-toolkit' import { invariant } from 'outvariant' import type { Logger } from '@open-draft/logger' import { concatArrayBuffer } from './utils/concatArrayBuffer' @@ -48,6 +47,7 @@ export class XMLHttpRequestController { [kIsRequestHandled]: boolean; [kFetchRequest]?: Request + private sync: boolean = false private method: string = 'GET' private url: URL = null as any @@ -104,6 +104,7 @@ export class XMLHttpRequestController { string | undefined, boolean | undefined, ] + this.sync = !(async ?? true) if (typeof url === 'undefined') { @@ -146,6 +147,13 @@ export class XMLHttpRequestController { body?: XMLHttpRequestBodyInit | Document | null, ] + if (this.sync) { + console.warn( + `Failed to intercept an XMLHttpRequest (${this.method} ${this.url}): synchronous requests are not supported. This request will be performed as-is.` + ) + return invoke() + } + this.request.addEventListener('load', () => { if (typeof this.onResponse !== 'undefined') { // Create a Fetch API Response representation of whichever @@ -320,10 +328,8 @@ export class XMLHttpRequestController { }, }) - if (!this.sync) { - // 1. Fire a progress event named loadstart at this with 0 and 0. - this.trigger('loadstart', this.request, { loaded: 0, total: 0 }) - } + // 1. Fire a progress event named loadstart at this with 0 and 0. + this.trigger('loadstart', this.request, { loaded: 0, total: 0 }) // 2. Let requestBodyTransmitted be 0. let requestBodyTransmitted = 0 @@ -392,15 +398,10 @@ export class XMLHttpRequestController { const responseReadController = new AbortController() const requestErrorSteps = ( - event: keyof XMLHttpRequestEventTargetEventMap, - exception?: Error + event: keyof XMLHttpRequestEventTargetEventMap ) => { this.setReadyState(this.request.DONE) - if (this.sync) { - throw exception - } - if (!uploadComplete) { this.trigger(event, this.request.upload, { loaded: 0, @@ -419,17 +420,11 @@ export class XMLHttpRequestController { const processResponse = async (response: Response) => { const handleErrors = () => { if (timedOut) { - requestErrorSteps( - 'timeout', - new DOMException('The operation timed out.') - ) + requestErrorSteps('timeout') } else if (responseReadController.signal.aborted) { - requestErrorSteps( - 'abort', - new DOMException('The operation was aborted.') - ) + requestErrorSteps('abort') } else if (isResponseError(response)) { - requestErrorSteps('error', new TypeError('A network error occurred.')) + requestErrorSteps('error') } } @@ -454,31 +449,6 @@ export class XMLHttpRequestController { response.headers.get('content-length') ?? '0' ) - /** - * @note The specification deviates in handling synchronous requests earlier, - * but it's easier for us to keep the logic around consistent by handling them here. - */ - if (this.sync) { - this.responseBuffer = await response - .arrayBuffer() - .then((arrayBuffer) => { - return new Uint8Array(arrayBuffer) - }) - - this.setReadyState(this.request.DONE) - - this.trigger('load', this.request, { - loaded: responseBodyLength, - total: responseBodyLength, - }) - this.trigger('loadend', this.request, { - loaded: responseBodyLength, - total: responseBodyLength, - }) - - return - } - this.setReadyState(this.request.HEADERS_RECEIVED) let receivedBytes = 0 @@ -509,7 +479,7 @@ export class XMLHttpRequestController { } const processResponseBodyError = () => { - requestErrorSteps('error', new TypeError('A network error occurred.')) + requestErrorSteps('error') } const processResponseEndOfBody = async () => { diff --git a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts index 2e8068c58..8c8ceb636 100644 --- a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts @@ -1,9 +1,6 @@ // @vitest-environment happy-dom import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { - spyOnXMLHttpRequest, - waitForXMLHttpRequest, -} from '#/test/setup/helpers-neutral' +import { spyOnXMLHttpRequest } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' const server = getTestServer() @@ -11,46 +8,17 @@ const interceptor = new XMLHttpRequestInterceptor() beforeAll(() => { interceptor.apply() + vi.spyOn(console, 'warn').mockImplementation(() => {}) }) afterEach(() => { interceptor.removeAllListeners() + vi.clearAllMocks() }) afterAll(() => { interceptor.dispose() -}) - -it('intercepts a synchronous bypassed request', async ({ task }) => { - const request = new XMLHttpRequest() - const { events } = spyOnXMLHttpRequest(request) - request.open('POST', server.http.url('/'), false) - request.setRequestHeader('content-type', 'text/plain') - request.send('hello world') - - /** - * @note In a regular synchronous XMLHttpRequest, you don't have to - * await anything as ".send()" will block the thread and consume the - * response body synchronously. We cannot do that since ".respondWith()" - * is async by design. - */ - await waitForXMLHttpRequest(request, false) - - expect.soft(request.status).toBe(200) - expect - .soft(request.getAllResponseHeaders().toLowerCase()) - .toContain('content-type: text/plain') - expect.soft(request.response).toBe('hello world') - expect.soft(request.responseText).toBe('hello world') - - if (task.file.projectName === 'browser') { - expect(events).toEqual([ - ['readystatechange', 1], - ['readystatechange', 4], - ['load', 4, { loaded: 11, total: 11 }], - ['loadend', 4, { loaded: 11, total: 11 }], - ]) - } + vi.restoreAllMocks() }) /** @@ -58,13 +26,14 @@ it('intercepts a synchronous bypassed request', async ({ task }) => { * which bypassed the "net.connect()" and makes such requests invisible to our interceptor. * We do not support synchronous XMLHttpRequest. */ -it('mocks response to a synchronous XMLHttpRequest', async ({ task }) => { +it('prints a warning upon attempts to handle a synchronous XMLHttpRequest', async ({ + task, +}) => { interceptor.on('request', ({ controller }) => { controller.respondWith( - new Response('hello world', { + new Response('must not receive this', { headers: { 'access-control-allow-origin': '*', - 'content-length': '11', }, }) ) @@ -75,21 +44,21 @@ it('mocks response to a synchronous XMLHttpRequest', async ({ task }) => { request.open('GET', server.http.url('/'), false) request.send() - await waitForXMLHttpRequest(request, false) - + expect.soft(request.readyState).toBe(4) expect.soft(request.status).toBe(200) - expect - .soft(request.getAllResponseHeaders().toLowerCase()) - .toContain('content-type: text/plain') - expect.soft(request.response).toBe('hello world') - expect.soft(request.responseText).toBe('hello world') + expect.soft(request.response).toBe('original-response') if (task.file.projectName === 'browser') { + await expect + .poll(() => console.warn) + .toHaveBeenCalledExactlyOnceWith( + `Failed to intercept an XMLHttpRequest (GET ${server.http.url('/')}): synchronous requests are not supported. This request will be performed as-is.` + ) expect(events).toEqual([ ['readystatechange', 1], ['readystatechange', 4], - ['load', 4, { loaded: 11, total: 11 }], - ['loadend', 4, { loaded: 11, total: 11 }], + ['load', 4, { loaded: 17, total: 17 }], + ['loadend', 4, { loaded: 17, total: 17 }], ]) } }) From 249c95a730614d02d03d409134be1eec327a930c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 17:39:19 +0100 Subject: [PATCH 137/198] test: add interceptor dispose test case --- .../xhr-interceptor-dispose.neutral.test.ts | 66 +++++++++++++ .../xhr-response-text.neutral.test.ts | 2 +- .../XMLHttpRequest/response/xhr.test.ts | 92 ------------------- 3 files changed, 67 insertions(+), 93 deletions(-) create mode 100644 test/modules/XMLHttpRequest/response/xhr-interceptor-dispose.neutral.test.ts delete mode 100644 test/modules/XMLHttpRequest/response/xhr.test.ts diff --git a/test/modules/XMLHttpRequest/response/xhr-interceptor-dispose.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-interceptor-dispose.neutral.test.ts new file mode 100644 index 000000000..867535ba7 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-interceptor-dispose.neutral.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('bypasses the requests after the interceptor is disposed', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '11', + }, + }) + ) + }) + + interceptor.dispose() + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/')) + request.send('hello world') + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + expect(request.response).toBe('hello world') + expect(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts index 04db7d8e9..a749c7fb3 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts @@ -24,7 +24,7 @@ afterAll(() => { it('intercepts a bypassed request with a text response', async ({ task }) => { const request = new XMLHttpRequest() const { events } = spyOnXMLHttpRequest(request) - request.open('POST', server.http.url('/blob')) + request.open('POST', server.http.url('/')) request.send('hello world') await waitForXMLHttpRequest(request) diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts deleted file mode 100644 index c6092714b..000000000 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// @vitest-environment happy-dom -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors } from '#/test/helpers' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/', (req, res) => { - res.status(200).send('/') - }) - app.get('/get', (req, res) => { - res.status(200).send('/get') - }) - app.post('/cookies', (req, res) => { - return res - .cookie('authToken', 'SECRET', { - secure: true, - expires: new Date(Date.now() + 90000), - }) - .send('ok') - }) -}) - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - const shouldMock = - [httpServer.http.url(), httpServer.https.url()].includes(request.url) || - ['/login'].includes(url.pathname) - - if (shouldMock) { - return controller.respondWith( - new Response('foo', { - status: 301, - statusText: 'Moved Permanently', - headers: { - 'Content-Type': 'application/hal+json', - }, - }) - ) - } - - if (request.url.endsWith('/network-error')) { - return controller.respondWith(Response.error()) - } - - if (request.url.endsWith('/exception')) { - throw new Error('Custom message') - } -}) - -beforeAll(async () => { - await httpServer.listen() - interceptor.apply() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() - vi.restoreAllMocks() -}) - -it('responds to an HTTP request to a relative URL that is handled in the middleware', async () => { - const request = new XMLHttpRequest() - request.open('POST', httpServer.https.url('/login')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toEqual(301) - expect(request.getAllResponseHeaders().toLowerCase()).toContain( - 'content-type: application/hal+json' - ) - expect(request.response).toEqual('foo') - expect(request.responseURL).toEqual(httpServer.https.url('/login')) -}) - -it('bypasses any request when the interceptor is restored', async () => { - interceptor.dispose() - - const request = new XMLHttpRequest() - request.open('GET', httpServer.https.url('/')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toEqual(200) - expect(request.response).toEqual('/') - expect(request.responseURL).toEqual(httpServer.https.url('/')) -}) From a7a98cb2a3a8187ffd8601c4e50255f802c81f4e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 17:39:37 +0100 Subject: [PATCH 138/198] test: remove obsolete xhr.browser.test.ts --- .../response/xhr.browser.runtime.js | 36 -------- .../response/xhr.browser.test.ts | 89 ------------------- 2 files changed, 125 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/response/xhr.browser.runtime.js delete mode 100644 test/modules/XMLHttpRequest/response/xhr.browser.test.ts diff --git a/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js deleted file mode 100644 index de9aca402..000000000 --- a/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js +++ /dev/null @@ -1,36 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() - -interceptor.on('request', async ({ request, requestId, controller }) => { - window.dispatchEvent( - new CustomEvent('resolver', { - detail: { - id: requestId, - method: request.method, - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - credentials: request.credentials, - body: await request.clone().text(), - }, - }) - ) - - const { serverHttpUrl, serverHttpsUrl } = window - - if ([serverHttpUrl, serverHttpsUrl].includes(request.url)) { - controller.respondWith( - new Response(JSON.stringify({ mocked: true }), { - status: 201, - statusText: 'Created', - headers: { - 'Content-Type': 'application/hal+json', - }, - }) - ) - } -}) - -interceptor.apply() - -window.interceptor = interceptor diff --git a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr.browser.test.ts deleted file mode 100644 index f363d1594..000000000 --- a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Page } from '@playwright/test' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { test, expect } from '../../../playwright.extend' -import { useCors } from '#/test/helpers' - -declare namespace window { - export const interceptor: XMLHttpRequestInterceptor - export let serverHttpUrl: string - export let serverHttpsUrl: string -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/', (req, res) => { - res.status(200).json({ route: '/' }) - }) - app.get('/get', (req, res) => { - res.status(200).json({ route: '/get' }) - }) -}) - -async function forwardServerUrls(page: Page): Promise { - await page.evaluate((httpUrl) => { - window.serverHttpUrl = httpUrl - }, httpServer.http.url('/')) - - await page.evaluate((httpsUrl) => { - window.serverHttpsUrl = httpsUrl - }, httpServer.https.url('/')) -} - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - -test('bypasses a request not handled in the resolver', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/get'), - }) - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.body).toEqual(JSON.stringify({ route: '/get' })) -}) - -test('bypasses any request when the interceptor is restored', async ({ - loadExample, - callRawXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - await page.evaluate(() => { - window.interceptor.dispose() - }) - - // Using the "createRawBrowserXMLHttpRequest" because when the interceptor - // is restored, it won't dispatch the "resolver" event. - const firstResponse = await callRawXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/'), - }) - - expect(firstResponse.status).toBe(200) - expect(firstResponse.statusText).toBe('OK') - expect(firstResponse.body).toEqual(JSON.stringify({ route: '/' })) - - const secondResponse = await callRawXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/get'), - }) - expect(secondResponse.status).toBe(200) - expect(secondResponse.statusText).toBe('OK') - expect(secondResponse.body).toEqual(JSON.stringify({ route: '/get' })) -}) From 58cb07d5964e9d6f2eaa1d5ba1bf4a8410da1634 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 17:56:40 +0100 Subject: [PATCH 139/198] fix(xhr): translate `withCredentials` to fetch api `credentials` --- .../XMLHttpRequestController.ts | 8 +- src/interceptors/XMLHttpRequest/node.ts | 9 + .../intercept/XMLHttpRequest.test.ts | 45 ----- .../xhr-with-credentials.neutral.test.ts | 170 ++++++++++++++++++ tsconfig.base.json | 2 +- 5 files changed, 183 insertions(+), 51 deletions(-) create mode 100644 test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index eb99d629f..8d014ed0b 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -14,7 +14,7 @@ import { parseJson } from '../../utils/parseJson' import { createResponse } from './utils/createResponse' import { createRequestId } from '../../createRequestId' import { getBodyByteLength } from './utils/getBodyByteLength' -import { FetchResponse } from '../../utils/fetchUtils' +import { FetchRequest, FetchResponse } from '../../utils/fetchUtils' import { isResponseError } from '../../utils/responseUtils' const kIsRequestHandled = Symbol('kIsRequestHandled') @@ -855,16 +855,14 @@ export class XMLHttpRequestController { const resolvedBody = body instanceof Document ? body.documentElement.innerText : body - const fetchRequest = new Request(this.url.href, { + const fetchRequest = new FetchRequest(this.url.href, { method: this.method, headers: this.requestHeaders, /** * @see https://xhr.spec.whatwg.org/#cross-origin-credentials */ credentials: this.request.withCredentials ? 'include' : 'same-origin', - body: ['GET', 'HEAD'].includes(this.method.toUpperCase()) - ? null - : resolvedBody, + body: resolvedBody, }) const proxyHeaders = createProxy(fetchRequest.headers, { diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 5e9cc4a60..4622b95d3 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -26,11 +26,13 @@ export class XMLHttpRequestInterceptor extends Interceptor httpInterceptor .on('request', async (args) => { if (args.initiator instanceof XMLHttpRequest) { + args.request = this.#transformRequest(args.request, args.initiator) await emitAsync(this.emitter, 'request', args) } }) .on('response', async (args) => { if (args.initiator instanceof XMLHttpRequest) { + args.request = this.#transformRequest(args.request, args.initiator) await emitAsync(this.emitter, 'response', args) } }) @@ -61,4 +63,11 @@ export class XMLHttpRequestInterceptor extends Interceptor this.logger.info('global "XMLHttpRequest" patched!') } + + #transformRequest(request: Request, initiator: XMLHttpRequest): Request { + return new Request(request.url, { + ...request, + credentials: initiator.withCredentials ? 'include' : 'same-origin', + }) + } } diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts index 19fbd0eaf..af4ba3e29 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts @@ -327,51 +327,6 @@ it('intercepts an HTTPS DELETE request', async () => { } }) -it('sets "credentials" to "include" on isomorphic request when "withCredentials" is true', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.https.url('/user')) - request.withCredentials = true - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('include') - } -}) - -it('sets "credentials" to "omit" on isomorphic request when "withCredentials" is not set', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.https.url('/user')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - { - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('same-origin') - } -}) - -it('sets "credentials" to "omit" on isomorphic request when "withCredentials" is false', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.https.url('/user')) - request.withCredentials = false - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - { - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('same-origin') - } -}) - it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', async () => { const request = new XMLHttpRequest() request.open('GET', httpServer.https.url('/user')) diff --git a/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts new file mode 100644 index 000000000..6282257b0 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts @@ -0,0 +1,170 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('sets "credentials" to "same-origin" for the request that does not have "withCredentials" set', async () => { + const pendingRequestFromRequestListener = Promise.withResolvers() + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + }) + ) + } + + pendingRequestFromRequestListener.resolve(request) + + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const pendingRequestFromResponseListener = Promise.withResolvers() + interceptor.on('response', ({ request }) => { + pendingRequestFromResponseListener.resolve(request) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') + + { + const request = await pendingRequestFromRequestListener.promise + expect(request.credentials).toBe('same-origin') + } + + { + const request = await pendingRequestFromResponseListener.promise + expect(request.credentials).toBe('same-origin') + } +}) + +it('sets "credentials" to "include" for the request that has "withCredentials" set to true', async () => { + const pendingRequestFromRequestListener = Promise.withResolvers() + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + }) + ) + } + + pendingRequestFromRequestListener.resolve(request) + + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const pendingRequestFromResponseListener = Promise.withResolvers() + interceptor.on('response', ({ request }) => { + pendingRequestFromResponseListener.resolve(request) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.withCredentials = true + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') + + { + const request = await pendingRequestFromRequestListener.promise + expect(request.credentials).toBe('include') + } + + { + const request = await pendingRequestFromResponseListener.promise + expect(request.credentials).toBe('include') + } +}) + +it('sets "credentials" to "same-origin" for the request that has "withCredentials" set to false', async () => { + const pendingRequestFromRequestListener = Promise.withResolvers() + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + }) + ) + } + + pendingRequestFromRequestListener.resolve(request) + + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const pendingRequestFromResponseListener = Promise.withResolvers() + interceptor.on('response', ({ request }) => { + pendingRequestFromResponseListener.resolve(request) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.withCredentials = false + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') + + { + const request = await pendingRequestFromRequestListener.promise + expect(request.credentials).toBe('same-origin') + } + + { + const request = await pendingRequestFromResponseListener.promise + expect(request.credentials).toBe('same-origin') + } +}) diff --git a/tsconfig.base.json b/tsconfig.base.json index 8aecc48ff..ad952cf5a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -7,7 +7,7 @@ "moduleResolution": "NodeNext", "esModuleInterop": true, "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], + "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator", "es2024"], "baseUrl": ".", "paths": { "#/src/*": ["./src/*"], From d5cafa1a0a3a7b637caf04a1e617b88596d7217a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 18:13:46 +0100 Subject: [PATCH 140/198] test(xhr): add `request.responseType` for array buffer --- src/utils/bufferUtils.ts | 2 - .../intercept/XMLHttpRequest.test.ts | 270 ------------------ .../xhr-response-array-buffer.neutral.test.ts | 13 +- ...-response-type-arraybuffer.neutral.test.ts | 136 +++++++++ test/setup/helpers-neutral.ts | 18 ++ 5 files changed, 155 insertions(+), 284 deletions(-) create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-type-arraybuffer.neutral.test.ts diff --git a/src/utils/bufferUtils.ts b/src/utils/bufferUtils.ts index 60dfd067c..20b19afb1 100644 --- a/src/utils/bufferUtils.ts +++ b/src/utils/bufferUtils.ts @@ -1,5 +1,3 @@ -import { findLastIndex } from 'node_modules/es-toolkit/dist/compat/compat.mjs' - const encoder = new TextEncoder() export function encodeBuffer(text: string): Uint8Array { diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts index af4ba3e29..29e58eefd 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts @@ -57,276 +57,6 @@ afterAll(async () => { await httpServer.close() }) -it('intercepts an HTTP HEAD request', async () => { - const url = httpServer.http.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('HEAD', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('HEAD') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTP GET request', async () => { - const url = httpServer.http.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('GET', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTP POST request', async () => { - const url = httpServer.http.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('POST', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send('post-payload') - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTP PUT request', async () => { - const url = httpServer.http.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('PUT', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send('put-payload') - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('PUT') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTP DELETE request', async () => { - const url = httpServer.http.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('DELETE', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('DELETE') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTPS HEAD request', async () => { - const url = httpServer.https.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('HEAD', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('HEAD') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTPS GET request', async () => { - const url = httpServer.https.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('GET', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTPS POST request', async () => { - const url = httpServer.https.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('POST', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send('post-payload') - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTPS PUT request', async () => { - const url = httpServer.https.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('PUT', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send('put-payload') - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('PUT') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('put-payload') - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - -it('intercepts an HTTPS DELETE request', async () => { - const url = httpServer.https.url('/user?id=123') - const request = new XMLHttpRequest() - request.open('DELETE', url) - request.setRequestHeader('x-custom-header', 'yes') - request.send() - - await waitForXMLHttpRequest(request) - - expect(resolver).toHaveBeenCalledTimes(1) - - { - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('DELETE') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - } -}) - it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', async () => { const request = new XMLHttpRequest() request.open('GET', httpServer.https.url('/user')) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts index fc98fc846..3c4c897bf 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom import { spyOnXMLHttpRequest, + toArrayBuffer, waitForXMLHttpRequest, } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' @@ -21,18 +22,6 @@ afterAll(() => { interceptor.dispose() }) -/** - * @note Use this utility because in Node.js (JSDOM), "request.response" - * becomes Uint8Array while in the browser it's correctly ArrayBuffer. - */ -function toArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer { - if (value instanceof Uint8Array) { - return value.buffer - } - - return value -} - it('intercepts a bypassed request with an ArrayBuffer response', async ({ task, }) => { diff --git a/test/modules/XMLHttpRequest/response/xhr-response-type-arraybuffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-type-arraybuffer.neutral.test.ts new file mode 100644 index 000000000..8b1054540 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-type-arraybuffer.neutral.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom +import { + arrayBufferFrom, + toArrayBuffer, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('reads the mocked ArrayBuffer response as-is if "responseType" is set to "arraybuffer"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new TextEncoder().encode('hello world'), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(toArrayBuffer(request.response)) + .toStrictEqual(arrayBufferFrom('hello world')) +}) + +it('reads the empty mocked text response as an empty ArrayBuffer if "responseType" is set to "arraybuffer"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(toArrayBuffer(request.response)).toStrictEqual(new ArrayBuffer(0)) +}) + +it('reads the mocked text response as ArrayBuffer if "responseType" is set to "arraybuffer"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(toArrayBuffer(request.response)) + .toStrictEqual(arrayBufferFrom('hello world')) +}) + +it('reads the mocked json response as ArrayBuffer if "responseType" is set to "arraybuffer"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json(json, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(toArrayBuffer(request.response)) + .toStrictEqual(arrayBufferFrom(JSON.stringify(json))) +}) + +it('reads the mocked blob response as ArrayBuffer if "responseType" is set to "arraybuffer"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new Blob(['hello world'], { type: 'text/plain' }), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(toArrayBuffer(request.response)) + .toStrictEqual(arrayBufferFrom('hello world')) +}) diff --git a/test/setup/helpers-neutral.ts b/test/setup/helpers-neutral.ts index 53009f456..b317cf480 100644 --- a/test/setup/helpers-neutral.ts +++ b/test/setup/helpers-neutral.ts @@ -90,3 +90,21 @@ export function spyOnXMLHttpRequestUpload(upload: XMLHttpRequestUpload) { events, } } + +/** + * @note Use this utility because in Node.js (JSDOM), "request.response" + * becomes Uint8Array while in the browser it's correctly ArrayBuffer. + */ +export function toArrayBuffer( + value: ArrayBuffer | Uint8Array +): ArrayBufferLike { + if (value instanceof Uint8Array) { + return value.buffer + } + + return value +} + +export function arrayBufferFrom(input: string): ArrayBufferLike { + return new TextEncoder().encode(input).buffer +} From bebcf26a777def2be9ec23add72fff6cca6fa1f6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 18:16:38 +0100 Subject: [PATCH 141/198] test(xhr): add remaining `request.responseType` test suites --- .../xhr-response-type-blob.neutral.test.ts | 129 +++++++++++++++++ .../xhr-response-type-json.neutral.test.ts | 133 ++++++++++++++++++ .../xhr-response-type-text.neutral.test.ts | 124 ++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-type-blob.neutral.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-type-json.neutral.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-type-text.neutral.test.ts diff --git a/test/modules/XMLHttpRequest/response/xhr-response-type-blob.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-type-blob.neutral.test.ts new file mode 100644 index 000000000..bee31c83f --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-type-blob.neutral.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('reads the mocked blob response as-is if "responseType" is set to "blob"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new Blob(['hello world'], { type: 'text/plain' }), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(await request.response.text()).toBe('hello world') +}) + +it('reads the empty mocked response as an empty Blob if "responseType" is set to "blob"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(request.response.size).toBe(0) +}) + +it('reads the mocked text response as Blob if "responseType" is set to "blob"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(await request.response.text()).toBe('hello world') +}) + +it('reads the mocked json response as Blob if "responseType" is set to "blob"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json(json, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(await request.response.text()).toBe(JSON.stringify(json)) +}) + +it('reads the mocked ArrayBuffer response as Blob if "responseType" is set to "blob"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new TextEncoder().encode('hello world'), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(await request.response.text()).toBe('hello world') +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-type-json.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-type-json.neutral.test.ts new file mode 100644 index 000000000..28c39712b --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-type-json.neutral.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('reads the mocked json response as json if "responseType" is set to "json"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json(json, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(json) +}) + +it('reads the empty mocked response as null if "responseType" is set to "json"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeNull() +}) + +it('reads the mocked text response as json if "responseType" is set to "json"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(JSON.stringify(json), { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'application/json', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(json) +}) + +it('reads the mocked ArrayBuffer response as json if "responseType" is set to "json"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new TextEncoder().encode(JSON.stringify(json)), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(json) +}) + +it('reads the mocked blob response as json if "responseType" is set to "json"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response( + new Blob([JSON.stringify(json)], { type: 'application/json' }), + { + headers: { 'access-control-allow-origin': '*' }, + } + ) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(json) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-type-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-type-text.neutral.test.ts new file mode 100644 index 000000000..cf31accd1 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-type-text.neutral.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('reads the mocked text response as-is if "responseType" is set to "text"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') +}) + +it('reads the empty mocked text response as an empty string if "responseType" is set to "text"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('') +}) + +it('reads the mocked json response as text if "responseType" is set to "text"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json(json, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe(JSON.stringify(json)) +}) + +it('reads the mocked ArrayBuffer response as text if "responseType" is set to "text"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new TextEncoder().encode('hello world'), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') +}) + +it('reads the mocked blob response as text if "responseType" is set to "text"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new Blob(['hello world'], { type: 'text/plain' }), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') +}) From e44b385340944bed4ff27a29389ee427d3ceeb6d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 18:17:08 +0100 Subject: [PATCH 142/198] chore: remove obsolete test suites --- .../XMLHttpRequest.browser.runtime.js | 19 -- .../intercept/XMLHttpRequest.browser.test.ts | 201 ------------------ .../intercept/XMLHttpRequest.test.ts | 78 ------- 3 files changed, 298 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js delete mode 100644 test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts delete mode 100644 test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js deleted file mode 100644 index fa3abcda3..000000000 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js +++ /dev/null @@ -1,19 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', async ({ request, requestId }) => { - window.dispatchEvent( - new CustomEvent('resolver', { - detail: { - id: requestId, - method: request.method, - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - credentials: request.credentials, - body: await request.text(), - }, - }) - ) -}) - -interceptor.apply() diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts deleted file mode 100644 index d2e2bb28e..000000000 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { invariant } from 'outvariant' -import { HttpServer } from '@open-draft/test-server/http' -import { RequestHandler } from 'express-serve-static-core' -import { test, expect } from '../../../playwright.extend' -import { INTERNAL_REQUEST_ID_HEADER_NAME } from '#/src/Interceptor' - -const httpServer = new HttpServer((app) => { - const strictCorsMiddleware: RequestHandler = (req, res, next) => { - invariant( - !req.header(INTERNAL_REQUEST_ID_HEADER_NAME), - 'Found unexpected internal "%s" request header in the browser for "%s %s"', - INTERNAL_REQUEST_ID_HEADER_NAME, - req.method, - req.url - ) - - res - .set('Access-Control-Allow-Origin', req.headers.origin) - .set('Access-Control-Allow-Methods', 'GET, POST') - .set('Access-Control-Allow-Headers', ['content-type', 'x-request-header']) - .set('Access-Control-Allow-Credentials', 'true') - return next() - } - - app.use(strictCorsMiddleware) - - const requestHandler: RequestHandler = (req, res) => { - res.status(200).send('user-body') - } - - app.options('/user', (req, res) => res.status(200).end()) - app.get('/user', requestHandler) - app.post('/user', requestHandler) -}) - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - -test('intercepts an HTTP GET request', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request, response] = await callXMLHttpRequest({ - method: 'GET', - url, - headers: { - 'x-request-header': 'yes', - }, - }) - - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(request.headers.get('x-request-header')).toBe('yes') - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.body).toBe('user-body') -}) - -test('intercepts an HTTP POST request', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request, response] = await callXMLHttpRequest({ - method: 'POST', - url, - headers: { - 'content-type': 'application/json', - 'x-request-header': 'yes', - }, - body: JSON.stringify({ user: 'john' }), - }) - - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.credentials).toBe('same-origin') - expect(await request.json()).toEqual({ user: 'john' }) - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.body).toBe('user-body') -}) - -test('sets "credentials" to "include" on isomorphic request when "withCredentials" is true', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request] = await callXMLHttpRequest({ - method: 'POST', - url, - withCredentials: true, - }) - - expect(request.credentials).toBe('include') -}) - -test('sets "credentials" to "same-origin" on isomorphic request when "withCredentials" is false', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request] = await callXMLHttpRequest({ - method: 'POST', - url, - withCredentials: false, - }) - - expect(request.credentials).toBe('same-origin') -}) - -test('sets "credentials" to "same-origin" on isomorphic request when "withCredentials" is not set', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request] = await callXMLHttpRequest({ - method: 'POST', - url, - }) - - expect(request.credentials).toBe('same-origin') -}) - -test('ignores the body for HEAD requests', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const call = callXMLHttpRequest({ - method: 'HEAD', - url, - body: 'test', - }) - - await expect(call).resolves.not.toThrowError() - - const [request] = await call - expect(request.body).toBe(null) -}) - -test('ignores the body for GET requests', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const call = callXMLHttpRequest({ - method: 'GET', - url, - body: 'test', - }) - - await expect(call).resolves.not.toThrowError() - - const [request] = await call - expect(request.body).toBe(null) -}) - -test('intercepts a synchronous XMLHttpRequest', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const call = callXMLHttpRequest({ - method: 'POST', - url, - body: 'hello world', - async: false, - }) - - await expect(call).resolves.not.toThrowError() - - const [request] = await call - expect(request.method).toBe('POST') - await expect(request.text()).resolves.toBe('hello world') -}) diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts deleted file mode 100644 index 29e58eefd..000000000 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// @vitest-environment happy-dom -import type { RequestHandler } from 'express' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors, REQUEST_ID_REGEXP } from '#/test/helpers' -import { toArrayBuffer, encodeBuffer } from '#/src/utils/bufferUtils' -import { RequestController } from '#/src/RequestController' -import { HttpRequestEventMap } from '#/src/glossary' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -declare namespace window { - export const _resourceLoader: { - _strictSSL: boolean - } -} - -const httpServer = new HttpServer((app) => { - app.use(useCors, (req, res, next) => { - res.set({ - 'Access-Control-Allow-Credentials': 'true', - }) - return next() - }) - - const handleUserRequest: RequestHandler = (_req, res) => { - res.status(200).send('user-body').end() - } - - app.get('/user', handleUserRequest) - app.post('/user', handleUserRequest) - app.put('/user', handleUserRequest) - app.delete('/user', handleUserRequest) - app.patch('/user', handleUserRequest) - app.head('/user', handleUserRequest) -}) - -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', resolver) - -beforeAll(async () => { - // Allow XHR requests to the local HTTPS server with a self-signed certificate. - window._resourceLoader._strictSSL = false - - await httpServer.listen() - - interceptor.apply() -}) - -afterEach(() => { - vi.resetAllMocks() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.https.url('/user')) - request.responseType = 'arraybuffer' - request.send() - - await waitForXMLHttpRequest(request) - - const expectedArrayBuffer = toArrayBuffer(encodeBuffer('user-body')) - const responseBuffer = request.response as ArrayBuffer - - // Must return an "ArrayBuffer" instance for "arraybuffer" response type. - expect(request.responseType).toBe('arraybuffer') - expect(responseBuffer).toBeInstanceOf(ArrayBuffer) - expect(responseBuffer.byteLength).toBe(expectedArrayBuffer.byteLength) - expect( - Buffer.from(responseBuffer).compare(Buffer.from(expectedArrayBuffer)) - ).toBe(0) -}) From 170861e22449385e0c2037844133e15ed2b48d44 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 18:56:53 +0100 Subject: [PATCH 143/198] fix(xhr): use `FetchRequest` to support modifying non-configurable requests --- src/interceptors/XMLHttpRequest/node.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 4622b95d3..b7eff4be0 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -5,6 +5,7 @@ import { Interceptor } from '#/src/Interceptor' import { HttpRequestEventMap } from '#/src/glossary' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { emitAsync } from '#/src/utils/emitAsync' +import { FetchRequest } from '#/src/utils/fetchUtils' export class XMLHttpRequestInterceptor extends Interceptor { static symbol = Symbol.for('xhr-interceptor') @@ -65,9 +66,12 @@ export class XMLHttpRequestInterceptor extends Interceptor } #transformRequest(request: Request, initiator: XMLHttpRequest): Request { - return new Request(request.url, { + return new FetchRequest(request.url, { ...request, + method: request.method, + headers: request.headers, credentials: initiator.withCredentials ? 'include' : 'same-origin', + body: request.body, }) } } From 3a11bb80d16c331b8eb237c2ccc7f47aa26cb37e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 Mar 2026 18:59:48 +0100 Subject: [PATCH 144/198] test(xhr): use ACAO header on mocked responses, drop explicit `OPTIONS` handling --- .../xhr-response-empty.neutral.test.ts | 43 +++++++------------ .../xhr-response-patching.neutral.test.ts | 11 +---- .../xhr-response-progress.neutral.test.ts | 22 +--------- .../xhr-response-redirect.neutral.test.ts | 11 +---- .../response/xhr-upload.neutral.test.ts | 11 +---- .../xhr-with-credentials.neutral.test.ts | 36 ++-------------- 6 files changed, 24 insertions(+), 110 deletions(-) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts index 569e6582f..f72f066b8 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts @@ -47,21 +47,13 @@ it('intercepts a bypassed request with an empty response', async ({ task }) => { it('responds with an empty mocked response to an HTTP request', async ({ task, }) => { - interceptor.on('request', ({ request, controller }) => { - /** - * @see Browser-likes dispatch an extra preflight request. - */ - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - }, - }) - ) - } - - controller.respondWith(new Response(null, { status: 204 })) + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) }) const request = new XMLHttpRequest() @@ -89,18 +81,15 @@ it('responds with an empty mocked response to an HTTP request', async ({ it('responds with an empty mocked response to an HTTPS request', async ({ task, }) => { - interceptor.on('request', ({ request, controller }) => { - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - }, - }) - ) - } - - controller.respondWith(new Response(null, { status: 204 })) + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + status: 204, + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) }) const request = new XMLHttpRequest() diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts index 5fd9abfbe..cbee56fb7 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts @@ -29,16 +29,6 @@ it.only('patches the original XMLHttpRequest response', async ({ task }) => { return controller.passthrough() } - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - }, - }) - ) - } - const originalRequest = new XMLHttpRequest() url.searchParams.set('type', 'passthrough') originalRequest.open(request.method, url.href) @@ -50,6 +40,7 @@ it.only('patches the original XMLHttpRequest response', async ({ task }) => { controller.respondWith( new Response(`${originalRequest.responseText}-patched`, { headers: { + 'access-control-allow-origin': '*', 'content-length': '15', }, }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts index 6812be454..adc80d849 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts @@ -54,16 +54,6 @@ it('intercepts a bypassed request with a stream response', async ({ task }) => { it('responds with a mocked immediate chunked response', async ({ task }) => { interceptor.on('request', ({ request, controller }) => { - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - }, - }) - ) - } - const pad = (value: string) => value + ' '.repeat(1024 - value.length) const chunks = [pad('hello'), pad(' '), pad('world')] @@ -82,6 +72,7 @@ it('responds with a mocked immediate chunked response', async ({ task }) => { controller.respondWith( new Response(stream, { headers: { + 'access-control-allow-origin': '*', 'content-type': 'text/plain', 'content-length': chunks.join('').length.toString(), }, @@ -115,16 +106,6 @@ it('responds with a mocked immediate chunked response', async ({ task }) => { it('responds with a mocked delayed chunked response', async ({ task }) => { interceptor.on('request', ({ request, controller }) => { - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - }, - }) - ) - } - /** * @note The browser buffers the incoming response chunks unless a chunk exceeds * 1KB. Simulate three chunks, each 1KB in size to trigger the "progress" event for each chunk. @@ -148,6 +129,7 @@ it('responds with a mocked delayed chunked response', async ({ task }) => { controller.respondWith( new Response(stream, { headers: { + 'access-control-allow-origin': '*', 'content-type': 'text/plain', 'content-length': chunks.join('').length.toString(), }, diff --git a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts index f0aa6e2ff..2c42954c4 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts @@ -57,16 +57,6 @@ it('intercepts a bypassed request with a redirect response', async ({ it('responds with a mocked redirect response', async ({ task }) => { interceptor.on('request', ({ request, controller }) => { - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - }, - }) - ) - } - if (request.url.endsWith('/original')) { return controller.respondWith( new Response(null, { @@ -81,6 +71,7 @@ it('responds with a mocked redirect response', async ({ task }) => { controller.respondWith( new Response('destination-body', { headers: { + 'access-control-allow-origin': '*', 'content-length': '16', }, }) diff --git a/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts index ee70eb9a6..20166c3e5 100644 --- a/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts @@ -59,19 +59,10 @@ it('intercepts a bypassed request with the upload listeners', async ({ it('fires the upload events for a mocked request', async ({ task }) => { interceptor.on('request', ({ request, controller }) => { - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - }, - }) - ) - } - controller.respondWith( new Response(request.body, { headers: { + 'access-control-allow-origin': '*', 'content-type': 'text/plain', 'content-length': '11', }, diff --git a/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts index 6282257b0..0800906e8 100644 --- a/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts @@ -20,23 +20,13 @@ afterAll(() => { it('sets "credentials" to "same-origin" for the request that does not have "withCredentials" set', async () => { const pendingRequestFromRequestListener = Promise.withResolvers() interceptor.on('request', ({ request, controller }) => { - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - 'access-control-allow-credentials': 'true', - }, - }) - ) - } - pendingRequestFromRequestListener.resolve(request) controller.respondWith( new Response('hello world', { headers: { 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', }, }) ) @@ -70,23 +60,13 @@ it('sets "credentials" to "same-origin" for the request that does not have "with it('sets "credentials" to "include" for the request that has "withCredentials" set to true', async () => { const pendingRequestFromRequestListener = Promise.withResolvers() interceptor.on('request', ({ request, controller }) => { - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - 'access-control-allow-credentials': 'true', - }, - }) - ) - } - pendingRequestFromRequestListener.resolve(request) controller.respondWith( new Response('hello world', { headers: { 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', }, }) ) @@ -121,23 +101,13 @@ it('sets "credentials" to "include" for the request that has "withCredentials" s it('sets "credentials" to "same-origin" for the request that has "withCredentials" set to false', async () => { const pendingRequestFromRequestListener = Promise.withResolvers() interceptor.on('request', ({ request, controller }) => { - if (request.method === 'OPTIONS') { - return controller.respondWith( - new Response(null, { - headers: { - 'access-control-allow-origin': '*', - 'access-control-allow-credentials': 'true', - }, - }) - ) - } - pendingRequestFromRequestListener.resolve(request) controller.respondWith( new Response('hello world', { headers: { 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', }, }) ) From 654c9d17eae97812e70614c05836a122d897f2f1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 11:36:00 +0100 Subject: [PATCH 145/198] test(xhr): fix "events" test --- .../XMLHttpRequest/features/events.test.ts | 233 ++++++++++++------ 1 file changed, 152 insertions(+), 81 deletions(-) diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index 17b79c7c4..99a04a44e 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -1,42 +1,50 @@ // @vitest-environment happy-dom -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors, REQUEST_ID_REGEXP } from '#/test/helpers' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' +import { REQUEST_ID_REGEXP } from '#/test/helpers' import { HttpRequestEventMap } from '#/src/index' import { RequestController } from '#/src/RequestController' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' -const server = new HttpServer((app) => { - app.use(useCors) - app.get('/bypassed', (req, res) => { - res.status(201).set('Content-Type', 'text/plain').send('original response') - }) -}) - +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - if (request.url.endsWith('/user')) { - return controller.respondWith( - new Response('mocked response', { - status: 200, - statusText: 'OK', - }) - ) - } +beforeAll(() => { + interceptor.apply() }) -beforeAll(async () => { - interceptor.apply() - await server.listen() +afterEach(() => { + interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await server.close() }) it('emits events for a handled request', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET', + }, + }) + ) + } + + controller.respondWith( + new Response('mocked response', { + status: 200, + statusText: 'OK', + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + const requestListener = vi.fn<(...args: HttpRequestEventMap['request']) => void>() const responseListener = @@ -44,40 +52,74 @@ it('emits events for a handled request', async () => { interceptor.on('request', requestListener) interceptor.on('response', responseListener) + const url = server.http.url('/user') const request = new XMLHttpRequest() - request.open('GET', server.http.url('/user')) + request.open('GET', url) request.send() await waitForXMLHttpRequest(request) - // Must call the "request" event listener. - expect(requestListener).toHaveBeenCalledTimes(1) - const [requestParams] = requestListener.mock.calls[0] + /** + * @note XMLHttpRequest in JSDOM/HappyDOM issues a preflight OPTIONS request. + */ + expect.soft(requestListener).toHaveBeenCalledTimes(2) - expect(requestParams.request).toBeInstanceOf(Request) - expect(requestParams.request.method).toBe('GET') - expect(requestParams.request.url).toBe(server.http.url('/user')) + // Preflight request. + { + const [{ request, requestId }] = requestListener.mock.calls[0] - expect(requestParams.requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + { + const [{ request, requestId }] = requestListener.mock.calls[1] + + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url.href) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } // Must call the "response" event listener. - expect(responseListener).toHaveBeenCalledTimes(1) - const [responseParams] = responseListener.mock.calls[0] - - expect(responseParams.response).toBeInstanceOf(Response) - expect(responseParams.response.status).toBe(200) - expect(responseParams.response.statusText).toBe('OK') - expect(responseParams.response.headers.get('Content-Type')).toBe( - 'text/plain;charset=UTF-8' - ) - expect(responseParams.response.bodyUsed).toBe(false) - expect(await responseParams.response.text()).toBe('mocked response') - - expect(responseParams.request).toBeInstanceOf(Request) - expect(responseParams.request.method).toBe('GET') - expect(responseParams.request.url).toBe(server.http.url('/user')) - - expect(responseParams.requestId).toMatch(REQUEST_ID_REGEXP) + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ response, request, requestId }] = responseListener.mock.calls[0] + + expect.soft(response).toBeInstanceOf(Response) + expect.soft(response.status).toBe(200) + expect.soft(response.body).toBeNull() + + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + // Mocked response. + { + const [{ response, request, requestId }] = responseListener.mock.calls[1] + + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('Content-Type')).toBe( + 'text/plain;charset=UTF-8' + ) + expect(response.bodyUsed).toBe(false) + await expect(response.text()).resolves.toBe('mocked response') + + expect(request).toBeInstanceOf(Request) + expect(request.method).toBe('GET') + expect(request.url).toBe(url.href) + + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('emits events for a bypassed request', async () => { @@ -88,44 +130,73 @@ it('emits events for a bypassed request', async () => { interceptor.on('request', requestListener) interceptor.on('response', responseListener) + const url = server.http.url('/bypassed') const request = new XMLHttpRequest() - request.open('GET', server.http.url('/bypassed')) + request.open('GET', url) request.send() await waitForXMLHttpRequest(request) - // Must call the "request" event listener. - expect(requestListener).toHaveBeenCalledTimes(1) - const [requestParams] = requestListener.mock.calls[0] + expect(requestListener).toHaveBeenCalledTimes(2) + + // Preflight request. + { + const [{ request, controller, requestId }] = requestListener.mock.calls[0] - expect(requestParams.request).toBeInstanceOf(Request) - expect(requestParams.request.method).toBe('GET') - expect(requestParams.request.url).toBe(server.http.url('/bypassed')) - expect(requestParams.controller).toBeInstanceOf(RequestController) + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + expect.soft(controller).toBeInstanceOf(RequestController) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } - // The last argument of the request listener is the request ID. - expect(requestParams.requestId).toMatch(REQUEST_ID_REGEXP) + { + const [{ request, controller, requestId }] = requestListener.mock.calls[1] - // Must call the "response" event listener. - expect(responseListener).toHaveBeenCalledTimes(1) - const [responseParams] = responseListener.mock.calls[0] - - expect(responseParams.response).toBeInstanceOf(Response) - expect(responseParams.response.status).toBe(201) - // Note that Express infers status texts from the code. - expect(responseParams.response.statusText).toBe('Created') - // Express also adds whitespace between the header pairs. - expect(responseParams.response.headers.get('Content-Type')).toBe( - 'text/plain; charset=utf-8' - ) - expect(responseParams.response.bodyUsed).toBe(false) - expect(await responseParams.response.text()).toBe('original response') - - // Response listener must provide a relevant request. - expect(responseParams.request).toBeInstanceOf(Request) - expect(responseParams.request.method).toBe('GET') - expect(responseParams.request.url).toBe(server.http.url('/bypassed')) - - // The last argument of the response listener is the request ID. - expect(responseParams.requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url.href) + expect.soft(controller).toBeInstanceOf(RequestController) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ response, request, requestId }] = responseListener.mock.calls[0] + + expect.soft(response).toBeInstanceOf(Response) + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('') + + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + { + const [responseParams] = responseListener.mock.calls[1] + + expect.soft(responseParams.response).toBeInstanceOf(Response) + expect.soft(responseParams.response.status).toBe(200) + expect.soft(responseParams.response.statusText).toBe('OK') + expect + .soft(responseParams.response.headers.get('Content-Type')) + .toBe('text/plain; charset=utf-8') + expect.soft(responseParams.response.bodyUsed).toBe(false) + await expect + .soft(responseParams.response.text()) + .resolves.toBe('original-response') + + // Response listener must provide a relevant request. + expect.soft(responseParams.request).toBeInstanceOf(Request) + expect.soft(responseParams.request.method).toBe('GET') + expect.soft(responseParams.request.url).toBe(url.href) + + // The last argument of the response listener is the request ID. + expect.soft(responseParams.requestId).toMatch(REQUEST_ID_REGEXP) + } }) From e5f55915204f7a9834fe51f0fc0fc3b9b388191f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 11:36:11 +0100 Subject: [PATCH 146/198] chore(vitest): set `content-type` as plain text, if not set --- vitest.setup.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vitest.setup.ts b/vitest.setup.ts index 08ecac9dd..73ac37f16 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -60,6 +60,10 @@ const server = new HttpServer((app) => { }) } + if (res.getHeader('content-type') == null) { + res.set('content-type', 'text/plain; charset=utf-8') + } + if (req.method === 'GET') { res.send('original-response') } else { From 202d43877528fc4a36d2a98bf10f539224a54f45 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 12:44:55 +0100 Subject: [PATCH 147/198] test(xhr): keep fixing tests --- .../XMLHttpRequestController.ts | 22 -- test/envs/node-with-websocket.ts | 2 +- test/envs/react-native-like.ts | 20 -- .../compliance/xhr-add-event-listener.test.ts | 2 +- .../xhr-event-callback-null.test.ts | 2 +- .../compliance/xhr-event-handlers.test.ts | 319 ------------------ .../compliance/xhr-events-order.test.ts | 146 -------- .../xhr-middleware-exception.test.ts | 2 +- .../compliance/xhr-modify-request.test.ts | 2 +- .../xhr-no-response-headers.test.ts | 2 +- .../compliance/xhr-ready-state-enums.test.ts | 2 +- .../compliance/xhr-request-headers.test.ts | 2 +- .../compliance/xhr-request-method.test.ts | 2 +- .../xhr-response-body-json-invalid.test.ts | 2 +- .../compliance/xhr-response-body-xml.test.ts | 3 +- ...-response-headers-case-sensitivity.test.ts | 2 +- .../compliance/xhr-response-headers.test.ts | 63 ++-- .../xhr-response-non-configurable.test.ts | 79 +++-- .../compliance/xhr-response-type.test.ts | 2 +- .../xhr-response-without-body.test.ts | 172 ++++++++-- .../compliance/xhr-status.test.ts | 2 +- .../compliance/xhr-timeout.test.ts | 63 ---- .../compliance/xhr-timeout.v-browser.test.ts | 75 ++++ .../regressions/xhr-axios-xhr-adapter.test.ts | 10 +- .../xhr-compressed-response.test.ts | 2 +- .../xhr-location-undefined.test.ts | 43 --- .../xhr-request-body-length.test.ts | 41 --- .../xhr-request-body-length.v-browser.test.ts | 42 +++ .../http/intercept/http-connect.test.ts | 2 - test/setup/helpers-neutral.ts | 30 +- vitest.config.ts | 1 - vitest.setup.ts | 5 + 32 files changed, 392 insertions(+), 772 deletions(-) delete mode 100644 test/envs/react-native-like.ts delete mode 100644 test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts delete mode 100644 test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts delete mode 100644 test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts create mode 100644 test/modules/XMLHttpRequest/compliance/xhr-timeout.v-browser.test.ts delete mode 100644 test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts delete mode 100644 test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts create mode 100644 test/modules/XMLHttpRequest/regressions/xhr-request-body-length.v-browser.test.ts diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 8d014ed0b..a25e13298 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -74,28 +74,6 @@ export class XMLHttpRequestController { this.responseBuffer = new Uint8Array() this.request = createProxy(initialRequest, { - setProperty: ([propertyName, nextValue], invoke) => { - switch (propertyName) { - case 'ontimeout': { - const eventName = propertyName.slice( - 2 - ) as keyof XMLHttpRequestEventTargetEventMap - - /** - * @note Proxy callbacks to event listeners because JSDOM has trouble - * translating these properties to callbacks. It seemed to be operating - * on events exclusively. - */ - this.request.addEventListener(eventName, nextValue as any) - - return invoke() - } - - default: { - return invoke() - } - } - }, methodCall: ([methodName, args], invoke) => { switch (methodName) { case 'open': { diff --git a/test/envs/node-with-websocket.ts b/test/envs/node-with-websocket.ts index 606f05c44..a1f187210 100644 --- a/test/envs/node-with-websocket.ts +++ b/test/envs/node-with-websocket.ts @@ -6,7 +6,7 @@ import { WebSocket } from 'undici' export default { name: 'node-with-websocket', - transformMode: 'ssr', + viteEnvironment: 'ssr', async setup(global, options) { const { teardown } = await builtinEnvironments.node.setup(global, options) diff --git a/test/envs/react-native-like.ts b/test/envs/react-native-like.ts deleted file mode 100644 index 6f7ef79f8..000000000 --- a/test/envs/react-native-like.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * React Native-like environment for Vitest. - */ -import { builtinEnvironments, type Environment } from 'vitest/environments' - -export default { - name: 'react-native-like', - transformMode: 'ssr', - async setup(global, options) { - const { teardown } = await builtinEnvironments.jsdom.setup(global, options) - - // React Native does not have the global "location" property. - Reflect.deleteProperty(globalThis, 'window') - Reflect.deleteProperty(globalThis, 'location') - - return { - teardown, - } - }, -} diff --git a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts index d446662a7..a2e4bec40 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts @@ -2,7 +2,7 @@ /** * @see https://github.com/mswjs/msw/issues/273 */ -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts index a69af7d97..ed74e59c0 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts @@ -3,7 +3,7 @@ * @see https://xhr.spec.whatwg.org/#event-handlers */ import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts deleted file mode 100644 index b35f43dbe..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * @note https://xhr.spec.whatwg.org/#event-handlers - */ -// @vitest-environment happy-dom -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const interceptor = new XMLHttpRequestInterceptor() - -const httpServer = new HttpServer((app) => { - app.get('/resource', (req, res) => { - res.send('hello') - }) - app.get('/error-response', (req, res) => { - res.status(500).send('Internal Server Error') - }) - app.get('/exception', (req, res) => { - throw new Error('Server error') - }) -}) - -beforeAll(async () => { - interceptor.apply() - interceptor.on('request', ({ request, controller }) => { - switch (true) { - case request.url.endsWith('/exception'): { - throw new Error('Custom error') - } - - case request.url.endsWith('/network-error'): { - return controller.respondWith(Response.error()) - } - - case request.url.endsWith('/error-response'): { - return controller.respondWith( - new Response('Internal Server Error', { status: 500 }) - ) - } - - default: - return controller.respondWith(new Response('hello')) - } - }) - - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/resource')], - ['mocked', () => 'http://localhost/resource'], -])( - `dispatches relevant events upon a successful %s response`, - async (_, getUrl) => { - const url = getUrl() - - const onReadyStateChangeHandler = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const onLoadStartHandler = vi.fn() - const onProgressHandler = vi.fn() - const onLoadHandler = vi.fn() - const onLoadEndHandler = vi.fn() - - const onReadyStateChangeListener = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const loadStartListener = vi.fn() - const progressListener = vi.fn() - const loadListener = vi.fn() - const loadEndListener = vi.fn() - - const request = new XMLHttpRequest() - request.open('GET', url) - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(200) - expect(request.responseText).toBe('hello') - - expect(onReadyStateChangeHandler).toHaveBeenCalledTimes(3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(1, 2) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(2, 3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(3, 4) - expect(onLoadStartHandler).toHaveBeenCalledTimes(1) - expect(onProgressHandler).toHaveBeenCalledTimes(1) - expect(onLoadHandler).toHaveBeenCalledTimes(1) - expect(onLoadEndHandler).toHaveBeenCalledTimes(1) - - expect(onReadyStateChangeListener).toHaveBeenCalledTimes(3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(1, 2) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(2, 3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(3, 4) - expect(loadStartListener).toHaveBeenCalledTimes(1) - expect(progressListener).toHaveBeenCalledTimes(1) - expect(loadListener).toHaveBeenCalledTimes(1) - expect(loadEndListener).toHaveBeenCalledTimes(1) - } -) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/error-response')], - ['mocked', () => 'http://localhost/error-response'], -])(`dispatches relevant events upon a %s error response`, async (_, getUrl) => { - const url = getUrl() - - const onReadyStateChangeHandler = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const onLoadStartHandler = vi.fn() - const onProgressHandler = vi.fn() - const onLoadHandler = vi.fn() - const onLoadEndHandler = vi.fn() - - const onReadyStateChangeListener = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const loadStartListener = vi.fn() - const progressListener = vi.fn() - const loadListener = vi.fn() - const loadEndListener = vi.fn() - - const request = new XMLHttpRequest() - request.open('GET', url) - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(500) - expect(request.responseText).toBe('Internal Server Error') - - expect(onReadyStateChangeHandler).toHaveBeenCalledTimes(3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(1, 2) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(2, 3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(3, 4) - expect(onLoadStartHandler).toHaveBeenCalledTimes(1) - expect(onProgressHandler).toHaveBeenCalledTimes(1) - expect(onLoadHandler).toHaveBeenCalledTimes(1) - expect(onLoadEndHandler).toHaveBeenCalledTimes(1) - - expect(onReadyStateChangeListener).toHaveBeenCalledTimes(3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(1, 2) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(2, 3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(3, 4) - expect(loadStartListener).toHaveBeenCalledTimes(1) - expect(progressListener).toHaveBeenCalledTimes(1) - expect(loadListener).toHaveBeenCalledTimes(1) - expect(loadEndListener).toHaveBeenCalledTimes(1) -}) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/network-error')], - ['mocked', () => 'http://localhost/network-error'], -])(`dispatches relevant events upon a %s request error`, async (_, getUrl) => { - const url = getUrl() - - const onReadyStateChangeHandler = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const onLoadStartHandler = vi.fn() - const onProgressHandler = vi.fn() - const onLoadHandler = vi.fn() - const onLoadEndHandler = vi.fn() - - const onReadyStateChangeListener = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const loadStartListener = vi.fn() - const progressListener = vi.fn() - const loadListener = vi.fn() - const loadEndListener = vi.fn() - - const request = new XMLHttpRequest() - request.open('GET', url) - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(0) - expect(request.responseText).toBe('') - - expect(onReadyStateChangeHandler).toHaveBeenCalledTimes(1) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(1, 4) - expect(onLoadStartHandler).not.toHaveBeenCalled() - expect(onProgressHandler).not.toHaveBeenCalled() - expect(onLoadHandler).not.toHaveBeenCalled() - expect(onLoadEndHandler).toHaveBeenCalledTimes(1) - - expect(onReadyStateChangeListener).toHaveBeenCalledTimes(1) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(1, 4) - expect(loadStartListener).not.toHaveBeenCalled() - expect(progressListener).not.toHaveBeenCalled() - expect(loadListener).not.toHaveBeenCalled() - expect(loadEndListener).toHaveBeenCalledTimes(1) -}) - -it('dispatched relevant events upon an unhandled exception in the interceptor', async () => { - const onReadyStateChangeHandler = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const onLoadStartHandler = vi.fn() - const onProgressHandler = vi.fn() - const onLoadHandler = vi.fn() - const onLoadEndHandler = vi.fn() - const onErrorHandler = vi.fn() - - const onReadyStateChangeListener = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const loadStartListener = vi.fn() - const progressListener = vi.fn() - const loadListener = vi.fn() - const loadEndListener = vi.fn() - const errorListener = vi.fn() - - const request = new XMLHttpRequest() - request.responseType = 'json' - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - request.onerror = onErrorHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - request.addEventListener('error', errorListener) - - // Open the connection after the callbacks/listeners have been added. - // Some XHR interactions, like file uploads, require you to add the - // progress listeners BEFORE the request is open. - request.open('GET', httpServer.https.url('/exception')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(500) - expect(request.statusText).toBe('Unhandled Exception') - expect(request.response).toEqual({ - name: 'Error', - message: 'Custom error', - stack: expect.any(String), - }) - - expect(onReadyStateChangeHandler).toHaveBeenCalledTimes(4) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(1, 1) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(2, 2) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(3, 3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(4, 4) - expect(onLoadStartHandler).toHaveBeenCalledTimes(1) - expect(onProgressHandler).toHaveBeenCalledTimes(1) - expect(onLoadHandler).toHaveBeenCalledTimes(1) - expect(onLoadEndHandler).toHaveBeenCalledTimes(1) - expect(onErrorHandler).not.toHaveBeenCalled() - - expect(onReadyStateChangeListener).toHaveBeenCalledTimes(4) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(1, 1) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(2, 2) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(3, 3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(4, 4) - expect(loadStartListener).toHaveBeenCalledTimes(1) - expect(progressListener).toHaveBeenCalledTimes(1) - expect(loadListener).toHaveBeenCalledTimes(1) - expect(loadEndListener).toHaveBeenCalledTimes(1) - expect(errorListener).not.toHaveBeenCalled() -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts deleted file mode 100644 index 995af6635..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -// @vitest-environment happy-dom -/** - * @see https://xhr.spec.whatwg.org/#events - */ -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { useCors } from '#/test/helpers' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/', (_req, res) => { - res.status(200).end() - }) - app.get('/numbers', (_req, res) => { - res.status(200).json([1, 2, 3]) - }) -}) - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - switch (url.pathname) { - case '/user': { - controller.respondWith(new Response()) - break - } - - case '/numbers-mock': { - controller.respondWith(new Response(JSON.stringify([1, 2, 3]))) - break - } - } -}) - -function spyOnEvents(req: XMLHttpRequest, listener: any) { - function wrapListener(this: XMLHttpRequest, event: Event) { - listener(event.type, this.readyState) - } - - req.addEventListener('readystatechange', wrapListener) - req.addEventListener('loadstart', wrapListener) - req.addEventListener('progress', wrapListener) - req.addEventListener('timeout', wrapListener) - req.addEventListener('load', wrapListener) - req.addEventListener('loadend', wrapListener) - req.addEventListener('abort', wrapListener) - req.addEventListener('error', wrapListener) -} - -beforeAll(async () => { - interceptor.apply() - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('emits correct events sequence for an unhandled request with no response body', async () => { - const listener = vi.fn() - const request = new XMLHttpRequest() - spyOnEvents(request, listener) - request.open('GET', httpServer.http.url()) - request.send() - - await waitForXMLHttpRequest(request) - - expect(listener.mock.calls).toEqual([ - ['readystatechange', 1], // OPEN - ['loadstart', 1], - ['readystatechange', 2], // HEADERS_RECEIVED - ['readystatechange', 4], // DONE - - ['load', 4], - ['loadend', 4], - ]) - expect(request.readyState).toEqual(4) -}) - -it('emits correct events sequence for a handled request with no response body', async () => { - const listener = vi.fn() - const request = new XMLHttpRequest() - spyOnEvents(request, listener) - request.open('GET', httpServer.http.url('/user')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(listener.mock.calls).toEqual([ - ['readystatechange', 1], // OPEN - ['loadstart', 1], - ['readystatechange', 2], // HEADERS_RECEIVED - ['readystatechange', 3], // LOADING - ['readystatechange', 4], // DONE - ['load', 4], - ['loadend', 4], - ]) - expect(request.readyState).toBe(4) -}) - -it('emits correct events sequence for an unhandled request with a response body', async () => { - const listener = vi.fn() - const request = new XMLHttpRequest() - spyOnEvents(request, listener) - request.open('GET', httpServer.http.url('/numbers')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(listener.mock.calls).toEqual([ - ['readystatechange', 1], // OPEN - ['loadstart', 1], - ['readystatechange', 2], // HEADERS_RECEIVED - ['readystatechange', 3], // LOADING - ['progress', 3], - ['readystatechange', 4], - ['load', 4], - ['loadend', 4], - ]) - expect(request.readyState).toBe(4) -}) - -it('emits correct events sequence for a handled request with a response body', async () => { - const listener = vi.fn() - const request = new XMLHttpRequest() - spyOnEvents(request, listener) - request.open('GET', httpServer.http.url('/numbers-mock')) - request.send() - - await waitForXMLHttpRequest(request) - - expect(listener.mock.calls).toEqual([ - ['readystatechange', 1], // OPEN - ['loadstart', 1], - ['readystatechange', 2], // HEADERS_RECEIVED - ['readystatechange', 3], // LOADING - ['progress', 3], - ['readystatechange', 4], // DONE - ['load', 4], - ['loadend', 4], - ]) - expect(request.readyState).toBe(4) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index 1f7f6c6b5..5536eab36 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -3,7 +3,7 @@ * @see https://github.com/mswjs/msw/issues/355 */ import axios from 'axios' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts index 814297286..91dc3ed24 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts index 3e2548523..13904431f 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts index d39ba0fee..eb417d533 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts index 7b0d61eda..e72f4d8c5 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts index a79b089f8..70483efa4 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts index 9cc79db41..777461ea0 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts index 1d7fdad71..9c9f2f356 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts @@ -1,11 +1,12 @@ // @vitest-environment happy-dom -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const XML_STRING = 'Content' describe('Content-Type: application/xml', () => { const interceptor = new XMLHttpRequestInterceptor() + interceptor.on('request', ({ controller }) => { controller.respondWith( new Response(XML_STRING, { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts index a17826408..6b959bdc3 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' import { useCors } from '#/test/helpers' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts index 326f3bf11..c59feb3c6 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts @@ -1,12 +1,12 @@ // @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) - app.head('/', (_req, res) => { + app.get('/', (_req, res) => { res .set({ // Specify which response headers to expose to the client. @@ -19,55 +19,64 @@ const httpServer = new HttpServer((app) => { }) const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - if (!url.searchParams.has('mock')) { - return - } - - controller.respondWith( - new Response(null, { - headers: { - etag: '123', - 'x-response-type': 'mock', - }, - }) - ) -}) beforeAll(async () => { interceptor.apply() await httpServer.listen() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(async () => { interceptor.dispose() await httpServer.close() }) -it('retrieves the mocked response headers when called ".getAllResponseHeaders()"', async () => { +it('returns the bypass response headers when called ".getAllResponseHeaders()"', async () => { const request = new XMLHttpRequest() - request.open('GET', '/?mock=true') + request.open('GET', httpServer.http.url('/')) request.send() await waitForXMLHttpRequest(request) - expect(request.getAllResponseHeaders()).toBe( - 'etag: 123\r\nx-response-type: mock' + expect(request.getAllResponseHeaders()).toContain( + 'etag: 456\r\nx-response-type: bypass' ) }) -it('returns the bypass response headers when called ".getAllResponseHeaders()"', async () => { +it('retrieves the mocked response headers when called ".getAllResponseHeaders()"', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'etag, x-response-type', + }, + }) + ) + } + + controller.respondWith( + new Response(null, { + headers: { + etag: '123', + 'x-response-type': 'mock', + }, + }) + ) + }) + const request = new XMLHttpRequest() - // Perform a HEAD request so that the response has no "Content-Type" header - // always appended by Express. - request.open('HEAD', httpServer.http.url('/')) + request.open('GET', 'http://any.host.here/irrelevant') request.send() await waitForXMLHttpRequest(request) expect(request.getAllResponseHeaders()).toBe( - 'etag: 456\r\nx-response-type: bypass' + 'etag: 123\r\nx-response-type: mock' ) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts index b1607c990..5be9d7963 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts @@ -3,8 +3,7 @@ * @see https://github.com/mswjs/msw/issues/2307 */ import { HttpServer } from '@open-draft/test-server/http' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchResponse } from '#/src/utils/fetchUtils' import { useCors } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' @@ -34,27 +33,49 @@ afterAll(async () => { }) it('handles non-configurable responses from the actual server', async () => { - const responsePromise = new DeferredPromise() - interceptor.on('response', ({ response }) => { - responsePromise.resolve(response) - }) + const responseListener = vi.fn() + interceptor.on('response', responseListener) + const url = httpServer.http.url('/resource') const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/resource')) + request.open('GET', url) request.send() await waitForXMLHttpRequest(request) - expect(request.status).toBe(101) - expect(request.statusText).toBe('Switching Protocols') - expect(request.responseText).toBe('') + expect.soft(request.status).toBe(101) + expect.soft(request.statusText).toBe('Switching Protocols') + expect.soft(request.responseText).toBe('') + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + expect.soft(response.status).toBe(204) + } - // Must expose the exact response in the listener. - await expect(responsePromise).resolves.toHaveProperty('status', 101) + { + const [{ request, response }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + expect.soft(response.status).toBe(101) + } }) it('supports mocking non-configurable responses', async () => { - interceptor.on('request', ({ controller }) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + /** * @note The Fetch API `Response` will still error on * non-configurable status codes. Instead, use this helper class. @@ -62,20 +83,34 @@ it('supports mocking non-configurable responses', async () => { controller.respondWith(new FetchResponse(null, { status: 101 })) }) - const responsePromise = new DeferredPromise() - interceptor.on('response', ({ response }) => { - responsePromise.resolve(response) - }) + const responseListener = vi.fn() + interceptor.on('response', responseListener) const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/resource')) + request.open('GET', 'http://any.host.here/irrelevant') request.send() await waitForXMLHttpRequest(request) - expect(request.status).toBe(101) - expect(request.responseText).toBe('') + expect.soft(request.status).toBe(101) + expect.soft(request.response).toBe('') + + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe('http://any.host.here/irrelevant') + expect.soft(response.status).toBe(204) + } + + { + const [{ request, response }] = responseListener.mock.calls[1] - // Must expose the exact response in the listener. - await expect(responsePromise).resolves.toHaveProperty('status', 101) + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe('http://any.host.here/irrelevant') + expect.soft(response.status).toBe(101) + } }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts index cbfebf22c..636dda91c 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { encodeBuffer } from '#/src/index' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { toArrayBuffer } from '#/src/utils/bufferUtils' import { readBlob } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts index d4c7238cf..b5edac2c8 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts @@ -1,12 +1,21 @@ // @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' import type { HttpRequestEventMap } from '#/src/index' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) + app.get('/cacheable', (req, res) => { + if (req.headers['if-none-match'] === '"etag-value"') { + return res.status(304).end() + } + res.set('ETag', '"etag-value"') + res.set('Cache-Control', 'max-age=0, must-revalidate') + res.status(200).send('original-response') + }) + app.get('/:statusCode', (req, res) => res.status(+req.params.statusCode).end() ) @@ -14,18 +23,13 @@ const httpServer = new HttpServer((app) => { const interceptor = new XMLHttpRequestInterceptor() -const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() - -interceptor.on('response', responseListener) - beforeAll(async () => { await httpServer.listen() interceptor.apply() }) afterEach(() => { - vi.resetAllMocks() + interceptor.removeAllListeners() }) afterAll(async () => { @@ -33,9 +37,14 @@ afterAll(async () => { await httpServer.close() }) -it('supports a 204 response withouth body for a bypassed request', async () => { +it('intercepts a bypassed request with a 204 response', async () => { + const responseListener = + vi.fn<(...args: HttpRequestEventMap['response']) => void>() + interceptor.on('response', responseListener) + + const url = httpServer.http.url('/204') const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/204')) + request.open('GET', url) request.send() await waitForXMLHttpRequest(request) @@ -50,41 +59,142 @@ it('supports a 204 response withouth body for a bypassed request', async () => { } satisfies Partial), }) ) - expect(responseListener).toHaveBeenCalledOnce() + + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(204) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } + + { + const [{ request, response }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(204) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } }) -it('supports a 202 response withouth body for a bypassed request', async () => { +it('intercepts a bypassed request with a 202 response', async () => { + const responseListener = + vi.fn<(...args: HttpRequestEventMap['response']) => void>() + interceptor.on('response', responseListener) + + const url = httpServer.http.url('/205') const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/205')) + request.open('GET', url) request.send() await waitForXMLHttpRequest(request) expect(request.response).toBe('') - expect(responseListener).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - response: expect.objectContaining({ - status: 205, - body: null, - } satisfies Partial), - }) - ) + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(204) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } + + { + const [{ request, response }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(205) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } + + // expect(responseListener).toHaveBeenNthCalledWith( + // 1, + // expect.objectContaining({ + // request: expect.objectContaining>({ + // method: 'OPTIONS', + // }), + // }) + // ) + // expect(responseListener).toHaveBeenNthCalledWith( + // 2, + // expect.objectContaining({ + // request: expect.objectContaining>({ + // method: 'GET', + // }), + // response: expect.objectContaining>({ + // status: 205, + // body: null, + // }), + // }) + // ) }) -it('represents a 304 response without body using fetch api response', async () => { +it('exposes a fetch api reference for a 304 response without body', async () => { + const responseListener = + vi.fn<(...args: HttpRequestEventMap['response']) => void>() + interceptor.on('response', responseListener) + + const url = httpServer.http.url('/cacheable') + + // First request populates the cache with ETag + max-age=0. + { + const request = new XMLHttpRequest() + request.open('GET', url) + request.send() + await waitForXMLHttpRequest(request) + } + + responseListener.mockClear() + + // Second request to the same URL triggers revalidation (If-None-Match), + // and the server responds with 304. const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/304')) + request.open('GET', url) request.send() await waitForXMLHttpRequest(request) - expect(request.response).toBe('') - expect(responseListener).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ - response: expect.objectContaining({ - status: 304, - body: null, - } satisfies Partial), - }) - ) + expect(request.response).toBe('original-response') + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(204) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } + + // Transparently resolved 304 from the server (HappyDOM responds from cache). + { + const [{ request, response }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(200) + expect.soft(response.url).toBe(url) + await expect.soft(response.text()).resolves.toBe('original-response') + } }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts index 369e24a90..c3135819e 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts @@ -2,7 +2,7 @@ /** * @see https://github.com/mswjs/interceptors/issues/281 */ -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts deleted file mode 100644 index 6ffbf13d8..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// @vitest-environment happy-dom -/** - * @see https://github.com/mswjs/interceptors/issues/7 - */ -import { setTimeout } from 'node:timers/promises' -import { HttpServer } from '@open-draft/test-server/http' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const httpServer = new HttpServer((app) => { - app.get('/', async (_req, res) => { - await setTimeout(50) - res.send('ok') - }) -}) - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(async () => { - interceptor.apply() - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('handles request timeout via the "ontimeout" callback', async () => { - const timeoutCalled = new DeferredPromise() - - const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/')) - request.timeout = 1 - request.ontimeout = function customTimeoutCallback() { - timeoutCalled.resolve(this.readyState) - } - request.send() - - await waitForXMLHttpRequest(request) - - const nextReadyState = await timeoutCalled - expect(nextReadyState).toBe(4) -}) - -it('handles request timeout via the "timeout" event listener', async () => { - const timeoutCalled = new DeferredPromise() - - const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/')) - request.timeout = 1 - request.addEventListener('timeout', function customTimeoutListener() { - expect(this.readyState).toBe(4) - timeoutCalled.resolve(this.readyState) - }) - request.send() - - await waitForXMLHttpRequest(request) - - const nextReadyState = await timeoutCalled - expect(nextReadyState).toBe(4) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.v-browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.v-browser.test.ts new file mode 100644 index 000000000..fc2320f0c --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-timeout.v-browser.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom +/** + * @see https://github.com/mswjs/interceptors/issues/7 + */ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + setTimeout, + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('fires the "ontimeout" callback for a bypassed request', async () => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/delay')) + + request.timeout = 5 + const timeoutCallback = vi.fn() + request.ontimeout = timeoutCallback + + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.readyState).toBe(4) + expect.soft(timeoutCallback).toHaveBeenCalledOnce() + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['timeout', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) +}) + +it('dispatches the "timeout" event for a bypassed request', async () => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/delay')) + + request.timeout = 5 + const timeoutListener = vi.fn() + request.addEventListener('timeout', timeoutListener) + + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.readyState).toBe(4) + expect.soft(timeoutListener).toHaveBeenCalledOnce() + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['timeout', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) +}) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts index 29e8e710b..bf982fa69 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts @@ -1,5 +1,5 @@ +// @vitest-environment happy-dom /** - * @vitest-environment happy-dom * @note This issue is only reproducible in "happy-dom". * @see https://github.com/mswjs/msw/issues/1816 */ @@ -10,7 +10,7 @@ import axios from 'axios' * Node's Readable instead, which is completely incompatible. */ import { Response as UndiciResponse } from 'undici' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const request = axios.create({ baseURL: 'http://localhost', @@ -34,7 +34,7 @@ it('performs a request with the "xhr" axios adapter', async () => { ) }) - const res = await request('/resource') - expect(res.status).toBe(200) - expect(res.data).toBe('Hello world') + const response = await request('/resource') + expect.soft(response.status).toBe(200) + expect.soft(response.data).toBe('Hello world') }) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts index 4706aab34..e856c29ec 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts @@ -4,7 +4,7 @@ */ import { HttpServer } from '@open-draft/test-server/http' import zlib from 'zlib' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { useCors } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' diff --git a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts deleted file mode 100644 index 4648c72ac..000000000 --- a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// @vitest-environment react-native-like -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('responds to a request with an absolute URL', async () => { - interceptor.once('request', ({ controller }) => { - controller.respondWith(new Response('Hello world')) - }) - - const request = new XMLHttpRequest() - request.open('GET', 'https://example.com/resource') - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toBe(200) - expect(request.response).toBe('Hello world') -}) - -it('throws on a request with a relative URL', async () => { - expect(() => { - const request = new XMLHttpRequest() - - /** - * @note Since the "location" is not present in React Native, - * relative requests will throw (nothing to be relative to). - * This is the correct behavior in React Native, where relative - * requests are a no-op. - */ - request.open('GET', '/relative/url') - request.send() - }).toThrow('Invalid URL') -}) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts deleted file mode 100644 index 0e2a9dfaf..000000000 --- a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// @vitest-environment happy-dom -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('does not lock the request body stream when calculating the body size', async () => { - interceptor.on('request', async ({ request, controller }) => { - // Read the request body in the interceptor. - const buffer = await request.arrayBuffer() - controller.respondWith(new Response(buffer)) - }) - - const uploadLoadStartListener = vi.fn() - const request = new XMLHttpRequest() - request.upload.addEventListener('loadstart', uploadLoadStartListener) - request.open('POST', '/resource') - request.send('request-body') - - await waitForXMLHttpRequest(request) - - // Must calculate the total request body size for the upload event. - const progressEvent = uploadLoadStartListener.mock.calls[0][0] - expect(progressEvent.total).toBe(12) - - // Must be able to read the request in the interceptor - // and use its body as the mocked response body. - expect(request.responseText).toBe('request-body') -}) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.v-browser.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.v-browser.test.ts new file mode 100644 index 000000000..90a1a8dad --- /dev/null +++ b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.v-browser.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + spyOnXMLHttpRequestUpload, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('does not lock the request body stream when calculating the total request body size for uploads', async () => { + interceptor.on('request', async ({ request, controller }) => { + const buffer = await request.arrayBuffer() + controller.respondWith(new Response(buffer)) + }) + + const request = new XMLHttpRequest() + const { events: uploadEvents } = spyOnXMLHttpRequestUpload(request.upload) + request.open('POST', '/resource') + request.send('hello world') + + await waitForXMLHttpRequest(request) + + expect.soft(request.responseText).toBe('hello world') + expect(uploadEvents).toEqual([ + ['loadstart', { loaded: 0, total: 11 }], + ['progress', { loaded: 11, total: 11 }], + ['load', { loaded: 11, total: 11 }], + ['loadend', { loaded: 11, total: 11 }], + ]) +}) diff --git a/test/modules/http/intercept/http-connect.test.ts b/test/modules/http/intercept/http-connect.test.ts index 082798da8..6b19e1d67 100644 --- a/test/modules/http/intercept/http-connect.test.ts +++ b/test/modules/http/intercept/http-connect.test.ts @@ -182,8 +182,6 @@ it('errors the intercepted "CONNECT" request', async () => { it.skip('mocks the entire proxy flow end-to-end', async () => { interceptor.on('request', ({ request, controller }) => { - console.log('-->', request.method, request.url) - if (request.method === 'CONNECT') { return controller.respondWith(new Response()) } diff --git a/test/setup/helpers-neutral.ts b/test/setup/helpers-neutral.ts index b317cf480..652c9556c 100644 --- a/test/setup/helpers-neutral.ts +++ b/test/setup/helpers-neutral.ts @@ -55,14 +55,14 @@ export function spyOnXMLHttpRequest(request: XMLHttpRequest) { } } - request.onreadystatechange = addEvent('readystatechange') - request.onprogress = addEvent('progress') - request.onloadstart = addEvent('loadstart') - request.onload = addEvent('load') - request.onloadend = addEvent('loadend') - request.ontimeout = addEvent('timeout') - request.onerror = addEvent('error') - request.onabort = addEvent('abort') + request.addEventListener('readystatechange', addEvent('readystatechange')) + request.addEventListener('progress', addEvent('progress')) + request.addEventListener('loadstart', addEvent('loadstart')) + request.addEventListener('load', addEvent('load')) + request.addEventListener('loadend', addEvent('loadend')) + request.addEventListener('timeout', addEvent('timeout')) + request.addEventListener('error', addEvent('error')) + request.addEventListener('abort', addEvent('abort')) return { events, @@ -78,13 +78,13 @@ export function spyOnXMLHttpRequestUpload(upload: XMLHttpRequestUpload) { } } - upload.onloadstart = addUploadEvent('loadstart') - upload.onprogress = addUploadEvent('progress') - upload.onload = addUploadEvent('load') - upload.onloadend = addUploadEvent('loadend') - upload.onabort = addUploadEvent('abort') - upload.onerror = addUploadEvent('error') - upload.ontimeout = addUploadEvent('timeout') + upload.addEventListener('loadstart', addUploadEvent('loadstart')) + upload.addEventListener('progress', addUploadEvent('progress')) + upload.addEventListener('load', addUploadEvent('load')) + upload.addEventListener('loadend', addUploadEvent('loadend')) + upload.addEventListener('abort', addUploadEvent('abort')) + upload.addEventListener('error', addUploadEvent('error')) + upload.addEventListener('timeout', addUploadEvent('timeout')) return { events, diff --git a/vitest.config.ts b/vitest.config.ts index 1b4a911d9..7572572ee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,7 +39,6 @@ export default defineConfig({ alias: { 'vitest-environment-node-with-websocket': './envs/node-with-websocket', - 'vitest-environment-react-native-like': './envs/react-native-like', }, }, }, diff --git a/vitest.setup.ts b/vitest.setup.ts index 73ac37f16..bfea200d9 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -50,6 +50,11 @@ const server = new HttpServer((app) => { res.destroy() }) + app.get('/delay', async (req, res) => { + await setTimeout(150) + res.send('original-response') + }) + app.all('*', (req, res) => { res.status(200).set(req.headers) From ad2d5f31b341b212f3e4dae881a9493cd8f4864e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 16:49:20 +0100 Subject: [PATCH 148/198] test(xhr): rewrite the xml response test --- .../compliance/xhr-response-body-xml.test.ts | 70 -------- .../response/xhr-response-xml.neutral.test.ts | 160 ++++++++++++++++++ 2 files changed, 160 insertions(+), 70 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts create mode 100644 test/modules/XMLHttpRequest/response/xhr-response-xml.neutral.test.ts diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts deleted file mode 100644 index 9c9f2f356..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -// @vitest-environment happy-dom -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const XML_STRING = 'Content' - -describe('Content-Type: application/xml', () => { - const interceptor = new XMLHttpRequestInterceptor() - - interceptor.on('request', ({ controller }) => { - controller.respondWith( - new Response(XML_STRING, { - status: 200, - headers: { 'Content-Type': 'application/xml' }, - }) - ) - }) - - beforeAll(() => { - interceptor.apply() - }) - - afterAll(() => { - interceptor.dispose() - }) - - it('supports a mocked response with an XML response body', async () => { - const request = new XMLHttpRequest() - request.open('GET', '/arbitrary-url') - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.responseXML).toStrictEqual( - new DOMParser().parseFromString(XML_STRING, 'application/xml') - ) - }) -}) - -describe('Content-Type: text/xml', () => { - const interceptor = new XMLHttpRequestInterceptor() - interceptor.on('request', ({ controller }) => { - controller.respondWith( - new Response(XML_STRING, { - status: 200, - headers: { 'Content-Type': 'text/xml' }, - }) - ) - }) - - beforeAll(() => { - interceptor.apply() - }) - - afterAll(() => { - interceptor.dispose() - }) - - it('supports a mocked response with an XML response body', async () => { - const request = new XMLHttpRequest() - request.open('GET', '/arbitrary-url') - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.responseXML).toStrictEqual( - new DOMParser().parseFromString(XML_STRING, 'text/xml') - ) - }) -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-xml.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-xml.neutral.test.ts new file mode 100644 index 000000000..4720df553 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-xml.neutral.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +const XML_STRING = 'Content' +const domParser = new DOMParser() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with an XML response', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/xml')) + request.setRequestHeader('content-type', 'application/xml') + request.send(XML_STRING) + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(XML_STRING) + + if (task.file.projectName === 'browser') { + /** + * @note XML response parsing in HappyDOM is broken. + */ + expect + .soft(request.responseXML) + .toEqual(domParser.parseFromString(XML_STRING, 'application/xml')) + + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 32, total: 32 }], + ['readystatechange', 4], + ['load', 4, { loaded: 32, total: 32 }], + ['loadend', 4, { loaded: 32, total: 32 }], + ]) + } +}) + +it('responds with a mocked "application/xml" response', async ({ task }) => { + const XML_STRING = 'Content' + + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith( + new Response(XML_STRING, { + headers: { + 'content-type': 'application/xml', + 'content-length': '32', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', '/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe(XML_STRING) + + if (task.file.projectName === 'browser') { + expect + .soft(request.responseXML) + .toStrictEqual(domParser.parseFromString(XML_STRING, 'application/xml')) + + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 32, total: 32 }], + ['readystatechange', 4], + ['load', 4, { loaded: 32, total: 32 }], + ['loadend', 4, { loaded: 32, total: 32 }], + ]) + } +}) + +it('responds with a mocked "text/html" response', async ({ task }) => { + const XML_STRING = 'Content' + + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith( + new Response(XML_STRING, { + headers: { + 'content-type': 'text/xml', + 'content-length': '32', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', '/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe(XML_STRING) + + if (task.file.projectName === 'browser') { + expect + .soft(request.responseXML) + .toStrictEqual(domParser.parseFromString(XML_STRING, 'text/xml')) + + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 32, total: 32 }], + ['readystatechange', 4], + ['load', 4, { loaded: 32, total: 32 }], + ['loadend', 4, { loaded: 32, total: 32 }], + ]) + } +}) From 8f3de040d7d56dd9922c7d15686079ad9253e149 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 16:55:57 +0100 Subject: [PATCH 149/198] test(xhr): migrate `readyState` enum test to browser-only --- .../compliance/xhr-ready-state-enums.test.ts | 27 ------------------ .../xhr-ready-state-enums.v-browser.test.ts | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 27 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts create mode 100644 test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.v-browser.test.ts diff --git a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts deleted file mode 100644 index eb417d533..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// @vitest-environment happy-dom -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('exposes ready state enums both as static and public properties', () => { - expect(XMLHttpRequest.UNSENT).toEqual(0) - expect(XMLHttpRequest.OPENED).toEqual(1) - expect(XMLHttpRequest.HEADERS_RECEIVED).toEqual(2) - expect(XMLHttpRequest.LOADING).toEqual(3) - expect(XMLHttpRequest.DONE).toEqual(4) - - const xhr = new XMLHttpRequest() - expect(xhr.UNSENT).toEqual(0) - expect(xhr.OPENED).toEqual(1) - expect(xhr.HEADERS_RECEIVED).toEqual(2) - expect(xhr.LOADING).toEqual(3) - expect(xhr.DONE).toEqual(4) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.v-browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.v-browser.test.ts new file mode 100644 index 000000000..14da4697f --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.v-browser.test.ts @@ -0,0 +1,28 @@ +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('exposes ready state enums as static properties on XMLHttpRequest', () => { + expect.soft(XMLHttpRequest.UNSENT).toEqual(0) + expect.soft(XMLHttpRequest.OPENED).toEqual(1) + expect.soft(XMLHttpRequest.HEADERS_RECEIVED).toEqual(2) + expect.soft(XMLHttpRequest.LOADING).toEqual(3) + expect.soft(XMLHttpRequest.DONE).toEqual(4) +}) + +it('exposes ready state enums as instance-level properties', () => { + const xhr = new XMLHttpRequest() + expect.soft(xhr.UNSENT).toEqual(0) + expect.soft(xhr.OPENED).toEqual(1) + expect.soft(xhr.HEADERS_RECEIVED).toEqual(2) + expect.soft(xhr.LOADING).toEqual(3) + expect.soft(xhr.DONE).toEqual(4) +}) From 97f7c1be8598c13e50c54e6dc235eeb2a448768f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 17:08:07 +0100 Subject: [PATCH 150/198] fix(xhr): forward `unhandledException` event to the interceptor --- src/glossary.ts | 1 + src/interceptors/XMLHttpRequest/node.ts | 5 +++ src/utils/handleRequest.ts | 1 + .../xhr-middleware-exception.test.ts | 40 +++++++++++++------ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/glossary.ts b/src/glossary.ts index e819bf53c..40294d929 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -28,6 +28,7 @@ export type HttpRequestEventMap = { ] unhandledException: [ args: { + initiator: unknown error: unknown request: Request requestId: string diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index b7eff4be0..6ed2fc451 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -37,6 +37,11 @@ export class XMLHttpRequestInterceptor extends Interceptor await emitAsync(this.emitter, 'response', args) } }) + .on('unhandledException', async (args) => { + if (args.initiator instanceof XMLHttpRequest) { + await emitAsync(this.emitter, 'unhandledException', args) + } + }) this.logger.info('patching global "XMLHttpRequest"...') diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 8c3651b3b..1e98dabac 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -164,6 +164,7 @@ export async function handleRequest( ) await emitAsync(options.emitter, 'unhandledException', { + initiator: options.initiator, error: result.error, request: options.request, requestId: options.requestId, diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index 5536eab36..33fbfbf3c 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -42,7 +42,16 @@ it('XMLHttpRequest: treats unhandled interceptor exceptions as 500 responses', a }) it('axios: unhandled interceptor exceptions are treated as 500 responses', async () => { - interceptor.on('request', () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + throw new Error('Custom error') }) @@ -154,11 +163,10 @@ it('handles exceptions as instructed in "unhandledException" listener (mock resp throw new Error('Custom error') }) interceptor.on('unhandledException', (args) => { - const { controller } = args unhandledExceptionListener(args) // Handle exceptions as a fallback 200 OK response. - controller.respondWith(new Response('fallback response')) + args.controller.respondWith(new Response('fallback response')) }) const requestErrorListener = vi.fn() @@ -170,30 +178,38 @@ it('handles exceptions as instructed in "unhandledException" listener (mock resp await waitForXMLHttpRequest(request) - expect(request.status).toBe(200) - expect(request.response).toBe('fallback response') + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('fallback response') - expect(unhandledExceptionListener).toHaveBeenCalledWith( + expect.soft(unhandledExceptionListener).toHaveBeenCalledWith( expect.objectContaining({ error: new Error('Custom error'), }) ) - expect(unhandledExceptionListener).toHaveBeenCalledOnce() - expect(requestErrorListener).not.toHaveBeenCalled() + expect.soft(unhandledExceptionListener).toHaveBeenCalledOnce() + expect.soft(requestErrorListener).not.toHaveBeenCalled() }) it('handles exceptions as instructed in "unhandledException" listener (request error)', async () => { const unhandledExceptionListener = vi.fn() - interceptor.on('request', () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + throw new Error('Custom error') }) interceptor.on('unhandledException', (args) => { - const { request, controller } = args unhandledExceptionListener(args) // Handle exceptions as request errors. - controller.errorWith(new Error('Fallback error')) + args.controller.errorWith(new Error('Fallback error')) }) const requestErrorListener = vi.fn() @@ -206,7 +222,7 @@ it('handles exceptions as instructed in "unhandledException" listener (request e await waitForXMLHttpRequest(request) expect(requestErrorListener).toHaveBeenCalledOnce() - expect(request.readyState).toBe(request.DONE) + expect(request.readyState).toBe(4) expect(unhandledExceptionListener).toHaveBeenCalledWith( expect.objectContaining({ From 5eff57fa499bd2045f814c76eafd96b0d3c4e84a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 17:09:51 +0100 Subject: [PATCH 151/198] fix(fetch): forward `unhandledException` events to the interceptor --- src/interceptors/fetch/node.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 282cbbc6d..22b4fe9b0 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -35,6 +35,11 @@ export class FetchInterceptor extends Interceptor { await emitAsync(this.emitter, 'response', args) } }) + .on('unhandledException', async (args) => { + if (args.initiator instanceof Request) { + await emitAsync(this.emitter, 'unhandledException', args) + } + }) this.subscriptions.push( applyPatch(globalThis, 'fetch', (realFetch) => { From 472a36f35af6c1232db9b0bd249d4249d27729ea Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 17:20:56 +0100 Subject: [PATCH 152/198] test(xhr): fix `xhr-event-callback-null` test suite --- ...> xhr-event-callback-null.neutral.test.ts} | 133 ++++++++++-------- vitest.setup.ts | 3 + 2 files changed, 80 insertions(+), 56 deletions(-) rename test/modules/XMLHttpRequest/compliance/{xhr-event-callback-null.test.ts => xhr-event-callback-null.neutral.test.ts} (52%) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.neutral.test.ts similarity index 52% rename from test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts rename to test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.neutral.test.ts index ed74e59c0..28b3e8b71 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.neutral.test.ts @@ -2,87 +2,83 @@ /** * @see https://xhr.spec.whatwg.org/#event-handlers */ -import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() -const httpServer = new HttpServer((app) => { - app.get('/resource', (req, res) => { - res.send('hello') - }) - app.get('/error-response', (req, res) => { - res.status(500).send('Internal Server Error') - }) - app.get('/exception', (req, res) => { - throw new Error('Server error') - }) -}) - -beforeAll(async () => { +beforeAll(() => { interceptor.apply() - interceptor.on('request', ({ request, controller }) => { - switch (true) { - case request.url.endsWith('/exception'): { - throw new Error('Custom error') - } - - case request.url.endsWith('/network-error'): { - return controller.respondWith(Response.error()) - } - - case request.url.endsWith('/error-response'): { - return controller.respondWith( - new Response('Internal Server Error', { status: 500 }) - ) - } - - default: - return controller.respondWith(new Response('hello')) - } - }) +}) - await httpServer.listen() +afterEach(() => { + interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await httpServer.close() }) it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/resource')], + ['passthrough', () => server.https.url('/').href], ['mocked', () => 'http://localhost/resource'], ])( `does not fail when unsetting event handlers for a successful %s response`, async (_, getUrl) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith(new Response('hello')) + }) const url = getUrl() const request = new XMLHttpRequest() - request.open('GET', url) + request.open('POST', url) request.onreadystatechange = null request.onloadstart = null request.onprogress = null request.onload = null request.onloadend = null request.ontimeout = null - request.send() + request.send('hello') await waitForXMLHttpRequest(request) - expect(request.readyState).toBe(4) - expect(request.status).toBe(200) - expect(request.responseText).toBe('hello') + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(200) + expect.soft(request.responseText).toBe('hello') } ) it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/error-response')], + ['passthrough', () => server.https.url('/server-error').href], ['mocked', () => 'http://localhost/error-response'], ])( `does not fail when unsetting event handlers for a %s error response`, async (_, getUrl) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith( + new Response('Internal Server Error', { status: 500 }) + ) + }) const url = getUrl() const request = new XMLHttpRequest() @@ -97,18 +93,30 @@ it.each<[name: string, getUrl: () => string]>([ await waitForXMLHttpRequest(request) - expect(request.readyState).toBe(4) - expect(request.status).toBe(500) - expect(request.responseText).toBe('Internal Server Error') + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(500) + expect.soft(request.responseText).toBe('Internal Server Error') } ) it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/network-error')], + ['passthrough', () => server.https.url('/network-error').href], ['mocked', () => 'http://localhost/network-error'], ])( `does not fail when unsetting event handlers for a %s request error`, async (_, getUrl) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith(Response.error()) + }) const url = getUrl() const request = new XMLHttpRequest() @@ -123,16 +131,29 @@ it.each<[name: string, getUrl: () => string]>([ await waitForXMLHttpRequest(request) - expect(request.readyState).toBe(4) - expect(request.status).toBe(0) - expect(request.responseText).toBe('') + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(0) + expect.soft(request.responseText).toBe('') } ) it('does not fail when unsetting event handlers during unhandled exception in the interceptor', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + throw new Error('Custom error') + }) + const request = new XMLHttpRequest() request.responseType = 'json' - request.open('GET', httpServer.https.url('/exception')) + request.open('GET', server.https.url('/network-error')) request.onreadystatechange = null request.onloadstart = null request.onprogress = null @@ -143,10 +164,10 @@ it('does not fail when unsetting event handlers during unhandled exception in th await waitForXMLHttpRequest(request) - expect(request.readyState).toBe(4) - expect(request.status).toBe(500) - expect(request.statusText).toBe('Unhandled Exception') - expect(request.response).toEqual({ + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(500) + expect.soft(request.statusText).toBe('Unhandled Exception') + expect.soft(request.response).toEqual({ name: 'Error', message: 'Custom error', stack: expect.any(String), diff --git a/vitest.setup.ts b/vitest.setup.ts index bfea200d9..c162ff4cd 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -46,6 +46,9 @@ const server = new HttpServer((app) => { Readable.fromWeb(stream as any).pipe(res) }) + app.get('/server-error', (req, res) => { + res.status(500).send('Internal Server Error') + }) app.get('/network-error', (req, res) => { res.destroy() }) From 09bf906158645a1affb80f4780042c4ccbb264db Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 17:23:26 +0100 Subject: [PATCH 153/198] test: remove `xhr-no-response` suite --- .../xhr-no-response-headers.test.ts | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts diff --git a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts deleted file mode 100644 index 13904431f..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// @vitest-environment happy-dom -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const interceptor = new XMLHttpRequestInterceptor() - -const httpServer = new HttpServer((app) => { - app.get('/user', (_req, res) => { - res.header('access-control-allow-origin', '*') - res.header('content-type', 'plain/text') - res.send('hello world') - }) -}) - -beforeAll(async () => { - interceptor.apply() - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('handles original response without any headers', async () => { - const request = new XMLHttpRequest() - request.open('GET', httpServer.http.url('/user')) - request.setRequestHeader('accept', 'plain/text') - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toEqual(200) - expect(request.statusText).toEqual('OK') - expect(request.responseText).toEqual('hello world') - /** - * @note Having an XHR response with no headers is virtually impossible - * due to the CORS and preflight requests policies. - */ - expect(request.getAllResponseHeaders()).toEqual( - ['content-type: plain/text; charset=utf-8', 'content-length: 11'].join( - '\r\n' - ) - ) -}) From 164c338503ec1a674a9364c28d13331f9867cfe2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 19:00:20 +0100 Subject: [PATCH 154/198] fix: translate `request` changes from upstream interceptors --- src/interceptors/http/index.ts | 40 ++++++++++++++----- src/utils/handleRequest.ts | 21 ++++++++-- .../compliance/xhr-modify-request.test.ts | 31 ++++++++------ .../compliance/http-modify-request.test.ts | 24 +++-------- 4 files changed, 73 insertions(+), 43 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 0a6363310..a70053e62 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -22,7 +22,7 @@ import { toBuffer } from '../../utils/bufferUtils' import { createRequestId } from '../../createRequestId' import { HttpRequestParser, HttpResponseParser } from './http-parser' import { emitAsync } from '../../utils/emitAsync' -import { handleRequest } from '../../utils/handleRequest' +import { handleRequest, HandleRequestOptions } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' import { @@ -114,7 +114,7 @@ export class HttpRequestInterceptor extends Interceptor { const respond = () => { return this.respondWith({ socket: socketController[kRawSocket], - request, + request: context.request, response, }) } @@ -145,7 +145,7 @@ export class HttpRequestInterceptor extends Interceptor { await emitAsync(this.emitter, 'response', { initiator, requestId, - request, + request: context.request, response: responseClone, isMockedResponse: true, }) @@ -161,7 +161,7 @@ export class HttpRequestInterceptor extends Interceptor { /** * @todo Would be great NOT to run this if request headers weren't modified. */ - this.#modifyHttpHeaders(request) + this.#modifyHttpHeaders(context.request) ) if (this.emitter.listenerCount('response')) { @@ -198,7 +198,7 @@ export class HttpRequestInterceptor extends Interceptor { await emitAsync(this.emitter, 'response', { initiator, requestId, - request, + request: context.request, response, isMockedResponse: false, }) @@ -223,13 +223,20 @@ export class HttpRequestInterceptor extends Interceptor { socketController['readyState'] ) - await handleRequest({ + /** + * @note Create a request resolution context. + * This is so modifications to the "request" in upstream interceptors + * are correctly picked up by the underlying HTTP interceptor. + */ + const context: HandleRequestOptions = { initiator, - request, requestId, + request, controller: requestController, emitter: this.emitter, - }) + } + + await handleRequest(context) }, }) @@ -346,11 +353,24 @@ export class HttpRequestInterceptor extends Interceptor { ) const rawHeaders = getRawFetchHeaders(request.headers) + const visitedHeaders = new Set() - for (const [name, value] of rawHeaders) { - httpMessageHeaders.set(name, value) + for (const [headerName] of rawHeaders) { + const normalizedHeaderName = headerName.toLowerCase() + + if (visitedHeaders.has(normalizedHeaderName)) { + continue + } + + visitedHeaders.add(normalizedHeaderName) + + // Use the merged value from Headers to correctly handle + // appended headers (e.g. "1, 2" instead of just "2"). + httpMessageHeaders.set(headerName, request.headers.get(headerName)!) } + visitedHeaders.clear() + const httpMessageHeadersString = Array.from(httpMessageHeaders) .map(([name, value]) => `${name}: ${value}`) .join('\r\n') diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 1e98dabac..159d437ce 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -13,7 +13,7 @@ import { InterceptorError } from '../InterceptorError' import { isNodeLikeError } from './isNodeLikeError' import { isObject } from './isObject' -interface HandleRequestOptions { +export interface HandleRequestOptions { initiator: unknown requestId: string request: Request @@ -103,12 +103,17 @@ export async function handleRequest( // for that event are finished (e.g. async listeners awaited). // By the end of this promise, the developer cannot affect the // request anymore. - const requestListenersPromise = emitAsync(options.emitter, 'request', { + const requestArgs = { initiator: options.initiator, requestId: options.requestId, request: options.request, controller: options.controller, - }) + } + const requestListenersPromise = emitAsync( + options.emitter, + 'request', + requestArgs + ) await Promise.race([ // Short-circuit the request handling promise if the request gets aborted. @@ -116,6 +121,16 @@ export async function handleRequest( requestListenersPromise, options.controller.handled, ]) + + /** + * @note If the "request" listener has replaced the request instance, + * propagate that mutation back to the underlying insterceptor. + * This happens with XMLHttpRequest that replaces request instances + * to correctly reflect the "withCredentials" option on the Fetch API request. + */ + if (requestArgs.request !== options.request) { + options.request = requestArgs.request + } }) // Handle the request being aborted while waiting for the request listeners. diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts index 91dc3ed24..6aff31365 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -27,19 +27,21 @@ const server = new HttpServer((app) => { const interceptor = new XMLHttpRequestInterceptor() beforeAll(async () => { - vi.spyOn(console, 'warn').mockImplementation(() => void 0) await server.listen() interceptor.apply() }) afterAll(async () => { - vi.restoreAllMocks() await server.close() interceptor.dispose() }) it('allows modifying outgoing request headers', async () => { interceptor.on('request', ({ request }) => { + if (request.method === 'OPTIONS') { + return + } + request.headers.delete('X-Delete-Header') request.headers.append('X-Append-Header', '2') request.headers.set('X-Set-Header', 'new-value') @@ -53,15 +55,20 @@ it('allows modifying outgoing request headers', async () => { await waitForXMLHttpRequest(request) - // Cannot delete XMLHttpRequest headers. - expect(request.getResponseHeader('x-delete-header')).toBe('a') - expect(console.warn).toHaveBeenCalledWith( - expect.stringMatching( - `XMLHttpRequest: Cannot remove a "X-Delete-Header" header from the Fetch API representation of the "GET http://127.0.0.1:\\d+/user" request. XMLHttpRequest headers cannot be removed.` + expect.soft(request.status).toBe(200) + expect + .soft( + request.getResponseHeader('x-delete-header'), + 'XMLHttpRequest headers cannot be deleted' ) - ) - - // Adding and modifying XMLHttpRequest headers is allowed. - expect(request.getResponseHeader('x-append-header')).toBe('1, 2') - expect(request.getResponseHeader('x-set-header')).toBe('new-value') + .toBe('a') + expect + .soft( + request.getResponseHeader('x-append-header'), + 'Appends a new header value' + ) + .toBe('1, 2') + expect + .soft(request.getResponseHeader('x-set-header'), 'Replace a header value') + .toBe('new-value') }) diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index 31be20a38..d1f8d3e6b 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -3,36 +3,24 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { toWebResponse } from '#/test/helpers' +import { getTestServer } from '#/test/setup/vitest' -const server = new HttpServer((app) => { - app.use('/user', (req, res) => { - const header = req.headers['x-appended-header'] - - if (header) { - res.set('x-appended-header', header) - } - - res.end() - }) -}) - +const server = getTestServer() const interceptor = new HttpRequestInterceptor() -beforeAll(async () => { +beforeAll(() => { interceptor.apply() - await server.listen() }) afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await server.close() }) -it('allows modifying the outgoing headers for a request without a body', async () => { +it('allows modifying the request headers for a request without a body', async () => { interceptor.on('request', ({ request }) => { request.headers.set('x-appended-header', 'modified') }) @@ -46,7 +34,7 @@ it('allows modifying the outgoing headers for a request without a body', async ( }) }) -it('allows modifying the outgoing request headers in a request with a body', async () => { +it('allows modifying the request headers for a request with a body', async () => { interceptor.on('request', ({ request }) => { request.headers.set('x-appended-header', 'modified') }) From b6d2a1f163ec20ebd154c9af821d114e6f7225eb Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 19:01:40 +0100 Subject: [PATCH 155/198] test(xhr): remove `xhr-add-event-listener` test (already tested) --- .../compliance/xhr-add-event-listener.test.ts | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts diff --git a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts deleted file mode 100644 index a2e4bec40..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// @vitest-environment happy-dom -/** - * @see https://github.com/mswjs/msw/issues/273 - */ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - if (request.url === 'https://test.mswjs.io/user') { - controller.respondWith( - new Response(JSON.stringify({ mocked: true }), { - status: 200, - statusText: 'OK', - headers: { - 'content-type': 'application/json', - 'x-header': 'yes', - }, - }) - ) - } -}) - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('calls the "load" event attached via "addEventListener" with a mocked response', async () => { - const request = new XMLHttpRequest() - request.open('GET', 'https://test.mswjs.io/user') - request.responseType = 'json' - request.send() - - await waitForXMLHttpRequest(request) - - expect(request.status).toBe(200) - expect(request.getAllResponseHeaders()).toEqual( - `content-type: application/json\r\nx-header: yes` - ) - expect(request.response).toEqual({ mocked: true }) -}) From c6035ce4926c39df4126e74d2896780f11cb20dc Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 19:05:16 +0100 Subject: [PATCH 156/198] feat: add `/http` export path --- http/package.json | 11 +++++++++++ package.json | 12 +++++++++++- pnpm-lock.yaml | 27 ++++++++++++++++++++++----- tsdown.config.mts | 1 + 4 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 http/package.json diff --git a/http/package.json b/http/package.json new file mode 100644 index 000000000..5517cf68f --- /dev/null +++ b/http/package.json @@ -0,0 +1,11 @@ +{ + "main": "../lib/node/interceptors/node/index.cjs", + "module": "../lib/node/interceptors/node/index.mjs", + "browser": null, + "exports": { + ".": { + "import": "./../lib/node/interceptors/node/index.mjs", + "default": "./../lib/node/interceptors/node/index.cjs" + } + } +} diff --git a/package.json b/package.json index f04a5dcb2..40e8b1463 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,16 @@ "import": "./lib/node/index.mjs", "default": "./lib/node/index.cjs" }, + "./http": { + "node": { + "require": "./lib/node/interceptors/http/index.cjs", + "import": "./lib/node/interceptors/http/index.mjs" + }, + "browser": null, + "require": "./lib/node/interceptors/http/index.cjs", + "import": "./lib/node/interceptors/http/index.mjs", + "default": "./lib/node/interceptors/http/index.cjs" + }, "./ClientRequest": { "node": { "require": "./lib/node/interceptors/ClientRequest/index.cjs", @@ -149,7 +159,7 @@ "typescript": "^5.8.2", "undici": "^7.22.0", "vitest": "^4.0.18", - "vitest-environment-miniflare": "^2.14.1", + "vitest-environment-miniflare": "^2.14.4", "web-encoding": "^1.1.5", "webpack": "^5.105.0", "webpack-http-server": "^0.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40fed1334..9167ceccd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,7 +163,7 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) vitest-environment-miniflare: - specifier: ^2.14.1 + specifier: ^2.14.4 version: 2.14.4(vitest@4.0.18) web-encoding: specifier: ^1.1.5 @@ -536,66 +536,82 @@ packages: '@miniflare/cache@2.14.4': resolution: {integrity: sha512-ayzdjhcj+4mjydbNK7ZGDpIXNliDbQY4GPcY2KrYw0v1OSUdj5kZUkygD09fqoGRfAks0d91VelkyRsAXX8FQA==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/core@2.14.4': resolution: {integrity: sha512-FMmZcC1f54YpF4pDWPtdQPIO8NXfgUxCoR9uyrhxKJdZu7M6n8QKopPVNuaxR40jcsdxb7yKoQoFWnHfzJD9GQ==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/d1@2.14.4': resolution: {integrity: sha512-pMBVq9XWxTDdm+RRCkfXZP+bREjPg1JC8s8C0JTovA9OGmLQXqGTnFxIaS9vf1d8k3uSUGhDzPTzHr0/AUW1gA==} engines: {node: '>=16.7'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/durable-objects@2.14.4': resolution: {integrity: sha512-+JrmHP6gHHrjxV8S3axVw5lGHLgqmAGdcO/1HJUPswAyJEd3Ah2YnKhpo+bNmV4RKJCtEq9A2hbtVjBTD2YzwA==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/html-rewriter@2.14.4': resolution: {integrity: sha512-GB/vZn7oLbnhw+815SGF+HU5EZqSxbhIa3mu2L5MzZ2q5VOD5NHC833qG8c2GzDPhIaZ99ITY+ZJmbR4d+4aNQ==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/kv@2.14.4': resolution: {integrity: sha512-QlERH0Z+klwLg0xw+/gm2yC34Nnr/I0GcQ+ASYqXeIXBwjqOtMBa3YVQnocaD+BPy/6TUtSpOAShHsEj76R2uw==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/queues@2.14.4': resolution: {integrity: sha512-aXQ5Ik8Iq1KGMBzGenmd6Js/jJgqyYvjom95/N9GptCGpiVWE5F0XqC1SL5rCwURbHN+aWY191o8XOFyY2nCUA==} engines: {node: '>=16.7'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/r2@2.14.4': resolution: {integrity: sha512-4ctiZWh7Ty7LB3brUjmbRiGMqwyDZgABYaczDtUidblo2DxX4JZPnJ/ZAyxMPNJif32kOJhcg6arC2hEthR9Sw==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/runner-vm@2.14.4': resolution: {integrity: sha512-Nog0bB9SVhPbZAkTWfO4lpLAUsBXKEjlb4y+y66FJw77mPlmPlVdpjElCvmf8T3VN/pqh83kvELGM+/fucMf4g==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/shared-test-environment@2.14.4': resolution: {integrity: sha512-FdU2/8wEd00vIu+MfofLiHcfZWz+uCbE2VTL85KpyYfBsNGAbgRtzFMpOXdoXLqQfRu6MBiRwWpb2FbMrBzi7g==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/shared@2.14.4': resolution: {integrity: sha512-upl4RSB3hyCnITOFmRZjJj4A72GmkVrtfZTilkdq5Qe5TTlzsjVeDJp7AuNUM9bM8vswRo+N5jOiot6O4PVwwQ==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/sites@2.14.4': resolution: {integrity: sha512-O5npWopi+fw9W9Ki0gy99nuBbgDva/iXy8PDC4dAXDB/pz45nISDqldabk0rL2t4W2+lY6LXKzdOw+qJO1GQTA==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/storage-file@2.14.4': resolution: {integrity: sha512-JxcmX0hXf4cB0cC9+s6ZsgYCq+rpyUKRPCGzaFwymWWplrO3EjPVxKCcMxG44jsdgsII6EZihYUN2J14wwCT7A==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/storage-memory@2.14.4': resolution: {integrity: sha512-9jB5BqNkMZ3SFjbPFeiVkLi1BuSahMhc/W1Y9H0W89qFDrrD+z7EgRgDtHTG1ZRyi9gIlNtt9qhkO1B6W2qb2A==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/watcher@2.14.4': resolution: {integrity: sha512-PYn05ET2USfBAeXF6NZfWl0O32KVyE8ncQ/ngysrh3hoIV7l3qGGH7ubeFx+D8VWQ682qYhwGygUzQv2j1tGGg==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/web-sockets@2.14.4': resolution: {integrity: sha512-stTxvLdJ2IcGOs76AnvGYAzGvx8JvQPRxC5DW0P5zdAAnhL33noqb5LKdPt3P37BKp9FzBKZHuihQI9oVqwm0g==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} @@ -3157,6 +3173,7 @@ packages: vitest-environment-miniflare@2.14.4: resolution: {integrity: sha512-DzwQWdY42sVYR6aUndw9FdCtl/i0oh3NkbkQpw+xq5aYQw5eiJn5kwnKaKQEWaoBe8Cso71X2i1EJGvi1jZ2xw==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 peerDependencies: vitest: '>=0.23.0' @@ -3799,7 +3816,7 @@ snapshots: '@miniflare/core': 2.14.4 '@miniflare/shared': 2.14.4 undici: 5.28.4 - ws: 8.18.1 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -4020,7 +4037,7 @@ snapshots: '@types/better-sqlite3@7.6.12': dependencies: - '@types/node': 18.19.67 + '@types/node': 22.13.9 '@types/body-parser@1.19.5': dependencies: @@ -4501,7 +4518,7 @@ snapshots: builtins@5.1.0: dependencies: - semver: 7.6.3 + semver: 7.7.3 busboy@1.6.0: dependencies: @@ -5621,7 +5638,7 @@ snapshots: dependencies: execa: 6.1.0 parse-package-name: 1.0.0 - semver: 7.6.3 + semver: 7.7.3 validate-npm-package-name: 4.0.0 object-assign@4.1.1: {} diff --git a/tsdown.config.mts b/tsdown.config.mts index 7ae87faae..5c07806ee 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -7,6 +7,7 @@ export default defineConfig([ './src/index.ts', './src/presets/node.ts', './src/RemoteHttpInterceptor.ts', + './src/interceptors/http/index.ts', './src/interceptors/ClientRequest/index.ts', './src/interceptors/XMLHttpRequest/node.ts', './src/interceptors/fetch/index.ts', From e0d4412a3e4724a55459df32293d6a9f251b1b8e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 9 Mar 2026 19:05:24 +0100 Subject: [PATCH 157/198] test(miniflare): fix the imports --- test/third-party/miniflare.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index 96d64e8e8..d071ac1ae 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -1,10 +1,10 @@ // @vitest-environment miniflare import http from 'node:http' import https from 'node:https' -import { BatchInterceptor } from '#/src/index' -import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { BatchInterceptor } from '@mswjs/interceptors' +import { HttpRequestInterceptor } from '@mswjs/interceptors/http' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { FetchInterceptor } from '@mswjs/interceptors/fetch' import { toWebResponse } from '#/test/helpers' const interceptor = new BatchInterceptor({ From d5c838fca1b788e75de1103642a190dad0dffdc8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 12 Mar 2026 12:01:43 +0100 Subject: [PATCH 158/198] fix(wip): fixing a bunch of failed tests --- package.json | 2 +- pnpm-lock.yaml | 16 +-- src/BatchInterceptor.test.ts | 26 ++-- src/BatchInterceptor.ts | 55 ++++---- src/Interceptor.test.ts | 28 ++-- src/Interceptor.ts | 45 +++---- src/RemoteHttpInterceptor.ts | 20 +-- src/events/http.ts | 92 +++++++++++++ src/events/websocket.ts | 47 +++++++ src/glossary.ts | 29 ----- src/index.ts | 3 +- src/interceptors/ClientRequest/index.ts | 22 ++-- src/interceptors/WebSocket/index.ts | 47 ++----- .../XMLHttpRequestController.ts | 5 +- .../XMLHttpRequest/XMLHttpRequestProxy.ts | 24 ++-- src/interceptors/XMLHttpRequest/node.ts | 37 +++--- src/interceptors/XMLHttpRequest/web.ts | 5 +- src/interceptors/fetch/index.ts | 41 +++--- src/interceptors/fetch/node.ts | 28 ++-- src/interceptors/http/index.ts | 56 ++++---- src/interceptors/net/index.ts | 57 +++++--- src/utils/emitAsync.ts | 25 ---- src/utils/handleRequest.ts | 40 +++--- src/utils/interceptor-utils.ts | 65 +++++++++ test/features/events/request.test.ts | 114 +++++++++------- test/features/events/response.test.ts | 123 ++++++++---------- ...response-forbidden-headers.neutral.test.ts | 1 - .../xhr-response-non-configurable.test.ts | 34 ++--- .../xhr-response-without-body.test.ts | 6 +- .../XMLHttpRequest/features/events.test.ts | 8 +- .../fetch/intercept/fetch.request.test.ts | 2 +- test/modules/fetch/intercept/fetch.test.ts | 24 ++-- test/modules/http/compliance/events.test.ts | 18 +-- .../http-res-read-multiple-times.test.ts | 2 +- .../intercept/http-client-request.test.ts | 12 +- test/modules/http/intercept/http.get.test.ts | 2 +- .../http/intercept/http.request.test.ts | 4 +- test/modules/http/intercept/https.get.test.ts | 4 +- .../http/intercept/https.request.test.ts | 2 +- test/third-party/miniflare-xhr.test.ts | 2 +- test/third-party/miniflare.test.ts | 13 +- test/third-party/supertest.test.ts | 5 +- vitest.setup.ts | 5 + 43 files changed, 671 insertions(+), 525 deletions(-) create mode 100644 src/events/http.ts create mode 100644 src/events/websocket.ts delete mode 100644 src/utils/emitAsync.ts create mode 100644 src/utils/interceptor-utils.ts diff --git a/package.json b/package.json index 40e8b1463..6e93936c9 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "es-toolkit": "^1.44.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" + "rettime": "^0.11.6" }, "resolutions": { "memfs": "^3.4.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9167ceccd..767cae4eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,9 @@ importers: outvariant: specifier: ^1.4.3 version: 1.4.3 - strict-event-emitter: - specifier: ^0.5.1 - version: 0.5.1 + rettime: + specifier: ^0.11.6 + version: 0.11.6 devDependencies: '@commitlint/cli': specifier: ^19.7.1 @@ -2667,6 +2667,9 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + rettime@0.11.6: + resolution: {integrity: sha512-2Mp+0ie3Ql9ivj2BbdCboaaLzRoz8B2WEmEvJTKvOmDAnpH+gGb1/urWGJRG4KPIvA0wfYSPsbIj3TBFTfTxew==} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -2850,9 +2853,6 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} - strict-event-emitter@0.5.1: - resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5910,6 +5910,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + rettime@0.11.6: {} + rfdc@1.4.1: {} rolldown-plugin-dts@0.19.1(rolldown@1.0.0-beta.55)(typescript@5.8.2): @@ -6160,8 +6162,6 @@ snapshots: streamsearch@1.1.0: {} - strict-event-emitter@0.5.1: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 diff --git a/src/BatchInterceptor.test.ts b/src/BatchInterceptor.test.ts index 544e62dea..e969d955e 100644 --- a/src/BatchInterceptor.test.ts +++ b/src/BatchInterceptor.test.ts @@ -1,3 +1,4 @@ +import { TypedEvent } from 'rettime' import { Interceptor } from './Interceptor' import { BatchInterceptor } from './BatchInterceptor' @@ -38,14 +39,16 @@ it('applies child interceptors', () => { }) it('proxies event listeners to the interceptors', () => { - class PrimaryInterceptor extends Interceptor<{ hello: [string] }> { + class PrimaryInterceptor extends Interceptor<{ + hello: TypedEvent + }> { constructor() { super(Symbol('primary')) } } class SecondaryInterceptor extends Interceptor<{ - goodbye: [string] + goodbye: TypedEvent }> { constructor() { super(Symbol('secondary')) @@ -69,14 +72,15 @@ it('proxies event listeners to the interceptors', () => { interceptor.on('goodbye', goodbyeListener) // Emulate the child interceptor emitting events. - instances.primary['emitter'].emit('hello', 'John') - instances.secondary['emitter'].emit('goodbye', 'Kate') + const helloEvent = new TypedEvent('hello', { data: 'John' }) + instances.primary['emitter'].emit(helloEvent) + + const goodbyeEvent = new TypedEvent('goodbye', { data: 'Kate' }) + instances.secondary['emitter'].emit(goodbyeEvent) // Must call the batch interceptor listener. - expect(helloListener).toHaveBeenCalledTimes(1) - expect(helloListener).toHaveBeenCalledWith('John') - expect(goodbyeListener).toHaveBeenCalledTimes(1) - expect(goodbyeListener).toHaveBeenCalledWith('Kate') + expect(helloListener).toHaveBeenCalledExactlyOnceWith(helloEvent) + expect(goodbyeListener).toHaveBeenCalledExactlyOnceWith(goodbyeEvent) }) it('disposes of child interceptors', async () => { @@ -166,7 +170,7 @@ it('forwards listeners removal via "off()"', () => { const listener = vi.fn() interceptor.on('foo', listener) - interceptor.off('foo', listener) + interceptor.removeListener('foo', listener) expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0) expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0) @@ -174,8 +178,8 @@ it('forwards listeners removal via "off()"', () => { it('forwards removal of all listeners by name via ".removeAllListeners()"', () => { type Events = { - foo: [] - bar: [] + foo: TypedEvent + bar: TypedEvent } class FirstInterceptor extends Interceptor { diff --git a/src/BatchInterceptor.ts b/src/BatchInterceptor.ts index 083ce5fb2..3001be7fb 100644 --- a/src/BatchInterceptor.ts +++ b/src/BatchInterceptor.ts @@ -1,20 +1,21 @@ -import { EventMap, Listener } from 'strict-event-emitter' -import { Interceptor, ExtractEventNames } from './Interceptor' +import { DefaultEventMap, Emitter, TypedListenerOptions } from 'rettime' +import { Interceptor } from './Interceptor' export interface BatchInterceptorOptions< - InterceptorList extends ReadonlyArray> + InterceptorList extends ReadonlyArray>, > { name: string interceptors: InterceptorList } export type ExtractEventMapType< - InterceptorList extends ReadonlyArray> -> = InterceptorList extends ReadonlyArray - ? InterceptorType extends Interceptor - ? EventMap + InterceptorList extends ReadonlyArray>, +> = + InterceptorList extends ReadonlyArray + ? InterceptorType extends Interceptor + ? EventMap + : never : never - : never /** * A batch interceptor that exposes a single interface @@ -22,14 +23,14 @@ export type ExtractEventMapType< */ export class BatchInterceptor< InterceptorList extends ReadonlyArray>, - Events extends EventMap = ExtractEventMapType + Events extends DefaultEventMap = ExtractEventMapType, > extends Interceptor { static symbol: symbol private interceptors: InterceptorList constructor(options: BatchInterceptorOptions) { - BatchInterceptor.symbol = Symbol(options.name) + BatchInterceptor.symbol = Symbol.for(options.name) super(BatchInterceptor.symbol) this.interceptors = options.interceptors } @@ -48,44 +49,48 @@ export class BatchInterceptor< } } - public on>( - event: EventName, - listener: Listener + public on>( + type: EventType, + listener: Emitter.Listener, + options?: TypedListenerOptions ): this { // Instead of adding a listener to the batch interceptor, // propagate the listener to each of the individual interceptors. for (const interceptor of this.interceptors) { - interceptor.on(event, listener) + interceptor.on(type, listener, options) } return this } - public once>( - event: EventName, - listener: Listener + public once>( + event: EventType, + listener: Emitter.Listener, + options?: Omit ): this { for (const interceptor of this.interceptors) { - interceptor.once(event, listener) + interceptor.once(event, listener, options) } return this } - public off>( - event: EventName, - listener: Listener + public removeListener< + EventType extends Emitter.AllEventTypes, + >( + event: EventType, + listener: Emitter.Listener ): this { for (const interceptor of this.interceptors) { - interceptor.off(event, listener) + interceptor.removeListener(event, listener) } return this } - public removeAllListeners>( - event?: EventName | undefined - ): this { + public removeAllListeners< + EventType extends Emitter.AllEventTypes, + >(event?: EventType | undefined): this { for (const interceptors of this.interceptors) { interceptors.removeAllListeners(event) } diff --git a/src/Interceptor.test.ts b/src/Interceptor.test.ts index 50d11e1e7..a727a01fb 100644 --- a/src/Interceptor.test.ts +++ b/src/Interceptor.test.ts @@ -1,3 +1,4 @@ +import { TypedEvent } from 'rettime' import { Interceptor, getGlobalSymbol, @@ -12,11 +13,6 @@ afterEach(() => { deleteGlobalSymbol(symbol) }) -it('does not set a maximum listeners limit', () => { - const interceptor = new Interceptor(symbol) - expect(interceptor['emitter'].getMaxListeners()).toBe(0) -}) - describe('on()', () => { it('adds a new listener using "on()"', () => { const interceptor = new Interceptor(symbol) @@ -36,15 +32,16 @@ describe('once()', () => { interceptor.once('foo', listener) expect(listener).not.toHaveBeenCalled() - interceptor['emitter'].emit('foo', 'bar') + const event = new TypedEvent('foo', { data: 'bar' }) + interceptor['emitter'].emit(event) expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith('bar') + expect(listener).toHaveBeenCalledExactlyOnceWith(event) listener.mockReset() - interceptor['emitter'].emit('foo', 'baz') - interceptor['emitter'].emit('foo', 'xyz') + interceptor['emitter'].emit(new TypedEvent('foo', { data: 'baz' })) + interceptor['emitter'].emit(new TypedEvent('foo', { data: 'xyz' })) expect(listener).toHaveBeenCalledTimes(0) }) }) @@ -58,7 +55,7 @@ describe('off()', () => { interceptor.on('event', listener) expect(interceptor['emitter'].listenerCount('event')).toBe(1) - interceptor.off('event', listener) + interceptor.removeListener('event', listener) expect(interceptor['emitter'].listenerCount('event')).toBe(0) }) }) @@ -170,14 +167,11 @@ describe('apply', () => { secondInterceptor.on('test', secondListener) // Emitting event in the first interceptor will bubble to the second one. - firstInterceptor['emitter'].emit('test', 'hello world') - - expect(firstListener).toHaveBeenCalledTimes(1) - expect(firstListener).toHaveBeenCalledWith('hello world') - - expect(secondListener).toHaveBeenCalledTimes(1) - expect(secondListener).toHaveBeenCalledWith('hello world') + const event = new TypedEvent('test', { data: 'hello world' }) + firstInterceptor['emitter'].emit(event) + expect(firstListener).toHaveBeenCalledExactlyOnceWith(event) + expect(secondListener).toHaveBeenCalledExactlyOnceWith(event) expect(secondInterceptor['emitter'].listenerCount('test')).toBe(0) }) }) diff --git a/src/Interceptor.ts b/src/Interceptor.ts index b33e74fdf..d86c76d11 100644 --- a/src/Interceptor.ts +++ b/src/Interceptor.ts @@ -1,5 +1,5 @@ import { Logger } from '@open-draft/logger' -import { Emitter, Listener } from 'strict-event-emitter' +import { Emitter, TypedListenerOptions } from 'rettime' export type InterceptorEventMap = Record export type InterceptorSubscription = () => void @@ -39,9 +39,6 @@ export enum InterceptorReadyState { DISPOSED = 'DISPOSED', } -export type ExtractEventNames> = - Events extends Record ? EventName : never - export class Interceptor { protected emitter: Emitter protected subscriptions: Array @@ -56,10 +53,6 @@ export class Interceptor { this.subscriptions = [] this.logger = new Logger(symbol.description!) - // Do not limit the maximum number of listeners - // so not to limit the maximum amount of parallel events emitted. - this.emitter.setMaxListeners(0) - this.logger.info('constructing the interceptor...') } @@ -107,7 +100,7 @@ export class Interceptor { // Add listeners to the running instance so they appear // at the top of the event listeners list and are executed first. - runningInstance.emitter.addListener(event, listener) + runningInstance.emitter.on(event, listener) // Ensure that once this interceptor instance is disposed, // it removes all listeners it has appended to the running interceptor instance. @@ -145,9 +138,10 @@ export class Interceptor { /** * Listen to the interceptor's public events. */ - public on>( - event: EventName, - listener: Listener + public on>( + event: EventType, + listener: Emitter.Listener, + options?: TypedListenerOptions ): this { const logger = this.logger.extend('on') @@ -161,29 +155,32 @@ export class Interceptor { logger.info('adding "%s" event listener:', event, listener) - this.emitter.on(event, listener) + this.emitter.on(event, listener, options) return this } - public once>( - event: EventName, - listener: Listener + public once>( + event: EventType, + listener: Emitter.Listener, + options: Omit ): this { - this.emitter.once(event, listener) + this.emitter.once(event, listener, options) return this } - public off>( - event: EventName, - listener: Listener + public removeListener< + EventType extends Emitter.AllEventTypes, + >( + event: EventType, + listener: Emitter.Listener ): this { - this.emitter.off(event, listener) + this.emitter.removeListener(event, listener) return this } - public removeAllListeners>( - event?: EventName - ): this { + public removeAllListeners< + EventType extends Emitter.AllEventTypes, + >(event?: EventType): this { this.emitter.removeAllListeners(event) return this } diff --git a/src/RemoteHttpInterceptor.ts b/src/RemoteHttpInterceptor.ts index f5252e9cd..760ad2137 100644 --- a/src/RemoteHttpInterceptor.ts +++ b/src/RemoteHttpInterceptor.ts @@ -1,5 +1,5 @@ import { ChildProcess } from 'child_process' -import { HttpRequestEventMap } from './glossary' +import { HttpRequestEventMap, HttpResponseEvent } from './events/http' import { Interceptor } from './Interceptor' import { BatchInterceptor } from './BatchInterceptor' import { ClientRequestInterceptor } from './interceptors/ClientRequest' @@ -202,20 +202,22 @@ export class RemoteHttpResolver extends Interceptor { this.process.send( `response:${requestJson.id}:${serializedResponse}`, - (error) => { + async (error) => { if (error) { return } // Emit an optimistic "response" event at this point, // not to rely on the back-and-forth signaling for the sake of the event. - this.emitter.emit('response', { - initiator: null, - request, - requestId: requestJson.id, - response: responseClone, - isMockedResponse: true, - }) + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator: null, + request, + requestId: requestJson.id, + response: responseClone, + responseType: 'mock', + }) + ) } ) diff --git a/src/events/http.ts b/src/events/http.ts new file mode 100644 index 000000000..09241752b --- /dev/null +++ b/src/events/http.ts @@ -0,0 +1,92 @@ +import { TypedEvent } from 'rettime' +import type { RequestController } from '../RequestController' + +export interface HttpRequestEventData { + request: Request + requestId: string + initiator: unknown + controller: RequestController +} + +export class HttpRequestEvent< + DataType extends HttpRequestEventData = HttpRequestEventData, +> extends TypedEvent { + public request: Request + public requestId: string + public initiator: unknown + public controller: RequestController + + constructor(data: DataType) { + super(...(['request', {}] as any)) + + this.request = data.request + this.requestId = data.requestId + this.initiator = data.initiator + this.controller = data.controller + } +} + +export type HttpResponseType = 'mock' | 'original' + +interface HttpResponseEventData { + response: Response + responseType: HttpResponseType + request: Request + requestId: string + initiator: unknown +} + +export class HttpResponseEvent< + DataType extends HttpResponseEventData = HttpResponseEventData, +> extends TypedEvent { + public response: Response + public responseType: HttpResponseType + public request: Request + public requestId: string + public initiator: unknown + + constructor(data: DataType) { + super(...(['response', {}] as any)) + + this.response = data.response + this.responseType = data.responseType + this.request = data.request + this.requestId = data.requestId + this.initiator = data.initiator + } +} + +interface UnhandledHttpExceptionEventData { + error: unknown + request: Request + requestId: string + initiator: unknown + controller: RequestController +} + +export class UnhandledHttpException< + DataType extends UnhandledHttpExceptionEventData = + UnhandledHttpExceptionEventData, +> extends TypedEvent { + public error: unknown + public request: Request + public requestId: string + public initiator: unknown + public controller: RequestController + + constructor(data: DataType) { + super(...(['unhandledException', {}] as any)) + + this.error = data.error + this.request = data.request + this.requestId = data.requestId + this.initiator = data.initiator + this.controller = data.controller + } +} + +export type HttpRequestEventMap = { + request: HttpRequestEvent + response: HttpResponseEvent + unhandledException: UnhandledHttpException +} diff --git a/src/events/websocket.ts b/src/events/websocket.ts new file mode 100644 index 000000000..74ff4da66 --- /dev/null +++ b/src/events/websocket.ts @@ -0,0 +1,47 @@ +import { TypedEvent } from 'rettime' +import type { + WebSocketClientConnection, + WebSocketServerConnection, +} from '../interceptors/WebSocket' + +/** + * The connection information. + */ +interface WebSocketConnectionInfo { + /** + * The protocols supported by the WebSocket client. + */ + protocols: string | Array | undefined +} + +interface WebSocketConnectionEventData { + /** + * The incoming WebSocket client connection. + */ + client: WebSocketClientConnection + /** + * The original WebSocket server connection. + */ + server: WebSocketServerConnection + info: WebSocketConnectionInfo +} + +export class WebSocketConnectionEvent< + DataType extends WebSocketConnectionEventData = WebSocketConnectionEventData, +> extends TypedEvent { + public client: WebSocketClientConnection + public server: WebSocketServerConnection + public info: WebSocketConnectionInfo + + constructor(data: DataType) { + super(...(['connection', {}] as any)) + + this.client = data.client + this.server = data.server + this.info = data.info + } +} + +export type WebSocketEventMap = { + connection: WebSocketConnectionEvent +} diff --git a/src/glossary.ts b/src/glossary.ts index 40294d929..10f336b9f 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -7,32 +7,3 @@ import type { RequestController } from './RequestController' export type { RequestController } export type RequestCredentials = 'omit' | 'include' | 'same-origin' - -export type HttpRequestEventMap = { - request: [ - args: { - initiator: unknown - request: Request - requestId: string - controller: RequestController - }, - ] - response: [ - args: { - initiator: unknown - response: Response - isMockedResponse: boolean - request: Request - requestId: string - }, - ] - unhandledException: [ - args: { - initiator: unknown - error: unknown - request: Request - requestId: string - controller: RequestController - }, - ] -} diff --git a/src/index.ts b/src/index.ts index 4a09d0436..3f0becfc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ -export * from './glossary' export * from './Interceptor' export * from './BatchInterceptor' export { RequestController, type RequestControllerSource, } from './RequestController' +export type { HttpRequestEventMap } from './events/http' +export type { WebSocketEventMap } from './events/websocket' /* Utils */ export { createRequestId } from './createRequestId' diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 48a791e07..0233f10c4 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,10 +1,11 @@ import http from 'node:http' import https from 'node:https' -import { HttpRequestEventMap } from '#/src/glossary' +import { HttpRequestEventMap } from '#/src/events/http' import { Interceptor } from '#/src/Interceptor' import { runInRequestContext } from '#/src/request-context' import { applyPatch } from '#/src/utils/apply-patch' import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { propagateHttpEvents } from '#/src/utils/interceptor-utils' export class ClientRequestInterceptor extends Interceptor { static symbol = Symbol('client-request-interceptor') @@ -19,17 +20,14 @@ export class ClientRequestInterceptor extends Interceptor { httpInterceptor.apply() this.subscriptions.push(() => httpInterceptor.dispose()) - httpInterceptor - .on('request', (args) => { - if (args.initiator instanceof http.ClientRequest) { - this.emitter.emit('request', args) - } - }) - .on('response', (args) => { - if (args.initiator instanceof http.ClientRequest) { - this.emitter.emit('response', args) - } - }) + const { controller } = propagateHttpEvents( + httpInterceptor['emitter'], + this.emitter, + (event) => { + return event.initiator instanceof http.ClientRequest + } + ) + this.subscriptions.push(() => controller.abort()) this.subscriptions.push( applyPatch(http, 'ClientRequest', (ClientRequest) => { diff --git a/src/interceptors/WebSocket/index.ts b/src/interceptors/WebSocket/index.ts index eecfa65bd..ec3ccf068 100644 --- a/src/interceptors/WebSocket/index.ts +++ b/src/interceptors/WebSocket/index.ts @@ -1,4 +1,8 @@ import { Interceptor } from '../../Interceptor' +import { + WebSocketConnectionEvent, + type WebSocketEventMap, +} from '../../events/websocket' import { WebSocketClientConnectionProtocol, WebSocketClientConnection, @@ -17,7 +21,6 @@ import { } from './WebSocketOverride' import { bindEvent } from './utils/bindEvent' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { emitAsync } from '../../utils/emitAsync' import { applyPatch } from '../../utils/apply-patch' export { @@ -39,32 +42,6 @@ export { CancelableMessageEvent, } from './utils/events' -export type WebSocketEventMap = { - connection: [args: WebSocketConnectionData] -} - -export type WebSocketConnectionData = { - /** - * The incoming WebSocket client connection. - */ - client: WebSocketClientConnection - - /** - * The original WebSocket server connection. - */ - server: WebSocketServerConnection - - /** - * The connection information. - */ - info: { - /** - * The protocols supported by the WebSocket client. - */ - protocols: string | Array | undefined - } -} - /** * Intercept the outgoing WebSocket connections created using * the global `WebSocket` class. @@ -118,13 +95,15 @@ export class WebSocketInterceptor extends Interceptor { // The "globalThis.WebSocket" class stands for // the client-side connection. Assume it's established // as soon as the WebSocket instance is constructed. - await emitAsync(this.emitter, 'connection', { - client: new WebSocketClientConnection(socket, transport), - server, - info: { - protocols, - }, - }) + await this.emitter.emitAsPromise( + new WebSocketConnectionEvent({ + client: new WebSocketClientConnection(socket, transport), + server, + info: { + protocols, + }, + }) + ) if (hasConnectionListeners) { socket[kPassthroughPromise].resolve(false) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index a25e13298..18037b45d 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -1,6 +1,7 @@ import { until } from '@open-draft/until' import { invariant } from 'outvariant' import type { Logger } from '@open-draft/logger' +import type { HttpResponseType } from '../../events/http' import { concatArrayBuffer } from './utils/concatArrayBuffer' import { createEvent } from './utils/createEvent' import { @@ -39,7 +40,7 @@ export class XMLHttpRequestController { this: XMLHttpRequestController, args: { response: Response - isMockedResponse: boolean + responseType: HttpResponseType request: Request requestId: string } @@ -150,7 +151,7 @@ export class XMLHttpRequestController { // Notify the consumer about the response. this.onResponse.call(this, { response: fetchResponse, - isMockedResponse: this[kIsRequestHandled], + responseType: this[kIsRequestHandled] ? 'mock' : 'original', request: fetchRequest, requestId: this.requestId!, }) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts index b7bdbc9df..85b74b90d 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts @@ -1,12 +1,12 @@ import type { Logger } from '@open-draft/logger' -import { XMLHttpRequestEmitter } from './web' +import type { Emitter } from 'rettime' +import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' import { RequestController } from '../../RequestController' import { XMLHttpRequestController } from './XMLHttpRequestController' import { handleRequest } from '../../utils/handleRequest' -import { emitAsync } from '../../utils/emitAsync' export interface XMLHttpRequestProxyOptions { - emitter: XMLHttpRequestEmitter + emitter: Emitter logger: Logger } @@ -89,7 +89,7 @@ export function createXMLHttpRequestProxy({ xhrRequestController.onResponse = async function ({ response, - isMockedResponse, + responseType, request, requestId, }) { @@ -98,13 +98,15 @@ export function createXMLHttpRequestProxy({ emitter.listenerCount('response') ) - await emitAsync(emitter, 'response', { - initiator: this.request, - response, - isMockedResponse, - request, - requestId, - }) + await emitter.emitAsPromise( + new HttpResponseEvent({ + initiator: this.request, + response, + responseType, + request, + requestId, + }) + ) } // Return the proxied request from the controller diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 6ed2fc451..d0b6c47b5 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -2,10 +2,10 @@ import { requestContext } from '#/src/request-context' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { applyPatch } from '#/src/utils/apply-patch' import { Interceptor } from '#/src/Interceptor' -import { HttpRequestEventMap } from '#/src/glossary' +import { HttpRequestEventMap } from '../../events/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { emitAsync } from '#/src/utils/emitAsync' import { FetchRequest } from '#/src/utils/fetchUtils' +import { propagateHttpEvents } from '#/src/utils/interceptor-utils' export class XMLHttpRequestInterceptor extends Interceptor { static symbol = Symbol.for('xhr-interceptor') @@ -24,24 +24,23 @@ export class XMLHttpRequestInterceptor extends Interceptor httpInterceptor.apply() this.subscriptions.push(() => httpInterceptor.dispose()) - httpInterceptor - .on('request', async (args) => { - if (args.initiator instanceof XMLHttpRequest) { - args.request = this.#transformRequest(args.request, args.initiator) - await emitAsync(this.emitter, 'request', args) - } - }) - .on('response', async (args) => { - if (args.initiator instanceof XMLHttpRequest) { - args.request = this.#transformRequest(args.request, args.initiator) - await emitAsync(this.emitter, 'response', args) - } - }) - .on('unhandledException', async (args) => { - if (args.initiator instanceof XMLHttpRequest) { - await emitAsync(this.emitter, 'unhandledException', args) + this.emitter.hooks.on('beforeEmit', (event) => { + event.modify = true + }) + + const { controller } = propagateHttpEvents( + httpInterceptor['emitter'], + this.emitter, + (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + return true } - }) + + return false + } + ) + this.subscriptions.push(() => controller.abort()) this.logger.info('patching global "XMLHttpRequest"...') diff --git a/src/interceptors/XMLHttpRequest/web.ts b/src/interceptors/XMLHttpRequest/web.ts index 3bdd12b73..dbb2b9f6c 100644 --- a/src/interceptors/XMLHttpRequest/web.ts +++ b/src/interceptors/XMLHttpRequest/web.ts @@ -1,12 +1,9 @@ -import { Emitter } from 'strict-event-emitter' -import { HttpRequestEventMap } from '../../glossary' +import { HttpRequestEventMap } from '../../events/http' import { Interceptor } from '../../Interceptor' import { createXMLHttpRequestProxy } from './XMLHttpRequestProxy' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { applyPatch } from '../../utils/apply-patch' -export type XMLHttpRequestEmitter = Emitter - export class XMLHttpRequestInterceptor extends Interceptor { static interceptorSymbol = Symbol.for('xhr-interceptor') diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index c704c1f2a..3e9c07b36 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -1,9 +1,8 @@ import { until } from '@open-draft/until' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '../../glossary' +import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' import { Interceptor } from '../../Interceptor' import { RequestController } from '../../RequestController' -import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { canParseUrl } from '../../utils/canParseUrl' import { createRequestId } from '../../createRequestId' @@ -76,13 +75,15 @@ export class FetchInterceptor extends Interceptor { this.logger.info('emitting the "response" event...') const responseClone = originalResponse.clone() - await emitAsync(this.emitter, 'response', { - initiator: requestCloneForResponseEvent, - response: responseClone, - isMockedResponse: false, - request: requestCloneForResponseEvent, - requestId, - }) + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator: requestCloneForResponseEvent, + request: requestCloneForResponseEvent, + requestId, + response: responseClone, + responseType: 'original', + }) + ) } // Resolve the response promise with the original response @@ -152,16 +153,18 @@ export class FetchInterceptor extends Interceptor { // Await the response listeners to finish before resolving // the response promise. This ensures all your logic finishes // before the interceptor resolves the pending response. - await emitAsync(this.emitter, 'response', { - initiator: request, - // Clone the mocked response for the "response" event listener. - // This way, the listener can read the response and not lock its body - // for the actual fetch consumer. - response: response.clone(), - isMockedResponse: true, - request, - requestId, - }) + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator: request, + // Clone the mocked response for the "response" event listener. + // This way, the listener can read the response and not lock its body + // for the actual fetch consumer. + response: response.clone(), + responseType: 'mock', + request, + requestId, + }) + ) } responsePromise.resolve(response) diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 22b4fe9b0..1952fb067 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -1,11 +1,11 @@ import { Interceptor } from '#/src/Interceptor' -import { HttpRequestEventMap } from '#/src/glossary' +import type { HttpRequestEventMap } from '#/src/events/http' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { canParseUrl } from '#/src/utils/canParseUrl' import { requestContext } from '#/src/request-context' import { applyPatch } from '#/src/utils/apply-patch' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { emitAsync } from '#/src/utils/emitAsync' +import { propagateHttpEvents } from '#/src/utils/interceptor-utils' export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') @@ -24,22 +24,14 @@ export class FetchInterceptor extends Interceptor { httpInterceptor.apply() this.subscriptions.push(() => httpInterceptor.dispose()) - httpInterceptor - .on('request', async (args) => { - if (args.initiator instanceof Request) { - await emitAsync(this.emitter, 'request', args) - } - }) - .on('response', async (args) => { - if (args.initiator instanceof Request) { - await emitAsync(this.emitter, 'response', args) - } - }) - .on('unhandledException', async (args) => { - if (args.initiator instanceof Request) { - await emitAsync(this.emitter, 'unhandledException', args) - } - }) + const { controller } = propagateHttpEvents( + httpInterceptor['emitter'], + this.emitter, + (event) => { + return event.initiator instanceof Request + } + ) + this.subscriptions.push(() => controller.abort()) this.subscriptions.push( applyPatch(globalThis, 'fetch', (realFetch) => { diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index a70053e62..3b154b92e 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -10,7 +10,7 @@ import type { ReadableStream } from 'node:stream/web' import { pipeline } from 'node:stream/promises' import { invariant } from 'outvariant' import { Interceptor } from '../../Interceptor' -import { type HttpRequestEventMap } from '../../glossary' +import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' import { RequestController } from '../../RequestController' import { getRawFetchHeaders, @@ -21,7 +21,6 @@ import { connectionOptionsToUrl } from '../net/utils/connection-options-to-url' import { toBuffer } from '../../utils/bufferUtils' import { createRequestId } from '../../createRequestId' import { HttpRequestParser, HttpResponseParser } from './http-parser' -import { emitAsync } from '../../utils/emitAsync' import { handleRequest, HandleRequestOptions } from '../../utils/handleRequest' import { isResponseError } from '../../utils/responseUtils' import { createLogger } from '../../utils/logger' @@ -111,6 +110,15 @@ export class HttpRequestInterceptor extends Interceptor { url: request.url, }) + /** + * @note Clone the response before "respondWith" because it will + * consume its body. This way, we can have a readable response copy + * for the "response" event below. + */ + const responseClone = isResponseError(response) + ? null + : response.clone() + const respond = () => { return this.respondWith({ socket: socketController[kRawSocket], @@ -131,24 +139,16 @@ export class HttpRequestInterceptor extends Interceptor { await respond() } - if ( - this.emitter.listenerCount('response') > 0 && - /** - * @note The "response" event is designed to observe responses. - * While a mocked "Response.error()" is, technically, a response, - * it must not emit the "response" event as it's treated as a request error. - */ - !isResponseError(response) - ) { - const responseClone = response.clone() - - await emitAsync(this.emitter, 'response', { - initiator, - requestId, - request: context.request, - response: responseClone, - isMockedResponse: true, - }) + if (responseClone) { + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator, + requestId, + request: context.request, + response: responseClone, + responseType: 'mock', + }) + ) } }, errorWith: (reason) => { @@ -195,13 +195,15 @@ export class HttpRequestInterceptor extends Interceptor { FetchResponse.setUrl(request.url, response) log('emitting "response" event...') - await emitAsync(this.emitter, 'response', { - initiator, - requestId, - request: context.request, - response, - isMockedResponse: false, - }) + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator, + requestId, + request: context.request, + response, + responseType: 'original', + }) + ) log('resuming socket...') mockSocket.resume() diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 6936391f0..c8eca4acc 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -9,15 +9,32 @@ import { TcpSocketController, TlsSocketController } from './socket-controller' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' import { createLogger } from '../../utils/logger' import { applyPatch } from '../../utils/apply-patch' +import { TypedEvent } from 'rettime' -interface SocketEventMap { - connection: [ - { - socket: net.Socket | tls.TLSSocket - controller: TcpSocketController | TlsSocketController - connectionOptions: NetworkConnectionOptions - }, - ] +interface SocketConnectionEventData { + socket: net.Socket | tls.TLSSocket + connectionOptions: NetworkConnectionOptions + controller: TcpSocketController | TlsSocketController +} + +class SocketConnectionEvent< + DataType extends SocketConnectionEventData = SocketConnectionEventData, +> extends TypedEvent { + public socket: net.Socket | tls.TLSSocket + public connectionOptions: NetworkConnectionOptions + public controller: TcpSocketController | TlsSocketController + + constructor(data: DataType) { + super(...(['connection', {}] as any)) + + this.socket = data.socket + this.connectionOptions = data.connectionOptions + this.controller = data.controller + } +} + +type SocketEventMap = { + connection: SocketConnectionEvent } const log = createLogger('SocketInterceptor') @@ -47,11 +64,13 @@ export class SocketInterceptor extends Interceptor { process.nextTick(() => { if ( - !this.emitter.emit('connection', { - socket: controller.serverSocket, - controller, - connectionOptions, - }) + !this.emitter.emit( + new SocketConnectionEvent({ + socket: controller.serverSocket, + controller, + connectionOptions, + }) + ) ) { log( 'no "connection" listeners found on the interceptor, passthrough...' @@ -115,11 +134,13 @@ export class SocketInterceptor extends Interceptor { process.nextTick(() => { if ( - !this.emitter.emit('connection', { - socket: controller.serverSocket, - controller, - connectionOptions: tlsConnectionOptions, - }) + !this.emitter.emit( + new SocketConnectionEvent({ + socket: controller.serverSocket, + controller, + connectionOptions: tlsConnectionOptions, + }) + ) ) { log( 'no "connection" listeners found on the interceptor, passthrough...' diff --git a/src/utils/emitAsync.ts b/src/utils/emitAsync.ts deleted file mode 100644 index 2edf1ceeb..000000000 --- a/src/utils/emitAsync.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Emitter, EventMap } from 'strict-event-emitter' - -/** - * Emits an event on the given emitter but executes - * the listeners sequentially. This accounts for asynchronous - * listeners (e.g. those having "sleep" and handling the request). - */ -export async function emitAsync< - Events extends EventMap, - EventName extends keyof Events ->( - emitter: Emitter, - eventName: EventName, - ...data: Events[EventName] -): Promise { - const listeners = emitter.listeners(eventName) - - if (listeners.length === 0) { - return - } - - for (const listener of listeners) { - await listener.apply(emitter, data) - } -} diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 159d437ce..7410ea932 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -1,8 +1,12 @@ -import type { Emitter } from 'strict-event-emitter' +import type { Emitter } from 'rettime' import { DeferredPromise } from '@open-draft/deferred-promise' import { until } from '@open-draft/until' -import type { HttpRequestEventMap } from '../glossary' -import { emitAsync } from './emitAsync' +import { + HttpRequestEvent, + HttpRequestEventData, + UnhandledHttpException, + type HttpRequestEventMap, +} from '../events/http' import { RequestController } from '../RequestController' import { createServerErrorResponse, @@ -103,16 +107,15 @@ export async function handleRequest( // for that event are finished (e.g. async listeners awaited). // By the end of this promise, the developer cannot affect the // request anymore. - const requestArgs = { + const requestEventData: HttpRequestEventData = { initiator: options.initiator, requestId: options.requestId, request: options.request, controller: options.controller, } - const requestListenersPromise = emitAsync( - options.emitter, - 'request', - requestArgs + const requestEvent = new HttpRequestEvent(requestEventData) + const requestListenersPromise = options.emitter.emitAsPromise( + requestEvent ) await Promise.race([ @@ -128,8 +131,8 @@ export async function handleRequest( * This happens with XMLHttpRequest that replaces request instances * to correctly reflect the "withCredentials" option on the Fetch API request. */ - if (requestArgs.request !== options.request) { - options.request = requestArgs.request + if (requestEvent.request !== options.request) { + options.request = requestEvent.request } }) @@ -149,6 +152,7 @@ export async function handleRequest( // If the developer has added "unhandledException" listeners, // allow them to handle the error. They can translate it to a // mocked response, network error, or forward it as-is. + console.log('[DEBUG handleRequest] unhandledException listenerCount:', options.emitter.listenerCount('unhandledException')) if (options.emitter.listenerCount('unhandledException') > 0) { // Create a new request controller just for the unhandled exception case. // This is needed because the original controller might have been already @@ -178,13 +182,15 @@ export async function handleRequest( } ) - await emitAsync(options.emitter, 'unhandledException', { - initiator: options.initiator, - error: result.error, - request: options.request, - requestId: options.requestId, - controller: unhandledExceptionController, - }) + await options.emitter.emitAsPromise( + new UnhandledHttpException({ + initiator: options.initiator, + error: result.error, + request: options.request, + requestId: options.requestId, + controller: unhandledExceptionController, + }) + ) // If all the "unhandledException" listeners have finished // but have not handled the request in any way, passthrough. diff --git a/src/utils/interceptor-utils.ts b/src/utils/interceptor-utils.ts new file mode 100644 index 000000000..0f87be765 --- /dev/null +++ b/src/utils/interceptor-utils.ts @@ -0,0 +1,65 @@ +import { DefaultEventMap, Emitter, EventMap } from 'rettime' + +export function propagateHttpEvents( + source: Emitter, + destination: Emitter, + predicate: (event: EventMap.Events) => boolean +) { + const controller = new AbortController() + + const propagateEvent = async (event: EventMap.Events) => { + if (predicate(event)) { + await destination.emitAsPromise(event) + } + } + + source.on( + '*', + async (event) => { + if (event.type !== 'response') { + await propagateEvent(event) + } + }, + { signal: controller.signal } + ) + + /** + * @note Lazily add a "response" listener to the HTTP interceptor if this + * interceptor receives a response listener. HTTP interceptor creates a + * response parser only if a "response" listener is present. + * + * Cannot use hooks for this because `removeAllListeners()` in rettime + * also removes hooks listeners, breaking lazy registration across tests. + */ + destination.hooks.on( + 'newListener', + (type) => { + if ( + type === 'response' && + !source.listeners('response').includes(propagateEvent) + ) { + source.on('response', propagateEvent, { signal: controller.signal }) + } + }, + { signal: controller.signal } + ) + + destination.hooks.on( + 'removeListener', + (type) => { + if ( + type === 'response' && + source.listeners('response').includes(propagateEvent) + ) { + source.removeListener('response', propagateEvent) + } + }, + { + signal: controller.signal, + } + ) + + return { + controller, + } +} diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 99c2ad429..50d2bef9b 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -1,97 +1,112 @@ // @vitest-environment happy-dom import http from 'node:http' -import { HttpServer } from '@open-draft/test-server/http' -import { useCors, REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' import { BatchInterceptor } from '#/src/BatchInterceptor' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' import { RequestController } from '#/src/RequestController' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.post('/user', (req, res) => { - res.status(201).end() - }) -}) - +const server = getTestServer() const interceptor = new BatchInterceptor({ name: 'batch-interceptor', interceptors: [new HttpRequestInterceptor(), new XMLHttpRequestInterceptor()], }) -beforeAll(async () => { +beforeAll(() => { interceptor.apply() - await httpServer.listen() }) afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await httpServer.close() }) it('ClientRequest: emits the "request" event upon the request', async () => { const requestListener = vi.fn() interceptor.on('request', requestListener) - const url = httpServer.http.url('/user') + const url = server.http.url('/user') const req = http.request(url, { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'content-type': 'application/json', }, }) req.write(JSON.stringify({ userId: 'abc-123' })) req.end() await toWebResponse(req) - expect(requestListener).toHaveBeenCalledTimes(1) + expect.soft(requestListener).toHaveBeenCalledTimes(1) const [{ request, requestId, controller }] = requestListener.mock.calls[0] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.headers.get('content-type')).toBe('application/json') - expect(request.credentials).toBe('same-origin') - await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) - expect(controller).toBeInstanceOf(RequestController) + expect.soft(request.method).toBe('POST') + expect.soft(request.url).toBe(url.href) + expect.soft(request.headers.get('content-type')).toBe('application/json') + expect.soft(request.credentials).toBe('same-origin') + await expect.soft(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect.soft(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) }) -it('XMLHttpRequest: emits the "request" event upon the request (no CORS)', async () => { +it.only('XMLHttpRequest: emits the "request" event upon the request (no CORS)', async () => { const requestListener = vi.fn() interceptor.on('request', requestListener) - const url = httpServer.http.url('/user') + /** + * @fixme Since both HttpRequestInterceptor and XMLHttpRequestInterceptor are used simultaneously, + * they produce DOUBLE events: + * - HTTP fires "request" + * - XHR *also* fires "request" (modified). + * + * This should be fixable if interceptors emit actual EVENTS and the upstream interceptors (e.g. XHR) + * can just call "event.preventImmediatePropagation()" for the underlying HTTP interceptor. + */ + throw new Error('READ THE COMMENT ABOVE THIS ERROR') + + // interceptor.on('request', ({ request }) => { + // console.trace(request.method, request.url) + // }) + + const url = server.http.url('/user') const request = new XMLHttpRequest() request.open('POST', url) - request.setRequestHeader('Content-Type', 'application/json') + request.setRequestHeader('content-type', 'application/json') request.send(JSON.stringify({ userId: 'abc-123' })) await waitForXMLHttpRequest(request) /** - * @note This XHR request, while cross-origin, doesn't have enough criteria - * to trigger the OPTIONS preflight request. + * @note XHR in HappyDOM issues a preflight OPTIONS request. */ - expect(requestListener).toHaveBeenCalledTimes(1) + expect.soft(requestListener).toHaveBeenCalledTimes(2) + // Preflight request. { - const [{ request, requestId, controller }] = requestListener.mock.calls[0] + const [{ request }] = requestListener.mock.calls[1] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.headers.get('content-type')).toBe('application/json') - expect(request.credentials).toBe('same-origin') - await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) - expect(controller).toBeInstanceOf(RequestController) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + await expect.soft(request.text()).resolves.toBe('') + } + + { + const [{ request, requestId, controller }] = requestListener.mock.calls[1] + + expect.soft(request.method).toBe('POST') + expect.soft(request.url).toBe(url.href) + expect.soft(request.headers.get('content-type')).toBe('application/json') + expect.soft(request.credentials).toBe('same-origin') + await expect.soft(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect.soft(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) } }) @@ -99,10 +114,10 @@ it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)' const requestListener = vi.fn() interceptor.on('request', requestListener) - const url = httpServer.http.url('/user') + const url = server.http.url('/user') const request = new XMLHttpRequest() request.open('POST', url) - request.setRequestHeader('Content-Type', 'application/json') + request.setRequestHeader('content-type', 'application/json') /** * @note The addition of this custom header triggers the OPTIONS request in XHR. */ @@ -111,9 +126,8 @@ it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)' await waitForXMLHttpRequest(request) - expect(requestListener).toHaveBeenCalledTimes(2) - - expect(requestListener).toHaveBeenNthCalledWith( + expect.soft(requestListener).toHaveBeenCalledTimes(2) + expect.soft(requestListener).toHaveBeenNthCalledWith( 1, expect.objectContaining({ request: expect.objectContaining({ @@ -122,7 +136,7 @@ it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)' }), }) ) - expect(requestListener).toHaveBeenNthCalledWith( + expect.soft(requestListener).toHaveBeenNthCalledWith( 2, expect.objectContaining({ request: expect.objectContaining({ @@ -135,13 +149,13 @@ it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)' { const [{ request, requestId, controller }] = requestListener.mock.calls[1] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.headers.get('content-type')).toBe('application/json') - expect(request.credentials).toBe('same-origin') - await expect(request.json()).resolves.toEqual({ userId: 'abc-123' }) - expect(controller).toBeInstanceOf(RequestController) + expect.soft(request.method).toBe('POST') + expect.soft(request.url).toBe(url.href) + expect.soft(request.headers.get('content-type')).toBe('application/json') + expect.soft(request.credentials).toBe('same-origin') + await expect.soft(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect.soft(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) } }) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 7490904b9..804d3a593 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -1,47 +1,37 @@ // @vitest-environment happy-dom import https from 'node:https' -import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '#/src/index' -import { BatchInterceptor } from '#/src/BatchInterceptor' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' -import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { BatchInterceptor, HttpRequestEventMap } from '@mswjs/interceptors' +import { HttpRequestInterceptor } from '@mswjs/interceptors/http' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { useCors, toWebResponse } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' -declare namespace window { - export const _resourceLoader: { - _strictSSL: boolean - } -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) +// const httpServer = new HttpServer((app) => { +// app.use(useCors) - app.get('/user', (_req, res) => { - res.status(509).send('must-use-mocks') - }) +// app.get('/user', (_req, res) => { +// res.status(509).send('must-use-mocks') +// }) - app.post('/account', (_req, res) => { - return res - .status(200) - .set('access-control-expose-headers', 'x-response-type') - .set('x-response-type', 'original') - .send('original-response-text') - }) -}) +// app.post('/account', (_req, res) => { +// return res +// .status(200) +// .set('access-control-expose-headers', 'x-response-type') +// .set('x-response-type', 'original') +// .send('original-response-text') +// }) +// }) +const server = getTestServer() const interceptor = new BatchInterceptor({ name: 'batch-interceptor', interceptors: [new HttpRequestInterceptor(), new XMLHttpRequestInterceptor()], }) -beforeAll(async () => { - // Allow XHR requests to the local HTTPS server with a self-signed certificate. - window._resourceLoader._strictSSL = false - +beforeAll(() => { interceptor.apply() - await httpServer.listen() }) afterEach(() => { @@ -49,10 +39,9 @@ afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() vi.restoreAllMocks() - await httpServer.close() }) it('ClientRequest: emits the "response" event for a mocked response', async () => { @@ -66,10 +55,10 @@ it('ClientRequest: emits the "response" event for a mocked response', async () = }) const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.once('response', responseListener) - const req = https.request(httpServer.https.url('/user'), { + const req = https.request(server.https.url('/user'), { method: 'GET', headers: { 'x-request-custom': 'yes', @@ -87,11 +76,10 @@ it('ClientRequest: emits the "response" event for a mocked response', async () = expect(responseListener).toHaveBeenCalledOnce() { - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[0] + const [{ response, request, responseType }] = responseListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user')) + expect(request.url).toBe(server.https.url('/user').href) expect(request.headers.get('x-request-custom')).toBe('yes') expect(request.credentials).toBe('same-origin') expect(request.body).toBe(null) @@ -102,16 +90,16 @@ it('ClientRequest: emits the "response" event for a mocked response', async () = expect(response.headers.get('x-response-type')).toBe('mocked') await expect(response.text()).resolves.toBe('mocked-response-text') - expect(isMockedResponse).toBe(true) + expect(responseType).toBe('mock') } }) it('ClientRequest: emits the "response" event upon the original response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) - const req = https.request(httpServer.https.url('/account'), { + const req = https.request(server.https.url('/account'), { method: 'POST', headers: { 'x-request-custom': 'yes', @@ -124,11 +112,10 @@ it('ClientRequest: emits the "response" event upon the original response', async expect(responseListener).toHaveBeenCalledOnce() - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[0] + const [{ response, request, responseType }] = responseListener.mock.calls[0] expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.https.url('/account')) + expect(request.url).toBe(server.https.url('/account').href) expect(request.headers.get('x-request-custom')).toBe('yes') expect(request.credentials).toBe('same-origin') await expect(request.text()).resolves.toBe('request-body') @@ -139,7 +126,7 @@ it('ClientRequest: emits the "response" event upon the original response', async expect(response.headers.get('x-response-type')).toBe('original') await expect(response.text()).resolves.toBe('original-response-text') - expect(isMockedResponse).toBe(false) + expect(responseType).toBe('original') }) it('XMLHttpRequest: emits the "response" event upon a mocked response', async () => { @@ -167,7 +154,7 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () }) const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) const url = 'http://any.host.here/resource' @@ -194,8 +181,7 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () expect(request.responseText).toBe('mocked-response-text') { - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[1] + const [{ response, request, responseType }] = responseListener.mock.calls[1] expect.soft(request.method).toBe('GET') expect.soft(request.url).toBe(url) @@ -208,16 +194,16 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () expect.soft(response.url).toBe(request.url) expect.soft(response.headers.get('x-response-type')).toBe('mocked') await expect(response.text()).resolves.toBe('mocked-response-text') - expect(isMockedResponse).toBe(true) + expect(responseType).toBe('mocked') } }) -it('XMLHttpRequest: emits the "response" event upon the original response', async () => { +it.only('XMLHttpRequest: emits the "response" event upon the original response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) - const url = httpServer.https.url('/account') + const url = server.http.url('/account') const request = new XMLHttpRequest() request.open('POST', url) request.setRequestHeader('x-request-custom', 'yes') @@ -241,14 +227,13 @@ it('XMLHttpRequest: emits the "response" event upon the original response', asyn expect(request.responseText).toBe('original-response-text') { - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[1] + const [{ response, request, responseType }] = responseListener.mock.calls[1] expect(request).toBeDefined() expect(response).toBeDefined() expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.https.url('/account')) + expect(request.url).toBe(url.href) expect(request.headers.get('x-request-custom')).toBe('yes') expect(request.credentials).toBe('same-origin') await expect(request.text()).resolves.toBe('request-body') @@ -259,7 +244,7 @@ it('XMLHttpRequest: emits the "response" event upon the original response', asyn expect(response.headers.get('x-response-type')).toBe('original') await expect(response.text()).resolves.toBe('original-response-text') - expect(isMockedResponse).toBe(false) + expect(responseType).toBe('original') } }) @@ -276,25 +261,25 @@ it('fetch: emits the "response" event upon a mocked response', async () => { }) const responseListenerArgs = new DeferredPromise< - HttpRequestEventMap['response'][0] + HttpRequestEventMap['response'] >() - interceptor.on('response', (args) => { + interceptor.on('response', (event) => { responseListenerArgs.resolve({ - ...args, - request: args.request.clone(), + ...event, + request: event.request.clone(), }) }) - await fetch(httpServer.https.url('/user'), { + await fetch(server.https.url('/user'), { headers: { 'x-request-custom': 'yes', }, }) - const { response, request, isMockedResponse } = await responseListenerArgs + const { response, request, responseType } = await responseListenerArgs expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user')) + expect(request.url).toBe(server.https.url('/user').href) expect(request.headers.get('x-request-custom')).toBe('yes') expect(request.credentials).toBe('same-origin') expect(request.body).toBe(null) @@ -305,7 +290,7 @@ it('fetch: emits the "response" event upon a mocked response', async () => { expect(response.headers.get('x-response-type')).toBe('mocked') await expect(response.text()).resolves.toBe('mocked-response-text') - expect(isMockedResponse).toBe(true) + expect(responseType).toBe('mock') }) it( @@ -313,7 +298,7 @@ it( { timeout: 1500 }, async () => { const responseListenerArgs = new DeferredPromise< - HttpRequestEventMap['response'][0] + HttpRequestEventMap['response'] >() interceptor.on('response', (args) => { responseListenerArgs.resolve({ @@ -322,7 +307,7 @@ it( }) }) - await fetch(httpServer.http.url('/account'), { + await fetch(server.http.url('/account'), { method: 'POST', headers: { 'x-request-custom': 'yes', @@ -330,10 +315,10 @@ it( body: 'request-body', }) - const { response, request, isMockedResponse } = await responseListenerArgs + const { response, request, responseType } = await responseListenerArgs expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.http.url('/account')) + expect(request.url).toBe(server.http.url('/account').href) expect(request.headers.get('x-request-custom')).toBe('yes') expect(request.credentials).toBe('same-origin') await expect(request.text()).resolves.toBe('request-body') @@ -344,12 +329,12 @@ it( expect(response.headers.get('x-response-type')).toBe('original') await expect(response.text()).resolves.toBe('original-response-text') - expect(isMockedResponse).toBe(false) + expect(responseType).toBe('original') } ) it('supports reading the request and response bodies in the "response" listener', async () => { - interceptor.on('request', ({ controller }) => { + interceptor.on('request', ({ request, controller }) => { controller.respondWith( new Response('mocked-response-text', { statusText: 'OK', @@ -368,7 +353,7 @@ it('supports reading the request and response bodies in the "response" listener' responseCallback(await response.clone().text()) }) - await fetch(httpServer.https.url('/user'), { + await fetch(server.https.url('/user'), { method: 'POST', body: 'request-body', }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts index e5782d91e..7259e555f 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts @@ -27,6 +27,5 @@ it('does not propagate the forbidden "cookie" header on the bypassed response', await waitForXMLHttpRequest(request) - console.log(request.getAllResponseHeaders()) expect(request.getAllResponseHeaders()).not.toMatch(/cookie/) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts index 5be9d7963..8f84fba58 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts @@ -2,44 +2,34 @@ /** * @see https://github.com/mswjs/msw/issues/2307 */ -import { HttpServer } from '@open-draft/test-server/http' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchResponse } from '#/src/utils/fetchUtils' -import { useCors } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/resource', (_req, res) => { - res.writeHead(101, 'Switching Protocols') - res.end() - }) -}) - -beforeAll(async () => { +beforeAll(() => { interceptor.apply() - await httpServer.listen() }) afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await httpServer.close() }) it('handles non-configurable responses from the actual server', async () => { const responseListener = vi.fn() interceptor.on('response', responseListener) - const url = httpServer.http.url('/resource') + const url = server.http.url('/status') const request = new XMLHttpRequest() - request.open('GET', url) - request.send() + request.open('POST', url) + request.send('101') await waitForXMLHttpRequest(request) @@ -47,20 +37,22 @@ it('handles non-configurable responses from the actual server', async () => { expect.soft(request.statusText).toBe('Switching Protocols') expect.soft(request.responseText).toBe('') + expect(responseListener).toHaveBeenCalledTimes(2) + // Preflight response. { const [{ request, response }] = responseListener.mock.calls[0] expect.soft(request.method).toBe('OPTIONS') - expect.soft(request.url).toBe(url) - expect.soft(response.status).toBe(204) + expect.soft(request.url).toBe(url.href) + expect.soft(response.status).toBe(200) } { const [{ request, response }] = responseListener.mock.calls[1] - expect.soft(request.method).toBe('GET') - expect.soft(request.url).toBe(url) + expect.soft(request.method).toBe('POST') + expect.soft(request.url).toBe(url.href) expect.soft(response.status).toBe(101) } }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts index b5edac2c8..320ffa915 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts @@ -39,7 +39,7 @@ afterAll(async () => { it('intercepts a bypassed request with a 204 response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) const url = httpServer.http.url('/204') @@ -88,7 +88,7 @@ it('intercepts a bypassed request with a 204 response', async () => { it('intercepts a bypassed request with a 202 response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) const url = httpServer.http.url('/205') @@ -148,7 +148,7 @@ it('intercepts a bypassed request with a 202 response', async () => { it('exposes a fetch api reference for a 304 response without body', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) const url = httpServer.http.url('/cacheable') diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index 99a04a44e..d66c9b73f 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -46,9 +46,9 @@ it('emits events for a handled request', async () => { }) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('request', requestListener) interceptor.on('response', responseListener) @@ -124,9 +124,9 @@ it('emits events for a handled request', async () => { it('emits events for a bypassed request', async () => { const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('request', requestListener) interceptor.on('response', responseListener) diff --git a/test/modules/fetch/intercept/fetch.request.test.ts b/test/modules/fetch/intercept/fetch.request.test.ts index 79df6570e..90e5ae9e1 100644 --- a/test/modules/fetch/intercept/fetch.request.test.ts +++ b/test/modules/fetch/intercept/fetch.request.test.ts @@ -29,7 +29,7 @@ afterAll(async () => { it('intercepts fetch requests constructed via a "Request" instance', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ diff --git a/test/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index 3c35e3b01..56da30280 100644 --- a/test/modules/fetch/intercept/fetch.test.ts +++ b/test/modules/fetch/intercept/fetch.test.ts @@ -40,7 +40,7 @@ afterAll(async () => { it('intercepts an HTTP HEAD request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -70,7 +70,7 @@ it('intercepts an HTTP HEAD request', async () => { it('intercepts an HTTP GET request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -99,7 +99,7 @@ it('intercepts an HTTP GET request', async () => { it('intercepts an HTTP POST request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -132,7 +132,7 @@ it('intercepts an HTTP POST request', async () => { it('intercepts an HTTP PUT request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -165,7 +165,7 @@ it('intercepts an HTTP PUT request', async () => { it('intercepts an HTTP DELETE request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -197,7 +197,7 @@ it('intercepts an HTTP DELETE request', async () => { it('intercepts an HTTP PATCH request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -230,7 +230,7 @@ it('intercepts an HTTP PATCH request', async () => { it('intercepts an HTTPS HEAD request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -262,7 +262,7 @@ it('intercepts an HTTPS HEAD request', async () => { it('intercepts an HTTPS GET request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -293,7 +293,7 @@ it('intercepts an HTTPS GET request', async () => { it('intercepts an HTTPS POST request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -327,7 +327,7 @@ it('intercepts an HTTPS POST request', async () => { it('intercepts an HTTPS PUT request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -360,7 +360,7 @@ it('intercepts an HTTPS PUT request', async () => { it('intercepts an HTTPS DELETE request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -392,7 +392,7 @@ it('intercepts an HTTPS DELETE request', async () => { it('intercepts an HTTPS PATCH request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index 11728f19c..51b7560ca 100644 --- a/test/modules/http/compliance/events.test.ts +++ b/test/modules/http/compliance/events.test.ts @@ -2,7 +2,7 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { HttpRequestEventMap } from '#/src/glossary' +import { HttpRequestEventMap } from '#/src/events/http' import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { @@ -25,7 +25,7 @@ afterAll(async () => { it('emits the "request" event for an outgoing request without body', async () => { const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.once('request', requestListener) await toWebResponse( @@ -50,7 +50,7 @@ it('emits the "request" event for an outgoing request without body', async () => it('emits the "request" event for an outgoing request with a body', async () => { const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.once('request', requestListener) const request = http.request(httpServer.http.url('/'), { @@ -81,7 +81,7 @@ it('emits the "request" event for an outgoing request with a body', async () => it('emits the "response" event for a mocked response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.once('request', ({ controller }) => { controller.respondWith(new Response('hello world')) }) @@ -101,12 +101,12 @@ it('emits the "response" event for a mocked response', async () => { response, requestId, request: requestFromListener, - isMockedResponse, + responseType, } = responseListener.mock.calls[0][0] expect(response).toBeInstanceOf(Response) expect(response.status).toBe(200) await expect(response.text()).resolves.toBe('hello world') - expect(isMockedResponse).toBe(true) + expect(responseType).toBe('mock') expect(requestId).toMatch(REQUEST_ID_REGEXP) expect(requestFromListener).toBeInstanceOf(Request) @@ -127,7 +127,7 @@ it('emits the "response" event for a mocked response', async () => { it('emits the "response" event for a bypassed response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.once('response', responseListener) const request = http.get(httpServer.http.url('/'), { @@ -144,12 +144,12 @@ it('emits the "response" event for a bypassed response', async () => { response, requestId, request: requestFromListener, - isMockedResponse, + responseType, } = responseListener.mock.calls[0][0] expect(response).toBeInstanceOf(Response) expect(response.status).toBe(200) await expect(response.text()).resolves.toBe('original-response') - expect(isMockedResponse).toBe(false) + expect(responseType).toBe('original') expect(requestId).toMatch(REQUEST_ID_REGEXP) expect(requestFromListener).toBeInstanceOf(Request) 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 0580da8c5..a6bd1b18b 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 @@ -15,7 +15,7 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index 716c9f3ea..4706d7071 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -32,7 +32,7 @@ afterAll(async () => { it('intercepts an HTTP ClientRequest request with request options', async () => { const url = new URL(httpServer.http.url('/user?id=123')) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) @@ -71,7 +71,7 @@ it('intercepts an HTTP ClientRequest request with request options', async () => it('intercepts an HTTP ClientRequest request with URL string', async () => { const url = httpServer.http.url('/user?id=123') const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) const req = new http.ClientRequest(url) @@ -101,7 +101,7 @@ it('intercepts an HTTP ClientRequest request with URL string', async () => { it('intercepts an HTTP ClientRequest request with URL instance', async () => { const url = new URL(httpServer.http.url('/user?id=123')) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) const req = new http.ClientRequest(url) @@ -131,7 +131,7 @@ it('intercepts an HTTP ClientRequest request with URL instance', async () => { it('intercepts an HTTPS ClientRequest request with URL string', async () => { const url = httpServer.https.url('/user?id=123') const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) const req = new http.ClientRequest(url, { @@ -164,7 +164,7 @@ it('intercepts an HTTPS ClientRequest request with URL string', async () => { it('intercepts an HTTPS ClientRequest request with URL instance', async () => { const url = new URL(httpServer.https.url('/user?id=123')) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) const req = new http.ClientRequest(url, { @@ -197,7 +197,7 @@ it('intercepts an HTTPS ClientRequest request with URL instance', async () => { it('intercepts an HTTPS ClientRequest request with request options', async () => { const url = new URL(httpServer.https.url('/user?id=123')) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) const req = new http.ClientRequest({ diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index b797e35db..f45aca35f 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -11,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 7ef55a67a..09c547f5d 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -3,7 +3,7 @@ import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' -import { HttpRequestEventMap } from '#/src/glossary' +import { HttpRequestEventMap } from '#/src/events/http' import { RequestController } from '#/src/RequestController' import { HttpRequestInterceptor } from '#/src/interceptors/http' @@ -18,7 +18,7 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 2447f7ddc..01ddb43de 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,7 +1,7 @@ import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' -import { HttpRequestEventMap } from '#/src/glossary' +import { HttpRequestEventMap } from '#/src/events/http' import { RequestController } from '#/src/RequestController' import { HttpRequestInterceptor } from '#/src/interceptors/http' @@ -11,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index 817f97a65..17b1a72e9 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -20,7 +20,7 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) diff --git a/test/third-party/miniflare-xhr.test.ts b/test/third-party/miniflare-xhr.test.ts index ee05c3ae8..fac822a1b 100644 --- a/test/third-party/miniflare-xhr.test.ts +++ b/test/third-party/miniflare-xhr.test.ts @@ -1,5 +1,5 @@ // @vitest-environment miniflare -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' let interceptor: XMLHttpRequestInterceptor diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index d071ac1ae..3dcc2cf77 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -8,7 +8,7 @@ import { FetchInterceptor } from '@mswjs/interceptors/fetch' import { toWebResponse } from '#/test/helpers' const interceptor = new BatchInterceptor({ - name: 'setup-server', + name: 'interceptor', interceptors: [ new HttpRequestInterceptor(), new XMLHttpRequestInterceptor(), @@ -22,24 +22,23 @@ beforeAll(() => { afterEach(() => { interceptor.removeAllListeners() - vi.clearAllMocks() }) afterAll(() => { interceptor.dispose() }) -test('responds to fetch', async () => { +it('responds to fetch', async () => { interceptor.once('request', ({ controller }) => { controller.respondWith(new Response('mocked-body')) }) const response = await fetch('https://any.host.here/') expect(response.status).toEqual(200) - expect(await response.text()).toEqual('mocked-body') + await expect(response.text()).resolves.toEqual('mocked-body') }) -test('responds to http.get', async () => { +it('responds to http.get', async () => { interceptor.once('request', ({ controller }) => { controller.respondWith(new Response('mocked-body')) }) @@ -48,7 +47,7 @@ test('responds to http.get', async () => { await expect(response.text()).resolves.toEqual('mocked-body') }) -test('responds to https.get', async () => { +it('responds to https.get', async () => { interceptor.once('request', ({ controller }) => { controller.respondWith(new Response('mocked-body')) }) @@ -57,7 +56,7 @@ test('responds to https.get', async () => { await expect(response.text()).resolves.toEqual('mocked-body') }) -test('throws when responding with a network error', async () => { +it('throws when responding with a network error', async () => { interceptor.once('request', ({ controller }) => { /** * @note "Response.error()" static method is NOT implemented in Miniflare. diff --git a/test/third-party/supertest.test.ts b/test/third-party/supertest.test.ts index ff677702f..cc8e59178 100644 --- a/test/third-party/supertest.test.ts +++ b/test/third-party/supertest.test.ts @@ -4,10 +4,9 @@ import supertest from 'supertest' import { HttpRequestEventMap } from '#/src/index' import { HttpRequestInterceptor } from '#/src/interceptors/http' -const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const requestListener = vi.fn<(event: HttpRequestEventMap['request']) => void>() const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() const interceptor = new HttpRequestInterceptor() interceptor.on('request', requestListener) diff --git a/vitest.setup.ts b/vitest.setup.ts index c162ff4cd..e260bca63 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1,12 +1,17 @@ import { Readable } from 'node:stream' import { setTimeout } from 'node:timers/promises' import { TestProject } from 'vitest/node' +import * as express from 'express' import { HttpServer } from '@open-draft/test-server/http' import { useCors } from './test/helpers' const server = new HttpServer((app) => { app.use(useCors) + app.post('/status', express.text(), (req, res) => { + res.writeHead(req.body).end() + }) + app.get('/redirect', (req, res) => { const baseUrl = new URL( `${req.secure ? 'https' : 'http'}://${req.get('host')}/` From 29ec8f41a89e1a7487154f11298269511f292979 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 12 Mar 2026 15:09:07 +0100 Subject: [PATCH 159/198] fix(wip): proxy events between interceptors --- pnpm-lock.yaml | 7 +- src/interceptors/ClientRequest/index.ts | 36 ++++++----- src/interceptors/XMLHttpRequest/node.ts | 62 ++++++++++-------- src/interceptors/fetch/node.ts | 34 +++++----- src/interceptors/http/index.ts | 6 +- src/utils/handleRequest.ts | 5 +- src/utils/interceptor-utils.ts | 64 +++++++------------ test/features/events/request.test.ts | 30 +++------ test/features/events/response.test.ts | 39 ++++++----- .../xhr-with-credentials.neutral.test.ts | 3 +- .../http-response-readable-stream.test.ts | 2 +- 11 files changed, 132 insertions(+), 156 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 767cae4eb..c17c730fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 1.4.3 rettime: specifier: ^0.11.6 - version: 0.11.6 + version: link:../../rettime devDependencies: '@commitlint/cli': specifier: ^19.7.1 @@ -2667,9 +2667,6 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - rettime@0.11.6: - resolution: {integrity: sha512-2Mp+0ie3Ql9ivj2BbdCboaaLzRoz8B2WEmEvJTKvOmDAnpH+gGb1/urWGJRG4KPIvA0wfYSPsbIj3TBFTfTxew==} - rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -5910,8 +5907,6 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - rettime@0.11.6: {} - rfdc@1.4.1: {} rolldown-plugin-dts@0.19.1(rolldown@1.0.0-beta.55)(typescript@5.8.2): diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 0233f10c4..a0be26ba0 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,33 +1,35 @@ import http from 'node:http' import https from 'node:https' -import { HttpRequestEventMap } from '#/src/events/http' -import { Interceptor } from '#/src/Interceptor' import { runInRequestContext } from '#/src/request-context' import { applyPatch } from '#/src/utils/apply-patch' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { propagateHttpEvents } from '#/src/utils/interceptor-utils' +import { Interceptor } from '#/src/Interceptor' +import { HttpRequestEventMap } from '#/src/events/http' +import { proxyEventListeners } from '#/src/utils/interceptor-utils' export class ClientRequestInterceptor extends Interceptor { - static symbol = Symbol('client-request-interceptor') + static symbol = Symbol.for('client-request-interceptor') + + #httpInterceptor: HttpRequestInterceptor constructor() { super(ClientRequestInterceptor.symbol) + + this.#httpInterceptor = new HttpRequestInterceptor() + this.subscriptions.push( + proxyEventListeners({ + from: this.emitter, + to: this.#httpInterceptor['emitter'], + filter: (event) => { + return event.initiator instanceof http.ClientRequest + }, + }) + ) } protected setup(): void { - const httpInterceptor = new HttpRequestInterceptor() - - httpInterceptor.apply() - this.subscriptions.push(() => httpInterceptor.dispose()) - - const { controller } = propagateHttpEvents( - httpInterceptor['emitter'], - this.emitter, - (event) => { - return event.initiator instanceof http.ClientRequest - } - ) - this.subscriptions.push(() => controller.abort()) + this.#httpInterceptor.apply() + this.subscriptions.push(() => this.#httpInterceptor.dispose()) this.subscriptions.push( applyPatch(http, 'ClientRequest', (ClientRequest) => { diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index d0b6c47b5..2aa25ebf3 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -1,17 +1,39 @@ +import { Emitter } from 'rettime' import { requestContext } from '#/src/request-context' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' -import { applyPatch } from '#/src/utils/apply-patch' import { Interceptor } from '#/src/Interceptor' -import { HttpRequestEventMap } from '../../events/http' import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { applyPatch } from '#/src/utils/apply-patch' import { FetchRequest } from '#/src/utils/fetchUtils' -import { propagateHttpEvents } from '#/src/utils/interceptor-utils' +import { HttpRequestEventMap } from '#/src/events/http' +import { proxyEventListeners } from '#/src/utils/interceptor-utils' export class XMLHttpRequestInterceptor extends Interceptor { static symbol = Symbol.for('xhr-interceptor') + #httpInterceptor: HttpRequestInterceptor + constructor() { super(XMLHttpRequestInterceptor.symbol) + + this.#httpInterceptor = new HttpRequestInterceptor() + this.subscriptions.push( + proxyEventListeners({ + from: this.emitter, + to: this.#httpInterceptor['emitter'], + filter: (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest( + event.request, + event.initiator + ) + return true + } + + return false + }, + }) + ) } protected checkEnvironment() { @@ -19,28 +41,8 @@ export class XMLHttpRequestInterceptor extends Interceptor } protected setup(): void { - const httpInterceptor = new HttpRequestInterceptor() - - httpInterceptor.apply() - this.subscriptions.push(() => httpInterceptor.dispose()) - - this.emitter.hooks.on('beforeEmit', (event) => { - event.modify = true - }) - - const { controller } = propagateHttpEvents( - httpInterceptor['emitter'], - this.emitter, - (event) => { - if (event.initiator instanceof XMLHttpRequest) { - event.request = this.#transformRequest(event.request, event.initiator) - return true - } - - return false - } - ) - this.subscriptions.push(() => controller.abort()) + this.#httpInterceptor.apply() + this.subscriptions.push(() => this.#httpInterceptor.dispose()) this.logger.info('patching global "XMLHttpRequest"...') @@ -70,11 +72,19 @@ export class XMLHttpRequestInterceptor extends Interceptor } #transformRequest(request: Request, initiator: XMLHttpRequest): Request { + const expectedCredentials = initiator.withCredentials + ? 'include' + : 'same-origin' + + if (request.credentials === expectedCredentials) { + return request + } + return new FetchRequest(request.url, { ...request, method: request.method, headers: request.headers, - credentials: initiator.withCredentials ? 'include' : 'same-origin', + credentials: expectedCredentials, body: request.body, }) } diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 1952fb067..6e3632b96 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -1,17 +1,30 @@ -import { Interceptor } from '#/src/Interceptor' -import type { HttpRequestEventMap } from '#/src/events/http' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { canParseUrl } from '#/src/utils/canParseUrl' import { requestContext } from '#/src/request-context' import { applyPatch } from '#/src/utils/apply-patch' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { propagateHttpEvents } from '#/src/utils/interceptor-utils' +import { Interceptor } from '#/src/Interceptor' +import { HttpRequestEventMap } from '#/src/events/http' +import { proxyEventListeners } from '#/src/utils/interceptor-utils' export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') + #httpInterceptor: HttpRequestInterceptor + constructor() { super(FetchInterceptor.symbol) + + this.#httpInterceptor = new HttpRequestInterceptor() + this.subscriptions.push( + proxyEventListeners({ + from: this.emitter, + to: this.#httpInterceptor['emitter'], + filter: (event) => { + return event.initiator instanceof XMLHttpRequest + }, + }) + ) } protected checkEnvironment() { @@ -19,19 +32,8 @@ export class FetchInterceptor extends Interceptor { } protected setup(): void { - const httpInterceptor = new HttpRequestInterceptor() - - httpInterceptor.apply() - this.subscriptions.push(() => httpInterceptor.dispose()) - - const { controller } = propagateHttpEvents( - httpInterceptor['emitter'], - this.emitter, - (event) => { - return event.initiator instanceof Request - } - ) - this.subscriptions.push(() => controller.abort()) + this.#httpInterceptor.apply() + this.subscriptions.push(() => this.#httpInterceptor.dispose()) this.subscriptions.push( applyPatch(globalThis, 'fetch', (realFetch) => { diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 3b154b92e..f8a201417 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -296,7 +296,10 @@ export class HttpRequestInterceptor extends Interceptor { callback?.() } - responseSocket._destroy = () => { + responseSocket._destroy = ( + _error: Error | null, + callback: (error: Error | null) => void + ) => { /** * Destroy the socket if the response stream errored. * @see https://github.com/mswjs/interceptors/issues/738 @@ -306,6 +309,7 @@ export class HttpRequestInterceptor extends Interceptor { * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_client.js#L586 */ socket.destroy() + callback(null) } serverResponse.assignSocket(responseSocket) diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 7410ea932..ea6eae150 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -114,9 +114,7 @@ export async function handleRequest( controller: options.controller, } const requestEvent = new HttpRequestEvent(requestEventData) - const requestListenersPromise = options.emitter.emitAsPromise( - requestEvent - ) + const requestListenersPromise = options.emitter.emitAsPromise(requestEvent) await Promise.race([ // Short-circuit the request handling promise if the request gets aborted. @@ -152,7 +150,6 @@ export async function handleRequest( // If the developer has added "unhandledException" listeners, // allow them to handle the error. They can translate it to a // mocked response, network error, or forward it as-is. - console.log('[DEBUG handleRequest] unhandledException listenerCount:', options.emitter.listenerCount('unhandledException')) if (options.emitter.listenerCount('unhandledException') > 0) { // Create a new request controller just for the unhandled exception case. // This is needed because the original controller might have been already diff --git a/src/utils/interceptor-utils.ts b/src/utils/interceptor-utils.ts index 0f87be765..fbb8b6550 100644 --- a/src/utils/interceptor-utils.ts +++ b/src/utils/interceptor-utils.ts @@ -1,65 +1,45 @@ -import { DefaultEventMap, Emitter, EventMap } from 'rettime' +import { Emitter } from 'rettime' -export function propagateHttpEvents( - source: Emitter, - destination: Emitter, - predicate: (event: EventMap.Events) => boolean -) { +export function proxyEventListeners>(options: { + from: T + to: T + filter: (event: Emitter.Events) => boolean +}) { const controller = new AbortController() - const propagateEvent = async (event: EventMap.Events) => { - if (predicate(event)) { - await destination.emitAsPromise(event) + const propagateEvent = async (event: Emitter.Events) => { + if (options.filter(event)) { + await options.from.emitAsPromise(event) } } - source.on( - '*', - async (event) => { - if (event.type !== 'response') { - await propagateEvent(event) - } - }, - { signal: controller.signal } - ) - - /** - * @note Lazily add a "response" listener to the HTTP interceptor if this - * interceptor receives a response listener. HTTP interceptor creates a - * response parser only if a "response" listener is present. - * - * Cannot use hooks for this because `removeAllListeners()` in rettime - * also removes hooks listeners, breaking lazy registration across tests. - */ - destination.hooks.on( + options.from.hooks.on( 'newListener', (type) => { - if ( - type === 'response' && - !source.listeners('response').includes(propagateEvent) - ) { - source.on('response', propagateEvent, { signal: controller.signal }) + if (!options.to.listeners(type).includes(propagateEvent)) { + options.to.on(type, propagateEvent, { signal: controller.signal }) } }, - { signal: controller.signal } + { + persist: true, + signal: controller.signal, + } ) - destination.hooks.on( + options.from.hooks.on( 'removeListener', (type) => { - if ( - type === 'response' && - source.listeners('response').includes(propagateEvent) - ) { - source.removeListener('response', propagateEvent) + if (options.from.listenerCount(type) === 1) { + options.to.removeListener(type, propagateEvent) } }, { + persist: true, signal: controller.signal, } ) - return { - controller, + return () => { + controller.abort() } } diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 50d2bef9b..988129dbc 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -1,9 +1,10 @@ // @vitest-environment happy-dom import http from 'node:http' import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' -import { BatchInterceptor } from '#/src/BatchInterceptor' -import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' +import { BatchInterceptor } from '@mswjs/interceptors' +import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { FetchInterceptor } from '@mswjs/interceptors/fetch' import { RequestController } from '#/src/RequestController' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' @@ -11,7 +12,11 @@ import { getTestServer } from '#/test/setup/vitest' const server = getTestServer() const interceptor = new BatchInterceptor({ name: 'batch-interceptor', - interceptors: [new HttpRequestInterceptor(), new XMLHttpRequestInterceptor()], + interceptors: [ + new ClientRequestInterceptor(), + new XMLHttpRequestInterceptor(), + new FetchInterceptor(), + ], }) beforeAll(() => { @@ -55,25 +60,10 @@ it('ClientRequest: emits the "request" event upon the request', async () => { expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) }) -it.only('XMLHttpRequest: emits the "request" event upon the request (no CORS)', async () => { +it('XMLHttpRequest: emits the "request" event upon the request (no CORS)', async () => { const requestListener = vi.fn() interceptor.on('request', requestListener) - /** - * @fixme Since both HttpRequestInterceptor and XMLHttpRequestInterceptor are used simultaneously, - * they produce DOUBLE events: - * - HTTP fires "request" - * - XHR *also* fires "request" (modified). - * - * This should be fixable if interceptors emit actual EVENTS and the upstream interceptors (e.g. XHR) - * can just call "event.preventImmediatePropagation()" for the underlying HTTP interceptor. - */ - throw new Error('READ THE COMMENT ABOVE THIS ERROR') - - // interceptor.on('request', ({ request }) => { - // console.trace(request.method, request.url) - // }) - const url = server.http.url('/user') const request = new XMLHttpRequest() request.open('POST', url) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 804d3a593..e16f67871 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -2,32 +2,21 @@ import https from 'node:https' import { DeferredPromise } from '@open-draft/deferred-promise' import { BatchInterceptor, HttpRequestEventMap } from '@mswjs/interceptors' -import { HttpRequestInterceptor } from '@mswjs/interceptors/http' +import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { useCors, toWebResponse } from '#/test/helpers' +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { toWebResponse } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' -// const httpServer = new HttpServer((app) => { -// app.use(useCors) - -// app.get('/user', (_req, res) => { -// res.status(509).send('must-use-mocks') -// }) - -// app.post('/account', (_req, res) => { -// return res -// .status(200) -// .set('access-control-expose-headers', 'x-response-type') -// .set('x-response-type', 'original') -// .send('original-response-text') -// }) -// }) - const server = getTestServer() const interceptor = new BatchInterceptor({ name: 'batch-interceptor', - interceptors: [new HttpRequestInterceptor(), new XMLHttpRequestInterceptor()], + interceptors: [ + new ClientRequestInterceptor(), + new XMLHttpRequestInterceptor(), + new FetchInterceptor(), + ], }) beforeAll(() => { @@ -198,7 +187,7 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () } }) -it.only('XMLHttpRequest: emits the "response" event upon the original response', async () => { +it('XMLHttpRequest: emits the "response" event upon the original response', async () => { const responseListener = vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) @@ -297,6 +286,14 @@ it( 'fetch: emits the "response" event upon the original response', { timeout: 1500 }, async () => { + interceptor.on('request', ({ initiator, request }) => { + console.log(request.method, request.url, initiator?.constructor?.name) + + if (request.method === 'OPTIONS') { + console.trace('fetch options?!') + } + }) + const responseListenerArgs = new DeferredPromise< HttpRequestEventMap['response'] >() @@ -334,7 +331,7 @@ it( ) it('supports reading the request and response bodies in the "response" listener', async () => { - interceptor.on('request', ({ request, controller }) => { + interceptor.on('request', ({ controller }) => { controller.respondWith( new Response('mocked-response-text', { statusText: 'OK', diff --git a/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts index 0800906e8..f79b44850 100644 --- a/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts @@ -1,6 +1,5 @@ // @vitest-environment happy-dom import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' -import { getTestServer } from '#/test/setup/vitest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() @@ -38,7 +37,7 @@ it('sets "credentials" to "same-origin" for the request that does not have "with }) const request = new XMLHttpRequest() - request.open('GET', 'http://any.host.here/irrelevant') + request.open('GET', 'http://any.host.here/irrelevants') request.send() await waitForXMLHttpRequest(request) 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 98b616ff3..291b1f230 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -398,7 +398,7 @@ it('treats unhandled exceptions during bypass response stream as response errors ) }) -it('treats unhandled exceptions during mock response stream as response errors', async () => { +it.only('treats unhandled exceptions during mock response stream as response errors', async () => { interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ start(controller) { From 415fd57246d1222c2bf2a022a6f463d12ffd0e8d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Mar 2026 18:00:00 +0100 Subject: [PATCH 160/198] fix: proper emitter reassignment on running instances --- src/Interceptor.ts | 24 +++++++--- src/interceptors/ClientRequest/index.ts | 2 +- src/interceptors/XMLHttpRequest/node.ts | 4 +- src/interceptors/fetch/node.ts | 7 +-- src/presets/browser.ts | 4 +- src/presets/node.ts | 4 +- src/utils/interceptor-utils.ts | 17 +++++-- test/features/presets/node-preset.test.ts | 56 +++++++++++++++++------ 8 files changed, 83 insertions(+), 35 deletions(-) diff --git a/src/Interceptor.ts b/src/Interceptor.ts index d86c76d11..191372769 100644 --- a/src/Interceptor.ts +++ b/src/Interceptor.ts @@ -1,5 +1,6 @@ import { Logger } from '@open-draft/logger' import { Emitter, TypedListenerOptions } from 'rettime' +import { listenerCount } from 'superagent' export type InterceptorEventMap = Record export type InterceptorSubscription = () => void @@ -94,24 +95,33 @@ export class Interceptor { if (runningInstance) { logger.info('found a running instance, reusing...') + // Point this instance's emitter to the running instance's emitter + // so that any references to `this.emitter` (e.g. in proxyEventListeners) + // resolve to the emitter that actually dispatches events. + this.emitter = runningInstance.emitter + + const listenersController = new AbortController() + // Proxy any listeners you set on this instance to the running instance. this.on = (event, listener) => { logger.info('proxying the "%s" listener', event) // Add listeners to the running instance so they appear // at the top of the event listeners list and are executed first. - runningInstance.emitter.on(event, listener) - - // Ensure that once this interceptor instance is disposed, - // it removes all listeners it has appended to the running interceptor instance. - this.subscriptions.push(() => { - runningInstance.emitter.removeListener(event, listener) - logger.info('removed proxied "%s" listener!', event) + runningInstance.emitter.on(event, listener, { + signal: listenersController.signal, }) return this } + // Ensure that once this interceptor instance is disposed, + // it removes all listeners it has appended to the running interceptor instance. + this.subscriptions.push(() => { + listenersController.abort() + logger.info('removed all proxied listeners!') + }) + this.readyState = InterceptorReadyState.APPLIED return diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index a0be26ba0..70c54b867 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -19,7 +19,7 @@ export class ClientRequestInterceptor extends Interceptor { this.subscriptions.push( proxyEventListeners({ from: this.emitter, - to: this.#httpInterceptor['emitter'], + to: () => this.#httpInterceptor['emitter'], filter: (event) => { return event.initiator instanceof http.ClientRequest }, diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 2aa25ebf3..4bc54462d 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -1,4 +1,3 @@ -import { Emitter } from 'rettime' import { requestContext } from '#/src/request-context' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { Interceptor } from '#/src/Interceptor' @@ -17,10 +16,11 @@ export class XMLHttpRequestInterceptor extends Interceptor super(XMLHttpRequestInterceptor.symbol) this.#httpInterceptor = new HttpRequestInterceptor() + this.subscriptions.push( proxyEventListeners({ from: this.emitter, - to: this.#httpInterceptor['emitter'], + to: () => this.#httpInterceptor['emitter'], filter: (event) => { if (event.initiator instanceof XMLHttpRequest) { event.request = this.#transformRequest( diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 6e3632b96..4e23568c9 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -16,12 +16,13 @@ export class FetchInterceptor extends Interceptor { super(FetchInterceptor.symbol) this.#httpInterceptor = new HttpRequestInterceptor() + this.subscriptions.push( proxyEventListeners({ from: this.emitter, - to: this.#httpInterceptor['emitter'], + to: () => this.#httpInterceptor['emitter'], filter: (event) => { - return event.initiator instanceof XMLHttpRequest + return event.initiator instanceof Request }, }) ) @@ -39,7 +40,7 @@ export class FetchInterceptor extends Interceptor { applyPatch(globalThis, 'fetch', (realFetch) => { return (input, init) => { /** - * @note Resolve potentially relative request URL against the present `location`. + * Resolve potentially relative request URL against the present `location`. * This is mainly for native `fetch` in browser-like environments. * @see https://github.com/mswjs/msw/issues/1625 */ diff --git a/src/presets/browser.ts b/src/presets/browser.ts index d727924b7..9d7fe2d3a 100644 --- a/src/presets/browser.ts +++ b/src/presets/browser.ts @@ -2,8 +2,8 @@ import { FetchInterceptor } from '../interceptors/fetch' import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest/web' /** - * The default preset provisions the interception of requests - * regardless of their type (fetch/XMLHttpRequest). + * A browser preset for the request interception regardless + * of their initiator (fetch, XMLHttpRequest). */ export default [ new FetchInterceptor(), diff --git a/src/presets/node.ts b/src/presets/node.ts index 332c80518..6eda8ec61 100644 --- a/src/presets/node.ts +++ b/src/presets/node.ts @@ -3,8 +3,8 @@ import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest/node' import { FetchInterceptor } from '../interceptors/fetch/node' /** - * The default preset provisions the interception of requests - * regardless of their type (http/https/XMLHttpRequest). + * A Node.js preset for the request interception regardless + * of their initiator (http, fetch, XMLHttpRequest). */ export default [ new ClientRequestInterceptor(), diff --git a/src/utils/interceptor-utils.ts b/src/utils/interceptor-utils.ts index fbb8b6550..cd53b0768 100644 --- a/src/utils/interceptor-utils.ts +++ b/src/utils/interceptor-utils.ts @@ -2,7 +2,12 @@ import { Emitter } from 'rettime' export function proxyEventListeners>(options: { from: T - to: T + /** + * A lazy getter of the destination emitter. + * Handy because proxying has to be set up during an interceptor's constructor + * when the this.emitter = runningInstance.emitter assignment hasn't been made yet. + */ + to: () => T filter: (event: Emitter.Events) => boolean }) { const controller = new AbortController() @@ -16,8 +21,10 @@ export function proxyEventListeners>(options: { options.from.hooks.on( 'newListener', (type) => { - if (!options.to.listeners(type).includes(propagateEvent)) { - options.to.on(type, propagateEvent, { signal: controller.signal }) + const to = options.to() + + if (!to.listeners(type).includes(propagateEvent)) { + to.on(type, propagateEvent, { signal: controller.signal }) } }, { @@ -29,8 +36,10 @@ export function proxyEventListeners>(options: { options.from.hooks.on( 'removeListener', (type) => { + const to = options.to() + if (options.from.listenerCount(type) === 1) { - options.to.removeListener(type, propagateEvent) + to.removeListener(type, propagateEvent) } }, { diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index f055a05bb..81f2760ab 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import http from 'node:http' -import { BatchInterceptor } from '../../../lib/node/index.mjs' -import nodeInterceptors from '../../../lib/node/presets/node.mjs' +import { BatchInterceptor } from '@mswjs/interceptors' +import nodeInterceptors from '@mswjs/interceptors/presets/node' import { toWebResponse } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' @@ -10,12 +10,23 @@ const interceptor = new BatchInterceptor({ interceptors: nodeInterceptors, }) -const requestListener = vi.fn() - beforeAll(() => { interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts and mocks a ClientRequest', async () => { + const requestListener = vi.fn() interceptor.on('request', ({ request, controller }) => { requestListener(request) + controller.respondWith( new Response('mocked', { headers: { @@ -24,17 +35,7 @@ beforeAll(() => { }) ) }) -}) - -afterEach(() => { - vi.clearAllMocks() -}) -afterAll(() => { - interceptor.dispose() -}) - -it('intercepts and mocks a ClientRequest', async () => { const request = http.get('http://localhost:3001/resource') const [response] = await toWebResponse(request) @@ -52,6 +53,19 @@ it('intercepts and mocks a ClientRequest', async () => { }) it('intercepts and mocks an XMLHttpRequest (jsdom)', async () => { + const requestListener = vi.fn() + interceptor.on('request', ({ request, controller }) => { + requestListener(request) + + controller.respondWith( + new Response('mocked', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + const request = new XMLHttpRequest() request.open('GET', 'http://localhost:3001/resource') request.send() @@ -70,6 +84,20 @@ it('intercepts and mocks an XMLHttpRequest (jsdom)', async () => { }) it('intercepts and mocks a fetch request', async () => { + const requestListener = vi.fn() + interceptor.on('request', ({ request, controller }) => { + + requestListener(request) + + controller.respondWith( + new Response('mocked', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + const response = await fetch('http://localhost:3001/resource') expect(requestListener).toHaveBeenCalledWith( From dc679f8fbb1bcf8bc702005757f36f06bf0766d5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Mar 2026 19:08:47 +0100 Subject: [PATCH 161/198] fix(http): read mocked response stream manually --- src/interceptors/http/index.ts | 89 ++++++++++++++++--- .../http/compliance/http-res-destroy.test.ts | 14 +-- .../http-response-readable-stream.test.ts | 2 +- 3 files changed, 85 insertions(+), 20 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index f8a201417..b2e1906e8 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -1,5 +1,4 @@ import net from 'node:net' -import { Readable } from 'node:stream' import { METHODS, STATUS_CODES, @@ -7,7 +6,6 @@ import { IncomingMessage, } from 'node:http' import type { ReadableStream } from 'node:stream/web' -import { pipeline } from 'node:stream/promises' import { invariant } from 'outvariant' import { Interceptor } from '../../Interceptor' import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' @@ -297,18 +295,20 @@ export class HttpRequestInterceptor extends Interceptor { } responseSocket._destroy = ( - _error: Error | null, + error: Error | null, callback: (error: Error | null) => void ) => { /** - * Destroy the socket if the response stream errored. + * Only destroy the socket on stream errors. + * On a clean end, the socket is already signaled via `socket.push(null)` + * in the main response flow. Destroying it here prematurely would prevent + * the client from processing the response (e.g. calling `response.destroy()`). * @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() + if (error) { + socket.destroy() + } + callback(null) } @@ -324,16 +324,79 @@ export class HttpRequestInterceptor extends Interceptor { rawResponseHeaders ) + /** + * @note Override the socket's `_destroy` before writing the response body. + * The underlying TCP handle (from `socket.connect()`) makes `_destroy` async + * (`_handle.close()` callback), which delays the 'error' event. Since the real + * TCP connection is irrelevant for mocked responses, take the synchronous path + * so that user-initiated `response.destroy(error)` emits the error promptly. + * This must happen before `serverResponse.end()` because the HTTP parser may + * fire the 'response' event synchronously during `socket.push()`. + */ + socket._destroy = function ( + error: Error | null, + callback: (error: Error | null) => void + ) { + if (error) { + /** + * Emit the error event as a microtask instead of relying on the default + * `process.nextTick(emitErrorNT)` from `callback(error)`. This is necessary + * because `respondWith` runs inside a microtask (from `await reader.read()`). + * A resolved DeferredPromise continuation (from toWebResponse) is queued as + * another microtask during the same phase. Since microtasks are drained before + * nextTick, the test's `await` would resolve before the error event fires. + * Using `queueMicrotask` ensures the error event is emitted within the current + * microtask phase, before other queued microtasks. + */ + queueMicrotask(() => this.emit('error', error)) + callback(null) + } else { + callback(null) + } + } + if (response.body) { - await pipeline( - Readable.fromWeb(response.body as ReadableStream), - serverResponse - ) + const reader = response.body.getReader() + + try { + while (true) { + const { done, value } = await reader.read() + + if (done) { + serverResponse.end() + break + } + + if (!serverResponse.write(value)) { + await new Promise((resolve) => { + serverResponse.once('drain', resolve) + }) + } + } + } catch { + /** + * @note Delay the socket destruction to allow the event loop + * to flush already-pushed response data (headers + body chunks) + * through the HTTP parser. Without this, the socket is destroyed + * on the same tick as `socket.push(data)` and the client never + * reads the response. + */ + await new Promise((resolve) => process.nextTick(resolve)) + socket.destroy() + return + } } else { serverResponse.end() } if (request.method !== 'CONNECT') { + /** + * @note Defer the end-of-stream signal so the HTTP parser has a chance + * to process already-pushed response data and fire the 'response' event + * before the socket is ended. Without this, the parser marks the response + * as "complete" before the client can interact with it (e.g. `response.destroy()`). + */ + await new Promise((resolve) => process.nextTick(resolve)) socket.push(null) } } diff --git a/test/modules/http/compliance/http-res-destroy.test.ts b/test/modules/http/compliance/http-res-destroy.test.ts index eb7367c6b..b84deb04f 100644 --- a/test/modules/http/compliance/http-res-destroy.test.ts +++ b/test/modules/http/compliance/http-res-destroy.test.ts @@ -38,9 +38,10 @@ it('emits the "error" event when a bypassed response is destroyed', async () => const [, rawResponse] = await toWebResponse(request) - expect(rawResponse.destroyed).toBe(true) - expect(socketErrorListener).toHaveBeenCalledOnce() - expect(socketErrorListener).toHaveBeenCalledWith(new Error('reason')) + expect.soft(rawResponse.destroyed).toBe(true) + expect + .soft(socketErrorListener) + .toHaveBeenCalledExactlyOnceWith(new Error('reason')) }) it('emits the "error" event when a mocked response is destroyed', async () => { @@ -61,7 +62,8 @@ it('emits the "error" event when a mocked response is destroyed', async () => { const [, rawResponse] = await toWebResponse(request) - expect(rawResponse.destroyed).toBe(true) - expect(socketErrorListener).toHaveBeenCalledOnce() - expect(socketErrorListener).toHaveBeenCalledWith(new Error('reason')) + expect.soft(rawResponse.destroyed).toBe(true) + expect + .soft(socketErrorListener) + .toHaveBeenCalledExactlyOnceWith(new Error('reason')) }) 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 291b1f230..98b616ff3 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -398,7 +398,7 @@ it('treats unhandled exceptions during bypass response stream as response errors ) }) -it.only('treats unhandled exceptions during mock response stream as response errors', async () => { +it('treats unhandled exceptions during mock response stream as response errors', async () => { interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ start(controller) { From c5cc770e58eb73a33084bcfe9602719f9c5001b0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 17 Mar 2026 19:18:43 +0100 Subject: [PATCH 162/198] fix(http): call original socket destroy for clean destroy paths --- src/interceptors/http/index.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index b2e1906e8..183923420 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -295,20 +295,18 @@ export class HttpRequestInterceptor extends Interceptor { } responseSocket._destroy = ( - error: Error | null, + _error: Error | null, callback: (error: Error | null) => void ) => { /** - * Only destroy the socket on stream errors. - * On a clean end, the socket is already signaled via `socket.push(null)` - * in the main response flow. Destroying it here prematurely would prevent - * the client from processing the response (e.g. calling `response.destroy()`). + * 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 */ - if (error) { - socket.destroy() - } - + socket.destroy() callback(null) } @@ -333,9 +331,10 @@ export class HttpRequestInterceptor extends Interceptor { * This must happen before `serverResponse.end()` because the HTTP parser may * fire the 'response' event synchronously during `socket.push()`. */ + const originalSocketDestroy = socket._destroy.bind(socket) socket._destroy = function ( error: Error | null, - callback: (error: Error | null) => void + callback: (error?: Error | null) => void ) { if (error) { /** @@ -351,7 +350,7 @@ export class HttpRequestInterceptor extends Interceptor { queueMicrotask(() => this.emit('error', error)) callback(null) } else { - callback(null) + originalSocketDestroy(error, callback) } } From d8ee38f28ead35ca7b3188c068a67dd940145076 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 22 Apr 2026 14:58:54 +0200 Subject: [PATCH 163/198] fix(wip): failing compliance tests --- src/interceptors/http/index.ts | 21 ++++++++++--------- .../xhr-response-patching.neutral.test.ts | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 183923420..b2e1906e8 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -295,18 +295,20 @@ export class HttpRequestInterceptor extends Interceptor { } responseSocket._destroy = ( - _error: Error | null, + error: Error | null, callback: (error: Error | null) => void ) => { /** - * Destroy the socket if the response stream errored. + * Only destroy the socket on stream errors. + * On a clean end, the socket is already signaled via `socket.push(null)` + * in the main response flow. Destroying it here prematurely would prevent + * the client from processing the response (e.g. calling `response.destroy()`). * @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() + if (error) { + socket.destroy() + } + callback(null) } @@ -331,10 +333,9 @@ export class HttpRequestInterceptor extends Interceptor { * This must happen before `serverResponse.end()` because the HTTP parser may * fire the 'response' event synchronously during `socket.push()`. */ - const originalSocketDestroy = socket._destroy.bind(socket) socket._destroy = function ( error: Error | null, - callback: (error?: Error | null) => void + callback: (error: Error | null) => void ) { if (error) { /** @@ -350,7 +351,7 @@ export class HttpRequestInterceptor extends Interceptor { queueMicrotask(() => this.emit('error', error)) callback(null) } else { - originalSocketDestroy(error, callback) + callback(null) } } diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts index cbee56fb7..bab5c3b42 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts @@ -21,7 +21,7 @@ afterAll(() => { interceptor.dispose() }) -it.only('patches the original XMLHttpRequest response', async ({ task }) => { +it('patches the original XMLHttpRequest response', async ({ task }) => { interceptor.on('request', async ({ request, controller }) => { const url = new URL(request.url) From b84749bcf3d73a2b96b2fd1215db5650ab29a99e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 24 Apr 2026 18:18:02 +0200 Subject: [PATCH 164/198] chore: replace `applyPatch` with `globalsRegistry` --- src/interceptors/ClientRequest/index.ts | 14 ++--- src/interceptors/WebSocket/index.ts | 6 +- src/interceptors/XMLHttpRequest/node.ts | 6 +- src/interceptors/XMLHttpRequest/web.ts | 4 +- src/interceptors/fetch/index.ts | 4 +- src/interceptors/fetch/node.ts | 4 +- src/interceptors/net/index.ts | 6 +- src/utils/apply-patch.ts | 50 ---------------- src/utils/globalsRegistry.test.ts | 80 ++++++++++++++++++++----- src/utils/globalsRegistry.ts | 59 +++++++++++------- 10 files changed, 125 insertions(+), 108 deletions(-) delete mode 100644 src/utils/apply-patch.ts diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 70c54b867..21a0680f2 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,7 +1,7 @@ import http from 'node:http' import https from 'node:https' import { runInRequestContext } from '#/src/request-context' -import { applyPatch } from '#/src/utils/apply-patch' +import { globalsRegistry } from '#/src/utils/globalsRegistry' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { Interceptor } from '#/src/Interceptor' import { HttpRequestEventMap } from '#/src/events/http' @@ -32,7 +32,7 @@ export class ClientRequestInterceptor extends Interceptor { this.subscriptions.push(() => this.#httpInterceptor.dispose()) this.subscriptions.push( - applyPatch(http, 'ClientRequest', (ClientRequest) => { + globalsRegistry.replaceGlobal(http, 'ClientRequest', (ClientRequest) => { return new Proxy(ClientRequest, { construct(target, args, newTarget) { return runInRequestContext(() => { @@ -41,29 +41,29 @@ export class ClientRequestInterceptor extends Interceptor { }, }) }), - applyPatch(http, 'get', (httpGet) => { + globalsRegistry.replaceGlobal(http, 'get', (httpGet) => { return function mockHttpGet(...args) { return runInRequestContext(() => { return httpGet(...(args as [any, any])) }) } }), - applyPatch(http, 'request', (httpRequest) => { + globalsRegistry.replaceGlobal(http, 'request', (httpRequest) => { return function mockHttpRequest(...args) { return runInRequestContext(() => { return httpRequest(...(args as [any, any])) }) } }), - applyPatch(https, 'get', (httpsGet) => { + globalsRegistry.replaceGlobal(https, 'get', (httpsGet) => { return function mockHttpsGet(...args) { return runInRequestContext(() => { return httpsGet(...(args as [any, any])) }) } }), - applyPatch(https, 'request', (httpsRequest) => { - return function mockHttpsGet(...args) { + globalsRegistry.replaceGlobal(https, 'request', (httpsRequest) => { + return function mockHttpsRequest(...args) { return runInRequestContext(() => { return httpsRequest(...(args as [any, any])) }) diff --git a/src/interceptors/WebSocket/index.ts b/src/interceptors/WebSocket/index.ts index 44ea97f7d..c6f95eab6 100644 --- a/src/interceptors/WebSocket/index.ts +++ b/src/interceptors/WebSocket/index.ts @@ -155,7 +155,11 @@ export class WebSocketInterceptor extends Interceptor { logger.info('patching global WebSocket...') this.subscriptions.push( - globalsRegistry.replaceGlobal('WebSocket', WebSocketProxy) + globalsRegistry.replaceGlobal( + globalThis, + 'WebSocket', + () => WebSocketProxy + ) ) logger.info('global WebSocket patched!', globalThis.WebSocket.name) diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 4bc54462d..65e65d820 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -2,7 +2,7 @@ import { requestContext } from '#/src/request-context' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { Interceptor } from '#/src/Interceptor' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { applyPatch } from '#/src/utils/apply-patch' +import { globalsRegistry } from '#/src/utils/globalsRegistry' import { FetchRequest } from '#/src/utils/fetchUtils' import { HttpRequestEventMap } from '#/src/events/http' import { proxyEventListeners } from '#/src/utils/interceptor-utils' @@ -47,8 +47,8 @@ export class XMLHttpRequestInterceptor extends Interceptor this.logger.info('patching global "XMLHttpRequest"...') this.subscriptions.push( - applyPatch(globalThis, 'XMLHttpRequest', () => { - return new Proxy(globalThis.XMLHttpRequest, { + globalsRegistry.replaceGlobal(globalThis, 'XMLHttpRequest', (realXMLHttpRequest) => { + return new Proxy(realXMLHttpRequest, { construct(target, args, newTarget) { const xmlHttpRequest = Reflect.construct(target, args, newTarget) diff --git a/src/interceptors/XMLHttpRequest/web.ts b/src/interceptors/XMLHttpRequest/web.ts index dbb2b9f6c..a4ebcc8f8 100644 --- a/src/interceptors/XMLHttpRequest/web.ts +++ b/src/interceptors/XMLHttpRequest/web.ts @@ -2,7 +2,7 @@ import { HttpRequestEventMap } from '../../events/http' import { Interceptor } from '../../Interceptor' import { createXMLHttpRequestProxy } from './XMLHttpRequestProxy' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { applyPatch } from '../../utils/apply-patch' +import { globalsRegistry } from '../../utils/globalsRegistry' export class XMLHttpRequestInterceptor extends Interceptor { static interceptorSymbol = Symbol.for('xhr-interceptor') @@ -21,7 +21,7 @@ export class XMLHttpRequestInterceptor extends Interceptor logger.info('patching "XMLHttpRequest"...') this.subscriptions.push( - applyPatch(globalThis, 'XMLHttpRequest', () => { + globalsRegistry.replaceGlobal(globalThis, 'XMLHttpRequest', () => { return createXMLHttpRequestProxy({ emitter: this.emitter, logger: this.logger, diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index 6e3dc118f..f7614c5fe 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -12,7 +12,7 @@ import { decompressResponse } from './utils/decompression' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { FetchResponse } from '../../utils/fetchUtils' import { isResponseError } from '../../utils/responseUtils' -import { applyPatch } from '../../utils/apply-patch' +import { globalsRegistry } from '../../utils/globalsRegistry' import { copyRawHeaders } from '../ClientRequest/utils/recordRawHeaders' export class FetchInterceptor extends Interceptor { @@ -28,7 +28,7 @@ export class FetchInterceptor extends Interceptor { protected async setup() { this.subscriptions.push( - applyPatch(globalThis, 'fetch', (realFetch) => { + globalsRegistry.replaceGlobal(globalThis, 'fetch', (realFetch) => { return async (input, init) => { const requestId = createRequestId() diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 4e23568c9..34ca9508f 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -1,7 +1,7 @@ import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { canParseUrl } from '#/src/utils/canParseUrl' import { requestContext } from '#/src/request-context' -import { applyPatch } from '#/src/utils/apply-patch' +import { globalsRegistry } from '#/src/utils/globalsRegistry' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { Interceptor } from '#/src/Interceptor' import { HttpRequestEventMap } from '#/src/events/http' @@ -37,7 +37,7 @@ export class FetchInterceptor extends Interceptor { this.subscriptions.push(() => this.#httpInterceptor.dispose()) this.subscriptions.push( - applyPatch(globalThis, 'fetch', (realFetch) => { + globalsRegistry.replaceGlobal(globalThis, 'fetch', (realFetch) => { return (input, init) => { /** * Resolve potentially relative request URL against the present `location`. diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index c8eca4acc..6df9ead1f 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -8,7 +8,7 @@ import { import { TcpSocketController, TlsSocketController } from './socket-controller' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' import { createLogger } from '../../utils/logger' -import { applyPatch } from '../../utils/apply-patch' +import { globalsRegistry } from '../../utils/globalsRegistry' import { TypedEvent } from 'rettime' interface SocketConnectionEventData { @@ -48,7 +48,7 @@ export class SocketInterceptor extends Interceptor { protected setup(): void { this.subscriptions.push( - applyPatch(net, 'connect', (realNetConnect) => { + globalsRegistry.replaceGlobal(net, 'connect', (realNetConnect) => { return (...args: [any, any]) => { log('net.connect()', args) @@ -98,7 +98,7 @@ export class SocketInterceptor extends Interceptor { return socket.connect(connectionOptions, connectionCallback) } }), - applyPatch(tls, 'connect', (realTlsConnect) => { + globalsRegistry.replaceGlobal(tls, 'connect', (realTlsConnect) => { return (...args: [any, any]) => { log('tls.connect()', args) diff --git a/src/utils/apply-patch.ts b/src/utils/apply-patch.ts deleted file mode 100644 index 8f08a69a8..000000000 --- a/src/utils/apply-patch.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { invariant } from 'outvariant' - -const IS_PATCHED_MODULE: unique symbol = Symbol.for('kIsPatchedModule') - -/** - * Apply a patch for the given property on the owner object. - * Returns a function that reverts that patch. - */ -export function applyPatch, K extends keyof T>( - owner: T, - key: K, - patch: (realValue: T[K]) => T[K] -): () => void { - const realValue = owner[key] - - invariant(!realValue[IS_PATCHED_MODULE], 'Failed to patch "%s"', key) - - const originalDescriptor = Object.getOwnPropertyDescriptor(owner, key) - - if (originalDescriptor) { - Object.defineProperty(owner, key, { - value: patch(realValue), - configurable: true, - }) - } else { - owner[key] = patch(realValue) - } - - Object.defineProperty(realValue, IS_PATCHED_MODULE, { - value: true, - enumerable: false, - configurable: true, - }) - - return () => { - /** - * @note If the property was defined as a descriptor, preserve it. - * For example, `globalThis.WebSocket` is defined that way in browser-likes. - */ - if (originalDescriptor) { - Object.defineProperty(owner, key, originalDescriptor) - } else { - owner[key] = realValue - } - - Object.defineProperty(realValue, IS_PATCHED_MODULE, { - value: undefined, - }) - } -} diff --git a/src/utils/globalsRegistry.test.ts b/src/utils/globalsRegistry.test.ts index 357177d92..6a82a4e15 100644 --- a/src/utils/globalsRegistry.test.ts +++ b/src/utils/globalsRegistry.test.ts @@ -17,7 +17,15 @@ afterEach(() => { }) it('replaces the global', () => { - globalsRegistry.replaceGlobal('foo', { original: false }) + globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) + expect(global.foo).toEqual({ original: false }) +}) + +it('exposes the real value to the replacement callback', () => { + globalsRegistry.replaceGlobal(global, 'foo', (realValue) => { + expect(realValue).toEqual({ original: true }) + return { original: false } + }) expect(global.foo).toEqual({ original: false }) }) @@ -30,7 +38,7 @@ it('replaces the global set on the prototype', () => { expect(global.foo).toEqual({ prototype: true }) expect(FakeGlobalScope.prototype.foo).toEqual({ prototype: true }) - globalsRegistry.replaceGlobal('foo', { original: false }) + globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) expect(global.foo).toEqual({ original: false }) expect(FakeGlobalScope.prototype.foo, 'Preserves prototype value').toEqual({ @@ -39,25 +47,65 @@ it('replaces the global set on the prototype', () => { }) it('replaces the global after it was restored', () => { - const restoreGlobal = globalsRegistry.replaceGlobal('foo', { + const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false, - }) + })) expect(global.foo).toEqual({ original: false }) restoreGlobal() expect(global.foo).toEqual({ original: true }) - globalsRegistry.replaceGlobal('foo', { original: false }) + globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) expect(global.foo).toEqual({ original: false }) }) +it('replaces a property on a custom owner', () => { + const owner = { bar: { original: true } } + const restoreGlobal = globalsRegistry.replaceGlobal(owner, 'bar', () => ({ + original: false, + })) + + expect(owner.bar).toEqual({ original: false }) + expect(global.foo).toEqual({ original: true }) + + restoreGlobal() + expect(owner.bar).toEqual({ original: true }) +}) + +it('tracks replacements per owner independently', () => { + const ownerA = { shared: 'a-original' } + const ownerB = { shared: 'b-original' } + + const restoreA = globalsRegistry.replaceGlobal( + ownerA, + 'shared', + () => 'a-next' + ) + const restoreB = globalsRegistry.replaceGlobal( + ownerB, + 'shared', + () => 'b-next' + ) + + expect(ownerA.shared).toBe('a-next') + expect(ownerB.shared).toBe('b-next') + + restoreA() + expect(ownerA.shared).toBe('a-original') + expect(ownerB.shared).toBe('b-next') + + restoreB() + expect(ownerB.shared).toBe('b-original') +}) + it('warns on replacing a non-existing global', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) globalsRegistry.replaceGlobal( + global, // @ts-expect-error Intentionally invalid value. 'NON-EXISTING', - { original: false } + () => ({ original: false }) ) expect(console.warn).toHaveBeenCalledExactlyOnceWith( 'Failed to replace a global value at "NON-EXISTING": not a global value.' @@ -65,18 +113,18 @@ it('warns on replacing a non-existing global', () => { }) it('throws if replacing an already replaced global', () => { - globalsRegistry.replaceGlobal('foo', { original: false }) + globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) expect(global.foo).toEqual({ original: false }) expect(() => - globalsRegistry.replaceGlobal('foo', { original: false }) + globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) ).toThrow('Failed to replace a global value at "foo": already replaced.') }) it('does nothing if restoring an already restored global', () => { - const restoreGlobal = globalsRegistry.replaceGlobal('foo', { + const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false, - }) + })) expect(global.foo).toEqual({ original: false }) @@ -88,9 +136,9 @@ it('does nothing if restoring an already restored global', () => { }) it('restores the global', () => { - const restoreGlobal = globalsRegistry.replaceGlobal('foo', { + const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false, - }) + })) expect(global.foo).toEqual({ original: false }) restoreGlobal() @@ -105,9 +153,9 @@ it('restores the global set on the prototype', () => { expect(global.foo).toEqual({ prototype: true }) - const restoreGlobal = globalsRegistry.replaceGlobal('foo', { + const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false, - }) + })) expect(global.foo).toEqual({ original: false }) @@ -125,9 +173,9 @@ it('restores global to the original property descriptor', () => { Object.defineProperty(global, 'foo', descriptor) expect(Object.getOwnPropertyDescriptor(global, 'foo')).toEqual(descriptor) - const restoreGlobal = globalsRegistry.replaceGlobal('foo', { + const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false, - }) + })) expect(global.foo).toEqual({ original: false }) expect(Object.getOwnPropertyDescriptor(global, 'foo')).toEqual({ diff --git a/src/utils/globalsRegistry.ts b/src/utils/globalsRegistry.ts index c0cd53c5c..e7cfb6c97 100644 --- a/src/utils/globalsRegistry.ts +++ b/src/utils/globalsRegistry.ts @@ -1,38 +1,43 @@ import { invariant } from 'outvariant' class GlobalsRegistry { - #globals = new Map void>() + #replacements = new Map void>>() - public replaceGlobal( + public replaceGlobal( + owner: Owner, key: K, - nextValue: (typeof globalThis)[K] + getNextValue: (realValue: Owner[K]) => Owner[K] ): () => void { + const ownerReplacements = this.#replacements.get(owner) + invariant( - !this.#globals.has(key), - `Failed to replace a global value at "${key}": already replaced.` + !ownerReplacements?.has(key), + `Failed to replace a global value at "${String(key)}": already replaced.` ) - const match = getDeepPropertyDescriptor(globalThis, key) + const match = getDeepPropertyDescriptor(owner, key) if (typeof match === 'undefined') { console.warn( - `Failed to replace a global value at "${key}": not a global value.` + `Failed to replace a global value at "${String(key)}": not a global value.` ) return () => {} } - Object.defineProperty(globalThis, key, { - value: nextValue, + Object.defineProperty(owner, key, { + value: getNextValue(owner[key]), enumerable: true, configurable: true, }) const restoreGlobal = () => { - if (!this.#globals.has(key)) { + const currentReplacements = this.#replacements.get(owner) + + if (!currentReplacements?.has(key)) { return } - if (match.owner === globalThis) { + if (match.owner === owner) { Object.defineProperty(match.owner, key, match.descriptor) } else { /** @@ -40,13 +45,21 @@ class GlobalsRegistry { * If the owner isn't `globalThis`, the property is likely nested in the prototype. * The registry does not meddle with those, they are left intact. */ - Reflect.deleteProperty(globalThis, key) + Reflect.deleteProperty(owner, key) } - this.#globals.delete(key) + currentReplacements.delete(key) + + if (currentReplacements.size === 0) { + this.#replacements.delete(owner) + } } - this.#globals.set(key, restoreGlobal) + if (ownerReplacements) { + ownerReplacements.set(key, restoreGlobal) + } else { + this.#replacements.set(owner, new Map([[key, restoreGlobal]])) + } return restoreGlobal } @@ -54,14 +67,16 @@ class GlobalsRegistry { public restoreAllGlobals(): void { const errors: Array = [] - for (const [, restoreGlobal] of this.#globals) { - try { - restoreGlobal() - } catch (error) { - if (error instanceof Error) { - errors.push(error) - } else { - throw error + for (const [, ownerReplacements] of this.#replacements) { + for (const [, restoreGlobal] of ownerReplacements) { + try { + restoreGlobal() + } catch (error) { + if (error instanceof Error) { + errors.push(error) + } else { + throw error + } } } } From bb18e41c5215689860e978634aedd074595dd965 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 24 Apr 2026 18:21:23 +0200 Subject: [PATCH 165/198] chore: rename `globalsRegistry` to `patchesRegistry` --- src/interceptors/ClientRequest/index.ts | 12 ++--- src/interceptors/WebSocket/index.ts | 8 +--- src/interceptors/XMLHttpRequest/node.ts | 44 ++++++++++--------- src/interceptors/XMLHttpRequest/web.ts | 4 +- src/interceptors/fetch/index.ts | 4 +- src/interceptors/fetch/node.ts | 4 +- src/interceptors/net/index.ts | 6 +-- src/utils/hasConfigurableGlobal.ts | 2 +- ...gistry.test.ts => patchesRegistry.test.ts} | 42 +++++++----------- ...{globalsRegistry.ts => patchesRegistry.ts} | 22 +++++----- 10 files changed, 70 insertions(+), 78 deletions(-) rename src/utils/{globalsRegistry.test.ts => patchesRegistry.test.ts} (77%) rename src/utils/{globalsRegistry.ts => patchesRegistry.ts} (83%) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 21a0680f2..99e9456db 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,7 +1,7 @@ import http from 'node:http' import https from 'node:https' import { runInRequestContext } from '#/src/request-context' -import { globalsRegistry } from '#/src/utils/globalsRegistry' +import { patchesRegistry } from '#/src/utils/patchesRegistry' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { Interceptor } from '#/src/Interceptor' import { HttpRequestEventMap } from '#/src/events/http' @@ -32,7 +32,7 @@ export class ClientRequestInterceptor extends Interceptor { this.subscriptions.push(() => this.#httpInterceptor.dispose()) this.subscriptions.push( - globalsRegistry.replaceGlobal(http, 'ClientRequest', (ClientRequest) => { + patchesRegistry.applyPatch(http, 'ClientRequest', (ClientRequest) => { return new Proxy(ClientRequest, { construct(target, args, newTarget) { return runInRequestContext(() => { @@ -41,28 +41,28 @@ export class ClientRequestInterceptor extends Interceptor { }, }) }), - globalsRegistry.replaceGlobal(http, 'get', (httpGet) => { + patchesRegistry.applyPatch(http, 'get', (httpGet) => { return function mockHttpGet(...args) { return runInRequestContext(() => { return httpGet(...(args as [any, any])) }) } }), - globalsRegistry.replaceGlobal(http, 'request', (httpRequest) => { + patchesRegistry.applyPatch(http, 'request', (httpRequest) => { return function mockHttpRequest(...args) { return runInRequestContext(() => { return httpRequest(...(args as [any, any])) }) } }), - globalsRegistry.replaceGlobal(https, 'get', (httpsGet) => { + patchesRegistry.applyPatch(https, 'get', (httpsGet) => { return function mockHttpsGet(...args) { return runInRequestContext(() => { return httpsGet(...(args as [any, any])) }) } }), - globalsRegistry.replaceGlobal(https, 'request', (httpsRequest) => { + patchesRegistry.applyPatch(https, 'request', (httpsRequest) => { return function mockHttpsRequest(...args) { return runInRequestContext(() => { return httpsRequest(...(args as [any, any])) diff --git a/src/interceptors/WebSocket/index.ts b/src/interceptors/WebSocket/index.ts index c6f95eab6..6ca8f417d 100644 --- a/src/interceptors/WebSocket/index.ts +++ b/src/interceptors/WebSocket/index.ts @@ -21,7 +21,7 @@ import { } from './WebSocketOverride' import { bindEvent } from './utils/bindEvent' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { globalsRegistry } from '../../utils/globalsRegistry' +import { patchesRegistry } from '../../utils/patchesRegistry' export { type WebSocketData, @@ -155,11 +155,7 @@ export class WebSocketInterceptor extends Interceptor { logger.info('patching global WebSocket...') this.subscriptions.push( - globalsRegistry.replaceGlobal( - globalThis, - 'WebSocket', - () => WebSocketProxy - ) + patchesRegistry.applyPatch(globalThis, 'WebSocket', () => WebSocketProxy) ) logger.info('global WebSocket patched!', globalThis.WebSocket.name) diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 65e65d820..c7354703e 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -2,7 +2,7 @@ import { requestContext } from '#/src/request-context' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { Interceptor } from '#/src/Interceptor' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { globalsRegistry } from '#/src/utils/globalsRegistry' +import { patchesRegistry } from '#/src/utils/patchesRegistry' import { FetchRequest } from '#/src/utils/fetchUtils' import { HttpRequestEventMap } from '#/src/events/http' import { proxyEventListeners } from '#/src/utils/interceptor-utils' @@ -47,25 +47,29 @@ export class XMLHttpRequestInterceptor extends Interceptor this.logger.info('patching global "XMLHttpRequest"...') this.subscriptions.push( - globalsRegistry.replaceGlobal(globalThis, 'XMLHttpRequest', (realXMLHttpRequest) => { - return new Proxy(realXMLHttpRequest, { - construct(target, args, newTarget) { - const xmlHttpRequest = Reflect.construct(target, args, newTarget) - - /** - * @note Use `.enterWith()` here because XHR in JSDOM is implemented - * via `http`/`https`. This makes the initiator cascading work properly. - */ - requestContext.enterWith({ initiator: xmlHttpRequest }) - - /** - * @todo Do we need to exit the async context at some point? - */ - - return xmlHttpRequest - }, - }) - }) + patchesRegistry.applyPatch( + globalThis, + 'XMLHttpRequest', + (realXMLHttpRequest) => { + return new Proxy(realXMLHttpRequest, { + construct(target, args, newTarget) { + const xmlHttpRequest = Reflect.construct(target, args, newTarget) + + /** + * @note Use `.enterWith()` here because XHR in JSDOM is implemented + * via `http`/`https`. This makes the initiator cascading work properly. + */ + requestContext.enterWith({ initiator: xmlHttpRequest }) + + /** + * @todo Do we need to exit the async context at some point? + */ + + return xmlHttpRequest + }, + }) + } + ) ) this.logger.info('global "XMLHttpRequest" patched!') diff --git a/src/interceptors/XMLHttpRequest/web.ts b/src/interceptors/XMLHttpRequest/web.ts index a4ebcc8f8..08935b55c 100644 --- a/src/interceptors/XMLHttpRequest/web.ts +++ b/src/interceptors/XMLHttpRequest/web.ts @@ -2,7 +2,7 @@ import { HttpRequestEventMap } from '../../events/http' import { Interceptor } from '../../Interceptor' import { createXMLHttpRequestProxy } from './XMLHttpRequestProxy' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { globalsRegistry } from '../../utils/globalsRegistry' +import { patchesRegistry } from '../../utils/patchesRegistry' export class XMLHttpRequestInterceptor extends Interceptor { static interceptorSymbol = Symbol.for('xhr-interceptor') @@ -21,7 +21,7 @@ export class XMLHttpRequestInterceptor extends Interceptor logger.info('patching "XMLHttpRequest"...') this.subscriptions.push( - globalsRegistry.replaceGlobal(globalThis, 'XMLHttpRequest', () => { + patchesRegistry.applyPatch(globalThis, 'XMLHttpRequest', () => { return createXMLHttpRequestProxy({ emitter: this.emitter, logger: this.logger, diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index f7614c5fe..d4bb155ea 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -12,7 +12,7 @@ import { decompressResponse } from './utils/decompression' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { FetchResponse } from '../../utils/fetchUtils' import { isResponseError } from '../../utils/responseUtils' -import { globalsRegistry } from '../../utils/globalsRegistry' +import { patchesRegistry } from '../../utils/patchesRegistry' import { copyRawHeaders } from '../ClientRequest/utils/recordRawHeaders' export class FetchInterceptor extends Interceptor { @@ -28,7 +28,7 @@ export class FetchInterceptor extends Interceptor { protected async setup() { this.subscriptions.push( - globalsRegistry.replaceGlobal(globalThis, 'fetch', (realFetch) => { + patchesRegistry.applyPatch(globalThis, 'fetch', (realFetch) => { return async (input, init) => { const requestId = createRequestId() diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 34ca9508f..f63ad5175 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -1,7 +1,7 @@ import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { canParseUrl } from '#/src/utils/canParseUrl' import { requestContext } from '#/src/request-context' -import { globalsRegistry } from '#/src/utils/globalsRegistry' +import { patchesRegistry } from '#/src/utils/patchesRegistry' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { Interceptor } from '#/src/Interceptor' import { HttpRequestEventMap } from '#/src/events/http' @@ -37,7 +37,7 @@ export class FetchInterceptor extends Interceptor { this.subscriptions.push(() => this.#httpInterceptor.dispose()) this.subscriptions.push( - globalsRegistry.replaceGlobal(globalThis, 'fetch', (realFetch) => { + patchesRegistry.applyPatch(globalThis, 'fetch', (realFetch) => { return (input, init) => { /** * Resolve potentially relative request URL against the present `location`. diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 6df9ead1f..8f897d88a 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -8,7 +8,7 @@ import { import { TcpSocketController, TlsSocketController } from './socket-controller' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' import { createLogger } from '../../utils/logger' -import { globalsRegistry } from '../../utils/globalsRegistry' +import { patchesRegistry } from '../../utils/patchesRegistry' import { TypedEvent } from 'rettime' interface SocketConnectionEventData { @@ -48,7 +48,7 @@ export class SocketInterceptor extends Interceptor { protected setup(): void { this.subscriptions.push( - globalsRegistry.replaceGlobal(net, 'connect', (realNetConnect) => { + patchesRegistry.applyPatch(net, 'connect', (realNetConnect) => { return (...args: [any, any]) => { log('net.connect()', args) @@ -98,7 +98,7 @@ export class SocketInterceptor extends Interceptor { return socket.connect(connectionOptions, connectionCallback) } }), - globalsRegistry.replaceGlobal(tls, 'connect', (realTlsConnect) => { + patchesRegistry.applyPatch(tls, 'connect', (realTlsConnect) => { return (...args: [any, any]) => { log('tls.connect()', args) diff --git a/src/utils/hasConfigurableGlobal.ts b/src/utils/hasConfigurableGlobal.ts index 4bdbbb477..42c13ebb4 100644 --- a/src/utils/hasConfigurableGlobal.ts +++ b/src/utils/hasConfigurableGlobal.ts @@ -1,4 +1,4 @@ -import { getDeepPropertyDescriptor } from './globalsRegistry' +import { getDeepPropertyDescriptor } from './patchesRegistry' /** * Returns a boolean indicating whether the given global property diff --git a/src/utils/globalsRegistry.test.ts b/src/utils/patchesRegistry.test.ts similarity index 77% rename from src/utils/globalsRegistry.test.ts rename to src/utils/patchesRegistry.test.ts index 6a82a4e15..efee8785a 100644 --- a/src/utils/globalsRegistry.test.ts +++ b/src/utils/patchesRegistry.test.ts @@ -1,5 +1,5 @@ import { it, beforeEach, afterEach, expect, vi } from 'vitest' -import { globalsRegistry } from './globalsRegistry' +import { patchesRegistry } from './patchesRegistry' declare global { var foo: { original: boolean } @@ -13,16 +13,16 @@ beforeEach(() => { afterEach(() => { Object.setPrototypeOf(global, realGlobalPrototype) - globalsRegistry.restoreAllGlobals() + patchesRegistry.restoreAllPatches() }) it('replaces the global', () => { - globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) + patchesRegistry.applyPatch(global, 'foo', () => ({ original: false })) expect(global.foo).toEqual({ original: false }) }) it('exposes the real value to the replacement callback', () => { - globalsRegistry.replaceGlobal(global, 'foo', (realValue) => { + patchesRegistry.applyPatch(global, 'foo', (realValue) => { expect(realValue).toEqual({ original: true }) return { original: false } }) @@ -38,7 +38,7 @@ it('replaces the global set on the prototype', () => { expect(global.foo).toEqual({ prototype: true }) expect(FakeGlobalScope.prototype.foo).toEqual({ prototype: true }) - globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) + patchesRegistry.applyPatch(global, 'foo', () => ({ original: false })) expect(global.foo).toEqual({ original: false }) expect(FakeGlobalScope.prototype.foo, 'Preserves prototype value').toEqual({ @@ -47,7 +47,7 @@ it('replaces the global set on the prototype', () => { }) it('replaces the global after it was restored', () => { - const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ + const restoreGlobal = patchesRegistry.applyPatch(global, 'foo', () => ({ original: false, })) expect(global.foo).toEqual({ original: false }) @@ -55,13 +55,13 @@ it('replaces the global after it was restored', () => { restoreGlobal() expect(global.foo).toEqual({ original: true }) - globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) + patchesRegistry.applyPatch(global, 'foo', () => ({ original: false })) expect(global.foo).toEqual({ original: false }) }) it('replaces a property on a custom owner', () => { const owner = { bar: { original: true } } - const restoreGlobal = globalsRegistry.replaceGlobal(owner, 'bar', () => ({ + const restoreGlobal = patchesRegistry.applyPatch(owner, 'bar', () => ({ original: false, })) @@ -76,16 +76,8 @@ it('tracks replacements per owner independently', () => { const ownerA = { shared: 'a-original' } const ownerB = { shared: 'b-original' } - const restoreA = globalsRegistry.replaceGlobal( - ownerA, - 'shared', - () => 'a-next' - ) - const restoreB = globalsRegistry.replaceGlobal( - ownerB, - 'shared', - () => 'b-next' - ) + const restoreA = patchesRegistry.applyPatch(ownerA, 'shared', () => 'a-next') + const restoreB = patchesRegistry.applyPatch(ownerB, 'shared', () => 'b-next') expect(ownerA.shared).toBe('a-next') expect(ownerB.shared).toBe('b-next') @@ -101,7 +93,7 @@ it('tracks replacements per owner independently', () => { it('warns on replacing a non-existing global', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) - globalsRegistry.replaceGlobal( + patchesRegistry.applyPatch( global, // @ts-expect-error Intentionally invalid value. 'NON-EXISTING', @@ -113,16 +105,16 @@ it('warns on replacing a non-existing global', () => { }) it('throws if replacing an already replaced global', () => { - globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) + patchesRegistry.applyPatch(global, 'foo', () => ({ original: false })) expect(global.foo).toEqual({ original: false }) expect(() => - globalsRegistry.replaceGlobal(global, 'foo', () => ({ original: false })) + patchesRegistry.applyPatch(global, 'foo', () => ({ original: false })) ).toThrow('Failed to replace a global value at "foo": already replaced.') }) it('does nothing if restoring an already restored global', () => { - const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ + const restoreGlobal = patchesRegistry.applyPatch(global, 'foo', () => ({ original: false, })) @@ -136,7 +128,7 @@ it('does nothing if restoring an already restored global', () => { }) it('restores the global', () => { - const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ + const restoreGlobal = patchesRegistry.applyPatch(global, 'foo', () => ({ original: false, })) expect(global.foo).toEqual({ original: false }) @@ -153,7 +145,7 @@ it('restores the global set on the prototype', () => { expect(global.foo).toEqual({ prototype: true }) - const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ + const restoreGlobal = patchesRegistry.applyPatch(global, 'foo', () => ({ original: false, })) @@ -173,7 +165,7 @@ it('restores global to the original property descriptor', () => { Object.defineProperty(global, 'foo', descriptor) expect(Object.getOwnPropertyDescriptor(global, 'foo')).toEqual(descriptor) - const restoreGlobal = globalsRegistry.replaceGlobal(global, 'foo', () => ({ + const restoreGlobal = patchesRegistry.applyPatch(global, 'foo', () => ({ original: false, })) diff --git a/src/utils/globalsRegistry.ts b/src/utils/patchesRegistry.ts similarity index 83% rename from src/utils/globalsRegistry.ts rename to src/utils/patchesRegistry.ts index e7cfb6c97..303cdd967 100644 --- a/src/utils/globalsRegistry.ts +++ b/src/utils/patchesRegistry.ts @@ -1,9 +1,9 @@ import { invariant } from 'outvariant' -class GlobalsRegistry { +class PatchesRegistry { #replacements = new Map void>>() - public replaceGlobal( + public applyPatch( owner: Owner, key: K, getNextValue: (realValue: Owner[K]) => Owner[K] @@ -30,7 +30,7 @@ class GlobalsRegistry { configurable: true, }) - const restoreGlobal = () => { + const restorePatch = () => { const currentReplacements = this.#replacements.get(owner) if (!currentReplacements?.has(key)) { @@ -42,7 +42,7 @@ class GlobalsRegistry { } else { /** * @todo Delete the proxy property set by the registry. - * If the owner isn't `globalThis`, the property is likely nested in the prototype. + * If the match's owner isn't the original owner, the property is likely nested in the prototype. * The registry does not meddle with those, they are left intact. */ Reflect.deleteProperty(owner, key) @@ -56,21 +56,21 @@ class GlobalsRegistry { } if (ownerReplacements) { - ownerReplacements.set(key, restoreGlobal) + ownerReplacements.set(key, restorePatch) } else { - this.#replacements.set(owner, new Map([[key, restoreGlobal]])) + this.#replacements.set(owner, new Map([[key, restorePatch]])) } - return restoreGlobal + return restorePatch } - public restoreAllGlobals(): void { + public restoreAllPatches(): void { const errors: Array = [] for (const [, ownerReplacements] of this.#replacements) { - for (const [, restoreGlobal] of ownerReplacements) { + for (const [, restorePatch] of ownerReplacements) { try { - restoreGlobal() + restorePatch() } catch (error) { if (error instanceof Error) { errors.push(error) @@ -87,7 +87,7 @@ class GlobalsRegistry { } } -export const globalsRegistry = new GlobalsRegistry() +export const patchesRegistry = new PatchesRegistry() interface DeepDescriptorMatch { owner: object From b4876dff1d69af0d19c1d2ba7a5838b212894125 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 15:10:15 +0200 Subject: [PATCH 166/198] chore: new `Interceptor` base class --- src/Interceptor.ts | 11 +-- src/disposable.ts | 13 ++++ src/interceptor-v2.test.ts | 136 +++++++++++++++++++++++++++++++++ src/interceptor-v2.ts | 100 ++++++++++++++++++++++++ src/interceptors/http/index.ts | 1 - 5 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 src/disposable.ts create mode 100644 src/interceptor-v2.test.ts create mode 100644 src/interceptor-v2.ts diff --git a/src/Interceptor.ts b/src/Interceptor.ts index 191372769..7878e66dd 100644 --- a/src/Interceptor.ts +++ b/src/Interceptor.ts @@ -1,6 +1,5 @@ import { Logger } from '@open-draft/logger' import { Emitter, TypedListenerOptions } from 'rettime' -import { listenerCount } from 'superagent' export type InterceptorEventMap = Record export type InterceptorSubscription = () => void @@ -216,7 +215,12 @@ export class Interceptor { // Delete the global symbol as soon as possible, // indicating that the interceptor is no longer running. - this.clearInstance() + if (this === this.getInstance()) { + this.clearInstance() + + this.emitter.removeAllListeners() + logger.info('removed the listeners!') + } logger.info('global symbol deleted:', getGlobalSymbol(this.symbol)) @@ -232,9 +236,6 @@ export class Interceptor { logger.info('disposed of all subscriptions!', this.subscriptions.length) } - this.emitter.removeAllListeners() - logger.info('destroyed the listener!') - this.readyState = InterceptorReadyState.DISPOSED } diff --git a/src/disposable.ts b/src/disposable.ts new file mode 100644 index 000000000..5ad368093 --- /dev/null +++ b/src/disposable.ts @@ -0,0 +1,13 @@ +export type DisposableSubscription = () => void + +export class Disposable { + protected subscriptions: Array = [] + + public dispose() { + let subscription: DisposableSubscription | undefined + + while ((subscription = this.subscriptions.pop())) { + subscription() + } + } +} diff --git a/src/interceptor-v2.test.ts b/src/interceptor-v2.test.ts new file mode 100644 index 000000000..32daf3a86 --- /dev/null +++ b/src/interceptor-v2.test.ts @@ -0,0 +1,136 @@ +import { TypedEvent } from 'rettime' +import { Interceptor } from './interceptor-v2' + +it('nesting interceptors', async () => { + const socketSetup = vi.fn() + const protocolSetup = vi.fn() + + class SocketInterceptor extends Interceptor<{ + data: TypedEvent + }> { + static symbol = Symbol.for('socket-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + socketSetup() + + queueMicrotask(() => { + this.emitter.emit(new TypedEvent('data', { data: 1 })) + this.emitter.emit(new TypedEvent('data', { data: 'hello' })) + this.emitter.emit(new TypedEvent('data', { data: 2 })) + }) + } + } + + class ProtocolInterceptor extends Interceptor<{ + request: TypedEvent + }> { + static symbol = Symbol.for('protocol-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + protocolSetup() + + const socket = Interceptor.singleton(SocketInterceptor) + socket.apply() + this.subscriptions.push(() => socket.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + socket.on( + 'data', + ({ data }) => { + this.emitter.emit(new TypedEvent('request', { data })) + }, + { signal: controller.signal } + ) + } + } + + class NumberInterceptor extends Interceptor<{ + number: TypedEvent + }> { + static symbol = Symbol.for('number-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + const protocol = Interceptor.singleton(ProtocolInterceptor) + protocol.apply() + this.subscriptions.push(() => protocol.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + protocol.on( + 'request', + ({ data }) => { + if (typeof data === 'number') { + this.emitter.emit(new TypedEvent('number', { data })) + } + }, + { signal: controller.signal } + ) + } + } + + class StringInterceptor extends Interceptor<{ + string: TypedEvent + }> { + static symbol = Symbol.for('string-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + const protocol = Interceptor.singleton(ProtocolInterceptor) + protocol.apply() + this.subscriptions.push(() => protocol.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + protocol.on( + 'request', + ({ data }) => { + if (typeof data === 'string') { + this.emitter.emit(new TypedEvent('string', { data })) + } + }, + { signal: controller.signal } + ) + } + } + + const numberListener = vi.fn() + const numberInterceptor = new NumberInterceptor() + numberInterceptor.on('number', ({ data }) => numberListener(data)) + numberInterceptor.apply() + + const stringListener = vi.fn() + const stringInterceptor = new StringInterceptor() + stringInterceptor.on('string', ({ data }) => stringListener(data)) + stringInterceptor.apply() + + expect(socketSetup).toHaveBeenCalledOnce() + expect(protocolSetup).toHaveBeenCalledOnce() + + await expect.poll(() => numberListener).toHaveBeenCalledTimes(2) + + numberInterceptor.dispose() + stringInterceptor.dispose() + + expect(numberListener).toHaveBeenNthCalledWith(1, 1) + expect(numberListener).toHaveBeenNthCalledWith(2, 2) + expect(stringListener).toHaveBeenCalledExactlyOnceWith('hello') +}) diff --git a/src/interceptor-v2.ts b/src/interceptor-v2.ts new file mode 100644 index 000000000..942baa327 --- /dev/null +++ b/src/interceptor-v2.ts @@ -0,0 +1,100 @@ +import { Emitter } from 'rettime' +import { InterceptorEventMap } from './Interceptor' +import { Disposable } from './disposable' + +export enum InterceptorReadyState { + INACTIVE = 'INACTIVE', + ACTIVE = 'ACTIVE', + DISPOSED = 'DISPOSED', +} + +const interceptorsRegistry = new Map>() + +export abstract class Interceptor< + Events extends InterceptorEventMap, +> extends Disposable { + declare ['constructor']: typeof Interceptor + + protected emitter: Emitter + + public readyState: InterceptorReadyState + public on: Emitter['on'] + public once: Emitter['once'] + public removeListener: Emitter['removeListener'] + public removeAllListeners: Emitter['removeAllListeners'] + + static readonly symbol: symbol + + #leaseCount: number + + static singleton>( + InterceptorClass: (new () => T) & { symbol: symbol } + ): T { + const symbol = InterceptorClass.symbol + const existing = interceptorsRegistry.get(symbol) + + if (existing instanceof InterceptorClass) { + return existing + } + + const newInstance = new InterceptorClass() + interceptorsRegistry.set(symbol, newInstance) + return newInstance + } + + constructor() { + super() + + this.#leaseCount = 0 + this.readyState = InterceptorReadyState.INACTIVE + + this.emitter = new Emitter() + this.on = this.emitter.on.bind(this.emitter) + this.once = this.emitter.once.bind(this.emitter) + this.removeListener = this.emitter.removeListener.bind(this.emitter) + this.removeAllListeners = this.emitter.removeAllListeners.bind(this.emitter) + } + + protected abstract predicate(): boolean + protected abstract setup(): void + + public apply(): void { + if (this.readyState === InterceptorReadyState.DISPOSED) { + return + } + + if (!this.predicate()) { + return + } + + this.#leaseCount++ + + if (this.#leaseCount === 1) { + try { + this.setup() + this.readyState = InterceptorReadyState.ACTIVE + } catch (error) { + this.dispose() + throw error + } + } + } + + public dispose(): void { + if (this.readyState === InterceptorReadyState.DISPOSED) { + return + } + + if (this.#leaseCount === 0) { + return + } + + this.#leaseCount-- + + if (this.#leaseCount === 0) { + super.dispose() + this.emitter.removeAllListeners() + this.readyState = InterceptorReadyState.DISPOSED + } + } +} diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index b2e1906e8..f45ca2b7d 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -5,7 +5,6 @@ import { ServerResponse, IncomingMessage, } from 'node:http' -import type { ReadableStream } from 'node:stream/web' import { invariant } from 'outvariant' import { Interceptor } from '../../Interceptor' import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' From 887340d05835bc0b3d9515a7d8317ea4ecff263e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 15:42:31 +0200 Subject: [PATCH 167/198] chore: migrate interceptors to the new base class --- src/BatchInterceptor.test.ts | 132 +++---- src/BatchInterceptor.ts | 133 +++++--- src/Interceptor.test.ts | 322 +++++++++--------- src/Interceptor.ts | 257 -------------- src/RemoteHttpInterceptor.ts | 2 +- src/index.ts | 4 +- ...rceptor-v2.test.ts => interceptor.test.ts} | 60 +++- src/{interceptor-v2.ts => interceptor.ts} | 5 +- src/interceptors/ClientRequest/index.ts | 40 +-- src/interceptors/WebSocket/index.ts | 13 +- src/interceptors/XMLHttpRequest/node.ts | 59 ++-- src/interceptors/XMLHttpRequest/web.ts | 15 +- src/interceptors/fetch/index.ts | 33 +- src/interceptors/fetch/node.ts | 43 ++- src/interceptors/http/index.ts | 14 +- src/interceptors/net/index.ts | 8 +- src/utils/interceptor-utils.ts | 54 --- 17 files changed, 482 insertions(+), 712 deletions(-) delete mode 100644 src/Interceptor.ts rename src/{interceptor-v2.test.ts => interceptor.test.ts} (72%) rename src/{interceptor-v2.ts => interceptor.ts} (94%) delete mode 100644 src/utils/interceptor-utils.ts diff --git a/src/BatchInterceptor.test.ts b/src/BatchInterceptor.test.ts index e969d955e..850e0ee6b 100644 --- a/src/BatchInterceptor.test.ts +++ b/src/BatchInterceptor.test.ts @@ -1,5 +1,5 @@ import { TypedEvent } from 'rettime' -import { Interceptor } from './Interceptor' +import { Interceptor } from './interceptor' import { BatchInterceptor } from './BatchInterceptor' afterEach(() => { @@ -8,20 +8,22 @@ afterEach(() => { it('applies child interceptors', () => { class PrimaryInterceptor extends Interceptor { - constructor() { - super(Symbol('primary')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('secondary')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const instances = { primary: new PrimaryInterceptor(), - secondary: new SecondaryInterceptor(), + secondary: new SecondInterceptor(), } const interceptor = new BatchInterceptor({ @@ -42,22 +44,24 @@ it('proxies event listeners to the interceptors', () => { class PrimaryInterceptor extends Interceptor<{ hello: TypedEvent }> { - constructor() { - super(Symbol('primary')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor<{ + class SecondInterceptor extends Interceptor<{ goodbye: TypedEvent }> { - constructor() { - super(Symbol('secondary')) + protected predicate(): boolean { + return true } + protected setup(): void {} } const instances = { primary: new PrimaryInterceptor(), - secondary: new SecondaryInterceptor(), + secondary: new SecondInterceptor(), } const interceptor = new BatchInterceptor({ @@ -85,20 +89,22 @@ it('proxies event listeners to the interceptors', () => { it('disposes of child interceptors', async () => { class PrimaryInterceptor extends Interceptor { - constructor() { - super(Symbol('primary')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('secondary')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const instances = { primary: new PrimaryInterceptor(), - secondary: new SecondaryInterceptor(), + secondary: new SecondInterceptor(), } const interceptor = new BatchInterceptor({ @@ -118,18 +124,20 @@ it('disposes of child interceptors', async () => { it('forwards listeners added via "on()"', () => { class FirstInterceptor extends Interceptor { - constructor() { - super(Symbol('first')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('second')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const firstInterceptor = new FirstInterceptor() - const secondInterceptor = new SecondaryInterceptor() + const secondInterceptor = new SecondInterceptor() const interceptor = new BatchInterceptor({ name: 'batch', @@ -139,9 +147,9 @@ it('forwards listeners added via "on()"', () => { const listener = vi.fn() interceptor.on('foo', listener) - expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(1) - expect(interceptor['emitter'].listenerCount('foo')).toBe(0) + expect(firstInterceptor['emitter'].listenerCount('*')).toBe(1) + expect(secondInterceptor['emitter'].listenerCount('*')).toBe(1) + expect(interceptor['emitter'].listenerCount('foo')).toBe(1) }) it('forwards listeners removal via "off()"', () => { @@ -150,18 +158,20 @@ it('forwards listeners removal via "off()"', () => { } class FirstInterceptor extends Interceptor { - constructor() { - super(Symbol('first')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('second')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const firstInterceptor = new FirstInterceptor() - const secondInterceptor = new SecondaryInterceptor() + const secondInterceptor = new SecondInterceptor() const interceptor = new BatchInterceptor({ name: 'batch', @@ -183,18 +193,20 @@ it('forwards removal of all listeners by name via ".removeAllListeners()"', () = } class FirstInterceptor extends Interceptor { - constructor() { - super(Symbol('first')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('second')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const firstInterceptor = new FirstInterceptor() - const secondInterceptor = new SecondaryInterceptor() + const secondInterceptor = new SecondInterceptor() const interceptor = new BatchInterceptor({ name: 'batch', @@ -206,33 +218,35 @@ it('forwards removal of all listeners by name via ".removeAllListeners()"', () = interceptor.on('foo', listener) interceptor.on('bar', listener) - expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(2) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2) - expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1) + expect(firstInterceptor['emitter'].listenerCount('*')).toBe(1) + expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0) + expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(0) + + expect(secondInterceptor['emitter'].listenerCount('*')).toBe(1) + expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0) + expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(0) interceptor.removeAllListeners('foo') - expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0) - expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1) + expect(interceptor['emitter'].listenerCount('foo')).toBe(0) }) it('forwards removal of all listeners via ".removeAllListeners()"', () => { class FirstInterceptor extends Interceptor { - constructor() { - super(Symbol('first')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('second')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const firstInterceptor = new FirstInterceptor() - const secondInterceptor = new SecondaryInterceptor() + const secondInterceptor = new SecondInterceptor() const interceptor = new BatchInterceptor({ name: 'batch', @@ -244,10 +258,12 @@ it('forwards removal of all listeners via ".removeAllListeners()"', () => { interceptor.on('foo', listener) interceptor.on('bar', listener) - expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(2) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2) - expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1) + expect(firstInterceptor['emitter'].listenerCount('*')).toBe(1) + expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0) + expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(0) + expect(secondInterceptor['emitter'].listenerCount('*')).toBe(1) + expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0) + expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(0) interceptor.removeAllListeners() diff --git a/src/BatchInterceptor.ts b/src/BatchInterceptor.ts index 3001be7fb..ae1932b24 100644 --- a/src/BatchInterceptor.ts +++ b/src/BatchInterceptor.ts @@ -1,5 +1,10 @@ -import { DefaultEventMap, Emitter, TypedListenerOptions } from 'rettime' -import { Interceptor } from './Interceptor' +import { + Emitter, + type DefaultEventMap, + type TypedListenerOptions, +} from 'rettime' +import { Interceptor } from './interceptor' +import { Logger } from '@open-draft/logger' export interface BatchInterceptorOptions< InterceptorList extends ReadonlyArray>, @@ -17,6 +22,8 @@ export type ExtractEventMapType< : never : never +const logger = new Logger('BatchInterceptor') + /** * A batch interceptor that exposes a single interface * to apply and operate with multiple interceptors at once. @@ -27,20 +34,34 @@ export class BatchInterceptor< > extends Interceptor { static symbol: symbol - private interceptors: InterceptorList + #logger: Logger + #interceptors: InterceptorList constructor(options: BatchInterceptorOptions) { BatchInterceptor.symbol = Symbol.for(options.name) - super(BatchInterceptor.symbol) - this.interceptors = options.interceptors + super() + this.#logger = logger.extend(options.name) + this.#interceptors = options.interceptors + + for (const interceptor of options.interceptors) { + interceptor.on('*', (event) => { + this.emitter.emit(event) + }) + } + } + + protected predicate(): boolean { + return this.#interceptors.every((interceptor) => { + return interceptor['predicate']() + }) } protected setup() { - const logger = this.logger.extend('setup') + const logger = this.#logger.extend('setup') - logger.info('applying all %d interceptors...', this.interceptors.length) + logger.info('applying all %d interceptors...', this.#interceptors.length) - for (const interceptor of this.interceptors) { + for (const interceptor of this.#interceptors) { logger.info('applying "%s" interceptor...', interceptor.constructor.name) interceptor.apply() @@ -49,52 +70,52 @@ export class BatchInterceptor< } } - public on>( - type: EventType, - listener: Emitter.Listener, - options?: TypedListenerOptions - ): this { - // Instead of adding a listener to the batch interceptor, - // propagate the listener to each of the individual interceptors. - for (const interceptor of this.interceptors) { - interceptor.on(type, listener, options) - } - - return this - } - - public once>( - event: EventType, - listener: Emitter.Listener, - options?: Omit - ): this { - for (const interceptor of this.interceptors) { - interceptor.once(event, listener, options) - } - - return this - } - - public removeListener< - EventType extends Emitter.AllEventTypes, - >( - event: EventType, - listener: Emitter.Listener - ): this { - for (const interceptor of this.interceptors) { - interceptor.removeListener(event, listener) - } - - return this - } - - public removeAllListeners< - EventType extends Emitter.AllEventTypes, - >(event?: EventType | undefined): this { - for (const interceptors of this.interceptors) { - interceptors.removeAllListeners(event) - } - - return this - } + // public on>( + // type: EventType, + // listener: Emitter.Listener, + // options?: TypedListenerOptions + // ): this { + // // Instead of adding a listener to the batch interceptor, + // // propagate the listener to each of the individual interceptors. + // for (const interceptor of this.interceptors) { + // interceptor.on(type, listener, options) + // } + + // return this + // } + + // public once>( + // event: EventType, + // listener: Emitter.Listener, + // options?: Omit + // ): this { + // for (const interceptor of this.interceptors) { + // interceptor.once(event, listener, options) + // } + + // return this + // } + + // public removeListener< + // EventType extends Emitter.AllEventTypes, + // >( + // event: EventType, + // listener: Emitter.Listener + // ): this { + // for (const interceptor of this.interceptors) { + // interceptor.removeListener(event, listener) + // } + + // return this + // } + + // public removeAllListeners< + // EventType extends Emitter.AllEventTypes, + // >(event?: EventType | undefined): this { + // for (const interceptors of this.interceptors) { + // interceptors.removeAllListeners(event) + // } + + // return this + // } } diff --git a/src/Interceptor.test.ts b/src/Interceptor.test.ts index a727a01fb..c6de737d3 100644 --- a/src/Interceptor.test.ts +++ b/src/Interceptor.test.ts @@ -1,198 +1,194 @@ import { TypedEvent } from 'rettime' -import { - Interceptor, - getGlobalSymbol, - deleteGlobalSymbol, - InterceptorReadyState, -} from './Interceptor' -import { nextTickAsync } from './utils/nextTick' - -const symbol = Symbol('test') - -afterEach(() => { - deleteGlobalSymbol(symbol) -}) +import { Interceptor } from './interceptor' -describe('on()', () => { - it('adds a new listener using "on()"', () => { - const interceptor = new Interceptor(symbol) - expect(interceptor['emitter'].listenerCount('event')).toBe(0) +it('nesting interceptors', async () => { + const socketSetup = vi.fn() + const protocolSetup = vi.fn() - const listener = vi.fn() - interceptor.on('event', listener) - expect(interceptor['emitter'].listenerCount('event')).toBe(1) - }) -}) + class SocketInterceptor extends Interceptor<{ + data: TypedEvent + }> { + static symbol = Symbol.for('socket-interceptor') -describe('once()', () => { - it('calls the listener only once', () => { - const interceptor = new Interceptor(symbol) - const listener = vi.fn() + protected predicate(): boolean { + return true + } - interceptor.once('foo', listener) - expect(listener).not.toHaveBeenCalled() + protected setup(): void { + socketSetup() - const event = new TypedEvent('foo', { data: 'bar' }) - interceptor['emitter'].emit(event) + queueMicrotask(() => { + this.emitter.emit(new TypedEvent('data', { data: 1 })) + this.emitter.emit(new TypedEvent('data', { data: 'hello' })) + this.emitter.emit(new TypedEvent('data', { data: 2 })) + }) + } + } - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledExactlyOnceWith(event) + class ProtocolInterceptor extends Interceptor<{ + request: TypedEvent + }> { + static symbol = Symbol.for('protocol-interceptor') - listener.mockReset() + protected predicate(): boolean { + return true + } - interceptor['emitter'].emit(new TypedEvent('foo', { data: 'baz' })) - interceptor['emitter'].emit(new TypedEvent('foo', { data: 'xyz' })) - expect(listener).toHaveBeenCalledTimes(0) - }) -}) + protected setup(): void { + protocolSetup() -describe('off()', () => { - it('removes a listener using "off()"', () => { - const interceptor = new Interceptor(symbol) - expect(interceptor['emitter'].listenerCount('event')).toBe(0) + const socket = Interceptor.singleton(SocketInterceptor) + socket.apply() + this.subscriptions.push(() => socket.dispose()) - const listener = vi.fn() - interceptor.on('event', listener) - expect(interceptor['emitter'].listenerCount('event')).toBe(1) + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) - interceptor.removeListener('event', listener) - expect(interceptor['emitter'].listenerCount('event')).toBe(0) - }) -}) + socket.on( + 'data', + ({ data }) => { + this.emitter.emit(new TypedEvent('request', { data })) + }, + { signal: controller.signal } + ) + } + } -describe('persistence', () => { - it('stores global reference to the applied interceptor', () => { - const interceptor = new Interceptor(symbol) - interceptor.apply() + class NumberInterceptor extends Interceptor<{ + number: TypedEvent + }> { + static symbol = Symbol.for('number-interceptor') - expect(getGlobalSymbol(symbol)).toEqual(interceptor) - }) + protected predicate(): boolean { + return true + } - it('deletes global reference when the interceptor is disposed', () => { - const interceptor = new Interceptor(symbol) + protected setup(): void { + const protocol = Interceptor.singleton(ProtocolInterceptor) + protocol.apply() + this.subscriptions.push(() => protocol.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + protocol.on( + 'request', + ({ data }) => { + if (typeof data === 'number') { + this.emitter.emit(new TypedEvent('number', { data })) + } + }, + { signal: controller.signal } + ) + } + } - interceptor.apply() - interceptor.dispose() + class StringInterceptor extends Interceptor<{ + string: TypedEvent + }> { + static symbol = Symbol.for('string-interceptor') - expect(getGlobalSymbol(symbol)).toBeUndefined() - }) + protected predicate(): boolean { + return true + } + + protected setup(): void { + const protocol = Interceptor.singleton(ProtocolInterceptor) + protocol.apply() + this.subscriptions.push(() => protocol.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + protocol.on( + 'request', + ({ data }) => { + if (typeof data === 'string') { + this.emitter.emit(new TypedEvent('string', { data })) + } + }, + { signal: controller.signal } + ) + } + } + + const numberListener = vi.fn() + const numberInterceptor = new NumberInterceptor() + numberInterceptor.on('number', ({ data }) => numberListener(data)) + numberInterceptor.apply() + + const stringListener = vi.fn() + const stringInterceptor = new StringInterceptor() + stringInterceptor.on('string', ({ data }) => stringListener(data)) + stringInterceptor.apply() + + expect(socketSetup).toHaveBeenCalledOnce() + expect(protocolSetup).toHaveBeenCalledOnce() + + await expect.poll(() => numberListener).toHaveBeenCalledTimes(2) + + numberInterceptor.dispose() + stringInterceptor.dispose() + + expect(numberListener).toHaveBeenNthCalledWith(1, 1) + expect(numberListener).toHaveBeenNthCalledWith(2, 2) + expect(stringListener).toHaveBeenCalledExactlyOnceWith('hello') }) -describe('readyState', () => { - it('sets the state to "INACTIVE" when the interceptor is created', () => { - const interceptor = new Interceptor(symbol) - expect(interceptor.readyState).toBe(InterceptorReadyState.INACTIVE) - }) - - it('leaves state as "INACTIVE" if the interceptor failed the environment check', async () => { - class MyInterceptor extends Interceptor { - protected checkEnvironment(): boolean { - return false - } +it('treats an interceptor as a singleton via "Interceptor.singleton()"', () => { + const setup = vi.fn() + const dispose = vi.fn() + + class MyInterceptor extends Interceptor<{ test: TypedEvent }> { + protected predicate(): boolean { + return true } - const interceptor = new MyInterceptor(symbol) - interceptor.apply() - expect(interceptor.readyState).toBe(InterceptorReadyState.INACTIVE) - }) + protected setup(): void { + setup() + } + + public dispose(): void { + super.dispose() + dispose() + } + } - it('performs state transition when the interceptor is applying', async () => { - const interceptor = new Interceptor(symbol) - interceptor.apply() + const interceptor = Interceptor.singleton(MyInterceptor) + expect(setup).not.toHaveBeenCalled() - // The interceptor's state transitions to APPLIED immediately. - // The only exception is if something throws during the setup. - expect(interceptor.readyState).toBe(InterceptorReadyState.APPLIED) - }) + interceptor.apply() + expect(setup).toHaveBeenCalledOnce() + + { + const interceptor = Interceptor.singleton(MyInterceptor) + expect(setup).toHaveBeenCalledOnce() - it('performs state transition when disposing of the interceptor', async () => { - const interceptor = new Interceptor(symbol) - interceptor.apply() interceptor.dispose() + expect(dispose).toHaveBeenCalledOnce() + } - // The interceptor's state transitions to DISPOSED immediately. - // The only exception is if something throws during the teardown. - expect(interceptor.readyState).toBe(InterceptorReadyState.DISPOSED) - }) + interceptor.dispose() + expect(dispose).toHaveBeenCalledTimes(2) }) -describe('apply', () => { - it('does not apply the same interceptor multiple times', () => { - const interceptor = new Interceptor(symbol) - const setupSpy = vi.spyOn( - interceptor, - // @ts-expect-error Protected property spy. - 'setup' - ) - - // Intentionally apply the same interceptor multiple times. - interceptor.apply() - interceptor.apply() - interceptor.apply() - - // The "setup" must not be called repeatedly. - expect(setupSpy).toHaveBeenCalledTimes(1) - - expect(getGlobalSymbol(symbol)).toEqual(interceptor) - }) - - it('does not call "apply" if the interceptor fails environment check', () => { - class MyInterceptor extends Interceptor<{}> { - checkEnvironment() { - return false - } +it('removes all listeners when the interceptor is disposed', () => { + class MyInterceptor extends Interceptor<{ test: TypedEvent }> { + protected predicate(): boolean { + return true } - const interceptor = new MyInterceptor(Symbol('test')) - const setupSpy = vi.spyOn( - interceptor, - // @ts-expect-error Protected property spy. - 'setup' - ) - interceptor.apply() - - expect(setupSpy).not.toHaveBeenCalled() - }) - - it('proxies listeners from new interceptor to already running interceptor', () => { - const firstInterceptor = new Interceptor(symbol) - const secondInterceptor = new Interceptor(symbol) - - firstInterceptor.apply() - const firstListener = vi.fn() - firstInterceptor.on('test', firstListener) - - secondInterceptor.apply() - const secondListener = vi.fn() - secondInterceptor.on('test', secondListener) - - // Emitting event in the first interceptor will bubble to the second one. - const event = new TypedEvent('test', { data: 'hello world' }) - firstInterceptor['emitter'].emit(event) - - expect(firstListener).toHaveBeenCalledExactlyOnceWith(event) - expect(secondListener).toHaveBeenCalledExactlyOnceWith(event) - expect(secondInterceptor['emitter'].listenerCount('test')).toBe(0) - }) -}) + protected setup(): void {} + } -describe('dispose', () => { - it('removes all listeners when the interceptor is disposed', async () => { - const interceptor = new Interceptor(symbol) + const interceptor = new MyInterceptor() + interceptor.apply() - interceptor.apply() - const listener = vi.fn() - interceptor.on('test', listener) - interceptor.dispose() + const listener = vi.fn() + interceptor.on('test', listener) - // Even after emitting an event, the listener must not get called. - interceptor['emitter'].emit('test') - expect(listener).not.toHaveBeenCalled() + interceptor.dispose() - // The listener must not be called on the next tick either. - await nextTickAsync(() => { - interceptor['emitter'].emit('test') - expect(listener).not.toHaveBeenCalled() - }) - }) + interceptor['emitter'].emit(new TypedEvent('test')) + expect(listener).not.toHaveBeenCalled() }) diff --git a/src/Interceptor.ts b/src/Interceptor.ts deleted file mode 100644 index 7878e66dd..000000000 --- a/src/Interceptor.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { Logger } from '@open-draft/logger' -import { Emitter, TypedListenerOptions } from 'rettime' - -export type InterceptorEventMap = Record -export type InterceptorSubscription = () => void - -/** - * Request header name to detect when a single request - * is being handled by nested interceptors (XHR -> ClientRequest). - * Obscure by design to prevent collisions with user-defined headers. - * Ideally, come up with the Interceptor-level mechanism for this. - * @see https://github.com/mswjs/interceptors/issues/378 - */ -export const INTERNAL_REQUEST_ID_HEADER_NAME = - 'x-interceptors-internal-request-id' - -export function getGlobalSymbol(symbol: Symbol): V | undefined { - return ( - // @ts-ignore https://github.com/Microsoft/TypeScript/issues/24587 - globalThis[symbol] || undefined - ) -} - -function setGlobalSymbol(symbol: Symbol, value: any): void { - // @ts-ignore - globalThis[symbol] = value -} - -export function deleteGlobalSymbol(symbol: Symbol): void { - // @ts-ignore - delete globalThis[symbol] -} - -export enum InterceptorReadyState { - INACTIVE = 'INACTIVE', - APPLYING = 'APPLYING', - APPLIED = 'APPLIED', - DISPOSING = 'DISPOSING', - DISPOSED = 'DISPOSED', -} - -export class Interceptor { - protected emitter: Emitter - protected subscriptions: Array - protected logger: Logger - - public readyState: InterceptorReadyState - - constructor(private readonly symbol: symbol) { - this.readyState = InterceptorReadyState.INACTIVE - - this.emitter = new Emitter() - this.subscriptions = [] - this.logger = new Logger(symbol.description!) - - this.logger.info('constructing the interceptor...') - } - - /** - * Determine if this interceptor can be applied - * in the current environment. - */ - protected checkEnvironment(): boolean { - return true - } - - /** - * Apply this interceptor to the current process. - * Returns an already running interceptor instance if it's present. - */ - public apply(): void { - const logger = this.logger.extend('apply') - logger.info('applying the interceptor...') - - if (this.readyState === InterceptorReadyState.APPLIED) { - logger.info('intercepted already applied!') - return - } - - const shouldApply = this.checkEnvironment() - - if (!shouldApply) { - logger.info('the interceptor cannot be applied in this environment!') - return - } - - this.readyState = InterceptorReadyState.APPLYING - - // Whenever applying a new interceptor, check if it hasn't been applied already. - // This enables to apply the same interceptor multiple times, for example from a different - // interceptor, only proxying events but keeping the stubs in a single place. - const runningInstance = this.getInstance() - - if (runningInstance) { - logger.info('found a running instance, reusing...') - - // Point this instance's emitter to the running instance's emitter - // so that any references to `this.emitter` (e.g. in proxyEventListeners) - // resolve to the emitter that actually dispatches events. - this.emitter = runningInstance.emitter - - const listenersController = new AbortController() - - // Proxy any listeners you set on this instance to the running instance. - this.on = (event, listener) => { - logger.info('proxying the "%s" listener', event) - - // Add listeners to the running instance so they appear - // at the top of the event listeners list and are executed first. - runningInstance.emitter.on(event, listener, { - signal: listenersController.signal, - }) - - return this - } - - // Ensure that once this interceptor instance is disposed, - // it removes all listeners it has appended to the running interceptor instance. - this.subscriptions.push(() => { - listenersController.abort() - logger.info('removed all proxied listeners!') - }) - - this.readyState = InterceptorReadyState.APPLIED - - return - } - - logger.info('no running instance found, setting up a new instance...') - - // Setup the interceptor. - this.setup() - - // Store the newly applied interceptor instance globally. - this.setInstance() - - this.readyState = InterceptorReadyState.APPLIED - } - - /** - * Setup the module augments and stubs necessary for this interceptor. - * This method is not run if there's a running interceptor instance - * to prevent instantiating an interceptor multiple times. - */ - protected setup(): void {} - - /** - * Listen to the interceptor's public events. - */ - public on>( - event: EventType, - listener: Emitter.Listener, - options?: TypedListenerOptions - ): this { - const logger = this.logger.extend('on') - - if ( - this.readyState === InterceptorReadyState.DISPOSING || - this.readyState === InterceptorReadyState.DISPOSED - ) { - logger.info('cannot listen to events, already disposed!') - return this - } - - logger.info('adding "%s" event listener:', event, listener) - - this.emitter.on(event, listener, options) - return this - } - - public once>( - event: EventType, - listener: Emitter.Listener, - options: Omit - ): this { - this.emitter.once(event, listener, options) - return this - } - - public removeListener< - EventType extends Emitter.AllEventTypes, - >( - event: EventType, - listener: Emitter.Listener - ): this { - this.emitter.removeListener(event, listener) - return this - } - - public removeAllListeners< - EventType extends Emitter.AllEventTypes, - >(event?: EventType): this { - this.emitter.removeAllListeners(event) - return this - } - - /** - * Disposes of any side-effects this interceptor has introduced. - */ - public dispose(): void { - const logger = this.logger.extend('dispose') - - if (this.readyState === InterceptorReadyState.DISPOSED) { - logger.info('cannot dispose, already disposed!') - return - } - - logger.info('disposing the interceptor...') - this.readyState = InterceptorReadyState.DISPOSING - - if (!this.getInstance()) { - logger.info('no interceptors running, skipping dispose...') - return - } - - // Delete the global symbol as soon as possible, - // indicating that the interceptor is no longer running. - if (this === this.getInstance()) { - this.clearInstance() - - this.emitter.removeAllListeners() - logger.info('removed the listeners!') - } - - logger.info('global symbol deleted:', getGlobalSymbol(this.symbol)) - - if (this.subscriptions.length > 0) { - logger.info('disposing of %d subscriptions...', this.subscriptions.length) - - for (const dispose of this.subscriptions) { - dispose() - } - - this.subscriptions = [] - - logger.info('disposed of all subscriptions!', this.subscriptions.length) - } - - this.readyState = InterceptorReadyState.DISPOSED - } - - private getInstance(): this | undefined { - const instance = getGlobalSymbol(this.symbol) - this.logger.info('retrieved global instance:', instance?.constructor?.name) - return instance - } - - private setInstance(): void { - setGlobalSymbol(this.symbol, this) - this.logger.info('set global instance!', this.symbol.description) - } - - private clearInstance(): void { - deleteGlobalSymbol(this.symbol) - this.logger.info('cleared global instance!', this.symbol.description) - } -} diff --git a/src/RemoteHttpInterceptor.ts b/src/RemoteHttpInterceptor.ts index 4b867d334..610b49f2b 100644 --- a/src/RemoteHttpInterceptor.ts +++ b/src/RemoteHttpInterceptor.ts @@ -1,6 +1,6 @@ import { ChildProcess } from 'child_process' import { HttpRequestEventMap, HttpResponseEvent } from './events/http' -import { Interceptor } from './Interceptor' +import { Interceptor } from './interceptor' import { BatchInterceptor } from './BatchInterceptor' import { ClientRequestInterceptor } from './interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest/web' diff --git a/src/index.ts b/src/index.ts index dc334a3a1..6b852d1ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -export * from './Interceptor' -export * from './BatchInterceptor' +export { Interceptor } from './interceptor' +export { BatchInterceptor } from './BatchInterceptor' export { RequestController, type RequestControllerSource, diff --git a/src/interceptor-v2.test.ts b/src/interceptor.test.ts similarity index 72% rename from src/interceptor-v2.test.ts rename to src/interceptor.test.ts index 32daf3a86..c6de737d3 100644 --- a/src/interceptor-v2.test.ts +++ b/src/interceptor.test.ts @@ -1,5 +1,5 @@ import { TypedEvent } from 'rettime' -import { Interceptor } from './interceptor-v2' +import { Interceptor } from './interceptor' it('nesting interceptors', async () => { const socketSetup = vi.fn() @@ -134,3 +134,61 @@ it('nesting interceptors', async () => { expect(numberListener).toHaveBeenNthCalledWith(2, 2) expect(stringListener).toHaveBeenCalledExactlyOnceWith('hello') }) + +it('treats an interceptor as a singleton via "Interceptor.singleton()"', () => { + const setup = vi.fn() + const dispose = vi.fn() + + class MyInterceptor extends Interceptor<{ test: TypedEvent }> { + protected predicate(): boolean { + return true + } + + protected setup(): void { + setup() + } + + public dispose(): void { + super.dispose() + dispose() + } + } + + const interceptor = Interceptor.singleton(MyInterceptor) + expect(setup).not.toHaveBeenCalled() + + interceptor.apply() + expect(setup).toHaveBeenCalledOnce() + + { + const interceptor = Interceptor.singleton(MyInterceptor) + expect(setup).toHaveBeenCalledOnce() + + interceptor.dispose() + expect(dispose).toHaveBeenCalledOnce() + } + + interceptor.dispose() + expect(dispose).toHaveBeenCalledTimes(2) +}) + +it('removes all listeners when the interceptor is disposed', () => { + class MyInterceptor extends Interceptor<{ test: TypedEvent }> { + protected predicate(): boolean { + return true + } + + protected setup(): void {} + } + + const interceptor = new MyInterceptor() + interceptor.apply() + + const listener = vi.fn() + interceptor.on('test', listener) + + interceptor.dispose() + + interceptor['emitter'].emit(new TypedEvent('test')) + expect(listener).not.toHaveBeenCalled() +}) diff --git a/src/interceptor-v2.ts b/src/interceptor.ts similarity index 94% rename from src/interceptor-v2.ts rename to src/interceptor.ts index 942baa327..4d6c5a287 100644 --- a/src/interceptor-v2.ts +++ b/src/interceptor.ts @@ -1,5 +1,4 @@ -import { Emitter } from 'rettime' -import { InterceptorEventMap } from './Interceptor' +import { Emitter, type DefaultEventMap } from 'rettime' import { Disposable } from './disposable' export enum InterceptorReadyState { @@ -11,7 +10,7 @@ export enum InterceptorReadyState { const interceptorsRegistry = new Map>() export abstract class Interceptor< - Events extends InterceptorEventMap, + Events extends DefaultEventMap, > extends Disposable { declare ['constructor']: typeof Interceptor diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 99e9456db..a9e6a892c 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -3,33 +3,35 @@ import https from 'node:https' import { runInRequestContext } from '#/src/request-context' import { patchesRegistry } from '#/src/utils/patchesRegistry' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { Interceptor } from '#/src/Interceptor' +import { Interceptor } from '../../interceptor' import { HttpRequestEventMap } from '#/src/events/http' -import { proxyEventListeners } from '#/src/utils/interceptor-utils' export class ClientRequestInterceptor extends Interceptor { static symbol = Symbol.for('client-request-interceptor') - #httpInterceptor: HttpRequestInterceptor - - constructor() { - super(ClientRequestInterceptor.symbol) - - this.#httpInterceptor = new HttpRequestInterceptor() - this.subscriptions.push( - proxyEventListeners({ - from: this.emitter, - to: () => this.#httpInterceptor['emitter'], - filter: (event) => { - return event.initiator instanceof http.ClientRequest - }, - }) - ) + protected predicate(): boolean { + return true } protected setup(): void { - this.#httpInterceptor.apply() - this.subscriptions.push(() => this.#httpInterceptor.dispose()) + const httpInterceptor = Interceptor.singleton(HttpRequestInterceptor) + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + httpInterceptor.on( + 'request', + (event) => { + if (event.initiator instanceof http.ClientRequest) { + this.emitter.emit(event) + } + }, + { + signal: controller.signal, + } + ) this.subscriptions.push( patchesRegistry.applyPatch(http, 'ClientRequest', (ClientRequest) => { diff --git a/src/interceptors/WebSocket/index.ts b/src/interceptors/WebSocket/index.ts index 6ca8f417d..1c8413b73 100644 --- a/src/interceptors/WebSocket/index.ts +++ b/src/interceptors/WebSocket/index.ts @@ -1,4 +1,4 @@ -import { Interceptor } from '../../Interceptor' +import { Interceptor } from '../../interceptor' import { WebSocketConnectionEvent, type WebSocketEventMap, @@ -22,6 +22,7 @@ import { import { bindEvent } from './utils/bindEvent' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { patchesRegistry } from '../../utils/patchesRegistry' +import { Logger } from '@open-draft/logger' export { type WebSocketData, @@ -42,6 +43,8 @@ export { CancelableMessageEvent, } from './utils/events' +const logger = new Logger('websocket') + /** * Intercept the outgoing WebSocket connections created using * the global `WebSocket` class. @@ -49,16 +52,12 @@ export { export class WebSocketInterceptor extends Interceptor { static symbol = Symbol.for('websocket-interceptor') - constructor() { - super(WebSocketInterceptor.symbol) - } - - protected checkEnvironment(): boolean { + protected predicate(): boolean { return hasConfigurableGlobal('WebSocket') } protected setup(): void { - const logger = this.logger.extend('setup') + logger.info('setup') const WebSocketProxy = new Proxy(globalThis.WebSocket, { construct: ( diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index c7354703e..2f8e9d3e0 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -1,50 +1,43 @@ import { requestContext } from '#/src/request-context' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' -import { Interceptor } from '#/src/Interceptor' +import { Interceptor } from '../../interceptor' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { patchesRegistry } from '#/src/utils/patchesRegistry' import { FetchRequest } from '#/src/utils/fetchUtils' import { HttpRequestEventMap } from '#/src/events/http' -import { proxyEventListeners } from '#/src/utils/interceptor-utils' +import { createLogger } from '#/src/utils/logger' + +const log = createLogger('xhr') export class XMLHttpRequestInterceptor extends Interceptor { static symbol = Symbol.for('xhr-interceptor') - #httpInterceptor: HttpRequestInterceptor - - constructor() { - super(XMLHttpRequestInterceptor.symbol) - - this.#httpInterceptor = new HttpRequestInterceptor() - - this.subscriptions.push( - proxyEventListeners({ - from: this.emitter, - to: () => this.#httpInterceptor['emitter'], - filter: (event) => { - if (event.initiator instanceof XMLHttpRequest) { - event.request = this.#transformRequest( - event.request, - event.initiator - ) - return true - } - - return false - }, - }) - ) - } - - protected checkEnvironment() { + protected predicate() { return hasConfigurableGlobal('XMLHttpRequest') } protected setup(): void { - this.#httpInterceptor.apply() - this.subscriptions.push(() => this.#httpInterceptor.dispose()) + const httpInterceptor = Interceptor.singleton(HttpRequestInterceptor) + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + httpInterceptor.on( + 'request', + (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + this.emitter.emit(event) + } + }, + { + signal: controller.signal, + } + ) - this.logger.info('patching global "XMLHttpRequest"...') + log('patching global "XMLHttpRequest"...') this.subscriptions.push( patchesRegistry.applyPatch( @@ -72,7 +65,7 @@ export class XMLHttpRequestInterceptor extends Interceptor ) ) - this.logger.info('global "XMLHttpRequest" patched!') + log('global "XMLHttpRequest" patched!') } #transformRequest(request: Request, initiator: XMLHttpRequest): Request { diff --git a/src/interceptors/XMLHttpRequest/web.ts b/src/interceptors/XMLHttpRequest/web.ts index 08935b55c..2f847b0e5 100644 --- a/src/interceptors/XMLHttpRequest/web.ts +++ b/src/interceptors/XMLHttpRequest/web.ts @@ -1,30 +1,27 @@ +import { Logger } from '@open-draft/logger' import { HttpRequestEventMap } from '../../events/http' -import { Interceptor } from '../../Interceptor' +import { Interceptor } from '../../interceptor' import { createXMLHttpRequestProxy } from './XMLHttpRequestProxy' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { patchesRegistry } from '../../utils/patchesRegistry' +const logger = new Logger('xhr') + export class XMLHttpRequestInterceptor extends Interceptor { static interceptorSymbol = Symbol.for('xhr-interceptor') - constructor() { - super(XMLHttpRequestInterceptor.interceptorSymbol) - } - - protected checkEnvironment() { + protected predicate() { return hasConfigurableGlobal('XMLHttpRequest') } protected setup() { - const logger = this.logger.extend('setup') - logger.info('patching "XMLHttpRequest"...') this.subscriptions.push( patchesRegistry.applyPatch(globalThis, 'XMLHttpRequest', () => { return createXMLHttpRequestProxy({ emitter: this.emitter, - logger: this.logger, + logger, }) }) ) diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index b2d5956f4..e2a6859b5 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -1,7 +1,7 @@ import { until } from '@open-draft/until' +import { Logger } from '@open-draft/logger' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' -import { Interceptor } from '../../Interceptor' import { RequestController } from '../../RequestController' import { handleRequest } from '../../utils/handleRequest' import { canParseUrl } from '../../utils/canParseUrl' @@ -14,21 +14,18 @@ import { FetchResponse } from '../../utils/fetchUtils' import { isResponseError } from '../../utils/responseUtils' import { patchesRegistry } from '../../utils/patchesRegistry' import { copyRawHeaders } from '../ClientRequest/utils/recordRawHeaders' +import { Interceptor } from '../../interceptor' + +const logger = new Logger('fetch') export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') - constructor() { - super(FetchInterceptor.symbol) - } - - protected checkEnvironment() { + protected predicate() { return hasConfigurableGlobal('fetch') } protected async setup() { - const logger = this.logger.extend('setup') - logger.info('patching global fetch...') this.subscriptions.push( @@ -55,7 +52,7 @@ export class FetchInterceptor extends Interceptor { const controller = new RequestController(request, { passthrough: async () => { - this.logger.info('request has not been handled, passthrough...') + logger.info('request has not been handled, passthrough...') /** * @note Clone the request instance right before performing it. @@ -73,10 +70,10 @@ export class FetchInterceptor extends Interceptor { return responsePromise.reject(responseError) } - this.logger.info('original fetch performed', originalResponse) + logger.info('original fetch performed', originalResponse) if (this.emitter.listenerCount('response') > 0) { - this.logger.info('emitting the "response" event...') + logger.info('emitting the "response" event...') const responseClone = FetchResponse.clone(originalResponse) await this.emitter.emitAsPromise( @@ -97,14 +94,14 @@ export class FetchInterceptor extends Interceptor { respondWith: async (rawResponse) => { // Handle mocked `Response.error()` (i.e. request errors). if (isResponseError(rawResponse)) { - this.logger.info('request has errored!', { + logger.info('request has errored!', { response: rawResponse, }) responsePromise.reject(createNetworkError(rawResponse)) return } - this.logger.info('received mocked response!', { + logger.info('received mocked response!', { rawResponse, }) @@ -152,7 +149,7 @@ export class FetchInterceptor extends Interceptor { } if (this.emitter.listenerCount('response') > 0) { - this.logger.info('emitting the "response" event...') + logger.info('emitting the "response" event...') // Await the response listeners to finish before resolving // the response promise. This ensures all your logic finishes @@ -174,15 +171,15 @@ export class FetchInterceptor extends Interceptor { responsePromise.resolve(response) }, errorWith: (reason) => { - this.logger.info('request has been aborted!', { reason }) + logger.info('request has been aborted!', { reason }) responsePromise.reject(reason) }, }) - this.logger.info('[%s] %s', request.method, request.url) - this.logger.info('awaiting for the mocked response...') + logger.info('[%s] %s', request.method, request.url) + logger.info('awaiting for the mocked response...') - this.logger.info( + logger.info( 'emitting the "request" event for %s listener(s)...', this.emitter.listenerCount('request') ) diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index f63ad5175..d197aaf67 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -3,38 +3,35 @@ import { canParseUrl } from '#/src/utils/canParseUrl' import { requestContext } from '#/src/request-context' import { patchesRegistry } from '#/src/utils/patchesRegistry' import { HttpRequestInterceptor } from '#/src/interceptors/http' -import { Interceptor } from '#/src/Interceptor' import { HttpRequestEventMap } from '#/src/events/http' -import { proxyEventListeners } from '#/src/utils/interceptor-utils' +import { Interceptor } from '../../interceptor' export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') - #httpInterceptor: HttpRequestInterceptor - - constructor() { - super(FetchInterceptor.symbol) - - this.#httpInterceptor = new HttpRequestInterceptor() - - this.subscriptions.push( - proxyEventListeners({ - from: this.emitter, - to: () => this.#httpInterceptor['emitter'], - filter: (event) => { - return event.initiator instanceof Request - }, - }) - ) - } - - protected checkEnvironment() { + protected predicate() { return hasConfigurableGlobal('fetch') } protected setup(): void { - this.#httpInterceptor.apply() - this.subscriptions.push(() => this.#httpInterceptor.dispose()) + const httpInterceptor = Interceptor.singleton(HttpRequestInterceptor) + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + httpInterceptor.on( + 'request', + (event) => { + if (event.initiator instanceof Request) { + this.emitter.emit(event) + } + }, + { + signal: controller.signal, + } + ) this.subscriptions.push( patchesRegistry.applyPatch(globalThis, 'fetch', (realFetch) => { diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index f45ca2b7d..4097ae3af 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -6,7 +6,6 @@ import { IncomingMessage, } from 'node:http' import { invariant } from 'outvariant' -import { Interceptor } from '../../Interceptor' import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' import { RequestController } from '../../RequestController' import { @@ -29,18 +28,19 @@ import { import { unwrapPendingData } from '../net/utils/flush-writes' import { FetchResponse } from '../../utils/fetchUtils' import { requestContext } from '../../request-context' +import { Interceptor } from '#/src/interceptor' const log = createLogger('HttpRequestInterceptor') export class HttpRequestInterceptor extends Interceptor { static symbol = Symbol.for('http-request-interceptor') - constructor() { - super(HttpRequestInterceptor.symbol) + protected predicate(): boolean { + return true } protected setup(): void { - const socketInterceptor = new SocketInterceptor() + const socketInterceptor = Interceptor.singleton(SocketInterceptor) socketInterceptor.apply() this.subscriptions.push(() => socketInterceptor.dispose()) @@ -51,6 +51,9 @@ export class HttpRequestInterceptor extends Interceptor { */ this.subscriptions.push(recordRawFetchHeaders()) + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + socketInterceptor.on( 'connection', ({ connectionOptions, socket, controller: socketController }) => { @@ -251,6 +254,9 @@ export class HttpRequestInterceptor extends Interceptor { }) .on('close', () => requestParser.free()) }) + }, + { + signal: controller.signal, } ) } diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 8f897d88a..a85761d5a 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -1,6 +1,6 @@ import net from 'node:net' import tls from 'node:tls' -import { Interceptor } from '../../Interceptor' +import { TypedEvent } from 'rettime' import { type NetworkConnectionOptions, normalizeNetConnectArgs, @@ -9,7 +9,7 @@ import { TcpSocketController, TlsSocketController } from './socket-controller' import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' import { createLogger } from '../../utils/logger' import { patchesRegistry } from '../../utils/patchesRegistry' -import { TypedEvent } from 'rettime' +import { Interceptor } from '#/src/interceptor' interface SocketConnectionEventData { socket: net.Socket | tls.TLSSocket @@ -42,8 +42,8 @@ const log = createLogger('SocketInterceptor') export class SocketInterceptor extends Interceptor { static symbol = Symbol.for('socket-interceptor') - constructor() { - super(SocketInterceptor.symbol) + protected predicate(): boolean { + return true } protected setup(): void { diff --git a/src/utils/interceptor-utils.ts b/src/utils/interceptor-utils.ts deleted file mode 100644 index cd53b0768..000000000 --- a/src/utils/interceptor-utils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Emitter } from 'rettime' - -export function proxyEventListeners>(options: { - from: T - /** - * A lazy getter of the destination emitter. - * Handy because proxying has to be set up during an interceptor's constructor - * when the this.emitter = runningInstance.emitter assignment hasn't been made yet. - */ - to: () => T - filter: (event: Emitter.Events) => boolean -}) { - const controller = new AbortController() - - const propagateEvent = async (event: Emitter.Events) => { - if (options.filter(event)) { - await options.from.emitAsPromise(event) - } - } - - options.from.hooks.on( - 'newListener', - (type) => { - const to = options.to() - - if (!to.listeners(type).includes(propagateEvent)) { - to.on(type, propagateEvent, { signal: controller.signal }) - } - }, - { - persist: true, - signal: controller.signal, - } - ) - - options.from.hooks.on( - 'removeListener', - (type) => { - const to = options.to() - - if (options.from.listenerCount(type) === 1) { - to.removeListener(type, propagateEvent) - } - }, - { - persist: true, - signal: controller.signal, - } - ) - - return () => { - controller.abort() - } -} From 78b43c0b994b7bf5486b92a68411614b650d8c7b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 16:05:04 +0200 Subject: [PATCH 168/198] fix(http): await `response` before responding --- src/interceptors/http/index.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 4097ae3af..8d50851eb 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -127,18 +127,6 @@ export class HttpRequestInterceptor extends Interceptor { }) } - if (socket.connecting) { - // Send a mocked response once the socket connects, just like the real server would. - // This preserves the correct order of events (e.g. connect, then data). - socket.once('connect', respond) - } else { - /** - * @note Reused sockets stay connected between requests and will not - * emit "connect" anymore. If that's the case, respond immediately. - */ - await respond() - } - if (responseClone) { await this.emitter.emitAsPromise( new HttpResponseEvent({ @@ -150,6 +138,18 @@ export class HttpRequestInterceptor extends Interceptor { }) ) } + + if (socket.connecting) { + // Send a mocked response once the socket connects, just like the real server would. + // This preserves the correct order of events (e.g. connect, then data). + socket.once('connect', respond) + } else { + /** + * @note Reused sockets stay connected between requests and will not + * emit "connect" anymore. If that's the case, respond immediately. + */ + await respond() + } }, errorWith: (reason) => { if (reason instanceof Error) { From e0fcf481fc7fbbf4aa90e80262d055b6f568494d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 16:24:56 +0200 Subject: [PATCH 169/198] fix(HttpRequestInterceptor): emit `close` in `socket._destroy` --- src/interceptors/http/index.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 8d50851eb..4e725b5ce 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -354,10 +354,19 @@ export class HttpRequestInterceptor extends Interceptor { * microtask phase, before other queued microtasks. */ queueMicrotask(() => this.emit('error', error)) - callback(null) - } else { - callback(null) } + + callback(null) + + /** + * @note `net.Socket` is constructed with `emitClose: false`, so Node's + * stream destroy machinery does not emit `'close'` automatically; the + * stock `net.Socket._destroy` only emits it via `_handle.close()`. + * Since this override replaces `_destroy`, emit `'close'` here so the + * mocked socket completes its lifecycle (otherwise consumers waiting + * on `'close'`, like `http.ClientRequest`, hang). + */ + process.nextTick(() => this.emit('close', error != null)) } if (response.body) { From 37bb993b7f42df0b573e9c74297f3c775e44f94c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 17:31:07 +0200 Subject: [PATCH 170/198] fix(http): preserve original forbidden headers --- src/interceptors/XMLHttpRequest/node.ts | 2 +- src/interceptors/http/index.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 2f8e9d3e0..cb44f199a 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -1,6 +1,6 @@ import { requestContext } from '#/src/request-context' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' -import { Interceptor } from '../../interceptor' +import { Interceptor } from '#/src/interceptor' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { patchesRegistry } from '#/src/utils/patchesRegistry' import { FetchRequest } from '#/src/utils/fetchUtils' diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 4e725b5ce..6ae1ccc4c 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -447,9 +447,20 @@ export class HttpRequestInterceptor extends Interceptor { visitedHeaders.add(normalizedHeaderName) + /** + * @note Forbidden Fetch headers (e.g. Host, Origin, Connection) + * are stripped from `request.headers` but remain in the raw + * headers list. Skip them so the original values from the + * outgoing HTTP message are preserved. + */ + const headerValue = request.headers.get(headerName) + if (headerValue === null) { + continue + } + // Use the merged value from Headers to correctly handle // appended headers (e.g. "1, 2" instead of just "2"). - httpMessageHeaders.set(headerName, request.headers.get(headerName)!) + httpMessageHeaders.set(headerName, headerValue) } visitedHeaders.clear() From 1846bb798498d4d15dc83879f0aa03e54d7edcdc Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 17:46:49 +0200 Subject: [PATCH 171/198] fix: add explicit `response` event listener --- src/interceptors/ClientRequest/index.ts | 11 +++++++++++ src/interceptors/XMLHttpRequest/node.ts | 12 ++++++++++++ src/interceptors/fetch/node.ts | 11 +++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index a9e6a892c..9f6455e6e 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -32,6 +32,17 @@ export class ClientRequestInterceptor extends Interceptor { signal: controller.signal, } ) + httpInterceptor.on( + 'response', + (event) => { + if (event.initiator instanceof http.ClientRequest) { + this.emitter.emit(event) + } + }, + { + signal: controller.signal, + } + ) this.subscriptions.push( patchesRegistry.applyPatch(http, 'ClientRequest', (ClientRequest) => { diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index cb44f199a..0a6cc585a 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -36,6 +36,18 @@ export class XMLHttpRequestInterceptor extends Interceptor signal: controller.signal, } ) + httpInterceptor.on( + 'response', + (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + this.emitter.emit(event) + } + }, + { + signal: controller.signal, + } + ) log('patching global "XMLHttpRequest"...') diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index d197aaf67..3a170b95f 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -32,6 +32,17 @@ export class FetchInterceptor extends Interceptor { signal: controller.signal, } ) + httpInterceptor.on( + 'response', + (event) => { + if (event.initiator instanceof Request) { + this.emitter.emit(event) + } + }, + { + signal: controller.signal, + } + ) this.subscriptions.push( patchesRegistry.applyPatch(globalThis, 'fetch', (realFetch) => { From 6b730c28bd0dafb8381790574fe3cba66598b742 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 17:54:40 +0200 Subject: [PATCH 172/198] fix: emit forwarded events as promise --- src/interceptors/ClientRequest/index.ts | 8 ++++---- src/interceptors/XMLHttpRequest/node.ts | 8 ++++---- src/interceptors/fetch/node.ts | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 9f6455e6e..49418b54e 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -23,9 +23,9 @@ export class ClientRequestInterceptor extends Interceptor { httpInterceptor.on( 'request', - (event) => { + async (event) => { if (event.initiator instanceof http.ClientRequest) { - this.emitter.emit(event) + await this.emitter.emitAsPromise(event) } }, { @@ -34,9 +34,9 @@ export class ClientRequestInterceptor extends Interceptor { ) httpInterceptor.on( 'response', - (event) => { + async (event) => { if (event.initiator instanceof http.ClientRequest) { - this.emitter.emit(event) + await this.emitter.emitAsPromise(event) } }, { diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 0a6cc585a..9b81d7474 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -26,10 +26,10 @@ export class XMLHttpRequestInterceptor extends Interceptor httpInterceptor.on( 'request', - (event) => { + async (event) => { if (event.initiator instanceof XMLHttpRequest) { event.request = this.#transformRequest(event.request, event.initiator) - this.emitter.emit(event) + await this.emitter.emitAsPromise(event) } }, { @@ -38,10 +38,10 @@ export class XMLHttpRequestInterceptor extends Interceptor ) httpInterceptor.on( 'response', - (event) => { + async (event) => { if (event.initiator instanceof XMLHttpRequest) { event.request = this.#transformRequest(event.request, event.initiator) - this.emitter.emit(event) + await this.emitter.emitAsPromise(event) } }, { diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 3a170b95f..abf41e0b2 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -23,9 +23,9 @@ export class FetchInterceptor extends Interceptor { httpInterceptor.on( 'request', - (event) => { + async (event) => { if (event.initiator instanceof Request) { - this.emitter.emit(event) + await this.emitter.emitAsPromise(event) } }, { @@ -34,9 +34,9 @@ export class FetchInterceptor extends Interceptor { ) httpInterceptor.on( 'response', - (event) => { + async (event) => { if (event.initiator instanceof Request) { - this.emitter.emit(event) + await this.emitter.emitAsPromise(event) } }, { From abf2b9d77ba14eea6404e1751cf39cb3c0066151 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 17:58:04 +0200 Subject: [PATCH 173/198] fix: add `unhandledException` listener --- src/interceptors/ClientRequest/index.ts | 11 +++++++++++ src/interceptors/XMLHttpRequest/node.ts | 12 ++++++++++++ src/interceptors/fetch/node.ts | 11 +++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 49418b54e..98bc483bf 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -43,6 +43,17 @@ export class ClientRequestInterceptor extends Interceptor { signal: controller.signal, } ) + httpInterceptor.on( + 'unhandledException', + async (event) => { + if (event.initiator instanceof http.ClientRequest) { + await this.emitter.emitAsPromise(event) + } + }, + { + signal: controller.signal, + } + ) this.subscriptions.push( patchesRegistry.applyPatch(http, 'ClientRequest', (ClientRequest) => { diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 9b81d7474..19ad8a949 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -48,6 +48,18 @@ export class XMLHttpRequestInterceptor extends Interceptor signal: controller.signal, } ) + httpInterceptor.on( + 'unhandledException', + async (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + await this.emitter.emitAsPromise(event) + } + }, + { + signal: controller.signal, + } + ) log('patching global "XMLHttpRequest"...') diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index abf41e0b2..b68f0e6d3 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -43,6 +43,17 @@ export class FetchInterceptor extends Interceptor { signal: controller.signal, } ) + httpInterceptor.on( + 'unhandledException', + async (event) => { + if (event.initiator instanceof Request) { + await this.emitter.emitAsPromise(event) + } + }, + { + signal: controller.signal, + } + ) this.subscriptions.push( patchesRegistry.applyPatch(globalThis, 'fetch', (realFetch) => { From 7e225576950969656822e7d09f8812593e0838f7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 18:05:23 +0200 Subject: [PATCH 174/198] test: clean up events/request test --- test/features/events/request.test.ts | 35 +++++++++++----------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 988129dbc..f1c4ed4a7 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -1,11 +1,10 @@ // @vitest-environment happy-dom import http from 'node:http' import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' -import { BatchInterceptor } from '@mswjs/interceptors' +import { BatchInterceptor, RequestController } from '@mswjs/interceptors' import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchInterceptor } from '@mswjs/interceptors/fetch' -import { RequestController } from '#/src/RequestController' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' @@ -79,7 +78,7 @@ it('XMLHttpRequest: emits the "request" event upon the request (no CORS)', async // Preflight request. { - const [{ request }] = requestListener.mock.calls[1] + const [{ request }] = requestListener.mock.calls[0] expect.soft(request.method).toBe('OPTIONS') expect.soft(request.url).toBe(url.href) @@ -117,24 +116,18 @@ it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)' await waitForXMLHttpRequest(request) expect.soft(requestListener).toHaveBeenCalledTimes(2) - expect.soft(requestListener).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - request: expect.objectContaining({ - method: 'OPTIONS', - url, - }), - }) - ) - expect.soft(requestListener).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - request: expect.objectContaining({ - method: 'POST', - url, - }), - }) - ) + + { + const [{ request, requestId, controller }] = requestListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + expect.soft(request.credentials).toBe('same-origin') + await expect.soft(request.text()).resolves.toBe('') + expect.soft(controller).toBeInstanceOf(RequestController) + + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } { const [{ request, requestId, controller }] = requestListener.mock.calls[1] From 21aa258f0b2f529ba28a149d950d362863bbedd8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 18:40:18 +0200 Subject: [PATCH 175/198] fix: account for `*` listeners when emitting `response` --- src/interceptors/fetch/index.ts | 6 +- src/interceptors/http/index.ts | 6 +- test/features/events/response.test.ts | 163 ++++++++++++++------------ 3 files changed, 97 insertions(+), 78 deletions(-) diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index e2a6859b5..ce112d283 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -148,7 +148,11 @@ export class FetchInterceptor extends Interceptor { } } - if (this.emitter.listenerCount('response') > 0) { + if ( + this.emitter.listenerCount('response') + + this.emitter.listenerCount('*') > + 0 + ) { logger.info('emitting the "response" event...') // Await the response listeners to finish before resolving diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 6ae1ccc4c..33d1fbce8 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -164,7 +164,11 @@ export class HttpRequestInterceptor extends Interceptor { this.#modifyHttpHeaders(context.request) ) - if (this.emitter.listenerCount('response')) { + if ( + this.emitter.listenerCount('response') + + this.emitter.listenerCount('*') > + 0 + ) { log('found "response" listener, pausing socket...') const mockSocket = socketController[kRawSocket] diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index e16f67871..14fc92869 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -95,7 +95,7 @@ it('ClientRequest: emits the "response" event upon the original response', async }, rejectUnauthorized: false, }) - req.write('request-body') + req.write('original-body') req.end() await toWebResponse(req) @@ -107,13 +107,13 @@ it('ClientRequest: emits the "response" event upon the original response', async expect(request.url).toBe(server.https.url('/account').href) expect(request.headers.get('x-request-custom')).toBe('yes') expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('request-body') + await expect(request.text()).resolves.toBe('original-body') expect(response.status).toBe(200) expect(response.statusText).toBe('OK') expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('original') - await expect(response.text()).resolves.toBe('original-response-text') + expect(response.headers.get('x-request-custom')).toBe('yes') + await expect(response.text()).resolves.toBe('original-body') expect(responseType).toBe('original') }) @@ -155,20 +155,23 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () await waitForXMLHttpRequest(request) expect(responseListener).toHaveBeenCalledTimes(2) - expect(responseListener).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - request: expect.objectContaining({ method: 'OPTIONS', url }), - }) - ) - expect(responseListener).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - request: expect.objectContaining({ method: 'GET', url }), - }) - ) expect(request.responseText).toBe('mocked-response-text') + { + const [{ response, request, responseType }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + expect.soft(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('') + + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('') + expect.soft(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('') + expect(responseType).toBe('mock') + } + { const [{ response, request, responseType }] = responseListener.mock.calls[1] @@ -181,9 +184,8 @@ it('XMLHttpRequest: emits the "response" event upon a mocked response', async () expect.soft(response.status).toBe(200) expect.soft(response.statusText).toBe('OK') expect.soft(response.url).toBe(request.url) - expect.soft(response.headers.get('x-response-type')).toBe('mocked') await expect(response.text()).resolves.toBe('mocked-response-text') - expect(responseType).toBe('mocked') + expect(responseType).toBe('mock') } }) @@ -196,24 +198,31 @@ it('XMLHttpRequest: emits the "response" event upon the original response', asyn const request = new XMLHttpRequest() request.open('POST', url) request.setRequestHeader('x-request-custom', 'yes') - request.send('request-body') + request.send('original-body') await waitForXMLHttpRequest(request) expect(responseListener).toHaveBeenCalledTimes(2) - expect(responseListener).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - request: expect.objectContaining({ method: 'OPTIONS', url }), - }) - ) - expect(responseListener).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - request: expect.objectContaining({ method: 'POST', url }), - }) - ) - expect(request.responseText).toBe('original-response-text') + expect(request.responseText).toBe('original-body') + + { + const [{ response, request, responseType }] = responseListener.mock.calls[0] + + expect(request).toBeDefined() + expect(response).toBeDefined() + + expect(request.method).toBe('OPTIONS') + expect(request.url).toBe(url.href) + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('') + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('') + + expect(responseType).toBe('original') + } { const [{ response, request, responseType }] = responseListener.mock.calls[1] @@ -225,20 +234,20 @@ it('XMLHttpRequest: emits the "response" event upon the original response', asyn expect(request.url).toBe(url.href) expect(request.headers.get('x-request-custom')).toBe('yes') expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('request-body') + await expect(request.text()).resolves.toBe('original-body') expect(response.status).toBe(200) expect(response.statusText).toBe('OK') expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('original') - await expect(response.text()).resolves.toBe('original-response-text') + expect(response.headers.get('x-request-custom')).toBe('yes') + await expect(response.text()).resolves.toBe('original-body') expect(responseType).toBe('original') } }) it('fetch: emits the "response" event upon a mocked response', async () => { - interceptor.on('request', ({ controller }) => { + interceptor.on('request', ({ request, controller }) => { controller.respondWith( new Response('mocked-response-text', { statusText: 'OK', @@ -249,15 +258,8 @@ it('fetch: emits the "response" event upon a mocked response', async () => { ) }) - const responseListenerArgs = new DeferredPromise< - HttpRequestEventMap['response'] - >() - interceptor.on('response', (event) => { - responseListenerArgs.resolve({ - ...event, - request: event.request.clone(), - }) - }) + const responseListener = vi.fn() + interceptor.on('response', responseListener) await fetch(server.https.url('/user'), { headers: { @@ -265,7 +267,9 @@ it('fetch: emits the "response" event upon a mocked response', async () => { }, }) - const { response, request, responseType } = await responseListenerArgs + await expect.poll(() => responseListener).toHaveBeenCalledTimes(1) + + const [{ response, request, responseType }] = responseListener.mock.calls[0] expect(request.method).toBe('GET') expect(request.url).toBe(server.https.url('/user').href) @@ -286,47 +290,54 @@ it( 'fetch: emits the "response" event upon the original response', { timeout: 1500 }, async () => { - interceptor.on('request', ({ initiator, request }) => { - console.log(request.method, request.url, initiator?.constructor?.name) - - if (request.method === 'OPTIONS') { - console.trace('fetch options?!') - } - }) - - const responseListenerArgs = new DeferredPromise< - HttpRequestEventMap['response'] - >() - interceptor.on('response', (args) => { - responseListenerArgs.resolve({ - ...args, - request: args.request.clone(), - }) - }) + const responseListener = vi.fn() + interceptor.on('response', responseListener) await fetch(server.http.url('/account'), { method: 'POST', headers: { 'x-request-custom': 'yes', }, - body: 'request-body', + body: 'original-body', }) - const { response, request, responseType } = await responseListenerArgs + await expect.poll(() => responseListener).toHaveBeenCalledTimes(2) - expect(request.method).toBe('POST') - expect(request.url).toBe(server.http.url('/account').href) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('request-body') + { + const [{ response, request, responseType }] = + responseListener.mock.calls[0] - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('original') - await expect(response.text()).resolves.toBe('original-response-text') + expect(request.method).toBe('OPTIONS') + expect(request.url).toBe(server.http.url('/account').href) + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('') - expect(responseType).toBe('original') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('') + + expect(responseType).toBe('original') + } + + { + const [{ response, request, responseType }] = + responseListener.mock.calls[1] + + expect(request.method).toBe('POST') + expect(request.url).toBe(server.http.url('/account').href) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('original-body') + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-request-custom')).toBe('yes') + await expect(response.text()).resolves.toBe('original-body') + + expect(responseType).toBe('original') + } } ) From 213767011663bd5964e154791dcf6e599550f378 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 18:51:02 +0200 Subject: [PATCH 176/198] fix(BatchInterceptor): change predicate from .every to .some --- src/BatchInterceptor.ts | 19 ++++++++---- src/RemoteHttpInterceptor.ts | 46 +++++++++++++++-------------- test/features/remote/remote.test.ts | 36 ++++++++++++---------- 3 files changed, 58 insertions(+), 43 deletions(-) diff --git a/src/BatchInterceptor.ts b/src/BatchInterceptor.ts index ae1932b24..9b042fbc9 100644 --- a/src/BatchInterceptor.ts +++ b/src/BatchInterceptor.ts @@ -44,16 +44,25 @@ export class BatchInterceptor< this.#interceptors = options.interceptors for (const interceptor of options.interceptors) { - interceptor.on('*', (event) => { - this.emitter.emit(event) + interceptor.on('*', async (event) => { + await this.emitter.emitAsPromise(event) }) } } protected predicate(): boolean { - return this.#interceptors.every((interceptor) => { - return interceptor['predicate']() - }) + for (const interceptor of this.#interceptors) { + if (interceptor['predicate']()) { + /** + * @note If at least one of the provided interceptors suits the environment, + * treat this batch interceptor as matching. Since all the interceptors abide + * by the same event map, it will handle the events from any that match. + */ + return true + } + } + + return false } protected setup() { diff --git a/src/RemoteHttpInterceptor.ts b/src/RemoteHttpInterceptor.ts index 610b49f2b..41e81bbac 100644 --- a/src/RemoteHttpInterceptor.ts +++ b/src/RemoteHttpInterceptor.ts @@ -1,10 +1,11 @@ -import { ChildProcess } from 'child_process' -import { HttpRequestEventMap, HttpResponseEvent } from './events/http' +import type { ChildProcess } from 'node:child_process' +import { Logger } from '@open-draft/logger' +import { type HttpRequestEventMap, HttpResponseEvent } from './events/http' import { Interceptor } from './interceptor' import { BatchInterceptor } from './BatchInterceptor' import { ClientRequestInterceptor } from './interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest/web' -import { FetchInterceptor } from './interceptors/fetch' +import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest/node' +import { FetchInterceptor } from './interceptors/fetch/node' import { handleRequest } from './utils/handleRequest' import { RequestController } from './RequestController' import { FetchRequest, FetchResponse } from './utils/fetchUtils' @@ -16,7 +17,7 @@ export interface SerializedRequest { method: string headers: Array<[string, string]> credentials: RequestCredentials - body: string + body: string | null } interface RevivedRequest extends Omit { @@ -28,9 +29,11 @@ export interface SerializedResponse { status: number statusText: string headers: Array<[string, string]> - body: string + body: string | null } +const logger = new Logger('remote-http-interceptor') + export class RemoteHttpInterceptor extends BatchInterceptor< [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor] > { @@ -62,12 +65,9 @@ export class RemoteHttpInterceptor extends BatchInterceptor< body: ['GET', 'HEAD'].includes(request.method) ? null : await request.text(), - } as SerializedRequest) + } satisfies SerializedRequest) - this.logger.info( - 'sent serialized request to the child:', - serializedRequest - ) + logger.info('sent serialized request to the child:', serializedRequest) process.send?.(`request:${serializedRequest}`) @@ -108,7 +108,7 @@ export class RemoteHttpInterceptor extends BatchInterceptor< }) // Listen for the mocked response message from the parent. - this.logger.info( + logger.info( 'add "message" listener to the parent process', handleParentMessage ) @@ -141,17 +141,19 @@ export interface RemoveResolverOptions { } export class RemoteHttpResolver extends Interceptor { - static symbol = Symbol('remote-resolver') + static symbol = Symbol('remote-http-resolver') private process: ChildProcess constructor(options: RemoveResolverOptions) { - super(RemoteHttpResolver.symbol) + super() this.process = options.process } - protected setup() { - const logger = this.logger.extend('setup') + protected predicate(): boolean { + return true + } + protected setup() { const handleChildMessage: NodeJS.MessageListener = async (message) => { logger.info('received message from child!', message) @@ -168,7 +170,7 @@ export class RemoteHttpResolver extends Interceptor { const requestJson = JSON.parse( serializedRequest, requestReviver - ) as RevivedRequest + ) satisfies RevivedRequest logger.info('parsed intercepted request', requestJson) @@ -183,22 +185,22 @@ export class RemoteHttpResolver extends Interceptor { passthrough: () => {}, respondWith: async (response) => { if (isResponseError(response)) { - this.logger.info('received a network error!', { response }) + logger.info('received a network error!', { response }) throw new Error('Not implemented') } - this.logger.info('received mocked response!', { response }) + logger.info('received mocked response!', { response }) const responseClone = FetchResponse.clone(response) const responseText = await responseClone.text() - // // Send the mocked response to the child process. + // Send the mocked response to the child process. const serializedResponse = JSON.stringify({ status: response.status, statusText: response.statusText, headers: Array.from(response.headers.entries()), body: responseText, - } as SerializedResponse) + } satisfies SerializedResponse) this.process.send( `response:${requestJson.id}:${serializedResponse}`, @@ -227,7 +229,7 @@ export class RemoteHttpResolver extends Interceptor { ) }, errorWith: (reason) => { - this.logger.info('request has errored!', { error: reason }) + logger.info('request has errored!', { error: reason }) throw new Error('Not implemented') }, }) diff --git a/test/features/remote/remote.test.ts b/test/features/remote/remote.test.ts index f34fe4e0b..e6b4578ce 100644 --- a/test/features/remote/remote.test.ts +++ b/test/features/remote/remote.test.ts @@ -12,26 +12,14 @@ const resolver = new RemoteHttpResolver({ process: child, }) -resolver.on('request', ({ controller }) => { - controller.respondWith( - new Response( - JSON.stringify({ - mockedFromParent: true, - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - } - ) - ) -}) - beforeAll(() => { resolver.apply() }) +afterEach(() => { + resolver.removeAllListeners() +}) + afterAll(() => { if (!child.killed) { child.kill() @@ -41,6 +29,22 @@ afterAll(() => { }) it('intercepts an HTTP request made in a child process', async () => { + resolver.on('request', ({ controller }) => { + controller.respondWith( + new Response( + JSON.stringify({ + mockedFromParent: true, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + ) + }) + child.send('make:request') const response = await new Promise((resolve, reject) => { From 33f82ea8178aea73b0e50911fc829ff4ef139c9e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 18:52:35 +0200 Subject: [PATCH 177/198] test: remove `HttpRequestInterceptor` from the batch --- test/features/request-initiator.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts index be486e047..3cc93f8a9 100644 --- a/test/features/request-initiator.test.ts +++ b/test/features/request-initiator.test.ts @@ -5,14 +5,12 @@ import { BatchInterceptor } from '#/src/BatchInterceptor' import { ClientRequestInterceptor } from '#/src/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' import { FetchInterceptor } from '#/src/interceptors/fetch/node' -import { HttpRequestInterceptor } from '#/src/interceptors/http' import { toWebResponse } from '#/test/helpers' -import { waitForXMLHttpRequest } from '../setup/helpers-neutral' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new BatchInterceptor({ name: 'interceptor', interceptors: [ - new HttpRequestInterceptor(), new ClientRequestInterceptor(), new XMLHttpRequestInterceptor(), new FetchInterceptor(), From 540e8a791b141e389e6fdaae7972c32063437432 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 20:00:22 +0200 Subject: [PATCH 178/198] chore: install `rettime` as a dependency --- package.json | 2 +- pnpm-lock.yaml | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 3403914c4..ca740c319 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "es-toolkit": "^1.44.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", - "rettime": "^0.11.6" + "rettime": "^0.11.8" }, "resolutions": { "memfs": "^3.4.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c17c730fb..a9fc3da72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^1.4.3 version: 1.4.3 rettime: - specifier: ^0.11.6 - version: link:../../rettime + specifier: ^0.11.8 + version: 0.11.8 devDependencies: '@commitlint/cli': specifier: ^19.7.1 @@ -2667,6 +2667,9 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + rettime@0.11.8: + resolution: {integrity: sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -5907,6 +5910,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + rettime@0.11.8: {} + rfdc@1.4.1: {} rolldown-plugin-dts@0.19.1(rolldown@1.0.0-beta.55)(typescript@5.8.2): From b88174cdae32c1d5850ff63b1095950426b988dd Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 20:41:25 +0200 Subject: [PATCH 179/198] fix(SocketController): remove `handle.setTypeOfService` (Node.js v24) --- src/interceptors/net/socket-controller.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index 81c4b8f13..fa1ca13d4 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -74,6 +74,7 @@ export interface TcpHandle { setBlocking: (blocking: boolean) => OperationStatus setNoDelay?: (noDelay: boolean) => void setKeepAlive?: (keepAlive: boolean, initialDelay: number) => void + setTypeOfService?: (tos: number) => OperationStatus shutdown: (reqest: unknown /* ShutdownWrap */) => OperationStatus close: () => void @@ -328,6 +329,16 @@ export class TcpSocketController extends SocketController { }) }) + /** + * Remove the "setTypeOfService" from the handle, if present (Node.js v24+). + * Removing it has no effect on the socket but prevents the "setTypeOfService EBADF" error. + * @see https://github.com/nodejs/node/blob/69a970f76814d40f55cf162d0cc3632fe8a7e599/lib/net.js#L661 + * @see https://github.com/nodejs/undici/blob/bf684f7de01616708a33a5d1c092177622394442/lib/dispatcher/client-h1.js#L1136 + */ + if (handle.setTypeOfService) { + handle.setTypeOfService = undefined + } + handle.connect = handle.connect6 = (request) => { log('handle.connect()') this.pendingConnection.resolve([request, handle]) From 3da89e32e3de5b644183378e47f372a765601282 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 1 May 2026 20:41:49 +0200 Subject: [PATCH 180/198] chore: set `node22` as the build target --- tsdown.config.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsdown.config.mts b/tsdown.config.mts index 5c07806ee..4d074e20c 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -15,7 +15,7 @@ export default defineConfig([ external: ['_http_common'], outDir: './lib/node', platform: 'node', - target: 'node20', + target: 'node22', outExtensions: (context) => ({ js: context.format === 'cjs' ? '.cjs' : '.mjs', dts: context.format === 'cjs' ? '.d.cts' : '.d.mts', From 6a434f78205799ba46e88276585cc10fc252780b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 May 2026 14:40:39 +0200 Subject: [PATCH 181/198] chore: keep intereceptors registry globally --- src/interceptor.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/interceptor.ts b/src/interceptor.ts index 4d6c5a287..b4f24e6e3 100644 --- a/src/interceptor.ts +++ b/src/interceptor.ts @@ -7,7 +7,12 @@ export enum InterceptorReadyState { DISPOSED = 'DISPOSED', } -const interceptorsRegistry = new Map>() +declare global { + var __MSW_INTERCEPTORS_REGISTRY: Map> | undefined +} + +const interceptorsRegistry = (globalThis.__MSW_INTERCEPTORS_REGISTRY ??= + new Map>()) export abstract class Interceptor< Events extends DefaultEventMap, From 4e6042c9e8f03a7cc332482c42d673693c8e9160 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 May 2026 17:45:51 +0200 Subject: [PATCH 182/198] fix: lazy listener forwarding --- package.json | 8 +- pnpm-lock.yaml | 10 +-- src/BatchInterceptor.ts | 103 +++++++++++------------- src/interceptor.ts | 36 ++++++--- src/interceptors/ClientRequest/index.ts | 74 ++++++++++++++--- src/interceptors/XMLHttpRequest/node.ts | 76 ++++++++++++++--- src/interceptors/fetch/index.ts | 6 +- src/interceptors/fetch/node.ts | 77 +++++++++++++++--- src/interceptors/http/index.ts | 6 +- test/features/events/request.test.ts | 10 +-- test/features/events/response.test.ts | 101 +++++++++++++++-------- test/third-party/miniflare.test.ts | 10 +-- tsdown.config.mts | 2 +- 13 files changed, 350 insertions(+), 169 deletions(-) diff --git a/package.json b/package.json index e7b1e011c..6fde6be77 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,9 @@ }, "./fetch": { "browser": "./lib/browser/interceptors/fetch/index.mjs", - "require": "./lib/node/interceptors/fetch/index.cjs", - "import": "./lib/node/interceptors/fetch/index.mjs", - "default": "./lib/node/interceptors/fetch/index.cjs" + "require": "./lib/node/interceptors/fetch/node.cjs", + "import": "./lib/node/interceptors/fetch/node.mjs", + "default": "./lib/node/interceptors/fetch/node.cjs" }, "./WebSocket": { "require": "./lib/browser/interceptors/WebSocket/index.cjs", @@ -174,7 +174,7 @@ "es-toolkit": "^1.44.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", - "rettime": "^0.11.8" + "rettime": "^0.11.10" }, "resolutions": { "memfs": "^3.4.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9fc3da72..48189ff9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^1.4.3 version: 1.4.3 rettime: - specifier: ^0.11.8 - version: 0.11.8 + specifier: ^0.11.10 + version: 0.11.10 devDependencies: '@commitlint/cli': specifier: ^19.7.1 @@ -2667,8 +2667,8 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - rettime@0.11.8: - resolution: {integrity: sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==} + rettime@0.11.10: + resolution: {integrity: sha512-zzgUfAF20wZtfDs72M15qiX6EutHxgEZ1PAEJUsWQsOi4aOdcK8BJ3/WSDHBhJ7P27fQjd6iOE1+CWIO8t3XOA==} rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -5910,7 +5910,7 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - rettime@0.11.8: {} + rettime@0.11.10: {} rfdc@1.4.1: {} diff --git a/src/BatchInterceptor.ts b/src/BatchInterceptor.ts index 9b042fbc9..e2693b488 100644 --- a/src/BatchInterceptor.ts +++ b/src/BatchInterceptor.ts @@ -1,10 +1,11 @@ import { - Emitter, + type Emitter, type DefaultEventMap, - type TypedListenerOptions, + TypedListenerOptions, + WithReservedEvents, } from 'rettime' -import { Interceptor } from './interceptor' import { Logger } from '@open-draft/logger' +import { Interceptor } from './interceptor' export interface BatchInterceptorOptions< InterceptorList extends ReadonlyArray>, @@ -39,15 +40,11 @@ export class BatchInterceptor< constructor(options: BatchInterceptorOptions) { BatchInterceptor.symbol = Symbol.for(options.name) + super() + this.#logger = logger.extend(options.name) this.#interceptors = options.interceptors - - for (const interceptor of options.interceptors) { - interceptor.on('*', async (event) => { - await this.emitter.emitAsPromise(event) - }) - } } protected predicate(): boolean { @@ -79,52 +76,44 @@ export class BatchInterceptor< } } - // public on>( - // type: EventType, - // listener: Emitter.Listener, - // options?: TypedListenerOptions - // ): this { - // // Instead of adding a listener to the batch interceptor, - // // propagate the listener to each of the individual interceptors. - // for (const interceptor of this.interceptors) { - // interceptor.on(type, listener, options) - // } - - // return this - // } - - // public once>( - // event: EventType, - // listener: Emitter.Listener, - // options?: Omit - // ): this { - // for (const interceptor of this.interceptors) { - // interceptor.once(event, listener, options) - // } - - // return this - // } - - // public removeListener< - // EventType extends Emitter.AllEventTypes, - // >( - // event: EventType, - // listener: Emitter.Listener - // ): this { - // for (const interceptor of this.interceptors) { - // interceptor.removeListener(event, listener) - // } - - // return this - // } - - // public removeAllListeners< - // EventType extends Emitter.AllEventTypes, - // >(event?: EventType | undefined): this { - // for (const interceptors of this.interceptors) { - // interceptors.removeAllListeners(event) - // } - - // return this - // } + public on: (typeof this.emitter)['on'] = (type, listener, options) => { + for (const interceptor of this.#interceptors) { + interceptor.on(type, listener, options) + } + + return this.emitter + } + + public once: (typeof this.emitter)['once'] = (type, listener, options) => { + for (const interceptor of this.#interceptors) { + interceptor.once(type, listener, options) + } + + return this.emitter + } + + public removeListener: (typeof this.emitter)['removeListener'] = ( + type, + listener + ) => { + for (const interceptor of this.#interceptors) { + interceptor.removeListener(type, listener) + } + } + + public removeAllListeners: (typeof this.emitter)['removeAllListeners'] = ( + type + ) => { + for (const interceptor of this.#interceptors) { + interceptor.removeAllListeners(type) + } + } + + public listeners: (typeof this.emitter)['listeners'] = (type) => { + return this.#interceptors[0].listeners(type) + } + + public listenerCount: (typeof this.emitter)['listenerCount'] = (type) => { + return this.#interceptors[0].listenerCount(type) + } } diff --git a/src/interceptor.ts b/src/interceptor.ts index b4f24e6e3..be261ccf5 100644 --- a/src/interceptor.ts +++ b/src/interceptor.ts @@ -22,10 +22,6 @@ export abstract class Interceptor< protected emitter: Emitter public readyState: InterceptorReadyState - public on: Emitter['on'] - public once: Emitter['once'] - public removeListener: Emitter['removeListener'] - public removeAllListeners: Emitter['removeAllListeners'] static readonly symbol: symbol @@ -51,12 +47,7 @@ export abstract class Interceptor< this.#leaseCount = 0 this.readyState = InterceptorReadyState.INACTIVE - this.emitter = new Emitter() - this.on = this.emitter.on.bind(this.emitter) - this.once = this.emitter.once.bind(this.emitter) - this.removeListener = this.emitter.removeListener.bind(this.emitter) - this.removeAllListeners = this.emitter.removeAllListeners.bind(this.emitter) } protected abstract predicate(): boolean @@ -101,4 +92,31 @@ export abstract class Interceptor< this.readyState = InterceptorReadyState.DISPOSED } } + + public on: Emitter['on'] = (type, listener, options) => { + return this.emitter.on(type, listener, options) + } + + public once: Emitter['once'] = (type, listener, options) => { + return this.emitter.once(type, listener, options) + } + + public listeners: Emitter['listeners'] = (type) => { + return this.emitter.listeners(type) + } + + public listenerCount: Emitter['listenerCount'] = (type) => { + return this.emitter.listenerCount(type) + } + + public removeListener: Emitter['removeListener'] = ( + type, + listener + ) => { + return this.emitter.removeListener(type, listener) + } + + public removeAllListeners: Emitter['removeAllListeners'] = (type) => { + return this.emitter.removeAllListeners(type) + } } diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 98bc483bf..e9736ddf7 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,6 +1,7 @@ import http from 'node:http' import https from 'node:https' -import { runInRequestContext } from '#/src/request-context' +import type { Emitter } from 'rettime' +import { requestContext, runInRequestContext } from '#/src/request-context' import { patchesRegistry } from '#/src/utils/patchesRegistry' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { Interceptor } from '../../interceptor' @@ -32,26 +33,77 @@ export class ClientRequestInterceptor extends Interceptor { signal: controller.signal, } ) - httpInterceptor.on( - 'response', - async (event) => { - if (event.initiator instanceof http.ClientRequest) { - await this.emitter.emitAsPromise(event) + + const responseListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'response' + > = async (event) => { + if (event.initiator instanceof http.ClientRequest) { + await this.emitter.emitAsPromise(event) + } + } + + const unhandledExceptionListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'unhandledException' + > = async (event) => { + if (event.initiator instanceof http.ClientRequest) { + await this.emitter.emitAsPromise(event) + } + } + + this.emitter.hooks.on( + 'newListener', + (type) => { + if ( + type === 'response' && + !httpInterceptor.listeners('response').includes(responseListener) + ) { + httpInterceptor.on('response', responseListener, { + signal: controller.signal, + }) + } + + if ( + type === 'unhandledException' && + !httpInterceptor + .listeners('unhandledException') + .includes(unhandledExceptionListener) + ) { + httpInterceptor.on('unhandledException', unhandledExceptionListener, { + signal: controller.signal, + }) } }, { signal: controller.signal, + persist: true, } ) - httpInterceptor.on( - 'unhandledException', - async (event) => { - if (event.initiator instanceof http.ClientRequest) { - await this.emitter.emitAsPromise(event) + + this.emitter.hooks.on( + 'removeListener', + (type) => { + if ( + type === 'response' && + this.emitter.listenerCount('response') === 0 + ) { + httpInterceptor.removeListener('response', responseListener) + } + + if ( + type === 'unhandledException' && + this.emitter.listenerCount('unhandledException') === 0 + ) { + httpInterceptor.removeListener( + 'unhandledException', + unhandledExceptionListener + ) } }, { signal: controller.signal, + persist: true, } ) diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts index 19ad8a949..241f5c0b7 100644 --- a/src/interceptors/XMLHttpRequest/node.ts +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -1,3 +1,4 @@ +import type { Emitter } from 'rettime' import { requestContext } from '#/src/request-context' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { Interceptor } from '#/src/interceptor' @@ -36,28 +37,79 @@ export class XMLHttpRequestInterceptor extends Interceptor signal: controller.signal, } ) - httpInterceptor.on( - 'response', - async (event) => { - if (event.initiator instanceof XMLHttpRequest) { - event.request = this.#transformRequest(event.request, event.initiator) - await this.emitter.emitAsPromise(event) + + const responseListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'response' + > = async (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + await this.emitter.emitAsPromise(event) + } + } + + const unhandledExceptionListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'unhandledException' + > = async (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + await this.emitter.emitAsPromise(event) + } + } + + this.emitter.hooks.on( + 'newListener', + (type) => { + if ( + type === 'response' && + !httpInterceptor.listeners('response').includes(responseListener) + ) { + httpInterceptor.on('response', responseListener, { + signal: controller.signal, + }) + } + + if ( + type === 'unhandledException' && + !httpInterceptor + .listeners('unhandledException') + .includes(unhandledExceptionListener) + ) { + httpInterceptor.on('unhandledException', unhandledExceptionListener, { + signal: controller.signal, + }) } }, { signal: controller.signal, + persist: true, } ) - httpInterceptor.on( - 'unhandledException', - async (event) => { - if (event.initiator instanceof XMLHttpRequest) { - event.request = this.#transformRequest(event.request, event.initiator) - await this.emitter.emitAsPromise(event) + + this.emitter.hooks.on( + 'removeListener', + (type) => { + if ( + type === 'response' && + this.emitter.listenerCount('response') === 0 + ) { + httpInterceptor.removeListener('response', responseListener) + } + + if ( + type === 'unhandledException' && + this.emitter.listenerCount('unhandledException') === 0 + ) { + httpInterceptor.removeListener( + 'unhandledException', + unhandledExceptionListener + ) } }, { signal: controller.signal, + persist: true, } ) diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index ce112d283..e2a6859b5 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -148,11 +148,7 @@ export class FetchInterceptor extends Interceptor { } } - if ( - this.emitter.listenerCount('response') + - this.emitter.listenerCount('*') > - 0 - ) { + if (this.emitter.listenerCount('response') > 0) { logger.info('emitting the "response" event...') // Await the response listeners to finish before resolving diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index b68f0e6d3..9d1da9f3c 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -1,3 +1,4 @@ +import type { Emitter } from 'rettime' import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' import { canParseUrl } from '#/src/utils/canParseUrl' import { requestContext } from '#/src/request-context' @@ -32,26 +33,77 @@ export class FetchInterceptor extends Interceptor { signal: controller.signal, } ) - httpInterceptor.on( - 'response', - async (event) => { - if (event.initiator instanceof Request) { - await this.emitter.emitAsPromise(event) + + const responseListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'response' + > = async (event) => { + if (event.initiator instanceof Request) { + await this.emitter.emitAsPromise(event) + } + } + + const unhandledExceptionListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'unhandledException' + > = async (event) => { + if (event.initiator instanceof Request) { + await this.emitter.emitAsPromise(event) + } + } + + this.emitter.hooks.on( + 'newListener', + (type) => { + if ( + type === 'response' && + !httpInterceptor.listeners('response').includes(responseListener) + ) { + httpInterceptor.on('response', responseListener, { + signal: controller.signal, + }) + } + + if ( + type === 'unhandledException' && + !httpInterceptor + .listeners('unhandledException') + .includes(unhandledExceptionListener) + ) { + httpInterceptor.on('unhandledException', unhandledExceptionListener, { + signal: controller.signal, + }) } }, { signal: controller.signal, + persist: true, } ) - httpInterceptor.on( - 'unhandledException', - async (event) => { - if (event.initiator instanceof Request) { - await this.emitter.emitAsPromise(event) + + this.emitter.hooks.on( + 'removeListener', + (type) => { + if ( + type === 'response' && + this.emitter.listenerCount('response') === 0 + ) { + httpInterceptor.removeListener('response', responseListener) + } + + if ( + type === 'unhandledException' && + this.emitter.listenerCount('unhandledException') === 0 + ) { + httpInterceptor.removeListener( + 'unhandledException', + unhandledExceptionListener + ) } }, { signal: controller.signal, + persist: true, } ) @@ -72,7 +124,10 @@ export class FetchInterceptor extends Interceptor { const request = new Request(resolvedInput, init) - requestContext.enterWith({ initiator: request }) + requestContext.enterWith({ + initiator: request, + }) + return realFetch(input, init) } }) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 33d1fbce8..25a2e1fd3 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -164,11 +164,7 @@ export class HttpRequestInterceptor extends Interceptor { this.#modifyHttpHeaders(context.request) ) - if ( - this.emitter.listenerCount('response') + - this.emitter.listenerCount('*') > - 0 - ) { + if (this.emitter.listenerCount('response') > 0) { log('found "response" listener, pausing socket...') const mockSocket = socketController[kRawSocket] diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index f1c4ed4a7..de61372ed 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -2,20 +2,14 @@ import http from 'node:http' import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' import { BatchInterceptor, RequestController } from '@mswjs/interceptors' -import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import nodeInterceptors from '@mswjs/interceptors/presets/node' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' const server = getTestServer() const interceptor = new BatchInterceptor({ name: 'batch-interceptor', - interceptors: [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], + interceptors: nodeInterceptors, }) beforeAll(() => { diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 14fc92869..87702d9ad 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -1,10 +1,7 @@ // @vitest-environment happy-dom import https from 'node:https' -import { DeferredPromise } from '@open-draft/deferred-promise' import { BatchInterceptor, HttpRequestEventMap } from '@mswjs/interceptors' -import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import nodeInterceptors from '@mswjs/interceptors/presets/node' import { toWebResponse } from '#/test/helpers' import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' import { getTestServer } from '#/test/setup/vitest' @@ -12,11 +9,7 @@ import { getTestServer } from '#/test/setup/vitest' const server = getTestServer() const interceptor = new BatchInterceptor({ name: 'batch-interceptor', - interceptors: [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], + interceptors: nodeInterceptors, }) beforeAll(() => { @@ -34,7 +27,7 @@ afterAll(() => { }) it('ClientRequest: emits the "response" event for a mocked response', async () => { - interceptor.on('request', ({ controller }) => { + interceptor.on('request', ({ request, controller }) => { controller.respondWith( new Response('mocked-response-text', { statusText: 'OK', @@ -59,10 +52,10 @@ it('ClientRequest: emits the "response" event for a mocked response', async () = const [response] = await toWebResponse(req) // Must receive a mocked response. - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') - expect(responseListener).toHaveBeenCalledOnce() + await expect.poll(() => responseListener).toHaveBeenCalledOnce() { const [{ response, request, responseType }] = responseListener.mock.calls[0] @@ -248,6 +241,17 @@ it('XMLHttpRequest: emits the "response" event upon the original response', asyn it('fetch: emits the "response" event upon a mocked response', async () => { interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'x-request-custom', + }, + }) + ) + } + controller.respondWith( new Response('mocked-response-text', { statusText: 'OK', @@ -267,23 +271,40 @@ it('fetch: emits the "response" event upon a mocked response', async () => { }, }) - await expect.poll(() => responseListener).toHaveBeenCalledTimes(1) + await expect.poll(() => responseListener).toHaveBeenCalledTimes(2) - const [{ response, request, responseType }] = responseListener.mock.calls[0] + { + const [{ response, request, responseType }] = responseListener.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(server.https.url('/user').href) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(request.method).toBe('OPTIONS') + expect(request.url).toBe(server.https.url('/user').href) + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('') - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('mocked') - await expect(response.text()).resolves.toBe('mocked-response-text') + expect(response.status).toBe(200) + expect(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('') + + expect(responseType).toBe('mock') + } - expect(responseType).toBe('mock') + { + const [{ response, request, responseType }] = responseListener.mock.calls[1] + + expect(request.method).toBe('GET') + expect(request.url).toBe(server.https.url('/user').href) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-response-type')).toBe('mocked') + await expect(response.text()).resolves.toBe('mocked-response-text') + + expect(responseType).toBe('mock') + } }) it( @@ -342,7 +363,18 @@ it( ) it('supports reading the request and response bodies in the "response" listener', async () => { - interceptor.on('request', ({ controller }) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'x-request-custom', + }, + }) + ) + } + controller.respondWith( new Response('mocked-response-text', { statusText: 'OK', @@ -366,10 +398,13 @@ it('supports reading the request and response bodies in the "response" listener' body: 'request-body', }) - await expect - .poll(() => requestCallback) - .toHaveBeenCalledExactlyOnceWith('request-body') - await expect - .poll(() => responseCallback) - .toHaveBeenCalledExactlyOnceWith('mocked-response-text') + await expect.poll(() => requestCallback).toHaveReturnedTimes(2) + + expect(requestCallback).toHaveBeenNthCalledWith(1, '') + expect(requestCallback).toHaveBeenNthCalledWith(2, 'request-body') + + await expect.poll(() => responseCallback).toHaveReturnedTimes(2) + + expect(responseCallback).toHaveBeenNthCalledWith(1, '') + expect(responseCallback).toHaveBeenNthCalledWith(2, 'mocked-response-text') }) diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index 3dcc2cf77..6b60f8a6c 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -2,18 +2,12 @@ import http from 'node:http' import https from 'node:https' import { BatchInterceptor } from '@mswjs/interceptors' -import { HttpRequestInterceptor } from '@mswjs/interceptors/http' -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import nodeInterceptors from '@mswjs/interceptors/presets/node' import { toWebResponse } from '#/test/helpers' const interceptor = new BatchInterceptor({ name: 'interceptor', - interceptors: [ - new HttpRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], + interceptors: nodeInterceptors, }) beforeAll(() => { diff --git a/tsdown.config.mts b/tsdown.config.mts index 4d074e20c..0fd97dd20 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -10,7 +10,7 @@ export default defineConfig([ './src/interceptors/http/index.ts', './src/interceptors/ClientRequest/index.ts', './src/interceptors/XMLHttpRequest/node.ts', - './src/interceptors/fetch/index.ts', + './src/interceptors/fetch/node.ts', ], external: ['_http_common'], outDir: './lib/node', From 794d330945ced2fb08c1ec243a0ccdeba9f5f1c8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 May 2026 20:46:12 +0200 Subject: [PATCH 183/198] chore: adjust `BatchInterceptor` expectations --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- src/BatchInterceptor.test.ts | 31 +++++++++++++++---------------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 6fde6be77..a5ca33c5e 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "es-toolkit": "^1.44.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", - "rettime": "^0.11.10" + "rettime": "^0.11.11" }, "resolutions": { "memfs": "^3.4.13" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48189ff9b..f52cf8f1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^1.4.3 version: 1.4.3 rettime: - specifier: ^0.11.10 - version: 0.11.10 + specifier: ^0.11.11 + version: 0.11.11 devDependencies: '@commitlint/cli': specifier: ^19.7.1 @@ -2667,8 +2667,8 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - rettime@0.11.10: - resolution: {integrity: sha512-zzgUfAF20wZtfDs72M15qiX6EutHxgEZ1PAEJUsWQsOi4aOdcK8BJ3/WSDHBhJ7P27fQjd6iOE1+CWIO8t3XOA==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -5910,7 +5910,7 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - rettime@0.11.10: {} + rettime@0.11.11: {} rfdc@1.4.1: {} diff --git a/src/BatchInterceptor.test.ts b/src/BatchInterceptor.test.ts index 850e0ee6b..d8c777c88 100644 --- a/src/BatchInterceptor.test.ts +++ b/src/BatchInterceptor.test.ts @@ -147,12 +147,15 @@ it('forwards listeners added via "on()"', () => { const listener = vi.fn() interceptor.on('foo', listener) - expect(firstInterceptor['emitter'].listenerCount('*')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('*')).toBe(1) - expect(interceptor['emitter'].listenerCount('foo')).toBe(1) + expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(1) + expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(1) + expect( + interceptor['emitter'].listenerCount('foo'), + 'Does not add the listener onto the batch interceptor' + ).toBe(0) }) -it('forwards listeners removal via "off()"', () => { +it('forwards listeners removal via "removeListener()"', () => { type Events = { foo: [] } @@ -218,13 +221,11 @@ it('forwards removal of all listeners by name via ".removeAllListeners()"', () = interceptor.on('foo', listener) interceptor.on('bar', listener) - expect(firstInterceptor['emitter'].listenerCount('*')).toBe(1) - expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0) - expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(0) + expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(2) + expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('*')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0) - expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(0) + expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2) + expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1) interceptor.removeAllListeners('foo') @@ -258,12 +259,10 @@ it('forwards removal of all listeners via ".removeAllListeners()"', () => { interceptor.on('foo', listener) interceptor.on('bar', listener) - expect(firstInterceptor['emitter'].listenerCount('*')).toBe(1) - expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0) - expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(0) - expect(secondInterceptor['emitter'].listenerCount('*')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0) - expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(0) + expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(2) + expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1) + expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2) + expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1) interceptor.removeAllListeners() From ebe222c6c98540c77608194e599c810070106d2a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 May 2026 20:59:02 +0200 Subject: [PATCH 184/198] chore: add `fetch/web` --- fetch/package.json | 12 +++++------ package.json | 21 +++++-------------- src/interceptors/fetch/{index.ts => web.ts} | 0 src/presets/browser.ts | 2 +- .../fetch/compliance/abort-conrtoller.test.ts | 2 +- .../compliance/fetch-follow-redirects.test.ts | 2 +- .../fetch-request-body-used.test.ts | 2 +- .../compliance/fetch-response-error.test.ts | 2 +- .../compliance/fetch-response-headers.test.ts | 2 +- .../fetch-response-non-configurable.test.ts | 2 +- .../compliance/fetch-response-url.test.ts | 2 +- .../response-content-encoding.browser.test.ts | 2 +- .../response-content-encoding.test.ts | 2 +- .../fetch/fetch-exception.browser.test.ts | 2 +- test/modules/fetch/fetch-exception.test.ts | 2 +- .../fetch/fetch-request-controller.test.ts | 2 +- .../intercept/fetch-relative-url.test.ts | 6 ++---- .../fetch/intercept/fetch.request.test.ts | 2 +- test/modules/fetch/intercept/fetch.test.ts | 2 +- .../fetch-await-response-event.test.ts | 2 +- .../fetch-response-patching.browser.test.ts | 2 +- .../fetch/response/fetch.browser.test.ts | 2 +- tsdown.config.mts | 4 ++-- 23 files changed, 33 insertions(+), 46 deletions(-) rename src/interceptors/fetch/{index.ts => web.ts} (100%) diff --git a/fetch/package.json b/fetch/package.json index 5c7a419e9..a55695a08 100644 --- a/fetch/package.json +++ b/fetch/package.json @@ -1,12 +1,12 @@ { - "main": "../lib/node/interceptors/fetch/index.cjs", - "module": "../lib/node/interceptors/fetch/index.mjs", - "browser": "../lib/browser/interceptors/fetch/index.mjs", + "main": "../lib/node/interceptors/fetch/node.cjs", + "module": "../lib/node/interceptors/fetch/node.mjs", + "browser": "../lib/browser/interceptors/fetch/web.mjs", "exports": { ".": { - "browser": "./../lib/browser/interceptors/fetch/index.mjs", - "import": "./../lib/node/interceptors/fetch/index.mjs", - "default": "./../lib/node/interceptors/fetch/index.cjs" + "browser": "./../lib/browser/interceptors/fetch/web.mjs", + "import": "./../lib/node/interceptors/fetch/node.mjs", + "default": "./../lib/node/interceptors/fetch/node.cjs" } } } diff --git a/package.json b/package.json index a5ca33c5e..dbfede6b7 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,8 @@ "types": "./lib/node/index.d.cts", "exports": { ".": { - "browser": { - "import": "./lib/browser/index.mjs", - "default": "./lib/browser/index.cjs" - }, - "import": "./lib/node/index.mjs", - "default": "./lib/node/index.cjs" + "browser": "./lib/browser/index.mjs", + "default": "./lib/node/index.mjs" }, "./http": { "node": { @@ -45,21 +41,14 @@ "import": "./lib/node/interceptors/XMLHttpRequest/node.mjs", "default": "./lib/node/interceptors/XMLHttpRequest/node.cjs" }, - "./XMLHttpRequest/web": { - "import": "./lib/browser/interceptors/XMLHttpRequest/web.mjs", - "default": "./lib/browser/interceptors/XMLHttpRequest/web.cjs" - }, + "./XMLHttpRequest/web": "./lib/browser/interceptors/XMLHttpRequest/web.mjs", "./fetch": { - "browser": "./lib/browser/interceptors/fetch/index.mjs", + "browser": "./lib/browser/interceptors/fetch/web.mjs", "require": "./lib/node/interceptors/fetch/node.cjs", "import": "./lib/node/interceptors/fetch/node.mjs", "default": "./lib/node/interceptors/fetch/node.cjs" }, - "./WebSocket": { - "require": "./lib/browser/interceptors/WebSocket/index.cjs", - "import": "./lib/browser/interceptors/WebSocket/index.mjs", - "default": "./lib/browser/interceptors/WebSocket/index.cjs" - }, + "./WebSocket": "./lib/browser/interceptors/WebSocket/index.mjs", "./RemoteHttpInterceptor": { "node": { "require": "./lib/node/RemoteHttpInterceptor.cjs", diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/web.ts similarity index 100% rename from src/interceptors/fetch/index.ts rename to src/interceptors/fetch/web.ts diff --git a/src/presets/browser.ts b/src/presets/browser.ts index 9d7fe2d3a..26827a086 100644 --- a/src/presets/browser.ts +++ b/src/presets/browser.ts @@ -1,4 +1,4 @@ -import { FetchInterceptor } from '../interceptors/fetch' +import { FetchInterceptor } from '../interceptors/fetch/web' import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest/web' /** diff --git a/test/modules/fetch/compliance/abort-conrtoller.test.ts b/test/modules/fetch/compliance/abort-conrtoller.test.ts index 95925849c..b63d745c0 100644 --- a/test/modules/fetch/compliance/abort-conrtoller.test.ts +++ b/test/modules/fetch/compliance/abort-conrtoller.test.ts @@ -2,7 +2,7 @@ import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { diff --git a/test/modules/fetch/compliance/fetch-follow-redirects.test.ts b/test/modules/fetch/compliance/fetch-follow-redirects.test.ts index 86e9d7d3a..cc75432b7 100644 --- a/test/modules/fetch/compliance/fetch-follow-redirects.test.ts +++ b/test/modules/fetch/compliance/fetch-follow-redirects.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-request-body-used.test.ts b/test/modules/fetch/compliance/fetch-request-body-used.test.ts index 221613a99..2be2be2c9 100644 --- a/test/modules/fetch/compliance/fetch-request-body-used.test.ts +++ b/test/modules/fetch/compliance/fetch-request-body-used.test.ts @@ -1,7 +1,7 @@ import * as express from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-error.test.ts b/test/modules/fetch/compliance/fetch-response-error.test.ts index 4ee272fd7..50cd34b23 100644 --- a/test/modules/fetch/compliance/fetch-response-error.test.ts +++ b/test/modules/fetch/compliance/fetch-response-error.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-headers.test.ts b/test/modules/fetch/compliance/fetch-response-headers.test.ts index 8da2e6946..ee9a6adac 100644 --- a/test/modules/fetch/compliance/fetch-response-headers.test.ts +++ b/test/modules/fetch/compliance/fetch-response-headers.test.ts @@ -1,4 +1,4 @@ -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts index 054bab99a..ed1fb4d3c 100644 --- a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts +++ b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' import { FetchResponse } from '#/src/utils/fetchUtils' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-url.test.ts b/test/modules/fetch/compliance/fetch-response-url.test.ts index def391b58..4386e0d4f 100644 --- a/test/modules/fetch/compliance/fetch-response-url.test.ts +++ b/test/modules/fetch/compliance/fetch-response-url.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/response-content-encoding.browser.test.ts b/test/modules/fetch/compliance/response-content-encoding.browser.test.ts index 63df081e1..759e3634f 100644 --- a/test/modules/fetch/compliance/response-content-encoding.browser.test.ts +++ b/test/modules/fetch/compliance/response-content-encoding.browser.test.ts @@ -2,7 +2,7 @@ import { HttpServer } from '@open-draft/test-server/http' import { test, expect } from '../../../playwright.extend' import { compressResponse, useCors } from '#/test/helpers' import { parseContentEncoding } from '#/src/interceptors/fetch/utils/decompression' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' declare namespace window { const interceptor: FetchInterceptor diff --git a/test/modules/fetch/compliance/response-content-encoding.test.ts b/test/modules/fetch/compliance/response-content-encoding.test.ts index 720f51e12..467604bde 100644 --- a/test/modules/fetch/compliance/response-content-encoding.test.ts +++ b/test/modules/fetch/compliance/response-content-encoding.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' import { compressResponse } from '#/test/helpers' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' import { parseContentEncoding } from '#/src/interceptors/fetch/utils/decompression' const httpServer = new HttpServer((app) => { diff --git a/test/modules/fetch/fetch-exception.browser.test.ts b/test/modules/fetch/fetch-exception.browser.test.ts index d6a53c48e..5ac9b8ac3 100644 --- a/test/modules/fetch/fetch-exception.browser.test.ts +++ b/test/modules/fetch/fetch-exception.browser.test.ts @@ -1,4 +1,4 @@ -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' import { test, expect } from '../../playwright.extend' declare global { diff --git a/test/modules/fetch/fetch-exception.test.ts b/test/modules/fetch/fetch-exception.test.ts index f1ef6c149..f19fa7f37 100644 --- a/test/modules/fetch/fetch-exception.test.ts +++ b/test/modules/fetch/fetch-exception.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/fetch-request-controller.test.ts b/test/modules/fetch/fetch-request-controller.test.ts index dda4fecc2..446643ba3 100644 --- a/test/modules/fetch/fetch-request-controller.test.ts +++ b/test/modules/fetch/fetch-request-controller.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { DeferredPromise } from '@open-draft/deferred-promise' import { InterceptorError } from '#/src/InterceptorError' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/intercept/fetch-relative-url.test.ts b/test/modules/fetch/intercept/fetch-relative-url.test.ts index 21a7ceec4..64cdeb6a1 100644 --- a/test/modules/fetch/intercept/fetch-relative-url.test.ts +++ b/test/modules/fetch/intercept/fetch-relative-url.test.ts @@ -1,7 +1,5 @@ -/** - * @vitest-environment jsdom - */ -import { FetchInterceptor } from '#/src/interceptors/fetch' +// @vitest-environment jsdom +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/intercept/fetch.request.test.ts b/test/modules/fetch/intercept/fetch.request.test.ts index 90e5ae9e1..0aea24790 100644 --- a/test/modules/fetch/intercept/fetch.request.test.ts +++ b/test/modules/fetch/intercept/fetch.request.test.ts @@ -2,7 +2,7 @@ import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestEventMap } from '#/src/index' import { REQUEST_ID_REGEXP } from '#/test/helpers' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' import { RequestController } from '#/src/RequestController' const httpServer = new HttpServer((app) => { diff --git a/test/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index 56da30280..4226aa5a1 100644 --- a/test/modules/fetch/intercept/fetch.test.ts +++ b/test/modules/fetch/intercept/fetch.test.ts @@ -3,7 +3,7 @@ import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestEventMap } from '#/src/index' import { REQUEST_ID_REGEXP } from '#/test/helpers' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' import { encodeBuffer } from '#/src/utils/bufferUtils' import { RequestController } from '#/src/RequestController' diff --git a/test/modules/fetch/response/fetch-await-response-event.test.ts b/test/modules/fetch/response/fetch-await-response-event.test.ts index 18508fead..6df4a7686 100644 --- a/test/modules/fetch/response/fetch-await-response-event.test.ts +++ b/test/modules/fetch/response/fetch-await-response-event.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { diff --git a/test/modules/fetch/response/fetch-response-patching.browser.test.ts b/test/modules/fetch/response/fetch-response-patching.browser.test.ts index f18fd8a18..dff5cbcad 100644 --- a/test/modules/fetch/response/fetch-response-patching.browser.test.ts +++ b/test/modules/fetch/response/fetch-response-patching.browser.test.ts @@ -1,7 +1,7 @@ import { HttpServer } from '@open-draft/test-server/http' import { Page } from '@playwright/test' import { test, expect } from '../../../playwright.extend' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' import { useCors } from '#/test/helpers' declare namespace window { diff --git a/test/modules/fetch/response/fetch.browser.test.ts b/test/modules/fetch/response/fetch.browser.test.ts index ba6b560b3..dd111e786 100644 --- a/test/modules/fetch/response/fetch.browser.test.ts +++ b/test/modules/fetch/response/fetch.browser.test.ts @@ -2,7 +2,7 @@ import { HttpServer } from '@open-draft/test-server/http' import { Page } from '@playwright/test' import { test, expect } from '../../../playwright.extend' import { useCors } from '#/test/helpers' -import { FetchInterceptor } from '#/src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' declare namespace window { export const interceptor: FetchInterceptor diff --git a/tsdown.config.mts b/tsdown.config.mts index 0fd97dd20..1757eaac7 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -31,13 +31,13 @@ export default defineConfig([ './src/index.ts', './src/presets/browser.ts', './src/interceptors/XMLHttpRequest/web.ts', - './src/interceptors/fetch/index.ts', + './src/interceptors/fetch/web.ts', './src/interceptors/WebSocket/index.ts', ], outDir: './lib/browser', platform: 'browser', target: 'chrome120', - format: ['cjs', 'esm'], + format: ['esm'], outExtensions: (context) => ({ js: context.format === 'cjs' ? '.cjs' : '.mjs', dts: context.format === 'cjs' ? '.d.cts' : '.d.mts', From 3cbf8a8db4ae5025b0540bea696e67aa5146433f Mon Sep 17 00:00:00 2001 From: Michael Solomon Date: Wed, 6 May 2026 13:45:03 +0300 Subject: [PATCH 185/198] feat: use `llhttp` parser (#781) Co-authored-by: Artem Zakharchenko --- _http_common.d.ts | 78 -- src/interceptors/http/http-parser.ts | 168 +-- src/interceptors/http/http-parser/index.ts | 302 ++++++ .../http/http-parser/llhttp/constants.d.ts | 957 ++++++++++++++++++ .../http/http-parser/llhttp/constants.js | 485 +++++++++ .../http/http-parser/llhttp/llhttp.wasm | Bin 0 -> 61045 bytes .../http/compliance/http-socket-reuse.test.ts | 1 + tsconfig.base.json | 1 - tsconfig.src.json | 1 - tsdown.config.mts | 5 +- 10 files changed, 1776 insertions(+), 222 deletions(-) delete mode 100644 _http_common.d.ts create mode 100644 src/interceptors/http/http-parser/index.ts create mode 100644 src/interceptors/http/http-parser/llhttp/constants.d.ts create mode 100644 src/interceptors/http/http-parser/llhttp/constants.js create mode 100755 src/interceptors/http/http-parser/llhttp/llhttp.wasm diff --git a/_http_common.d.ts b/_http_common.d.ts deleted file mode 100644 index 476227de8..000000000 --- a/_http_common.d.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { Socket } from 'node:net' -import type { IncomingMessage, OutgoingMessage } from 'node:http' - -declare var methods: Array - -declare var HTTPParser: { - new (): HTTPParser - REQUEST: 0 - RESPONSE: 1 - - /** - * @see https://github.com/nodejs/node/blob/229cc3be28eab3153c16bc55bc67d1e81c4a7067/src/node_http_parser.cc#L76 - */ - readonly kOnMessageBegin: unique symbol - readonly kOnHeaders: unique symbol - readonly kOnHeadersComplete: unique symbol - readonly kOnBody: unique symbol - readonly kOnMessageComplete: unique symbol - readonly kOnExecute: unique symbol - readonly kOnTimeout: unique symbol -} - -export interface HTTPParser { - new (): HTTPParser - - [HTTPParser.kOnMessageBegin]?: (() => void) | null - [HTTPParser.kOnHeaders]?: HeadersCallback - [HTTPParser.kOnHeadersComplete]?: ParserType extends 0 - ? RequestHeadersCompleteCallback | null - : ResponseHeadersCompleteCallback | null - [HTTPParser.kOnBody]?: ((chunk: Buffer) => void) | null - [HTTPParser.kOnMessageComplete]?: (() => void) | null - [HTTPParser.kOnExecute]?: (() => void) | null - [HTTPParser.kOnTimeout]?: (() => void) | null - - initialize(type: ParserType, asyncResource: object): void - execute(buffer: Buffer): void - _url: string - _headers?: Array - _consumed?: boolean - maxHeaderPairs: number - socket?: Socket | null - incoming?: IncomingMessage | null - outgoing?: OutgoingMessage | null - onIncoming?: (() => void) | null - joinDuplicateHeaders?: unknown - finish(): void - unconsume(): void - remove(): void - close(): void - free(): void -} - -export type HeadersCallback = (rawHeaders: Array, url: string) => void - -export type RequestHeadersCompleteCallback = ( - versionMajor: number, - versionMinor: number, - rawHeaders: Array, - idk: number, - path: string, - idk2: unknown, - idk3: unknown, - idk4: unknown, - shouldKeepAlive: boolean -) => void - -export type ResponseHeadersCompleteCallback = ( - versionMajor: number, - versionMinor: number, - headers: Array, - method: string | undefined, - url: string | undefined, - status: number, - statusText: string, - upgrade: boolean, - shouldKeepAlive: boolean -) => void diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index 6a0059b01..48283b3a6 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -1,81 +1,7 @@ -import { - methods as HTTP_METHODS, - HTTPParser, - type HeadersCallback, - type RequestHeadersCompleteCallback, - type ResponseHeadersCompleteCallback, -} from '_http_common' -import net from 'node:net' import { Readable } from 'node:stream' import { invariant } from 'outvariant' import { FetchRequest, FetchResponse } from '../../utils/fetchUtils' - -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 -} - -class HttpParser { - static REQUEST = HTTPParser.REQUEST - static RESPONSE = HTTPParser.RESPONSE - - #parser: HTTPParser - - constructor(kind: ParserKind, hooks: ParserHooks) { - this.#parser = new HTTPParser() - this.#parser.initialize(kind, {}) - - this.#parser[HTTPParser.kOnMessageBegin] = hooks.onMessageBegin - this.#parser[HTTPParser.kOnHeaders] = hooks.onHeaders - this.#parser[HTTPParser.kOnHeadersComplete] = hooks.onHeadersComplete - this.#parser[HTTPParser.kOnBody] = hooks.onBody - this.#parser[HTTPParser.kOnMessageComplete] = hooks.onMessageComplete - this.#parser[HTTPParser.kOnExecute] = hooks.onExecute - this.#parser[HTTPParser.kOnTimeout] = hooks.onTimeout - } - - public execute(data: Buffer): void { - this.#parser.execute(data) - } - - /** - * @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) { - Reflect.set(socket, 'parser', null) - } - } -} +import { HttpParser } from './http-parser/index' interface HttpRequestParserOptions { connectionOptions: { @@ -85,56 +11,35 @@ interface HttpRequestParserOptions { onRequest: (request: Request) => void } -export class HttpRequestParser extends HttpParser { - #rawHeadersBuffer: Array +export class HttpRequestParser extends HttpParser<1> { #requestBodyStream?: Readable constructor(options: HttpRequestParserOptions) { - super(HttpParser.REQUEST, { - onHeaders: (rawHeaders) => { - this.#rawHeadersBuffer.push(...rawHeaders) - }, - onHeadersComplete: ( - _, - __, - rawHeaders = [], - rawMethod, - path, - ____, - _____, - ______, - shouldKeepAlive - ) => { + super(1, { + onHeadersComplete: ({ rawHeaders, method, url: path }) => { /** * @note When the socket is reused, "connectionOptions" will point * to the "net.connect()" call options that established the connection, * which may differ from the description of the current request (e.g. method). * Rely on the HTTPParser supplying us with the correct "rawMethod" number. */ - const resolvedMethod = - (typeof rawMethod === 'string' - ? rawMethod - : typeof rawMethod === 'number' - ? HTTP_METHODS[rawMethod] - : options.connectionOptions.method) || + const finalMethod = ( + method || options.connectionOptions.method || 'GET' - const finalMethod = resolvedMethod.toUpperCase() + ).toUpperCase() const url = new URL(path || '', options.connectionOptions.url) - const headers = FetchResponse.parseRawHeaders([ - ...this.#rawHeadersBuffer, - ...rawHeaders, - ]) + const headers = FetchResponse.parseRawHeaders([...rawHeaders]) // 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}` - ) + const credentials = Buffer.from( + `${url.username}:${url.password}` + ).toString('base64') + headers.set('authorization', `Basic ${credentials}`) } url.username = '' url.password = '' @@ -154,7 +59,6 @@ export class HttpRequestParser extends HttpParser { credentials: 'same-origin', body: Readable.toWeb(this.#requestBodyStream) as any, }) - options.onRequest(request) }, onBody: (chunk) => { @@ -166,52 +70,37 @@ export class HttpRequestParser extends HttpParser { this.#requestBodyStream.push(chunk) }, onMessageComplete: () => { - this.#rawHeadersBuffer.length = 0 this.#requestBodyStream?.push(null) }, }) - - this.#rawHeadersBuffer = [] } - public free(socket?: net.Socket): void { - super.free(socket) - this.#rawHeadersBuffer.length = 0 + public free(): void { + this.destroy() + this.#requestBodyStream?.destroy() this.#requestBodyStream = undefined } } -export class HttpResponseParser extends HttpParser { - #responseRawHeadersBuffer: Array +export class HttpResponseParser extends HttpParser<2> { #responseBodyStream?: Readable | null constructor(options: { onResponse: (response: Response) => void }) { - super(HttpParser.RESPONSE, { - onHeaders: (rawHeaders) => { - this.#responseRawHeadersBuffer.push(...rawHeaders) - }, - onHeadersComplete: ( - versionMajor, - versionMinor, + super(2, { + onHeadersComplete: ({ rawHeaders, - method, - url, - status, - statusText - ) => { - const headers = FetchResponse.parseRawHeaders([ - ...this.#responseRawHeadersBuffer, - ...(rawHeaders || []), - ]) - - this.#responseBodyStream = new Readable({ read() {} }) + statusCode: status, + statusMessage: statusText, + }) => { + const headers = FetchResponse.parseRawHeaders([...rawHeaders]) const response = new FetchResponse( FetchResponse.isResponseWithBody(status) - ? (Readable.toWeb(this.#responseBodyStream) as any) + ? (Readable.toWeb( + (this.#responseBodyStream = new Readable({ read() {} })) + ) as any) : null, { - url, status, statusText, headers, @@ -232,13 +121,10 @@ export class HttpResponseParser extends HttpParser { this.#responseBodyStream?.push(null) }, }) - - this.#responseRawHeadersBuffer = [] } - public free(socket?: net.Socket): void { - super.free(socket) - this.#responseRawHeadersBuffer = [] + public free(): void { + this.destroy() this.#responseBodyStream = null } } diff --git a/src/interceptors/http/http-parser/index.ts b/src/interceptors/http/http-parser/index.ts new file mode 100644 index 000000000..07bdafa8d --- /dev/null +++ b/src/interceptors/http/http-parser/index.ts @@ -0,0 +1,302 @@ +import fs from 'node:fs' +import * as constants from './llhttp/constants.js' + +export { constants } + +export interface RequestHeadersComplete { + versionMajor: number + versionMinor: number + rawHeaders: Array + method: string + url: string + upgrade: boolean + shouldKeepAlive: boolean +} + +export interface ResponseHeadersComplete { + versionMajor: number + versionMinor: number + rawHeaders: Array + statusCode: number + statusMessage: string + upgrade: boolean + shouldKeepAlive: boolean +} + +const KIND_REQUEST = constants.TYPE.REQUEST +const KIND_RESPONSE = constants.TYPE.RESPONSE + +type ParserKind = typeof KIND_REQUEST | typeof KIND_RESPONSE + +export interface ParserCallbacks { + onMessageBegin?: () => number | void + onHeadersComplete?: ( + info: K extends typeof KIND_REQUEST + ? RequestHeadersComplete + : ResponseHeadersComplete + ) => number | void + onBody?: (body: Buffer) => number | void + onMessageComplete?: () => number | void +} + +export type RequestParserCallbacks = ParserCallbacks +export type ResponseParserCallbacks = ParserCallbacks + +const kPointer = Symbol('kPtr') +const kUrl = Symbol('kUrl') +const kStatusMessage = Symbol('kStatusMessage') +const kHeadersFields = Symbol('kHeadersFields') +const kHeadersValues = Symbol('kHeadersValues') +const kLastHeaderCallback = Symbol('kLastHeaderCallback') +const kCallbacks = Symbol('kCallbacks') +const kType = Symbol('kType') + +const HEADER_CB_NONE = 0 +const HEADER_CB_FIELD = 1 +const HEADER_CB_VALUE = 2 + +const parsersMap = new Map>() + +const methodNames = Object.fromEntries( + Object.entries(constants.METHODS).map(([name, num]) => [num, name]) +) as Record + +function readStringFrom(pointer: number, length: number): string { + return Buffer.from(llhttp_memory.buffer, pointer, length).toString('latin1') +} + +const llhttpModule = new WebAssembly.Module( + fs.readFileSync(__dirname + '/llhttp/llhttp.wasm') +) + +const llhttpInstance = new WebAssembly.Instance(llhttpModule, { + env: { + wasm_on_message_begin(parserPointer: number) { + const parser = parsersMap.get(parserPointer)! + parser[kUrl] = '' + parser[kStatusMessage] = '' + parser[kHeadersFields] = [] + parser[kHeadersValues] = [] + parser[kLastHeaderCallback] = HEADER_CB_NONE + return parser[kCallbacks].onMessageBegin?.() ?? 0 + }, + // Request only + wasm_on_url(parserPointer: number, at: number, length: number) { + parsersMap.get(parserPointer)![kUrl] = readStringFrom(at, length) + return 0 + }, + // Response only + wasm_on_status(parserPointer: number, at: number, length: number) { + parsersMap.get(parserPointer)![kStatusMessage] = readStringFrom( + at, + length + ) + return 0 + }, + wasm_on_header_field(parserPointer: number, at: number, length: number) { + const parser = parsersMap.get(parserPointer)! + const chunk = readStringFrom(at, length) + const fields = parser[kHeadersFields] + + // llhttp emits header field/value across multiple callbacks when the + // bytes span buffer boundaries. Concatenate consecutive same-type + // callbacks into a single entry; switch entries on field<->value transitions. + if (parser[kLastHeaderCallback] === HEADER_CB_FIELD) { + fields[fields.length - 1] += chunk + } else { + fields.push(chunk) + parser[kLastHeaderCallback] = HEADER_CB_FIELD + } + return 0 + }, + wasm_on_header_value(parserPointer: number, at: number, length: number) { + const parser = parsersMap.get(parserPointer)! + const chunk = readStringFrom(at, length) + const values = parser[kHeadersValues] + + if (parser[kLastHeaderCallback] === HEADER_CB_VALUE) { + values[values.length - 1] += chunk + } else { + values.push(chunk) + parser[kLastHeaderCallback] = HEADER_CB_VALUE + } + return 0 + }, + wasm_on_headers_complete( + parserPointer: number, + statusCode: number, + rawUpgrade: number, + rawShouldKeepAlive: number + ) { + const parser = parsersMap.get(parserPointer)! + const versionMajor = llhttp_get_version_major(parserPointer) + const versionMinor = llhttp_get_version_minor(parserPointer) + const rawHeaders: Array = [] + const upgrade = rawUpgrade === 1 + const shouldKeepAlive = rawShouldKeepAlive === 1 + + for (let c = 0; c < parser[kHeadersFields].length; c++) { + rawHeaders.push(parser[kHeadersFields][c]!, parser[kHeadersValues][c]!) + } + + if (parser[kType] === KIND_REQUEST) { + const method = methodNames[llhttp_get_method(parserPointer)] + const url = parser[kUrl] + const callback = parser[kCallbacks] as ParserCallbacks< + typeof KIND_REQUEST + > + + return ( + callback.onHeadersComplete?.({ + versionMajor, + versionMinor, + rawHeaders, + method, + url, + upgrade, + shouldKeepAlive, + }) ?? 0 + ) + } else { + const statusCode = llhttp_get_status_code(parserPointer) as number + const statusMessage = parser[kStatusMessage] + const callback = parser[kCallbacks] as ParserCallbacks< + typeof KIND_RESPONSE + > + + return ( + callback.onHeadersComplete?.({ + versionMajor, + versionMinor, + rawHeaders, + statusCode, + statusMessage, + upgrade, + shouldKeepAlive, + }) ?? 0 + ) + } + }, + wasm_on_body(parserPointer: number, at: number, length: number) { + const parser = parsersMap.get(parserPointer)! + // Create a copy of the body chunk, as the underlying memory buffer is reused by llhttp and can be overwritten on the next callback call. + // Maybe not the most efficient way, but it is simple and safe. + const body = Buffer.from(new Uint8Array(llhttp_memory.buffer, at, length)) + return parser[kCallbacks].onBody?.(body) ?? 0 + }, + wasm_on_message_complete(parserPointer: number) { + return ( + parsersMap.get(parserPointer)![kCallbacks].onMessageComplete?.() ?? 0 + ) + }, + }, +}) + +const llhttp_memory = llhttpInstance.exports.memory as WebAssembly.Memory +const llhttp_alloc = llhttpInstance.exports.llhttp_alloc as CallableFunction +const llhttp_malloc = llhttpInstance.exports.malloc as CallableFunction +const llhttp_execute = llhttpInstance.exports.llhttp_execute as CallableFunction +const llhttp_get_type = llhttpInstance.exports + .llhttp_get_type as CallableFunction +const llhttp_get_upgrade = llhttpInstance.exports + .llhttp_get_upgrade as CallableFunction +const llhttp_should_keep_alive = llhttpInstance.exports + .llhttp_should_keep_alive as CallableFunction +const llhttp_get_method = llhttpInstance.exports + .llhttp_get_method as CallableFunction +const llhttp_get_status_code = llhttpInstance.exports + .llhttp_get_status_code as CallableFunction +const llhttp_get_version_minor = llhttpInstance.exports + .llhttp_get_http_minor as CallableFunction +const llhttp_get_version_major = llhttpInstance.exports + .llhttp_get_http_major as CallableFunction +const llhttp_get_error_reason = llhttpInstance.exports + .llhttp_get_error_reason as CallableFunction +const llhttp_get_error_pos = llhttpInstance.exports + .llhttp_get_error_pos as CallableFunction +const llhttp_free = llhttpInstance.exports.free as CallableFunction + +const initialize = llhttpInstance.exports._initialize as CallableFunction +initialize() // wasi reactor + +export class HttpParser { + [kPointer]: number; + [kUrl]: string = ''; + [kStatusMessage]: string = ''; + [kHeadersFields]: Array = []; + [kHeadersValues]: Array = []; + [kLastHeaderCallback]: number = HEADER_CB_NONE; + [kCallbacks]: ParserCallbacks; + [kType]: ParserKind + + constructor(type: K, callbacks: ParserCallbacks) { + this[kType] = type + this[kCallbacks] = callbacks + + const parserPointer = llhttp_alloc(type) + + if (parserPointer === 0) { + throw new Error('Failed to allocate llhttp parser') + } + + this[kPointer] = parserPointer + parsersMap.set(this[kPointer], this) + } + + destroy() { + // Guard against multiple calls to free/destroy. + if (this[kPointer] === 0) { + return + } + + parsersMap.delete(this[kPointer]) + llhttp_free(this[kPointer]) + this[kPointer] = 0 + } + + execute(data: Buffer) { + const pointer = llhttp_malloc(data.byteLength) + + if (pointer === 0) { + throw new Error('Failed to allocate llhttp input buffer') + } + + let ret: number + try { + const buffer = new Uint8Array(llhttp_memory.buffer) + buffer.set(data, pointer) + ret = llhttp_execute(this[kPointer], pointer, data.byteLength) + } catch (error) { + // Free the input buffer if a user callback threw synchronously, + // otherwise the wasm heap leaks the chunk on every execute() call. + llhttp_free(pointer) + throw error + } + + if (ret === constants.ERROR.PAUSED_UPGRADE) { + // Find how many bytes llhttp consumed + const errorPos = llhttp_get_error_pos(this[kPointer]) + const consumed = errorPos - pointer + llhttp_free(pointer) + // Return the unconsumed trailing bytes (tunnel/protocol data) + return data.subarray(consumed) + } + + llhttp_free(pointer) + this.#checkError(ret) + + return null // fully consumed + } + + #checkError(errorCode: number) { + if (errorCode === constants.ERROR.OK) { + return + } + + const errorPointer = llhttp_get_error_reason(this[kPointer]) + const buffer = new Uint8Array(llhttp_memory.buffer) + const length = buffer.indexOf(0, errorPointer) - errorPointer + + throw new Error(readStringFrom(errorPointer, length)) + } +} diff --git a/src/interceptors/http/http-parser/llhttp/constants.d.ts b/src/interceptors/http/http-parser/llhttp/constants.d.ts new file mode 100644 index 000000000..b5e907f7f --- /dev/null +++ b/src/interceptors/http/http-parser/llhttp/constants.d.ts @@ -0,0 +1,957 @@ +export type IntDict = Readonly>; +type Simplify = T extends any[] | Date ? T : { + [K in keyof T]: T[K]; +} & {}; +export declare const ERROR: { + readonly OK: 0; + readonly INTERNAL: 1; + readonly STRICT: 2; + readonly CR_EXPECTED: 25; + readonly LF_EXPECTED: 3; + readonly UNEXPECTED_CONTENT_LENGTH: 4; + readonly UNEXPECTED_SPACE: 30; + readonly CLOSED_CONNECTION: 5; + readonly INVALID_METHOD: 6; + readonly INVALID_URL: 7; + readonly INVALID_CONSTANT: 8; + readonly INVALID_VERSION: 9; + readonly INVALID_HEADER_TOKEN: 10; + readonly INVALID_CONTENT_LENGTH: 11; + readonly INVALID_CHUNK_SIZE: 12; + readonly INVALID_STATUS: 13; + readonly INVALID_EOF_STATE: 14; + readonly INVALID_TRANSFER_ENCODING: 15; + readonly CB_MESSAGE_BEGIN: 16; + readonly CB_HEADERS_COMPLETE: 17; + readonly CB_MESSAGE_COMPLETE: 18; + readonly CB_CHUNK_HEADER: 19; + readonly CB_CHUNK_COMPLETE: 20; + readonly PAUSED: 21; + readonly PAUSED_UPGRADE: 22; + readonly PAUSED_H2_UPGRADE: 23; + readonly USER: 24; + readonly CB_URL_COMPLETE: 26; + readonly CB_STATUS_COMPLETE: 27; + readonly CB_METHOD_COMPLETE: 32; + readonly CB_VERSION_COMPLETE: 33; + readonly CB_HEADER_FIELD_COMPLETE: 28; + readonly CB_HEADER_VALUE_COMPLETE: 29; + readonly CB_CHUNK_EXTENSION_NAME_COMPLETE: 34; + readonly CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35; + readonly CB_RESET: 31; + readonly CB_PROTOCOL_COMPLETE: 38; +}; +export declare const TYPE: { + readonly BOTH: 0; + readonly REQUEST: 1; + readonly RESPONSE: 2; +}; +export declare const FLAGS: { + readonly CONNECTION_KEEP_ALIVE: number; + readonly CONNECTION_CLOSE: number; + readonly CONNECTION_UPGRADE: number; + readonly CHUNKED: number; + readonly UPGRADE: number; + readonly CONTENT_LENGTH: number; + readonly SKIPBODY: number; + readonly TRAILING: number; + readonly TRANSFER_ENCODING: number; +}; +export declare const LENIENT_FLAGS: { + readonly HEADERS: number; + readonly CHUNKED_LENGTH: number; + readonly KEEP_ALIVE: number; + readonly TRANSFER_ENCODING: number; + readonly VERSION: number; + readonly DATA_AFTER_CLOSE: number; + readonly OPTIONAL_LF_AFTER_CR: number; + readonly OPTIONAL_CRLF_AFTER_CHUNK: number; + readonly OPTIONAL_CR_BEFORE_LF: number; + readonly SPACES_AFTER_CHUNK_SIZE: number; + readonly HEADER_VALUE_RELAXED: number; +}; +export declare const STATUSES: { + readonly CONTINUE: 100; + readonly SWITCHING_PROTOCOLS: 101; + readonly PROCESSING: 102; + readonly EARLY_HINTS: 103; + readonly RESPONSE_IS_STALE: 110; + readonly REVALIDATION_FAILED: 111; + readonly DISCONNECTED_OPERATION: 112; + readonly HEURISTIC_EXPIRATION: 113; + readonly MISCELLANEOUS_WARNING: 199; + readonly OK: 200; + readonly CREATED: 201; + readonly ACCEPTED: 202; + readonly NON_AUTHORITATIVE_INFORMATION: 203; + readonly NO_CONTENT: 204; + readonly RESET_CONTENT: 205; + readonly PARTIAL_CONTENT: 206; + readonly MULTI_STATUS: 207; + readonly ALREADY_REPORTED: 208; + readonly TRANSFORMATION_APPLIED: 214; + readonly IM_USED: 226; + readonly MISCELLANEOUS_PERSISTENT_WARNING: 299; + readonly MULTIPLE_CHOICES: 300; + readonly MOVED_PERMANENTLY: 301; + readonly FOUND: 302; + readonly SEE_OTHER: 303; + readonly NOT_MODIFIED: 304; + readonly USE_PROXY: 305; + readonly SWITCH_PROXY: 306; + readonly TEMPORARY_REDIRECT: 307; + readonly PERMANENT_REDIRECT: 308; + readonly BAD_REQUEST: 400; + readonly UNAUTHORIZED: 401; + readonly PAYMENT_REQUIRED: 402; + readonly FORBIDDEN: 403; + readonly NOT_FOUND: 404; + readonly METHOD_NOT_ALLOWED: 405; + readonly NOT_ACCEPTABLE: 406; + readonly PROXY_AUTHENTICATION_REQUIRED: 407; + readonly REQUEST_TIMEOUT: 408; + readonly CONFLICT: 409; + readonly GONE: 410; + readonly LENGTH_REQUIRED: 411; + readonly PRECONDITION_FAILED: 412; + readonly PAYLOAD_TOO_LARGE: 413; + readonly URI_TOO_LONG: 414; + readonly UNSUPPORTED_MEDIA_TYPE: 415; + readonly RANGE_NOT_SATISFIABLE: 416; + readonly EXPECTATION_FAILED: 417; + readonly IM_A_TEAPOT: 418; + readonly PAGE_EXPIRED: 419; + readonly ENHANCE_YOUR_CALM: 420; + readonly MISDIRECTED_REQUEST: 421; + readonly UNPROCESSABLE_ENTITY: 422; + readonly LOCKED: 423; + readonly FAILED_DEPENDENCY: 424; + readonly TOO_EARLY: 425; + readonly UPGRADE_REQUIRED: 426; + readonly PRECONDITION_REQUIRED: 428; + readonly TOO_MANY_REQUESTS: 429; + readonly REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430; + readonly REQUEST_HEADER_FIELDS_TOO_LARGE: 431; + readonly LOGIN_TIMEOUT: 440; + readonly NO_RESPONSE: 444; + readonly RETRY_WITH: 449; + readonly BLOCKED_BY_PARENTAL_CONTROL: 450; + readonly UNAVAILABLE_FOR_LEGAL_REASONS: 451; + readonly CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460; + readonly INVALID_X_FORWARDED_FOR: 463; + readonly REQUEST_HEADER_TOO_LARGE: 494; + readonly SSL_CERTIFICATE_ERROR: 495; + readonly SSL_CERTIFICATE_REQUIRED: 496; + readonly HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497; + readonly INVALID_TOKEN: 498; + readonly CLIENT_CLOSED_REQUEST: 499; + readonly INTERNAL_SERVER_ERROR: 500; + readonly NOT_IMPLEMENTED: 501; + readonly BAD_GATEWAY: 502; + readonly SERVICE_UNAVAILABLE: 503; + readonly GATEWAY_TIMEOUT: 504; + readonly HTTP_VERSION_NOT_SUPPORTED: 505; + readonly VARIANT_ALSO_NEGOTIATES: 506; + readonly INSUFFICIENT_STORAGE: 507; + readonly LOOP_DETECTED: 508; + readonly BANDWIDTH_LIMIT_EXCEEDED: 509; + readonly NOT_EXTENDED: 510; + readonly NETWORK_AUTHENTICATION_REQUIRED: 511; + readonly WEB_SERVER_UNKNOWN_ERROR: 520; + readonly WEB_SERVER_IS_DOWN: 521; + readonly CONNECTION_TIMEOUT: 522; + readonly ORIGIN_IS_UNREACHABLE: 523; + readonly TIMEOUT_OCCURED: 524; + readonly SSL_HANDSHAKE_FAILED: 525; + readonly INVALID_SSL_CERTIFICATE: 526; + readonly RAILGUN_ERROR: 527; + readonly SITE_IS_OVERLOADED: 529; + readonly SITE_IS_FROZEN: 530; + readonly IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561; + readonly NETWORK_READ_TIMEOUT: 598; + readonly NETWORK_CONNECT_TIMEOUT: 599; +}; +export declare const FINISH: { + readonly SAFE: 0; + readonly SAFE_WITH_CB: 1; + readonly UNSAFE: 2; +}; +export declare const HEADER_STATE: { + readonly GENERAL: 0; + readonly CONNECTION: 1; + readonly CONTENT_LENGTH: 2; + readonly TRANSFER_ENCODING: 3; + readonly UPGRADE: 4; + readonly CONNECTION_KEEP_ALIVE: 5; + readonly CONNECTION_CLOSE: 6; + readonly CONNECTION_UPGRADE: 7; + readonly TRANSFER_ENCODING_CHUNKED: 8; +}; +export declare const METHODS_HTTP1_HEAD: { + readonly HEAD: 2; +}; +/** + * HTTP methods as defined by RFC-9110 and other specifications. + * @see https://httpwg.org/specs/rfc9110.html#method.definitions + */ +export declare const METHODS_BASIC_HTTP: { + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; +}; +export declare const METHODS_WEBDAV: { + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; +}; +export declare const METHODS_SUBVERSION: { + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; +}; +export declare const METHODS_UPNP: { + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; +}; +export declare const METHODS_CALDAV: { + readonly MKCALENDAR: 30; +}; +export declare const METHODS_NON_STANDARD: { + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; +}; +export declare const METHODS_ICECAST: { + readonly SOURCE: 33; +}; +export declare const METHODS_AIRPLAY: Simplify>; +export declare const METHODS_RAOP: { + readonly FLUSH: 45; +}; +export declare const METHODS_RTSP: { + readonly FLUSH: 45; + readonly GET: 1; + readonly POST: 3; + readonly OPTIONS: 6; + readonly DESCRIBE: 35; + readonly ANNOUNCE: 36; + readonly SETUP: 37; + readonly PLAY: 38; + readonly PAUSE: 39; + readonly TEARDOWN: 40; + readonly GET_PARAMETER: 41; + readonly SET_PARAMETER: 42; + readonly REDIRECT: 43; + readonly RECORD: 44; +}; +export declare const METHODS_HTTP1: { + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; +}; +export declare const METHODS_HTTP2: { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; +}; +export declare const METHODS_HTTP: { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; +}; +export declare const METHODS: { + readonly FLUSH: 45; + readonly GET: 1; + readonly POST: 3; + readonly OPTIONS: 6; + readonly DESCRIBE: 35; + readonly ANNOUNCE: 36; + readonly SETUP: 37; + readonly PLAY: 38; + readonly PAUSE: 39; + readonly TEARDOWN: 40; + readonly GET_PARAMETER: 41; + readonly SET_PARAMETER: 42; + readonly REDIRECT: 43; + readonly RECORD: 44; + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly PUT: 4; + readonly CONNECT: 5; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; +}; +export declare const ALPHA: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z"]; +export declare const NUM_MAP: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; +}; +export declare const HEX_MAP: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + readonly A: 10; + readonly B: 11; + readonly C: 12; + readonly D: 13; + readonly E: 14; + readonly F: 15; + readonly a: 10; + readonly b: 11; + readonly c: 12; + readonly d: 13; + readonly e: 14; + readonly f: 15; +}; +export declare const DIGIT: readonly ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export declare const ALPHANUM: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export declare const MARK: readonly ["-", "_", ".", "!", "~", "*", "'", "(", ")"]; +export declare const USERINFO_CHARS: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "_", ".", "!", "~", "*", "'", "(", ")", "%", ";", ":", "&", "=", "+", "$", ","]; +export declare const URL_CHAR: readonly ["!", "\"", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", ":", ";", "<", "=", ">", "@", "[", "\\", "]", "^", "_", "`", "{", "|", "}", "~", "A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export declare const HEX: readonly ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F"]; +export declare const TOKEN: readonly ["!", "#", "$", "%", "&", "'", "*", "+", "-", ".", "^", "_", "`", "|", "~", "A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export declare const HTAB: readonly ["\t"]; +export declare const SP: readonly [" "]; +export declare const HTAB_SP_VCHAR_OBS_TEXT: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const HEADER_CHARS: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const RELAXED_HEADER_CHARS: readonly [1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 127, "\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const CONNECTION_TOKEN_CHARS: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const QDTEXT: readonly ["\t", " ", 33, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const MAJOR: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; +}; +export declare const MINOR: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; +}; +export declare const SPECIAL_HEADERS: { + readonly connection: 1; + readonly 'content-length': 2; + readonly 'proxy-connection': 1; + readonly 'transfer-encoding': 3; + readonly upgrade: 4; +}; +declare const _default: { + ERROR: { + readonly OK: 0; + readonly INTERNAL: 1; + readonly STRICT: 2; + readonly CR_EXPECTED: 25; + readonly LF_EXPECTED: 3; + readonly UNEXPECTED_CONTENT_LENGTH: 4; + readonly UNEXPECTED_SPACE: 30; + readonly CLOSED_CONNECTION: 5; + readonly INVALID_METHOD: 6; + readonly INVALID_URL: 7; + readonly INVALID_CONSTANT: 8; + readonly INVALID_VERSION: 9; + readonly INVALID_HEADER_TOKEN: 10; + readonly INVALID_CONTENT_LENGTH: 11; + readonly INVALID_CHUNK_SIZE: 12; + readonly INVALID_STATUS: 13; + readonly INVALID_EOF_STATE: 14; + readonly INVALID_TRANSFER_ENCODING: 15; + readonly CB_MESSAGE_BEGIN: 16; + readonly CB_HEADERS_COMPLETE: 17; + readonly CB_MESSAGE_COMPLETE: 18; + readonly CB_CHUNK_HEADER: 19; + readonly CB_CHUNK_COMPLETE: 20; + readonly PAUSED: 21; + readonly PAUSED_UPGRADE: 22; + readonly PAUSED_H2_UPGRADE: 23; + readonly USER: 24; + readonly CB_URL_COMPLETE: 26; + readonly CB_STATUS_COMPLETE: 27; + readonly CB_METHOD_COMPLETE: 32; + readonly CB_VERSION_COMPLETE: 33; + readonly CB_HEADER_FIELD_COMPLETE: 28; + readonly CB_HEADER_VALUE_COMPLETE: 29; + readonly CB_CHUNK_EXTENSION_NAME_COMPLETE: 34; + readonly CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35; + readonly CB_RESET: 31; + readonly CB_PROTOCOL_COMPLETE: 38; + }; + TYPE: { + readonly BOTH: 0; + readonly REQUEST: 1; + readonly RESPONSE: 2; + }; + FLAGS: { + readonly CONNECTION_KEEP_ALIVE: number; + readonly CONNECTION_CLOSE: number; + readonly CONNECTION_UPGRADE: number; + readonly CHUNKED: number; + readonly UPGRADE: number; + readonly CONTENT_LENGTH: number; + readonly SKIPBODY: number; + readonly TRAILING: number; + readonly TRANSFER_ENCODING: number; + }; + LENIENT_FLAGS: { + readonly HEADERS: number; + readonly CHUNKED_LENGTH: number; + readonly KEEP_ALIVE: number; + readonly TRANSFER_ENCODING: number; + readonly VERSION: number; + readonly DATA_AFTER_CLOSE: number; + readonly OPTIONAL_LF_AFTER_CR: number; + readonly OPTIONAL_CRLF_AFTER_CHUNK: number; + readonly OPTIONAL_CR_BEFORE_LF: number; + readonly SPACES_AFTER_CHUNK_SIZE: number; + readonly HEADER_VALUE_RELAXED: number; + }; + STATUSES: { + readonly CONTINUE: 100; + readonly SWITCHING_PROTOCOLS: 101; + readonly PROCESSING: 102; + readonly EARLY_HINTS: 103; + readonly RESPONSE_IS_STALE: 110; + readonly REVALIDATION_FAILED: 111; + readonly DISCONNECTED_OPERATION: 112; + readonly HEURISTIC_EXPIRATION: 113; + readonly MISCELLANEOUS_WARNING: 199; + readonly OK: 200; + readonly CREATED: 201; + readonly ACCEPTED: 202; + readonly NON_AUTHORITATIVE_INFORMATION: 203; + readonly NO_CONTENT: 204; + readonly RESET_CONTENT: 205; + readonly PARTIAL_CONTENT: 206; + readonly MULTI_STATUS: 207; + readonly ALREADY_REPORTED: 208; + readonly TRANSFORMATION_APPLIED: 214; + readonly IM_USED: 226; + readonly MISCELLANEOUS_PERSISTENT_WARNING: 299; + readonly MULTIPLE_CHOICES: 300; + readonly MOVED_PERMANENTLY: 301; + readonly FOUND: 302; + readonly SEE_OTHER: 303; + readonly NOT_MODIFIED: 304; + readonly USE_PROXY: 305; + readonly SWITCH_PROXY: 306; + readonly TEMPORARY_REDIRECT: 307; + readonly PERMANENT_REDIRECT: 308; + readonly BAD_REQUEST: 400; + readonly UNAUTHORIZED: 401; + readonly PAYMENT_REQUIRED: 402; + readonly FORBIDDEN: 403; + readonly NOT_FOUND: 404; + readonly METHOD_NOT_ALLOWED: 405; + readonly NOT_ACCEPTABLE: 406; + readonly PROXY_AUTHENTICATION_REQUIRED: 407; + readonly REQUEST_TIMEOUT: 408; + readonly CONFLICT: 409; + readonly GONE: 410; + readonly LENGTH_REQUIRED: 411; + readonly PRECONDITION_FAILED: 412; + readonly PAYLOAD_TOO_LARGE: 413; + readonly URI_TOO_LONG: 414; + readonly UNSUPPORTED_MEDIA_TYPE: 415; + readonly RANGE_NOT_SATISFIABLE: 416; + readonly EXPECTATION_FAILED: 417; + readonly IM_A_TEAPOT: 418; + readonly PAGE_EXPIRED: 419; + readonly ENHANCE_YOUR_CALM: 420; + readonly MISDIRECTED_REQUEST: 421; + readonly UNPROCESSABLE_ENTITY: 422; + readonly LOCKED: 423; + readonly FAILED_DEPENDENCY: 424; + readonly TOO_EARLY: 425; + readonly UPGRADE_REQUIRED: 426; + readonly PRECONDITION_REQUIRED: 428; + readonly TOO_MANY_REQUESTS: 429; + readonly REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430; + readonly REQUEST_HEADER_FIELDS_TOO_LARGE: 431; + readonly LOGIN_TIMEOUT: 440; + readonly NO_RESPONSE: 444; + readonly RETRY_WITH: 449; + readonly BLOCKED_BY_PARENTAL_CONTROL: 450; + readonly UNAVAILABLE_FOR_LEGAL_REASONS: 451; + readonly CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460; + readonly INVALID_X_FORWARDED_FOR: 463; + readonly REQUEST_HEADER_TOO_LARGE: 494; + readonly SSL_CERTIFICATE_ERROR: 495; + readonly SSL_CERTIFICATE_REQUIRED: 496; + readonly HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497; + readonly INVALID_TOKEN: 498; + readonly CLIENT_CLOSED_REQUEST: 499; + readonly INTERNAL_SERVER_ERROR: 500; + readonly NOT_IMPLEMENTED: 501; + readonly BAD_GATEWAY: 502; + readonly SERVICE_UNAVAILABLE: 503; + readonly GATEWAY_TIMEOUT: 504; + readonly HTTP_VERSION_NOT_SUPPORTED: 505; + readonly VARIANT_ALSO_NEGOTIATES: 506; + readonly INSUFFICIENT_STORAGE: 507; + readonly LOOP_DETECTED: 508; + readonly BANDWIDTH_LIMIT_EXCEEDED: 509; + readonly NOT_EXTENDED: 510; + readonly NETWORK_AUTHENTICATION_REQUIRED: 511; + readonly WEB_SERVER_UNKNOWN_ERROR: 520; + readonly WEB_SERVER_IS_DOWN: 521; + readonly CONNECTION_TIMEOUT: 522; + readonly ORIGIN_IS_UNREACHABLE: 523; + readonly TIMEOUT_OCCURED: 524; + readonly SSL_HANDSHAKE_FAILED: 525; + readonly INVALID_SSL_CERTIFICATE: 526; + readonly RAILGUN_ERROR: 527; + readonly SITE_IS_OVERLOADED: 529; + readonly SITE_IS_FROZEN: 530; + readonly IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561; + readonly NETWORK_READ_TIMEOUT: 598; + readonly NETWORK_CONNECT_TIMEOUT: 599; + }; + FINISH: { + readonly SAFE: 0; + readonly SAFE_WITH_CB: 1; + readonly UNSAFE: 2; + }; + HEADER_STATE: { + readonly GENERAL: 0; + readonly CONNECTION: 1; + readonly CONTENT_LENGTH: 2; + readonly TRANSFER_ENCODING: 3; + readonly UPGRADE: 4; + readonly CONNECTION_KEEP_ALIVE: 5; + readonly CONNECTION_CLOSE: 6; + readonly CONNECTION_UPGRADE: 7; + readonly TRANSFER_ENCODING_CHUNKED: 8; + }; + ALPHA: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z"]; + NUM_MAP: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + }; + HEX_MAP: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + readonly A: 10; + readonly B: 11; + readonly C: 12; + readonly D: 13; + readonly E: 14; + readonly F: 15; + readonly a: 10; + readonly b: 11; + readonly c: 12; + readonly d: 13; + readonly e: 14; + readonly f: 15; + }; + DIGIT: readonly ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + ALPHANUM: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + MARK: readonly ["-", "_", ".", "!", "~", "*", "'", "(", ")"]; + USERINFO_CHARS: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "_", ".", "!", "~", "*", "'", "(", ")", "%", ";", ":", "&", "=", "+", "$", ","]; + URL_CHAR: readonly ["!", "\"", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", ":", ";", "<", "=", ">", "@", "[", "\\", "]", "^", "_", "`", "{", "|", "}", "~", "A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + HEX: readonly ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F"]; + TOKEN: readonly ["!", "#", "$", "%", "&", "'", "*", "+", "-", ".", "^", "_", "`", "|", "~", "A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + HEADER_CHARS: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + RELAXED_HEADER_CHARS: readonly [1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 127, "\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + CONNECTION_TOKEN_CHARS: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + QDTEXT: readonly ["\t", " ", 33, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + HTAB_SP_VCHAR_OBS_TEXT: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + MAJOR: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + }; + MINOR: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + }; + SPECIAL_HEADERS: { + readonly connection: 1; + readonly 'content-length': 2; + readonly 'proxy-connection': 1; + readonly 'transfer-encoding': 3; + readonly upgrade: 4; + }; + METHODS: { + readonly FLUSH: 45; + readonly GET: 1; + readonly POST: 3; + readonly OPTIONS: 6; + readonly DESCRIBE: 35; + readonly ANNOUNCE: 36; + readonly SETUP: 37; + readonly PLAY: 38; + readonly PAUSE: 39; + readonly TEARDOWN: 40; + readonly GET_PARAMETER: 41; + readonly SET_PARAMETER: 42; + readonly REDIRECT: 43; + readonly RECORD: 44; + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly PUT: 4; + readonly CONNECT: 5; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + }; + METHODS_HTTP: { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; + }; + METHODS_HTTP1_HEAD: { + readonly HEAD: 2; + }; + METHODS_HTTP1: { + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; + }; + METHODS_HTTP2: { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + }; + METHODS_ICECAST: { + readonly SOURCE: 33; + }; + METHODS_RTSP: { + readonly FLUSH: 45; + readonly GET: 1; + readonly POST: 3; + readonly OPTIONS: 6; + readonly DESCRIBE: 35; + readonly ANNOUNCE: 36; + readonly SETUP: 37; + readonly PLAY: 38; + readonly PAUSE: 39; + readonly TEARDOWN: 40; + readonly GET_PARAMETER: 41; + readonly SET_PARAMETER: 42; + readonly REDIRECT: 43; + readonly RECORD: 44; + }; +}; +export default _default; \ No newline at end of file diff --git a/src/interceptors/http/http-parser/llhttp/constants.js b/src/interceptors/http/http-parser/llhttp/constants.js new file mode 100644 index 000000000..87a91a9dd --- /dev/null +++ b/src/interceptors/http/http-parser/llhttp/constants.js @@ -0,0 +1,485 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SPECIAL_HEADERS = exports.MINOR = exports.MAJOR = exports.QDTEXT = exports.CONNECTION_TOKEN_CHARS = exports.RELAXED_HEADER_CHARS = exports.HEADER_CHARS = exports.HTAB_SP_VCHAR_OBS_TEXT = exports.SP = exports.HTAB = exports.TOKEN = exports.HEX = exports.URL_CHAR = exports.USERINFO_CHARS = exports.MARK = exports.ALPHANUM = exports.DIGIT = exports.HEX_MAP = exports.NUM_MAP = exports.ALPHA = exports.METHODS = exports.METHODS_HTTP = exports.METHODS_HTTP2 = exports.METHODS_HTTP1 = exports.METHODS_RTSP = exports.METHODS_RAOP = exports.METHODS_AIRPLAY = exports.METHODS_ICECAST = exports.METHODS_NON_STANDARD = exports.METHODS_CALDAV = exports.METHODS_UPNP = exports.METHODS_SUBVERSION = exports.METHODS_WEBDAV = exports.METHODS_BASIC_HTTP = exports.METHODS_HTTP1_HEAD = exports.HEADER_STATE = exports.FINISH = exports.STATUSES = exports.LENIENT_FLAGS = exports.FLAGS = exports.TYPE = exports.ERROR = void 0; +exports.ERROR = { + OK: 0, + INTERNAL: 1, + STRICT: 2, + CR_EXPECTED: 25, + LF_EXPECTED: 3, + UNEXPECTED_CONTENT_LENGTH: 4, + UNEXPECTED_SPACE: 30, + CLOSED_CONNECTION: 5, + INVALID_METHOD: 6, + INVALID_URL: 7, + INVALID_CONSTANT: 8, + INVALID_VERSION: 9, + INVALID_HEADER_TOKEN: 10, + INVALID_CONTENT_LENGTH: 11, + INVALID_CHUNK_SIZE: 12, + INVALID_STATUS: 13, + INVALID_EOF_STATE: 14, + INVALID_TRANSFER_ENCODING: 15, + CB_MESSAGE_BEGIN: 16, + CB_HEADERS_COMPLETE: 17, + CB_MESSAGE_COMPLETE: 18, + CB_CHUNK_HEADER: 19, + CB_CHUNK_COMPLETE: 20, + PAUSED: 21, + PAUSED_UPGRADE: 22, + PAUSED_H2_UPGRADE: 23, + USER: 24, + CB_URL_COMPLETE: 26, + CB_STATUS_COMPLETE: 27, + CB_METHOD_COMPLETE: 32, + CB_VERSION_COMPLETE: 33, + CB_HEADER_FIELD_COMPLETE: 28, + CB_HEADER_VALUE_COMPLETE: 29, + CB_CHUNK_EXTENSION_NAME_COMPLETE: 34, + CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35, + CB_RESET: 31, + CB_PROTOCOL_COMPLETE: 38, +}; +exports.TYPE = { + BOTH: 0, // default + REQUEST: 1, + RESPONSE: 2, +}; +exports.FLAGS = { + CONNECTION_KEEP_ALIVE: 1 << 0, + CONNECTION_CLOSE: 1 << 1, + CONNECTION_UPGRADE: 1 << 2, + CHUNKED: 1 << 3, + UPGRADE: 1 << 4, + CONTENT_LENGTH: 1 << 5, + SKIPBODY: 1 << 6, + TRAILING: 1 << 7, + // 1 << 8 is unused + TRANSFER_ENCODING: 1 << 9, +}; +exports.LENIENT_FLAGS = { + HEADERS: 1 << 0, + CHUNKED_LENGTH: 1 << 1, + KEEP_ALIVE: 1 << 2, + TRANSFER_ENCODING: 1 << 3, + VERSION: 1 << 4, + DATA_AFTER_CLOSE: 1 << 5, + OPTIONAL_LF_AFTER_CR: 1 << 6, + OPTIONAL_CRLF_AFTER_CHUNK: 1 << 7, + OPTIONAL_CR_BEFORE_LF: 1 << 8, + SPACES_AFTER_CHUNK_SIZE: 1 << 9, + HEADER_VALUE_RELAXED: 1 << 10, +}; +exports.STATUSES = { + CONTINUE: 100, + SWITCHING_PROTOCOLS: 101, + PROCESSING: 102, + EARLY_HINTS: 103, + RESPONSE_IS_STALE: 110, // Unofficial + REVALIDATION_FAILED: 111, // Unofficial + DISCONNECTED_OPERATION: 112, // Unofficial + HEURISTIC_EXPIRATION: 113, // Unofficial + MISCELLANEOUS_WARNING: 199, // Unofficial + OK: 200, + CREATED: 201, + ACCEPTED: 202, + NON_AUTHORITATIVE_INFORMATION: 203, + NO_CONTENT: 204, + RESET_CONTENT: 205, + PARTIAL_CONTENT: 206, + MULTI_STATUS: 207, + ALREADY_REPORTED: 208, + TRANSFORMATION_APPLIED: 214, // Unofficial + IM_USED: 226, + MISCELLANEOUS_PERSISTENT_WARNING: 299, // Unofficial + MULTIPLE_CHOICES: 300, + MOVED_PERMANENTLY: 301, + FOUND: 302, + SEE_OTHER: 303, + NOT_MODIFIED: 304, + USE_PROXY: 305, + SWITCH_PROXY: 306, // No longer used + TEMPORARY_REDIRECT: 307, + PERMANENT_REDIRECT: 308, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + PAYMENT_REQUIRED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + METHOD_NOT_ALLOWED: 405, + NOT_ACCEPTABLE: 406, + PROXY_AUTHENTICATION_REQUIRED: 407, + REQUEST_TIMEOUT: 408, + CONFLICT: 409, + GONE: 410, + LENGTH_REQUIRED: 411, + PRECONDITION_FAILED: 412, + PAYLOAD_TOO_LARGE: 413, + URI_TOO_LONG: 414, + UNSUPPORTED_MEDIA_TYPE: 415, + RANGE_NOT_SATISFIABLE: 416, + EXPECTATION_FAILED: 417, + IM_A_TEAPOT: 418, + PAGE_EXPIRED: 419, // Unofficial + ENHANCE_YOUR_CALM: 420, // Unofficial + MISDIRECTED_REQUEST: 421, + UNPROCESSABLE_ENTITY: 422, + LOCKED: 423, + FAILED_DEPENDENCY: 424, + TOO_EARLY: 425, + UPGRADE_REQUIRED: 426, + PRECONDITION_REQUIRED: 428, + TOO_MANY_REQUESTS: 429, + REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430, // Unofficial + REQUEST_HEADER_FIELDS_TOO_LARGE: 431, + LOGIN_TIMEOUT: 440, // Unofficial + NO_RESPONSE: 444, // Unofficial + RETRY_WITH: 449, // Unofficial + BLOCKED_BY_PARENTAL_CONTROL: 450, // Unofficial + UNAVAILABLE_FOR_LEGAL_REASONS: 451, + CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460, // Unofficial + INVALID_X_FORWARDED_FOR: 463, // Unofficial + REQUEST_HEADER_TOO_LARGE: 494, // Unofficial + SSL_CERTIFICATE_ERROR: 495, // Unofficial + SSL_CERTIFICATE_REQUIRED: 496, // Unofficial + HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497, // Unofficial + INVALID_TOKEN: 498, // Unofficial + CLIENT_CLOSED_REQUEST: 499, // Unofficial + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, + HTTP_VERSION_NOT_SUPPORTED: 505, + VARIANT_ALSO_NEGOTIATES: 506, + INSUFFICIENT_STORAGE: 507, + LOOP_DETECTED: 508, + BANDWIDTH_LIMIT_EXCEEDED: 509, + NOT_EXTENDED: 510, + NETWORK_AUTHENTICATION_REQUIRED: 511, + WEB_SERVER_UNKNOWN_ERROR: 520, // Unofficial + WEB_SERVER_IS_DOWN: 521, // Unofficial + CONNECTION_TIMEOUT: 522, // Unofficial + ORIGIN_IS_UNREACHABLE: 523, // Unofficial + TIMEOUT_OCCURED: 524, // Unofficial + SSL_HANDSHAKE_FAILED: 525, // Unofficial + INVALID_SSL_CERTIFICATE: 526, // Unofficial + RAILGUN_ERROR: 527, // Unofficial + SITE_IS_OVERLOADED: 529, // Unofficial + SITE_IS_FROZEN: 530, // Unofficial + IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561, // Unofficial + NETWORK_READ_TIMEOUT: 598, // Unofficial + NETWORK_CONNECT_TIMEOUT: 599, // Unofficial +}; +exports.FINISH = { + SAFE: 0, + SAFE_WITH_CB: 1, + UNSAFE: 2, +}; +exports.HEADER_STATE = { + GENERAL: 0, + CONNECTION: 1, + CONTENT_LENGTH: 2, + TRANSFER_ENCODING: 3, + UPGRADE: 4, + CONNECTION_KEEP_ALIVE: 5, + CONNECTION_CLOSE: 6, + CONNECTION_UPGRADE: 7, + TRANSFER_ENCODING_CHUNKED: 8, +}; +exports.METHODS_HTTP1_HEAD = { + HEAD: 2, +}; +/** + * HTTP methods as defined by RFC-9110 and other specifications. + * @see https://httpwg.org/specs/rfc9110.html#method.definitions + */ +exports.METHODS_BASIC_HTTP = { + DELETE: 0, + GET: 1, + ...exports.METHODS_HTTP1_HEAD, + POST: 3, + PUT: 4, + CONNECT: 5, + OPTIONS: 6, + TRACE: 7, + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + PATCH: 28, + /* RFC-2068, section 19.6.1.2 */ + LINK: 31, + UNLINK: 32, +}; +exports.METHODS_WEBDAV = { + COPY: 8, + LOCK: 9, + MKCOL: 10, + MOVE: 11, + PROPFIND: 12, + PROPPATCH: 13, + SEARCH: 14, + UNLOCK: 15, + BIND: 16, + REBIND: 17, + UNBIND: 18, + ACL: 19, +}; +exports.METHODS_SUBVERSION = { + REPORT: 20, + MKACTIVITY: 21, + CHECKOUT: 22, + MERGE: 23, +}; +exports.METHODS_UPNP = { + 'M-SEARCH': 24, + NOTIFY: 25, + SUBSCRIBE: 26, + UNSUBSCRIBE: 27, +}; +exports.METHODS_CALDAV = { + MKCALENDAR: 30, +}; +exports.METHODS_NON_STANDARD = { + /** + * Not defined in any RFC but commonly used + */ + PURGE: 29, + /* DRAFT https://www.ietf.org/archive/id/draft-ietf-httpbis-safe-method-w-body-02.html */ + QUERY: 46, +}; +exports.METHODS_ICECAST = { + SOURCE: 33, +}; +exports.METHODS_AIRPLAY = { + GET: 1, + POST: 3, +}; +exports.METHODS_RAOP = { + FLUSH: 45, +}; +/* RFC-2326 RTSP */ +exports.METHODS_RTSP = { + OPTIONS: exports.METHODS_BASIC_HTTP.OPTIONS, + DESCRIBE: 35, + ANNOUNCE: 36, + SETUP: 37, + PLAY: 38, + PAUSE: 39, + TEARDOWN: 40, + GET_PARAMETER: 41, + SET_PARAMETER: 42, + REDIRECT: 43, + RECORD: 44, + ...exports.METHODS_AIRPLAY, + ...exports.METHODS_RAOP, +}; +exports.METHODS_HTTP1 = { + ...exports.METHODS_BASIC_HTTP, + ...exports.METHODS_WEBDAV, + ...exports.METHODS_SUBVERSION, + ...exports.METHODS_UPNP, + ...exports.METHODS_CALDAV, + ...exports.METHODS_NON_STANDARD, + // TODO(indutny): should we allow it with HTTP? + ...exports.METHODS_ICECAST, +}; +exports.METHODS_HTTP2 = { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + PRI: 34, +}; +exports.METHODS_HTTP = { + ...exports.METHODS_HTTP1, + ...exports.METHODS_HTTP2, +}; +exports.METHODS = { + ...exports.METHODS_HTTP1, + ...exports.METHODS_HTTP2, + ...exports.METHODS_RTSP, +}; +// ALPHA: https://tools.ietf.org/html/rfc5234#appendix-B.1 +exports.ALPHA = [ + "A", "a", "B", "b", "C", "c", "D", "d", + "E", "e", "F", "f", "G", "g", "H", "h", + "I", "i", "J", "j", "K", "k", "L", "l", + "M", "m", "N", "n", "O", "o", "P", "p", + "Q", "q", "R", "r", "S", "s", "T", "t", + "U", "u", "V", "v", "W", "w", "X", "x", + "Y", "y", "Z", "z", +]; +exports.NUM_MAP = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, + 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, +}; +exports.HEX_MAP = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, + 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, + A: 0XA, B: 0XB, C: 0XC, D: 0XD, E: 0XE, F: 0XF, + a: 0xa, b: 0xb, c: 0xc, d: 0xd, e: 0xe, f: 0xf, +}; +// DIGIT: https://tools.ietf.org/html/rfc5234#appendix-B.1 +exports.DIGIT = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', +]; +exports.ALPHANUM = [...exports.ALPHA, ...exports.DIGIT]; +exports.MARK = ['-', '_', '.', '!', '~', '*', '\'', '(', ')']; +exports.USERINFO_CHARS = [...exports.ALPHANUM, ...exports.MARK, '%', ';', ':', '&', '=', '+', '$', ',']; +// TODO(indutny): use RFC +exports.URL_CHAR = [ + '!', '"', '$', '%', '&', '\'', + '(', ')', '*', '+', ',', '-', '.', '/', + ':', ';', '<', '=', '>', + '@', '[', '\\', ']', '^', '_', + '`', + '{', '|', '}', '~', + ...exports.ALPHANUM +]; +exports.HEX = [...exports.DIGIT, 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F']; +/* Tokens as defined by rfc 2616. Also lowercases them. + * token = 1* + * separators = "(" | ")" | "<" | ">" | "@" + * | "," | ";" | ":" | "\" | <"> + * | "/" | "[" | "]" | "?" | "=" + * | "{" | "}" | SP | HT + */ +exports.TOKEN = [ + '!', '#', '$', '%', '&', '\'', + '*', '+', '-', '.', + '^', '_', '`', + '|', '~', + ...exports.ALPHANUM +]; +// HTAB: https://tools.ietf.org/html/rfc5234#appendix-B.1 +exports.HTAB = ['\t']; +// SP: https://tools.ietf.org/html/rfc5234#appendix-B.1 +exports.SP = [' ']; +// VCHAR: https://tools.ietf.org/html/rfc5234#appendix-B.1 +const VCHAR = [ + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, + 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, + 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, + 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, +]; +// OBS_TEXT: https://datatracker.ietf.org/doc/html/rfc9110#name-collected-abnf +// 0x80 - 0xff +const OBS_TEXT = [ + 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, + 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, + 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, + 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, + 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, + 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, + 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, + 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, + 0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf, + 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf, + 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, + 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef, + 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, + 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, +]; +exports.HTAB_SP_VCHAR_OBS_TEXT = [...exports.HTAB, ...exports.SP, ...VCHAR, ...OBS_TEXT]; +exports.HEADER_CHARS = exports.HTAB_SP_VCHAR_OBS_TEXT; +const RELAXED_CTRL_CHARS = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // Before TAB + 0x0b, 0x0c, // VT, FF (between TAB and CR) + 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, // After CR/LF, before space + 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, + 0x1e, 0x1f, + 0x7f, // DEL +]; +// Relaxed header chars includes control characters (above) that are not allowed +// by default. This excludes only NULL (0x00), CR (0x0d), LF (0x0a). +exports.RELAXED_HEADER_CHARS = [...RELAXED_CTRL_CHARS, ...exports.HEADER_CHARS]; +// ',' = \x2c +exports.CONNECTION_TOKEN_CHARS = [ + ...exports.HTAB, ...exports.SP, + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x2b, /* */ 0x2d, 0x2e, 0x2f, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, + 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, + 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, + 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, + ...OBS_TEXT +]; +// QDTEXT: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.4 +exports.QDTEXT = [ + ...exports.HTAB, ...exports.SP, + 0x21, + 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, + 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, + 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, + 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, + 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, + 0x5b, + 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64, + 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, + 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, + 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, + 0x7d, 0x7e, + ...OBS_TEXT +]; +exports.MAJOR = exports.NUM_MAP; +exports.MINOR = exports.MAJOR; +exports.SPECIAL_HEADERS = { + 'connection': exports.HEADER_STATE.CONNECTION, + 'content-length': exports.HEADER_STATE.CONTENT_LENGTH, + 'proxy-connection': exports.HEADER_STATE.CONNECTION, + 'transfer-encoding': exports.HEADER_STATE.TRANSFER_ENCODING, + 'upgrade': exports.HEADER_STATE.UPGRADE, +}; +exports.default = { + ERROR: exports.ERROR, + TYPE: exports.TYPE, + FLAGS: exports.FLAGS, + LENIENT_FLAGS: exports.LENIENT_FLAGS, + STATUSES: exports.STATUSES, + FINISH: exports.FINISH, + HEADER_STATE: exports.HEADER_STATE, + ALPHA: exports.ALPHA, + NUM_MAP: exports.NUM_MAP, + HEX_MAP: exports.HEX_MAP, + DIGIT: exports.DIGIT, + ALPHANUM: exports.ALPHANUM, + MARK: exports.MARK, + USERINFO_CHARS: exports.USERINFO_CHARS, + URL_CHAR: exports.URL_CHAR, + HEX: exports.HEX, + TOKEN: exports.TOKEN, + HEADER_CHARS: exports.HEADER_CHARS, + RELAXED_HEADER_CHARS: exports.RELAXED_HEADER_CHARS, + CONNECTION_TOKEN_CHARS: exports.CONNECTION_TOKEN_CHARS, + QDTEXT: exports.QDTEXT, + HTAB_SP_VCHAR_OBS_TEXT: exports.HTAB_SP_VCHAR_OBS_TEXT, + MAJOR: exports.MAJOR, + MINOR: exports.MINOR, + SPECIAL_HEADERS: exports.SPECIAL_HEADERS, + METHODS: exports.METHODS, + METHODS_HTTP: exports.METHODS_HTTP, + METHODS_HTTP1_HEAD: exports.METHODS_HTTP1_HEAD, + METHODS_HTTP1: exports.METHODS_HTTP1, + METHODS_HTTP2: exports.METHODS_HTTP2, + METHODS_ICECAST: exports.METHODS_ICECAST, + METHODS_RTSP: exports.METHODS_RTSP, +}; \ No newline at end of file diff --git a/src/interceptors/http/http-parser/llhttp/llhttp.wasm b/src/interceptors/http/http-parser/llhttp/llhttp.wasm new file mode 100755 index 0000000000000000000000000000000000000000..fbc71415244e77b2ab5756f290e70383f030ca2c GIT binary patch literal 61045 zcmeHw2Yg(`wfCL7D{18`*#Zn0Oj*eUY`_%9&k!KQSMthMEK7*&>Fiso9LG`HW%f?3T)9%M+*|RVG~x^$XYW!*SFUvAy@S`z-Yy>9M^z@3@7{0R z{-~m(r@LcezH@%Qx36Pf&(dW}^8I!ta=zRrE~9sA_>UDWL;zVqz66}?Lw zhbg0NN?(6x|B61HIMya=2ptO+<(JGa{A$0>B`fm!RfR3JPtW`Vby9$;#U(&hrJ9>k zDKH{kqExApU#U~7N=xxj#>cYo2X+y)5Ux3tn-sr*$hq`j4n z?|5Z@E1OihG{3Z`_dwV2Iu>;=>R;5kWYG`vPWc8Mc$~keH$ShxW5J5|tt*!1osGwv?E1N*b3uQ;x5MJFNtJm^KID6Q zyL+5X*N-Rl^mg>-JNtUNoveKWgqS&eQ*BW-)!@yto<66>@04&OPqv^nQXHp?N_JhhpNS!Sw6sTifKXq2tCD3GgX8O6MFNym}} zHgm7D_0~h>oYx!XC9Tfni9=_H*;&xjn+FJIn=K1u?OWD4FW+auv&xP>YUH+s5H@y5 zj16RBN#_Ci`ObD$Bdu}i&gbX%b>w>%IF04wl;bOJ?b`YoJ5JeY+SFZkRcGN!a_ z)a8eynqURCciia{4jvd7aQr)-!M|M-T_;!RI9@E#SJPal>L_OO8kG7`*72*l)>I^| zQH@&TrJSsjHKD&WlCjTK4;cK{sBk+v9H`|?pR%KMOIa(yll#Q^DMW-U70!v z9nX(aDHziM$!YBPzt)BpY7|Gyld9BovS_E&a|{z9!$>Qm92(y~nXD6gV&^q8u( z*8W7T^RXJc?ni39_5Y#9ZLs0^jW*t7)9e@Ob5;GJs+q9a*EZi`;-oFN+WG@Ed7Evw z+kVQ|-&gOcssHhJ^{$$>!;atBY3E(Ox$C#4`?Ym5a`iK3HGF$^S+=~luYbjU`yX)N z4}bLI|2$~rfI3(mq7GGusl(L~>PU5zI$9m0j#bC0P&T(I$NEi&Q<5B^VLt)1?oa|k-Av@O#NK_LS3RRRhOyD)fMVWb(OkWU8Am5*Qx8( zFVzj|Ms<_AS>2*;Rkx|z)g9_ib(gwZty1@>d)0mFe)WL*m3mM;q#jm}s7KXf>euRV z^&9n;`m=gMy{Vp5e^O7Wr`2!O@6;RW_v#t-ta?s8uU=3usz0cg)XQqMdPTje{-|D4 z&Kl+UPIbECHH4!P{t;m%!dDTFLHG*7Duk;Mu7mJpgkurDgm7Jie?Yh%!WR*)kMISA z;}AZNa07(TA>0t*vk1o{d zLHHYln<0E0;RJ-gMp%aMF@#@3_$b275k7)&3xp3NoQUutgli*w5aC(~e}!-o!Uqs; ziST}ee^7|rhsah4??w0$6YfD|GQw2|w?TL}!fg@Wg>XBBcOu*#;T;I4AiN#n*Ad=^ z@MTtTDtgs-ro zYY_1eUX5^9gjXTl3E`CpM~@Ls;S2$B03xe$^28My$F2N?M&BEMqfd_*2(^(} zY(ySq?mW7Ln%}IR=pz7&#h|7a2JUkv}kUBqA>{as(nT zGjcd0s~I^AkyjWw6p>dMIRueEGIB5?uQ4*f6mol|=fBQ`gFOEYhX3jLr3ilP`G4Z$ zk37Ew!4EzEO+Fsz`Dp|Pc>bUHxWDJW#c)5*{|mzvp8qz(e$P)K==1!)^0C+R-(k4i z^Z&+hndkqVVUOp(%dp$?-($Gc^WSH<#PdI3xY+YQWZ32TE`mj#{|`Pc^!$$)?(6v< zGhE>LpD@gO{-+G*d;Vt(=Xw6;4EOQ;H4HmF{|knDd;XUUJ3N1YEck)vAI$Lko_`3# z?|J^A48QC7hcVpC^ABgZr{^ERa1YNvlHu;2e-y*rJpX8hb3OkUhV7nzEWKaOFm z=O54TJDz_6!xqm!kzupvpTw}q^G{|t$Ma8N*y#DEGMw%Cr!oAt=bz57!SjE@aF*wv z!EmPMpUJS^^Uq?K^Zc_J&hY$m7}k0IxeRMP|2&4i=bz7Ty669t;kP{h0*1SK{)G&` z>G>Bi+{N=RX1KHG|BT^Ip8s=(-|+ljFx=7eFJZWY=U>Wjn&)4}@IO5Na)whq{|bg* z_xvjvPVxM!7;f+RS2Nts^RHpJt><6Ma2wCRj^Sj_zn-l#x+!S_( z;U+LI3^(?C1{=YsFdPq?!f->F6ownXqA(l>gTioq*b|29!JIH$7uJN~SQrzA>%f*U zTpOl@;aadH469&B7>ea8L0`#bly?$hp*?i22#?!)eb?yuYj-22`8+j;6hcOa(5F>&IFfzCgBZPa@DvTEH!l>a6 zj2>>oDB>23CT_y0;s%T^uE!|jT8uWX#;D^;bs0t>7h_a%p}GJgl=CrCITs_AvoUfx z17nzzF{U{YW1HhK#yJvWp2IQrIaCd(l?SzO*n~mT54^0ZQCW9p<&XU@XbJDZ*x#gF zq_lBd6()VAboYEWlUV8qSNH#S*@V+wUmS-!ZNmAk_AKtO2^YHB^|+l08ulhKgt*P- z*g!@gw}$VZakYPWOPKUWS9`2Che@xw+P}RiOnS=|$NNU4IFp_2d{tk0Z3<3;3kf%q zC%?vQ24;ckIeX$eJ2~4k=Qe(Nd8Mkjf#qtQ9a33@=B2N8a;CvwF~#p;@fu9ad?%as z*PU6p1R2wqe9@CBm{alJIU6~qxmAd+JI$?-(c1MX&fX|Zy(3;FlCo;DeH^awRdVRsx`<@^%0K3+4!?NVOwl zrYo4q@M%5Er}a%iO;3&tMA6Nqk;vq#2Xr%;7mb5oY$zo*8~kFtyqJ;zu#u$JgxMc7 z0Q8Fp0Q+KNDUpq}I|t+!Yc7EzOsBBLHk~lRLB&P_HXRJ2Mrvw|)x;tEg@!Yeoq<&% z(=#h4*lLc@taYc;!x8>XcHX^NL|0@ViE00!uF-DF*}}s1t5NCEEC^{aqN*ncEdoT?^3=Onpj0R3CoX?v5Ls&c_C=SIwXv43utV|*t z7L%|jX}4<9QV={L4f9kb8;sw=**u`G;_xJ8oZdJOqR8;vl=y;{Q@4lJflsV#%vxKl zV=D`I!- zMjL~f+zK=W+YEEM6=#vkYNdIjVX#>RsB2{blDB`?=y9$yX_A^W=;sDJvj!tqIZE0u zFf4HRjil);?(#|}jv}Y5X$@~bEx@#bM&(R4I1TQATAr&^6bRkg4~Yn+VU{T=oAZSj zSeoh0Vs`500tObQGR7eb{>tM6@M8%c#;{<%=R?~^hE|+TG?Nl9xI*T$C6_=ys_r#H zuQ8rRK`x6Tn$55#7&}eyDu)D!?qEkPcD-0lT(BGq+>X#NWMePi|0ELq@xY%D`ytu`P(N1*=vaA2}AL`>i!tF zzfNvfMmeOpeY^!ZV?-d28!1TXKQVd-)X^F$8<(PM^zK*-*dGz#W4;>T?Fo2HLBKHB z1tEU%lYqO@Xh&HDzgaN*d!fusU}GFPB!br7Yti41*sV3VG1v)7`|mU&kTlRmbuQ>) zG|OQkwk6cH3-Sd0L}>N5f+iw1q^ z8j7bB%${WO7s&4TFifHQsKs~`L?+FbX z7Nmi!5*j9jRYhn>+N6&xWaBPtsBmSCJ(B)P8;7qE|BWLj zuPs}oIo}cdH!g&~RP~|I0P%!q3v9iF@_Z5LPIke<9`UdqrA-z0#&yz4PXRf8)6kO+x#4Eh7#aFiv&14*bzy9&);Jj8l&`7yGVxo}y{4i( z@msTb7CApm{Fu{C{MKx~re)21e`Vjonv7pnPX)+kIB@=%#!5T9T$?kEHrau*`R<(* za~blpxsX>k7f3*p=(RkMC2M+r3Y$yD5o9)G0H-2!>j+s)bT|dFwR+u=yS? zfL|dx&^nNyINU<{(0a&+F=d2~LiyObQ30`iRl?G<^`pANnHk4*9YBd`s7#tlUKve8 z{HKMMB{H&mIA)xcI34V;9e)k?dqJg5pZW2bgWHlgbA77fN+B}dMV%gc>YG|2fyi2P zCbBs*x5_Dr{Zxq- z5&)M~NmODhl~u`$fy_s!Nt0gBnNHaA#WeCx4Z1H(u4FUl)VlmsS2bXzz$A2BNc;3@ z(AS0d%*qrV;eRkEri$&%o3_hYCkxB!(DLY>1B~P}wgXzw(pcnGT{Y>dTcoiSIF|;h zvTzF2z{Y==wnKMscLj>=p#}Reg7|Gk5qA@ae;gw2660YI2OywvaC&DoG4NA_0HB}N z=qrPM8k|5gV<{IU(Dsn?Lh5~RIH4ocFf_WdbbP)P{2(T+p`!#-ta(Nut-`D#C;Ls` z*27mt^y2(*E+cF8LYwuIVvzi!3YhL2L!r}ReRu(}mUZxRLB;!&$s58V^eW#A(n3kS z1(NDHDr0_bCJ;(lYDZO8NM*_WobXwe-p>Z8zen2s`l4-5p$$d$!?5MkT4X7?MMd^{ zfO$6p=DMO_C}JU)_d_sJe|@FMSuxGH4q*Nk0ds9pFmY<$4Z*Pf;S}&%3Ks`?@YzBI z3?%sam^^Wk!vAYbJVHUdM#NO)Ti1xKyy6Uscybm%S0=+osKZYr|2y&80w>}skS`l8 zPNm$4jMyIFmoc1lN2M(>fJgeNBf|08=2pU4t?c7KwLtIf7pm^TAg`>c~7*>J*aVR6Rz6EIa%2A=NNlro<=A@7Z>Hp zo2*&Vm`XghNpPj$EJ7UODFX3N#UbkbImgFAzd_LKIvuR^sA}#38buVf$Q1flBiPb4 zdeo?LN3Z#3uvv#&)UX$a8{e=Mhuh>Zx4_MFn5^WMI)LDk2s(Kz91i?xgX>nm8(p#$H-uj7lFr>hd@z7 zwc@;v=A*UwqN9I|voRP|Lwt(pX1p;@lg4;0UQ3{x^lbsU>iGbF2|7sFy!q!%l$;j3`2z-U*s)h<>mJG)RV(0ero-G94)lb7(IUZqG$ znw91(r5UztXEk*QRlo710GV=&464!F&5ntS?JI-&G0m6F268j_) zs9hE=^HQ`#Yyf>k`F1=|mdUofVlz?7o6zFopyz?l2n>`=hl*J11J^8lq3 z*3v#7#^+Fx^v*PBtbKo!5pVl^G@lO--^-5l!@}p&`JU2c;L8s4L&E1X_+Dg69aGUFI7?CtnKP1=g?aeB1t%j=0|r@VJ>U0M}N08=q0JeZHN~pFj|Phe%dXtFJt{ z=m-ik8j!1SsP<+!hRR_KRW{6AfcA5s4UP=_F;l_G)|`l{<%*2t;Z;-aG-qxGk!8+I z{?yC>^IM4d#~~%R^7$iB!rkPSk`s$klAP6eBc!B`gW6#z!8k)3oPZLZ(5lmW+AZ`G zMhyM65PIzhpv&Nd<}QGq1@v0Iv))2Kz9{tMRMaaWbbkaRVJ;nru??W}G?yQ1MVWVy zA&6~7&aRDi>LNc?7c{0EfcPM6%p2L5@1rp}pJ53)cEkj|6cY5U5fG#u;3zveg2A~F zj;k&7V~RpAO(?~)A@p4Vy<`|sqE(Eub0VPc8e^x7#Kkps1GOMS*K&OOQxb9gfT!1z zhwp_vmBS~051+5+`**=(9^0@~9#xdevILb+hE(nXDoclwI(2Pkl3B@|7&~PRP`2 z`25$n;eMEDaK*x=y8;z^&Mx++b_wc}6GSL)?X#RDdK-MW1U$~HS?yOIaUcH|P^nnm?;zIrp>PWLknJ^ZVjPN0rd1R(nNPD4xNX2aZK)>2=p~Yp(h38rV#q}BcPqmSsXgI zZs2g8n=B1qN6!st*I2W06_L;MfI8I4#+4A(`qC8D{ zeAk9N-5NX{&f|lVoZu-p25cSUsd&@yNy*cQ@2a-p&)FuR;{ww0a>x&EA=oNbllbZI zW_2yJvRhfA;80_a9Nfw@DYXwf(8B1K{ToILh1l&gb)sFiG^NEgL^5%EqK< zUKz4+;s`|Z!$EB1ehqWh)k1&2DDQB6N93 z`{pAMAvm-N=$u8zxn8=v7W&^u4E@p&`qxGPovvVGCFw{#pfA_4~M{(h`)`Ph+l+6Oc((X@K+N=Yyt2S^pR={{hbj*|5*sV zW(3gxHV8U53DxL>-4^;^i$YHh6wVK!R|EQR0|mO7@do62OSL{;ZlS+j6ne5ZKP!Zu z1@z&1b2_DQ=-fCJKLjP-FT7RP^eNhUwRMf2;A~d-rzF|Mzp2dvkvg6th1*E1h@HUa z--gep^7-lT`7}O11remTY#H%ZQAQ-S_>7Pdn~XpR{xXOWy#Hg9m>!8654Vtv&`e;r ziW{S_VZ%~&BC&olq~a7lKfyAZ1Wv`t@B;2+yq+=6rqQ3m!7`(aCz(3z^T~YvO}tK- zx`OMg@p=qh%SJXkFD$`*0GFeb#-YCQ4OPrHO-l30AsaUwfi%MjPRJ8C*x?oey2uv# z>qVg_hoi@Y&^G||;fAAc41&(>fw=pC4!ec^T2bi9Zs^Dm`Zz!zZtz0aDXvSruLn0S zaQI-M|FJ0aBOZ0zfct6A*MFQ0OjGP zHsIhU8fy~3<8l(Z(-!*kMWH9X-j73ujRy4L24pV`g3fEKM#of6{P5?IQD*izTQS;l z@KCa9Rh;SK#y=O;&>PUjWSlYbQ@A?N^a%%>!uI(PKHm+#bChN&f2JtqNw?wskaBFg z7}jl|rx;gJ?vuto5{?-y^xqeSo>Y=|L+GObeKqqT$ix2>!suOloynfTbfYL)+4ty-5#&! zOg56ZJXELWAPgCso#*j!DGpbxEdQP=%D<%Cz8&(fbOdrsCoA51-06euKU_bv(4Q;{ zJ=syc5kfBk^x+&{xO55V+`ErmM_kRb(4Qy@Jt;epv7*_&IOLo$U9&jtynHVmlO6Fq z#b>jA-koUf3fxa@f2uTSXSv1e=CIx6+ygJ3qd#lOdAuk&NoKANnMq?k95dksCdk3ifwk-FpEpqYz)_~$!&9&p%@wlMX;G+ zl3&7MhFm{~7y4ehOXH?UMq~E~Npdf4d{4eLrUo-vcSlb(JZ88_!r+LD-{f9AT!1J< zqG|QEmIC1PO?td4Q9RANSR_&?*x)kRS^YQ|j(uPH-UxC?PERO@yLGVgELZdiGc>1w zPmJ$JLn)=3ln4L~YSNEF8OG%Rx=GPJ5FITf$FK@XA(Bi-BLTA~fEkfVsg#5LEOJR8 zNYc`i!JRgPKs|ACAU#n~`g+2P<9su{lUzlJSNih4ME4@Tmq`OoU{hp!t}nM4UZh!T zFxR(AII7f5S<4oNX=~#IcY&Y5wSAVJ`XH7IVl#rKru>PAcpEuDDUP7RO{GEq9nt6n z>v^507HW-dWTz9@{g@U67)U3ehCRH?J8qp4RW4=yx43if659+)U-1}kmY9GX&ejdP zaSS`+<`C_z4dM;4y+Ji>-50TN6_OL$H}b%4GMDXALsjxpkxFj;DwTXzq!MyedcP1& z|Mc**Um%6(&nVbh;1459Oo|qD@}8oeaZ5_eobpsUII5tx5T9%LoxzH?78u1V0;7%Y z|1~cCU@IE}TQqqsYn)$kmgjJmvN*G^8%g0}OqPDCz{WPy*cx&%=3X;G=3Wi;l^aJ- z3ss!@BI5yeTw2!?eYZIHSB7sH(+f_@6wv#&PeSbnv&CTWsXbBS`H0pT8e%jr3^gq> z&8FOsda|uNgT+LtGw7}cSRu5CLSYj1HKZnu3XX1S2e%}XDzyh#!muopNrz8^VopSS z=!qT|B4SrpGFTC^e1TmWi8jFi6qk|<0b<0J6qA+biZi088onrRz}JcG*@xXj+7}Ct;36{$wX^v3`YScEptwWA!;-rIKQ-CWDf!e)j0WtUe1Im5ZoX zn(L>-o11A`bbllFALFKK@f$dXg2>ijKR1XDMsSZPzic{|erI5NZ|uh&a6C~yU%AU)*z z&@eQ4*cEl@tHKYk?TwbCnpOQW+Lo-O$Plv75jobnnyf#7jrl>mF$GvPurDFD7%ptPp%a0EAMh?QB2!wdExNVygindL1 z-aT^Uk`pt-&AS7THYZ=9ZQd)=Hbh!(cwh0~**2_IE|6crw)y+;ZF79AZTxB(&7)b+ zD<;j=RdKeJr{Vzhmn9a)0KY-|doWn}?_;f|uh7R@<6I1m zwL(ma=z6^E``Hw)#VZNU7NMO-REf0b>qRPIo5?3b_C;l!h^(o{z18wrkp*mFYd&Hp zT=myRXN)3LjA*GICU{z}dqH{Gd^_ zcdRRx?pP_UhYK0r|jr<>xFQTP}P&>mQOXSF3|HXI(%cY~1Xi-8g+01J8g z{pM3VI5QWp0jnT9hFoX_(HkL3x=3i75QG(kU5yxkMv(%FByam%>|1HnGE# z<&nyz_hW+nPryJZkh3-1;HD74Lw|8Q)X9OX6 zj7JBy3K1pS|FG$Yu22^4YfLlQ;kz!RKu;f@-zg2(*Jg$Wz=s=J5MW{Far)XK^una4X`-@L83_C!_HhL#HwYd`4M@6vPMq zAHTL`@#!jt&zOP67I(>nqEQ0V#+^m+rVN8jFgdfidRD*m6M<4c&e4+8@)g z7HXYE!n+!??kJAKqF~ukmP2u)BSeF94N2)e1=3sQ?8Kki$*e;v{f)Jj;PF1nWHY2e zAG91LJ(d0}1wLiPFQHi$EGWi;Q6pu+Pm(Oq7s>nOlnfV=5nM4)ut^r^IWmh+z8F3u z)|``)_+Uj@aoTvuCdpf7@!MGFZm3DHxLe0rsP9hYhdUw*4kzKJT$pDnD*Gt`7g+?C znZQn*9AcZy)Ge>HA0S=)-jc-r99<9We!&UhJG4nA2<>(J2l0K(81({|7wm25iHzh5 zbz&U=?n+m?wS0HMQ4>*cvh#J{MaR2^?t^iSK4J++@ZNCt9=9Uu9;8FcPVuuSyLnI+ zu|(PDP7%Y-`RU*SUGa249=s6Ti^~27;iJZ`F0#O-p8yX1_!RVB}`S0b=!f`mW@x7*Sabzo|>WO4Yx_s~bUy7YPQSwgFx*t?Px z`jbM13C6T=n8_pTo~9Q8SnwV8fCWrWN-w74AbEou45WYX2WWfr##?R(!`5*g8s`W= zFtJ<*o^li8o(3(TErwYdTcAox#HbD6GZGv=iCIhgm^(^Y+KfH7b?a|;?uoOrWWgSS zrGSC;#H_T>69zad%@2(7&~n6dQYwwqp-JeIrfO=m~gcr~&fm z`I4Q${p3xn=8b$)bqA4sLzT93EphJ1w@@5h;Di5@KRe_LxU!1%%h#Y}+>i}CYO2Xl zM6*W$3umy!z7;i@;e1)Mpla=8XjEXDB0}PFIL$S8sV~@h%1LN4Ex?ptti@+F8Ne06 zXJxa-XPz@u&o5#>VxJj9IG}@0pZ2s2I3VXUojR>m` z?haK{u*wjt5@2QaA5`nDoJkj627M4ljTNq=!>4&IuVxp+3VUWphSj@CtWw0P6j)8M zQeKc(GUN=fqM9WYKm>uxjO%DcPElu42>2OdY+8e!2b(itYQlAY)B)w)+W!%%Nhy~z zRxrIB5og3iV{4U*YGGY46r$*AS52~HWjKk-@kY8C`3?v^sV2rkOWrm{q)9XTD!OI% zKRp#81nBLEF^>#*pcTx0=NKZ>n0!5^H-T~gHV`Z5%tda<*X9I+2FEvd9h?3jH0VnE1MJk4znUE&zShbwMq@WgOyz+xdK7y_d#-X90Gk#=IhaYS*I$<2 zS_~C|+F3!Z)xv6HAI-4-$oThMq#K-}zhJFDtED|%e$l#4n5@|wo$xDA>(ybc_>_Zo z>u^C0zmGjB(sX>Tm1nrmU{1u9fiy|~ZKbF*UE)-NF;OXE$ImW8PkV~MQ8&RJN{jyH z^%iWJILR-a4u#gcD}u=&T)4s`EFzE;)|fzQH=|S#wHBYB{MNG_^!(%O(2Uo36=i&( zo3rpaV181Zo`MLLd{|r`bJWeix9qe4ffH^KmO&3~slnjsG$oi&==OZvc`sbInxrd| zfkjzKy0{__$v6GAbj*?@F}NTcL<9qn9nA;IH;D)duMgP46z4Q;)V~bWLO$2Qb1fS& z=j51n1PYB_1kHf~3T!8xBRUMhH0g73A^A;#4H3A!;!*a~aN(20{3g7{79UvA0>6EP z2>F~jKDT6A4Q>-gD*jHm#X{04^f6!3kq*J{WCDfEe7ieKAG8Ke|AD60>gf2F}-+F*^)xXR^q^at%0 zaG7Wow@sQQn71rLEz&U5<>6;8w-O#k+uTpgR*G5M64?Y!IHPM4R#6^Cuje{gT}l4% z`5NSszH619&6Oq_JezB-JH+9qk<959uuFyJbT=PN+C5MJW4;2rAD*PA1BWZm<6|&# z+~=7yd~b>e?>9rOwa7Qb7dX)Z@(oT$zA3>1g}TCQ^pkb=a$Dg~tkuFz#yBm{%gV~V zTBo`sv@?ZbT8?lB@D6KLIp({+qxCOSKFX`8G!F#=^WLj`|D-e`=wUr1;yxP$S(q8Qt1RRkh z>}ukxNFsPzQ?v!=bsN>gQXkSjX0)ZfO8+!%=Al4f-fP-HEold}q#e|fc2G;&K`m(q zwWJ-?l6Fu_+CeR82eqUf)ROj;rrp)FE3Z~n2UCL(7m=pYTKi$1F}qO`v!%7^BeErj z$tP*x@fGNao(#7>@KcV$F?=tccPqeY=6z(co-%kc&*%LawnBTr&gVIDbmKg!=HRFw z?De2FN^FVXXXXoK`)PcslwYAQlRzf;^+u29O}^+ zh{7lOH!&9RT|sbUVFvW*)-^r}$Arpp1WS$^IL(dKe`FQZj4J~OagO?TUJ&%S=++{X zc;HS|*JfY`_>uOmS1PqKtFUNTUCJH}?iWvhAt>p}mf}k+HMlt<1CI`qfoXp0x~m#} zY&M0}w8hn>DJcQpNfwY$PDUg$5mtf+HcF=2r7oua)Q0ghr~XjBI>kiHK%wFQbr40=Qv?cz=jn5^(dQoeip8YK(Y`5UJ2IUPpxg7#{}h=E$Tf;Z(a_uJ7~*S* zND6amR3)^?8RJ!f7Xyyy#2ZFw{n4E7P~IlMfK>qsH0IOrfF@TrJa0Yw+I|obEf-5( z&x~^=nQTQ>!nTQSl~*nVV}xr`#(GnvK7R}BNYov4dDgk=s7v&01Q2lhC%Uh(Yw`Vub zX=-lyPHUUZ*FJZ*-S^mYukUt#@B22XaQK75$??>Vy*oYs2yXgOAhM${!UtX-A|8Bx#ex{!fF>u~6`QJ8her4)8 z+0=i^FLeI%FVx`)CjaxiejRWQ9?5hwrh3zNjP`H`VYQHhmV^2ZZz*7H}6k3_^&p6`^50&e3S1FCcMn@)8KQIc|P=V zUH@wa&KHJ`*UkHT4E@)d@FN5FQ$xo^hM&)xdM>f;XX-n}z(3dE^>dSNl___E!RI`~ z*9T4cBhB+m2HssJ-(L*hJ~s7VY}#qS;PbS>^I-$`@8N#15*5LbZ5I@~x ztVi%VBat&Wa5Rbgl%ghB!eZ*1`)Tbzy?n6p<>^qw2 zMeZPziAK0x5g(x%Zd}TnQ=~}R7EzqRTa1!$BnBM$rzYjE)SdsDlaYpA9$yoc+KM4C z)z&t01TwbBkO-VPLIeyiz6u8_Mufmo@cu@^fn98EBj-SsEixnreqtQA;hJE&t!?B8 zjI~9EMBq(NCaLuZDcagXlomcKrzLc z_|=PSSG-6uW#8Hs8IsJij~W@7d)V3rlUWRbtSvGm0+*UejNv4$#nv`*1U9ophD6|y z(?`aEc3azE1d28E_r7|Oy^9ygVPNE95X4?_ia&O_KbCVBilbK9&%~?C@oFlL2G>`o zFiXMoN!Z~?GcF_8?R>{O(Z%4GrA$Rw>&wWdbWGaUI<3g7VPI^I!6-8YSL)ybHWcIp z0askI=%=uUijyHa*Fqh6hMAvI|26DhW_UJsV7m8ofPhEYy5+m(X&}u7Z@frFHgb&uA$|$KVOm1UU=x@l4uVtl7<;<$ zM8yZgKy1r+PBWKyi*#qHh_>OQBYdoJIcVcY)|LZfj8`!w!Uv=iTRsw7$vmZon-U34 zRV$44j!JVInY{ETF3)LK=LXK>kXstmmNHK;+1W}$?oR{%WYwM%W}FhxX*@Fk2Fmm^ z=adpymL#z((O8xkEK3ZQDUG18Oryuf(e;9h6PLsDl54Q|t-C3p0rM=|WA^Dh1qKzz zSy`O9Kt+Tx8z%^hu(*S{DnsEYR~?NnRtbfxj=@(2_$tFr!B-}nG_qiktEYjUVB>nn zZmGPL6U3YGs}|R&%_OQBUg%#Rvm4JogOW8k59&_m86OcH7&r`d zt#Wp7&($gLQ!`!uvgQ7>m;dhalD-=3Mv$VAE;tTX>P(D&{yb{%`~h6O010{FumK1X zyv1ranzR__F(F@U3{)Vwrnv*A67%>35rxADNU!OrzjB(p+C(GuFD5dF88i|7fEeh4 z?=qUAGJVc+U5LCZ5f(w!cz#nqV?GjYmH&axW}Xgv))e|UlBt9kG4FBq?SMpVm8R@b z`mM>E<)=yqXe=yi8oT@*Q4jey(Y=(|Pjg=+7|Rg<0L_@i{+Q)8Y0OMc(`|?MQR1`d z#r`bRKFxiMrQzpdIW2RTZ8g$$|v`fJL?P++RM9Q6zQY)N%F zaVPSW`0e~ow5-x#f%|Wk)T< zbfo|8b3mkvEtMgE4_FRL(1T{G!OIk}Lm~L+wCWRG@s(j^z%i4xhFXHZh8kx)iec(J ziwNf4s~s*2W>M+5Y8pL6FxJT83Rj>fG9hh_5|jWeN(n3QW^9ej7iavjU7B>HRWS{P zu?PSrKw-H}+)#aAWmZey!;EDE9Ix--yRBF<3_D*nKz%+^?e7BlajzaXhBGD8%wYTL$9A&coU-m}eZY3+xvfoed9L4`DR=iKx|D zu!(vMvDJ8q;RZuW<^VX?xKJ5|4@ONaIf}x(OUI2U?1Q!R)=s%$eY!^Jn$aMn`U3VX z;vNs*#aDENUHp(8Ji4GXJZBJ@Mzeuo{)0|ejR(s}3>VjwF0U!imO#az65CC4uO$uv zy&tnz7PntxE4?&2fby=IvTTY>D9ff{^QHQD1wMR1@i3qel_gp?v)Gd5_~Jc;hgYO) z5*L;Yg_h}Rz+w_(n3AEu`cBpF=tKeImGzad$Xse9zr^8zY^`LUBtAHda%M5Afdhn= zSiHQe8v9gB*<4Ui%;JeLF{MM=SeK=&vDiUo1zc*NLys|}z=Ago>*6f< z7SxC(O^YJ&MoE%^^8gTkxxp4F;J8E=^$h63@S-VD^Wvb@(Q`?2EQcfsn&fR7Y9(hD zQ@VC33F@$xT54XRQ0Zd*c0-<8LlHulSy_smB$R3huU?|(DRD56`~1;Rpx87Hx;O0z z;=DqvypLiG?;BL_OE$dWFcAhSaM z6R)fv8}U|Ak*H+o%EBxhN2N%Sh~b=5F+PEFYMzqLsU(Ye08z!9Q&YC6bBgvj0L(Wo zCxn8&CF)xYg)p{=T#5UuWV@IoumaXe_)T#Z4_5|u&>c!C`Z%g2_*|jK;3+^$2{c#_ zh{Z%>+R)1A|7nl~CY;)_J#*xEWow{;)`q0 zHWUH)Ep)D>zt7+tI{h$6UEn^;8@93@tw03un$9z4ZGate2hKCSP;|vqT>2*>eie`w zNMQ!M+VDjI?VaJ94(@bRqfIs8@l#KQ{64ruXi4twN!5Z?RiG|#`{*)TqoSS4av+!P zM{frj1UM0dr~*OBz&6Cr3E~Jg!#RW&!8uH8=TN*n@ei}*H6U30LpabJ!fm`C9bdY5 z^aaZ4|J9Vz6DlW{F@(dIMDvpXxJ{1)gU{8#L#9sXLPF{^Ua(!v&!Gf}11=awjMGI( zaCzY1V2=_q(8e2GgfBwBgwMv1(m_G1(?JQ0LevBRA*+MdK}l%`1=|n_IN}nBuPcI~ z{U@#ERDx_t)syU>L5u8N@?gJ3W<<0$g0%?Rg}mbk6r>XOEcURY8Af3+n!!j98_J|D zq#74JB)FL6nQZix$X><9k=R)LOK%vYL373-t93m|EvUk|p;B#+^+y0|>bY(glHplGl> zf?DxZjXdJf$RmtOAdf>$ctCdC>1cK{-2s1AY_>LuMJ7B%05MxmafDUT!YLCT5EnFp zo#}vt%1j4sDO-UDC?cB%FUhDfY;uD~p}S0Y@2G#SOYFF6f%#Ivx~`ad=9cT>&JetpMdt`n#Gkfk-gIS6Dj~-!Ghf<79Cb9drhxDZmlMLVd9M zQXgtzM@_3xqa*{eW1AoKff+~~2iCGfPbOl@s3}ORVj5j`jK<428ZSe?qp(PT5j#Lh zA=K>3dp@IpxCVMOF2gz?m$B+2mVcv~$oxtTw|>$>3u35>2A!0O;(*e!L0UxB$fj8a z7r+=Tk{GlIiVDne5slU&O9JX8!BrXxta?bVyciq2z+?k_LWM8#t)AEd=cV*U*%EEF-Oy}RD|dRpW;6)ck4Yq&Z$-Fh z0*=fB^Iam_LQ$Q;i5OBwkPog7Ai~vgwW|Y$(tMYR`7UGoiuo1?+oiJu!J+v^KG+VL zZ)v46$_)igzylKZp=y9M)Pzfbs4jz@yM(VXrQs6uEhU&0{DS#T!Ekt^9Bg-en!bg1 zaHYy%sV8f8upYqE*^d8XnJ#tFa5ZRmt{Vb3asjO3aur~Z8q67@2_cm-uD~h>WP)-+ z-Qj-_5WEy~Kutpt(Phx1B|~_UslnN7S4+3mW4H_vJ?7C6sx2lMD6pm~c2^<;(p6+I zF4rKbeVixTSApB0mIW;Wx*RMB=qsUR#{Gxpeevbhcl9#oJW4x zC+v^LTs?Rp(hre97H!;8j8ayN7GUKnCa>_o;Qv7YORee)BR9fAK-hDl-9`0p(m?ek7Eyf+Y1n~+k-`epD}60m3`~UTM%$uDw6Sr<*3PYE#R!|)t8FmGMdsdiZ_PW zsX#`NCJlqa|3w*vSYd2qOmaYQ$OH(n7QUe0krk(ZR`{gf;gNyi`yET;AmIxcGD!Gr z&jNysPlm$?y7_H8i*$^jw}JGcl1Og@YgN3rfpAsO+_1M%+R~snp0LWjsVw-6u(OfE z_&Dj{&IUsS{T~HQG@9c{b~zlxXk!l{ger0@qq~0eg&4QWehEtGpk77>2_-2QIO(Wi z!a0fK!djt^fo0Qu47^O~W28k&Xy_<)Bo=+9hJZOw8eWW_v2(;YK2Qwi;n<0MfjFgr z42wYcfVk^GmX65mnk*b$=!IAxJs9+tjB~uqeM|{?Iv-obT>jI+&?*yYp7kqw)($0jmQ^)IhV+cA~$@H-e6SQ(T!% z;{|1xF6xGQ4odCpilhcI-?yxDUfzhkEfTz}YYkMwzOzbB?H>fg(9_?uINv>lK0=TJ zWCPXeGq%f#uuil*YS^3^JuCKElFv@JnTvD93Zs)-x~%`eY~0ul2G0r;dD?qByZaX8 zdnf0*=k?5A)V(iMxqFNL>^}MIGU_(W(Ef{HI1|NYtXQ^W(Y((7eDZ~5T~r{Jb@nfG z;uREuG6)-I&Z53PViibQU8`wG5mB+=1&mwdWfY@?eWUpwq6woKIz+alu85=;X1+!% zFlc>RE27e{|H4K6Fx{cNM%8BsZVa6*knmVG%bC3gv3+mTI~e?iYlMIxliyYx$UOyb z5beuX^gw>%+>*5Ulb3ZaD%cI7&qta0i9#a)V~ezOuINMgeGbf`Bhbv~3oK#KX1V5i z={NI^?cuV^SdfppwGAJGRBNK8FH2?)*rSbo_&U*Rg)fXD3NQ=bMEj>O)!Kmq1vI|4 z>j;Zz3KHE<_}UUQxL~5A35%E-Y*Gns-F_Ab8oj=BB1xUHi6km+jjNCoFu? z(%KLt1|5|6pi^jQyWv*CgUPn^<`=+13}KUWBZY=BYEjI);;f1`q{eN2z^VOvdgo{J z=qpyhukOu?G3v%v?H+z zFY9t`ZGLTIt|Qmf&cyG`&9(07)HS#4>C9=~EjOd1CD%H~Z^GNgJ)QRE=8l}-ida)~ zdqe%6PQBmIh&(fLExD!{xu&{3omziJ$4tLHx4XZm)6&R)=C(U^vvPH_n-MW@I@%lN z<%MJLArTvXl@FU8k=V}gip3$YYy;j?Jdo1?arKrwiykr zkW1icz&Uk|4IrqauCckTXli3KYO3`c{ieD|9+GEkp#W-zU--A8^V*3|+e zDsg7wj|pw(c zYio0>vwN=ACd_S`-PF8$lX=k!bY{-APYsYsN;LuRhKJS;e{TCMrq%hx%@k^D1LJdO zfO@7w6$FOm#2y_WZFj$Q2E+g{hb@Ta^5^7$8Ji*U#0G(`F;lLi8K7Eiqt(p-$eO5+uNjeEq-%oF=pmE zYI9IPr>y}D1A6tX&3olEUbdVj7D_Q2FKnWI0-)N48E73R*EEY7*0Coh6*|CxIWZ}m z+uG>VHa6GI2I{qFM!yw}XVj#%xsg1>zkXe#DWzp4Fe?Uw`i5NNj5Z+B+|lT_0`<8~ z&Gl$&D4djQo()NEY-o}Iz#Mc~sG}V^1S2{th7njv-G!((%`~s(Hi6sPz$D(*H_mOF zh1!~1T6`E|XU^m{G$0~qCXG&W(@egn6^4S)Sk>hkVRK*|VMWOJHUS6uYZ{!vrbb(< zz144OtEZ+k)iuvxnLye&CX5p!LfO`^S5AyQjYxY#)7+e7f~C<$00w9dRI)ut)@*GX zlx_rNP&!(1P?|LbgI;JwDGag$25~NILjoUNB)11RML`{5t|n;kpsK8JCqY2jXEoCl z7ve;R2w3=XMlRr%6?^#J!p&Tu2nnO(wD>R_>=Iz!TVOVGkjb{WE$l)d{BvN-(TnWa zl5=J@H!&Ydt->8auZMhPrN+ zq*|CvN28<0mbp+AXHJg)+FrVdIwju+dIp;Ilx{RkX#h>4deot|c63yzpW-Xz&*+%j zGP4yXGMY4NN|35&9v#1_sd+Aph67p0zbqcn!L}AA&d9aZwKmjhF4mej+<@lR8B)MD zjBZ%rXFV#NA=+BsfXLjYS_Z8-3BXxxEceRI09!;Fq!Ah$n|DW&I0IGzgz2{J9KVrX z3A<3;n6OuDI22;O21?W%umjJH&CM-vrrO1r2%2@!V`E(8xv@Tcu0ypf1qTD7qsh{2 zbc_eo(Og$I7j6I>f*_<9hciSf!HT9C4bls;eN8Hy#bh5DytO`7q7Mz;Ym*qlNJx12 z!ZdQ2(8B&ix~wE>+QD=*52S1AFiEXB@s46?goy5BM%yfZcFtzh-3Mg_cF}fl9ezs- zoIu?Kb6_W-!umnX2>N<>vQ0B~Z;PW{;R*#@CBXWv;5EI2ncn+05^OJVyU!TA@AT@XZnA8 zqr6ihdFh)l!yOv`ejZCvrQxZ8KrTw`b6+I zH54V43@-i>Z(xy&G{T6H`{y(2i9jxFFOh}qp6=eB0}h-#FPI}tfUv7PoVnvFkDhp} z99220Z4UnpvMbk71+-GTphs_f-ERtW(IS4!Bj9eMVW~PJxPmc2uvU}&90k0 z*Q8@@pe~1fRsIa@zXAt6BNG(}D@~liX#9VIs=(>(G8-LSLc$*C! z=U&8j8tXXwZ{RrVBEBKg&&Kmp_`3wp{p&f-iAZ~CeaD%F_|-_i7U8jYer+A#iLeRv zl%f2F_K>R)Yos7R<;Q43xI}d*|@%$3%=tG$|@ps`k z$9WfNw;{d&@8{$1Ej*ut^h(fl9OB2|Z#$%)iSWA!cfj8`yn7jCDv)n`#P0)rH{x$O Oa9W1 { const request = https.request(httpServer.https.url('/resource/two'), { rejectUnauthorized: false, + headers: { 'content-length': '6' }, }) request.write('second') request.end() diff --git a/tsconfig.base.json b/tsconfig.base.json index 2f19be517..899c90c41 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -12,7 +12,6 @@ "paths": { "#/src/*": ["./src/*"], "#/test/*": ["./test/*"], - "_http_common": ["./_http_common.d.ts"], "internal:brotli-decompress": [ "./src/interceptors/fetch/utils/brotli-decompress.ts", ], diff --git a/tsconfig.src.json b/tsconfig.src.json index 2d5ae4bdf..cb7574bac 100644 --- a/tsconfig.src.json +++ b/tsconfig.src.json @@ -10,7 +10,6 @@ "paths": { "#/src/*": ["./src/*"], "#/test/*": ["./test/*"], - "_http_common": ["./_http_common.d.ts"], "internal:brotli-decompress": [ "./src/interceptors/fetch/utils/brotli-decompress.ts", ], diff --git a/tsdown.config.mts b/tsdown.config.mts index 1757eaac7..d03525e1d 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -12,7 +12,10 @@ export default defineConfig([ './src/interceptors/XMLHttpRequest/node.ts', './src/interceptors/fetch/node.ts', ], - external: ['_http_common'], + copy: { + from: './src/interceptors/http/http-parser/llhttp/**', + to: './lib/node/llhttp', + }, outDir: './lib/node', platform: 'node', target: 'node22', From 41bea93bfcd0c562e015049b91a4e25c365f3aae Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 12:56:08 +0200 Subject: [PATCH 186/198] fix(http): skip headers modification if `request.headers` have not changed --- src/interceptors/http/index.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 25a2e1fd3..3e1d2d1da 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -158,9 +158,6 @@ export class HttpRequestInterceptor extends Interceptor { }, passthrough: () => { const realSocket = socketController.passthrough( - /** - * @todo Would be great NOT to run this if request headers weren't modified. - */ this.#modifyHttpHeaders(context.request) ) @@ -431,14 +428,39 @@ export class HttpRequestInterceptor extends Interceptor { const parts = httpMessage.toString(encoding).split('\r\n') const headersEndIndex = parts.findIndex((field) => field === '') const httpMessageHeaderPairs = parts.slice(1, headersEndIndex) + + // Extract raw [name, value] tuples from the wire format so they + // can be compared against the request's raw fetch headers. + const httpMessageRawHeaders = httpMessageHeaderPairs.map( + (line): [string, string] => { + const separatorIndex = line.indexOf(': ') + return [line.slice(0, separatorIndex), line.slice(separatorIndex + 2)] + } + ) + + const requestRawHeaders = getRawFetchHeaders(request.headers) + + // If the raw headers from the outgoing HTTP message and the request + // headers are identical, send the message as-is to avoid the cost + // (and side effects) of reserializing the headers block. + const headersUnchanged = + httpMessageRawHeaders.length === requestRawHeaders.length && + httpMessageRawHeaders.every((tuple, index) => { + const requestTuple = requestRawHeaders[index] + return tuple[0] === requestTuple[0] && tuple[1] === requestTuple[1] + }) + + if (headersUnchanged) { + return httpMessage + } + const httpMessageHeaders = FetchResponse.parseRawHeaders( httpMessageHeaderPairs.flatMap((header) => header.split(': ')) ) - const rawHeaders = getRawFetchHeaders(request.headers) const visitedHeaders = new Set() - for (const [headerName] of rawHeaders) { + for (const [headerName] of requestRawHeaders) { const normalizedHeaderName = headerName.toLowerCase() if (visitedHeaders.has(normalizedHeaderName)) { From 8626290827594f4bf089b5e3d0d89e0913a621fe Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 13:10:24 +0200 Subject: [PATCH 187/198] fix(BatchInterceptor): make batch interceptor singleton a no-op --- src/BatchInterceptor.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/BatchInterceptor.ts b/src/BatchInterceptor.ts index e2693b488..ad7af217d 100644 --- a/src/BatchInterceptor.ts +++ b/src/BatchInterceptor.ts @@ -33,14 +33,10 @@ export class BatchInterceptor< InterceptorList extends ReadonlyArray>, Events extends DefaultEventMap = ExtractEventMapType, > extends Interceptor { - static symbol: symbol - #logger: Logger #interceptors: InterceptorList constructor(options: BatchInterceptorOptions) { - BatchInterceptor.symbol = Symbol.for(options.name) - super() this.#logger = logger.extend(options.name) From 93cead87a3b1e0e335b0b779521ea4bb11a120af Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 17:27:08 +0200 Subject: [PATCH 188/198] docs: add jsdoc to interceptors --- src/interceptors/ClientRequest/index.ts | 5 ++++- src/interceptors/fetch/node.ts | 7 +++++++ src/interceptors/fetch/web.ts | 3 +++ src/interceptors/http/index.ts | 4 ++++ src/interceptors/net/index.ts | 3 +++ 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index e9736ddf7..058f8eec1 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,12 +1,15 @@ import http from 'node:http' import https from 'node:https' import type { Emitter } from 'rettime' -import { requestContext, runInRequestContext } from '#/src/request-context' +import { runInRequestContext } from '#/src/request-context' import { patchesRegistry } from '#/src/utils/patchesRegistry' import { HttpRequestInterceptor } from '#/src/interceptors/http' import { Interceptor } from '../../interceptor' import { HttpRequestEventMap } from '#/src/events/http' +/** + * Interceptor for HTTP requests in Node.js made via `http.ClientRequest`. + */ export class ClientRequestInterceptor extends Interceptor { static symbol = Symbol.for('client-request-interceptor') diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 9d1da9f3c..709f8bb6e 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -7,6 +7,13 @@ import { HttpRequestInterceptor } from '#/src/interceptors/http' import { HttpRequestEventMap } from '#/src/events/http' import { Interceptor } from '../../interceptor' +/** + * Interceptor for `fetch` requests in Node.js. + * @note This interceptor only affects requests performed via + * the global `fetch` function. To intercept fetch requests performed + * by other means (e.g. direct `request()` from Undici) use the + * `HttpRequestInterceptor` instead. + */ export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') diff --git a/src/interceptors/fetch/web.ts b/src/interceptors/fetch/web.ts index e2a6859b5..8d406ec05 100644 --- a/src/interceptors/fetch/web.ts +++ b/src/interceptors/fetch/web.ts @@ -18,6 +18,9 @@ import { Interceptor } from '../../interceptor' const logger = new Logger('fetch') +/** + * Interceptor for `fetch` requests in the browser. + */ export class FetchInterceptor extends Interceptor { static symbol = Symbol.for('fetch-interceptor') diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 3e1d2d1da..2ab19fb25 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -32,6 +32,10 @@ import { Interceptor } from '#/src/interceptor' const log = createLogger('HttpRequestInterceptor') +/** + * Interceptor for HTTP requests in Node.js. + * Routes socket connections through an HTTP parser. + */ export class HttpRequestInterceptor extends Interceptor { static symbol = Symbol.for('http-request-interceptor') diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index a85761d5a..bd2c8fc03 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -39,6 +39,9 @@ type SocketEventMap = { const log = createLogger('SocketInterceptor') +/** + * Interceptor for `net.Socket` connections. + */ export class SocketInterceptor extends Interceptor { static symbol = Symbol.for('socket-interceptor') From 06e3f5481306a8f6864289fb54fde9a487f7e35d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 17:28:49 +0200 Subject: [PATCH 189/198] chore: rename `record-raw-headers` file --- .../{recordRawHeaders.test.ts => record-raw-headers.test.ts} | 2 +- .../utils/{recordRawHeaders.ts => record-raw-headers.ts} | 0 src/interceptors/fetch/web.ts | 2 +- src/interceptors/http/index.ts | 2 +- src/utils/fetchUtils.ts | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename src/interceptors/ClientRequest/utils/{recordRawHeaders.test.ts => record-raw-headers.test.ts} (99%) rename src/interceptors/ClientRequest/utils/{recordRawHeaders.ts => record-raw-headers.ts} (100%) diff --git a/src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts b/src/interceptors/ClientRequest/utils/record-raw-headers.test.ts similarity index 99% rename from src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts rename to src/interceptors/ClientRequest/utils/record-raw-headers.test.ts index 55e2d5e7c..2f8a00e38 100644 --- a/src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts +++ b/src/interceptors/ClientRequest/utils/record-raw-headers.test.ts @@ -3,7 +3,7 @@ import { recordRawFetchHeaders, restoreHeadersPrototype, getRawFetchHeaders, -} from './recordRawHeaders' +} from './record-raw-headers' const url = 'http://localhost' diff --git a/src/interceptors/ClientRequest/utils/recordRawHeaders.ts b/src/interceptors/ClientRequest/utils/record-raw-headers.ts similarity index 100% rename from src/interceptors/ClientRequest/utils/recordRawHeaders.ts rename to src/interceptors/ClientRequest/utils/record-raw-headers.ts diff --git a/src/interceptors/fetch/web.ts b/src/interceptors/fetch/web.ts index 8d406ec05..44d30ccf3 100644 --- a/src/interceptors/fetch/web.ts +++ b/src/interceptors/fetch/web.ts @@ -13,7 +13,7 @@ import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { FetchResponse } from '../../utils/fetchUtils' import { isResponseError } from '../../utils/responseUtils' import { patchesRegistry } from '../../utils/patchesRegistry' -import { copyRawHeaders } from '../ClientRequest/utils/recordRawHeaders' +import { copyRawHeaders } from '../ClientRequest/utils/record-raw-headers' import { Interceptor } from '../../interceptor' const logger = new Logger('fetch') diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 2ab19fb25..648e2794f 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -11,7 +11,7 @@ import { RequestController } from '../../RequestController' import { getRawFetchHeaders, recordRawFetchHeaders, -} from '../ClientRequest/utils/recordRawHeaders' +} from '../ClientRequest/utils/record-raw-headers' import { SocketInterceptor } from '../net' import { connectionOptionsToUrl } from '../net/utils/connection-options-to-url' import { toBuffer } from '../../utils/bufferUtils' diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index 06c865dd3..7c432df6f 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -1,4 +1,4 @@ -import { copyRawHeaders } from '../interceptors/ClientRequest/utils/recordRawHeaders' +import { copyRawHeaders } from '../interceptors/ClientRequest/utils/record-raw-headers' import { canParseUrl } from './canParseUrl' import { getValueBySymbol } from './getValueBySymbol' import { isResponseError } from './responseUtils' From 6e657dc9c6b082241e40e4c07ad386678b55768c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 17:30:31 +0200 Subject: [PATCH 190/198] chore: move undici test from fetch to http --- .../{fetch/compliance => http/third-party}/undici.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename test/modules/{fetch/compliance => http/third-party}/undici.test.ts (97%) diff --git a/test/modules/fetch/compliance/undici.test.ts b/test/modules/http/third-party/undici.test.ts similarity index 97% rename from test/modules/fetch/compliance/undici.test.ts rename to test/modules/http/third-party/undici.test.ts index 548e39d13..05f9c991e 100644 --- a/test/modules/fetch/compliance/undici.test.ts +++ b/test/modules/http/third-party/undici.test.ts @@ -1,8 +1,9 @@ // @vitest-environment node import { fetch, request } from 'undici' -import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { HttpRequestInterceptor } from '@mswjs/interceptors/http' const interceptor = new HttpRequestInterceptor() + beforeAll(() => { interceptor.apply() }) From dc9d2c43d00e1021e79945ff8f79ff43b3ce2491 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 17:38:28 +0200 Subject: [PATCH 191/198] test: turn browser preset to vbm test --- .../presets/browser-preset.browser.test.ts | 37 ----------- .../presets/browser-preset.runtime.js | 26 -------- .../presets/browser-preset.v-browser.test.ts | 64 +++++++++++++++++++ 3 files changed, 64 insertions(+), 63 deletions(-) delete mode 100644 test/features/presets/browser-preset.browser.test.ts delete mode 100644 test/features/presets/browser-preset.runtime.js create mode 100644 test/features/presets/browser-preset.v-browser.test.ts diff --git a/test/features/presets/browser-preset.browser.test.ts b/test/features/presets/browser-preset.browser.test.ts deleted file mode 100644 index 05ab577db..000000000 --- a/test/features/presets/browser-preset.browser.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { test, expect } from '../../playwright.extend' - -test('intercepts and mocks a fetch request', async ({ loadExample, page }) => { - await loadExample(require.resolve('./browser-preset.runtime.js')) - - // Perform a fetch request. - const response = await page.evaluate(() => { - return fetch('http://localhost:3001/resource').then(async (response) => { - return { - status: response.status, - statusText: response.statusText, - text: await response.text(), - } - }) - }) - - expect(response.status).toBe(200) - expect(response.text).toBe('mocked') -}) - -test('intercepts and mocks an XMLHttpRequest', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./browser-preset.runtime.js')) - - const [request, response] = await callXMLHttpRequest({ - method: 'GET', - url: 'http://localhost:3001/resource', - }) - - expect(request.method).toBe('GET') - expect(request.url).toBe('http://localhost:3001/resource') - - expect(response.status).toBe(200) - expect(response.body).toBe('mocked') -}) diff --git a/test/features/presets/browser-preset.runtime.js b/test/features/presets/browser-preset.runtime.js deleted file mode 100644 index ef0bb5fb2..000000000 --- a/test/features/presets/browser-preset.runtime.js +++ /dev/null @@ -1,26 +0,0 @@ -import { BatchInterceptor } from '@mswjs/interceptors' -import browserInterceptors from '@mswjs/interceptors/presets/browser' - -const interceptor = new BatchInterceptor({ - name: 'browser-preset-interceptor', - interceptors: browserInterceptors, -}) - -interceptor.apply() - -interceptor.on('request', async ({ request, requestId, controller }) => { - window.dispatchEvent( - new CustomEvent('resolver', { - detail: { - id: requestId, - method: request.method, - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - credentials: request.credentials, - body: await request.clone().text(), - }, - }) - ) - - controller.respondWith(new Response('mocked')) -}) diff --git a/test/features/presets/browser-preset.v-browser.test.ts b/test/features/presets/browser-preset.v-browser.test.ts new file mode 100644 index 000000000..4852bd52d --- /dev/null +++ b/test/features/presets/browser-preset.v-browser.test.ts @@ -0,0 +1,64 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { BatchInterceptor } from '@mswjs/interceptors' +import browserInterceptors from '@mswjs/interceptors/presets/browser' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' + +const interceptor = new BatchInterceptor({ + name: 'browser', + interceptors: browserInterceptors, +}) + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts and mocks a fetch request', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.respondWith(new Response('mocked')) + }) + + const response = await fetch('http://localhost:3001/resource') + + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked') + + const request = await requestPromise + + expect(request.method).toBe('GET') + expect(request.url).toBe('http://localhost:3001/resource') + expect(request.body).toBeNull() +}) + +it('intercepts and mocks an XMLHttpRequest', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.respondWith(new Response('mocked')) + }) + + const xhr = new XMLHttpRequest() + xhr.open('GET', 'http://localhost:3001/resource') + xhr.send() + + await waitForXMLHttpRequest(xhr) + + expect(xhr.status).toBe(200) + expect(xhr.response).toBe('mocked') + + const request = await requestPromise + + expect(request.method).toBe('GET') + expect(request.url).toBe('http://localhost:3001/resource') +}) From b0f1acd4236ae098a13b69af58e6d970abe64bcf Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 18:33:48 +0200 Subject: [PATCH 192/198] fix(SocketController): remove paused buffer --- src/interceptors/net/socket-controller.ts | 31 ----------------------- 1 file changed, 31 deletions(-) diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts index fa1ca13d4..fddf0b063 100644 --- a/src/interceptors/net/socket-controller.ts +++ b/src/interceptors/net/socket-controller.ts @@ -248,7 +248,6 @@ export class TcpSocketController extends SocketController { #connectionOptions?: NetworkConnectionOptions #realWriteGeneric: net.Socket['_writeGeneric'] #passthroughSocket: net.Socket | null = null - #passthroughPausedBuffer: Array = [] #bufferedWrites: Array> = [] constructor( @@ -288,7 +287,6 @@ export class TcpSocketController extends SocketController { .on('close', () => { log('client socket closed!') this.#passthroughSocket = null - this.#passthroughPausedBuffer = [] this.#bufferedWrites = [] }) @@ -451,14 +449,6 @@ export class TcpSocketController extends SocketController { #onRealSocketData = (data: Buffer) => { log('real socket "data" event:\n', data?.toString()) - if (this.socket.isPaused()) { - log('client socket paused, buffering...') - this.#passthroughPausedBuffer.push(data) - return - } - - log('pushing real data to the client socket...') - if (!this.socket.push(data)) { log( 'client socket forbade more pushes, pausing the passthrough socket...' @@ -614,27 +604,6 @@ export class TcpSocketController extends SocketController { return this.#realWriteGeneric.apply(this.socket, args) } - // Buffer to hold data chunks while the mock socket is paused. - // This allows async response event listeners to complete before - // data flows to the mock socket and triggers ClientRequest events. - this.#passthroughPausedBuffer = [] - this.socket.resume = new Proxy(this.socket.resume, { - apply: (target, thisArg, argArray) => { - const result = Reflect.apply(target, thisArg, argArray) - - while (this.#passthroughPausedBuffer.length > 0) { - const bufferedData = this.#passthroughPausedBuffer.shift()! - - if (!this.socket.push(bufferedData)) { - this.#passthroughSocket?.pause() - break - } - } - - return result - }, - }) - this.socket.address = realSocket.address.bind(realSocket) this.socket.removeListener('drain', this.#onMockSocketDrain) From eac28a91c06c74c9a37617367b216cc379de4369 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 18:43:52 +0200 Subject: [PATCH 193/198] test: a few fetch response tests --- .../fetch-response-stream.neutral.test.ts | 81 +++++++++++++++++++ .../fetch-response-text.neutral.test.ts | 52 ++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 test/modules/fetch/response/fetch-response-stream.neutral.test.ts create mode 100644 test/modules/fetch/response/fetch-response-text.neutral.test.ts diff --git a/test/modules/fetch/response/fetch-response-stream.neutral.test.ts b/test/modules/fetch/response/fetch-response-stream.neutral.test.ts new file mode 100644 index 000000000..9da7cc927 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-stream.neutral.test.ts @@ -0,0 +1,81 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { getTestServer } from '#/test/setup/vitest' +import { setTimeout } from '#/test/setup/helpers-neutral' + +const server = getTestServer() +const interceptor = new FetchInterceptor() + +const encoder = new TextEncoder() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a stream response', async () => { + const response = await fetch(server.http.url('/stream'), { + method: 'POST', + body: 'original', + }) + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('original') +}) + +it('responds with a stream response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response( + new ReadableStream({ + async pull(controller) { + controller.enqueue(encoder.encode('hello')) + await setTimeout(100) + controller.enqueue(encoder.encode(' ')) + await setTimeout(100) + controller.enqueue(encoder.encode('world')) + controller.close() + }, + }) + ) + ) + }) + + const response = await fetch('http://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('hello world') +}) + +it('responds with a stream response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response( + new ReadableStream({ + async pull(controller) { + controller.enqueue(encoder.encode('hello')) + await setTimeout(100) + controller.enqueue(encoder.encode(' ')) + await setTimeout(100) + controller.enqueue(encoder.encode('world')) + controller.close() + }, + }) + ) + ) + }) + + const response = await fetch('https://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/fetch/response/fetch-response-text.neutral.test.ts b/test/modules/fetch/response/fetch-response-text.neutral.test.ts new file mode 100644 index 000000000..f0683d286 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-text.neutral.test.ts @@ -0,0 +1,52 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new FetchInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a text response', async () => { + const response = await fetch(server.http.url('/'), { + method: 'POST', + body: 'original', + }) + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('original') +}) + +it('responds with a text response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const response = await fetch('http://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('hello world') +}) + +it('responds with a text response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const response = await fetch('https://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('hello world') +}) From c5e5c6fdc1f89921e6c5e23d45e22dcc5c45eef6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 19:14:32 +0200 Subject: [PATCH 194/198] test: add fetch json response tests --- .../fetch-response-json.neutral.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 test/modules/fetch/response/fetch-response-json.neutral.test.ts diff --git a/test/modules/fetch/response/fetch-response-json.neutral.test.ts b/test/modules/fetch/response/fetch-response-json.neutral.test.ts new file mode 100644 index 000000000..4fd5a8f90 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-json.neutral.test.ts @@ -0,0 +1,53 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new FetchInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a json response', async () => { + const response = await fetch(server.http.url('/json'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ message: 'original' }), + }) + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.json()).resolves.toEqual({ message: 'original' }) +}) + +it('responds with a json response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.json({ message: 'hello world' })) + }) + + const response = await fetch('http://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.json()).resolves.toEqual({ message: 'hello world' }) +}) + +it('responds with a json response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.json({ message: 'hello world' })) + }) + + const response = await fetch('https://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.json()).resolves.toEqual({ message: 'hello world' }) +}) From 50201a9aee70138394f1c6781a6f2131411c02d3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 20:14:27 +0200 Subject: [PATCH 195/198] fix(fetch): pass the same `request` to prevent "already consumed" error --- src/interceptors/fetch/node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts index 709f8bb6e..91dc7477a 100644 --- a/src/interceptors/fetch/node.ts +++ b/src/interceptors/fetch/node.ts @@ -135,7 +135,7 @@ export class FetchInterceptor extends Interceptor { initiator: request, }) - return realFetch(input, init) + return realFetch(request) } }) ) From ba341d2f5e1f5c431f0ffbc64a76e2813915690b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 20:14:46 +0200 Subject: [PATCH 196/198] test: add fetch response patching test --- test/helpers.ts | 1 + .../fetch-response-patching.neutral.test.ts | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 test/modules/fetch/response/fetch-response-patching.neutral.test.ts diff --git a/test/helpers.ts b/test/helpers.ts index eaaafcf55..4fbdfb273 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -170,6 +170,7 @@ export const useCors: RequestHandler = (_req, res, next) => { res.set({ 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', + 'access-control-allow-methods': '*', }) return next() } diff --git a/test/modules/fetch/response/fetch-response-patching.neutral.test.ts b/test/modules/fetch/response/fetch-response-patching.neutral.test.ts new file mode 100644 index 000000000..fb67635a7 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-patching.neutral.test.ts @@ -0,0 +1,63 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new FetchInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('patches the original response', async () => { + interceptor.on('request', async ({ request, controller }) => { + const url = new URL(request.url) + + if (url.searchParams.get('passthrough') === '1') { + return controller.passthrough() + } + + url.searchParams.set('passthrough', '1') + const originalResponse = await fetch( + new Request(url, { + method: request.method, + headers: request.headers, + /** + * @note Read the request's body to prevent Chrome from throwing + * "TypeError: Failed to fetch" due to refusing to send a streaming + * request body over HTTP/1.1. + */ + body: await request.text(), + }) + ) + const originalData = await originalResponse.json() + + controller.respondWith( + Response.json({ + ...originalData, + mock: true, + }) + ) + }) + + const response = await fetch(server.http.url('/'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ message: 'hello world' }), + }) + + expect.soft(response.status).toBe(200) + await expect.soft(response.json()).resolves.toEqual({ + message: 'hello world', + mock: true, + }) +}) From ab63c278b0d326834a059a4e34123b4f1e4b00d0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 May 2026 20:19:10 +0200 Subject: [PATCH 197/198] test: delete legacy response patching test --- .../fetch-response-patching.browser.test.ts | 86 ------------------- .../fetch-response-patching.runtime.js | 22 ----- 2 files changed, 108 deletions(-) delete mode 100644 test/modules/fetch/response/fetch-response-patching.browser.test.ts delete mode 100644 test/modules/fetch/response/fetch-response-patching.runtime.js diff --git a/test/modules/fetch/response/fetch-response-patching.browser.test.ts b/test/modules/fetch/response/fetch-response-patching.browser.test.ts deleted file mode 100644 index dff5cbcad..000000000 --- a/test/modules/fetch/response/fetch-response-patching.browser.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { HttpServer } from '@open-draft/test-server/http' -import { Page } from '@playwright/test' -import { test, expect } from '../../../playwright.extend' -import { FetchInterceptor } from '#/src/interceptors/fetch/web' -import { useCors } from '#/test/helpers' - -declare namespace window { - export const interceptor: FetchInterceptor - export let originalUrl: string -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/original', (req, res) => { - res - .set('access-control-expose-headers', 'x-custom-header') - .set('x-custom-header', 'yes') - .send('hello') - }) -}) - -async function forwardServerUrl(page: Page): Promise { - await page.evaluate((url) => { - window.originalUrl = url - }, httpServer.http.url('/original')) -} - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - -test('supports response patching', async ({ loadExample, page }) => { - await loadExample(require.resolve('./fetch-response-patching.runtime.js')) - await forwardServerUrl(page) - - const res = await page.evaluate(() => { - return fetch('http://localhost/mocked').then((res) => { - return res.text().then((text) => { - return { - status: res.status, - statusText: res.statusText, - headers: Array.from(res.headers.entries()), - text, - } - }) - }) - }) - const headers = new Headers(res.headers) - - expect(res.status).toBe(200) - expect(res.statusText).toBe('OK') - expect(headers.get('x-custom-header')).toBe('yes') - expect(res.text).toBe('hello world') -}) - -test('throws an AbortError, when the request has been aborted', async ({ - loadExample, - page, -}) => { - await loadExample(require.resolve('./fetch-response-patching.runtime.js')) - await forwardServerUrl(page) - - const result = await page.evaluate(async () => { - const controller = new AbortController() - const response = fetch('http://localhost/mocked', { - signal: controller.signal, - }).then( - () => { - throw new Error('Fetch did not reject') - }, - (error) => ({ - isDomException: error instanceof DOMException, - name: error.name, - }) - ) - controller.abort() - return await response - }) - - expect(result.name).toBe('AbortError') - expect(result.isDomException).toBe(true) -}) diff --git a/test/modules/fetch/response/fetch-response-patching.runtime.js b/test/modules/fetch/response/fetch-response-patching.runtime.js deleted file mode 100644 index 6eff78be2..000000000 --- a/test/modules/fetch/response/fetch-response-patching.runtime.js +++ /dev/null @@ -1,22 +0,0 @@ -import { FetchInterceptor } from '@mswjs/interceptors/fetch' - -const interceptor = new FetchInterceptor() - -interceptor.on('request', async ({ request, controller }) => { - const url = new URL(request.url) - - if (url.pathname === '/mocked') { - await new Promise((resolve) => setTimeout(resolve, 0)) - - const originalResponse = await fetch(window.originalUrl) - const originalText = await originalResponse.text() - - controller.respondWith( - new Response(`${originalText} world`, originalResponse) - ) - } -}) - -interceptor.apply() - -window.interceptor = interceptor From 0b1ede0bfc7ee1b209417864624a75629f3f3242 Mon Sep 17 00:00:00 2001 From: Michael Solomon Date: Mon, 11 May 2026 19:49:36 +0300 Subject: [PATCH 198/198] fix: support responses exceeding high watermark (#782) Co-authored-by: Artem Zakharchenko --- src/Interceptor.test.ts | 194 ------------------ src/interceptor.test.ts | 22 +- src/interceptor.ts | 2 +- src/interceptors/http/index.ts | 1 + src/interceptors/net/utils/address-info.ts | 2 +- test/modules/http/compliance/http.test.ts | 6 +- test/modules/http/response/http-https.test.ts | 17 +- 7 files changed, 43 insertions(+), 201 deletions(-) delete mode 100644 src/Interceptor.test.ts diff --git a/src/Interceptor.test.ts b/src/Interceptor.test.ts deleted file mode 100644 index c6de737d3..000000000 --- a/src/Interceptor.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { TypedEvent } from 'rettime' -import { Interceptor } from './interceptor' - -it('nesting interceptors', async () => { - const socketSetup = vi.fn() - const protocolSetup = vi.fn() - - class SocketInterceptor extends Interceptor<{ - data: TypedEvent - }> { - static symbol = Symbol.for('socket-interceptor') - - protected predicate(): boolean { - return true - } - - protected setup(): void { - socketSetup() - - queueMicrotask(() => { - this.emitter.emit(new TypedEvent('data', { data: 1 })) - this.emitter.emit(new TypedEvent('data', { data: 'hello' })) - this.emitter.emit(new TypedEvent('data', { data: 2 })) - }) - } - } - - class ProtocolInterceptor extends Interceptor<{ - request: TypedEvent - }> { - static symbol = Symbol.for('protocol-interceptor') - - protected predicate(): boolean { - return true - } - - protected setup(): void { - protocolSetup() - - const socket = Interceptor.singleton(SocketInterceptor) - socket.apply() - this.subscriptions.push(() => socket.dispose()) - - const controller = new AbortController() - this.subscriptions.push(() => controller.abort()) - - socket.on( - 'data', - ({ data }) => { - this.emitter.emit(new TypedEvent('request', { data })) - }, - { signal: controller.signal } - ) - } - } - - class NumberInterceptor extends Interceptor<{ - number: TypedEvent - }> { - static symbol = Symbol.for('number-interceptor') - - protected predicate(): boolean { - return true - } - - protected setup(): void { - const protocol = Interceptor.singleton(ProtocolInterceptor) - protocol.apply() - this.subscriptions.push(() => protocol.dispose()) - - const controller = new AbortController() - this.subscriptions.push(() => controller.abort()) - - protocol.on( - 'request', - ({ data }) => { - if (typeof data === 'number') { - this.emitter.emit(new TypedEvent('number', { data })) - } - }, - { signal: controller.signal } - ) - } - } - - class StringInterceptor extends Interceptor<{ - string: TypedEvent - }> { - static symbol = Symbol.for('string-interceptor') - - protected predicate(): boolean { - return true - } - - protected setup(): void { - const protocol = Interceptor.singleton(ProtocolInterceptor) - protocol.apply() - this.subscriptions.push(() => protocol.dispose()) - - const controller = new AbortController() - this.subscriptions.push(() => controller.abort()) - - protocol.on( - 'request', - ({ data }) => { - if (typeof data === 'string') { - this.emitter.emit(new TypedEvent('string', { data })) - } - }, - { signal: controller.signal } - ) - } - } - - const numberListener = vi.fn() - const numberInterceptor = new NumberInterceptor() - numberInterceptor.on('number', ({ data }) => numberListener(data)) - numberInterceptor.apply() - - const stringListener = vi.fn() - const stringInterceptor = new StringInterceptor() - stringInterceptor.on('string', ({ data }) => stringListener(data)) - stringInterceptor.apply() - - expect(socketSetup).toHaveBeenCalledOnce() - expect(protocolSetup).toHaveBeenCalledOnce() - - await expect.poll(() => numberListener).toHaveBeenCalledTimes(2) - - numberInterceptor.dispose() - stringInterceptor.dispose() - - expect(numberListener).toHaveBeenNthCalledWith(1, 1) - expect(numberListener).toHaveBeenNthCalledWith(2, 2) - expect(stringListener).toHaveBeenCalledExactlyOnceWith('hello') -}) - -it('treats an interceptor as a singleton via "Interceptor.singleton()"', () => { - const setup = vi.fn() - const dispose = vi.fn() - - class MyInterceptor extends Interceptor<{ test: TypedEvent }> { - protected predicate(): boolean { - return true - } - - protected setup(): void { - setup() - } - - public dispose(): void { - super.dispose() - dispose() - } - } - - const interceptor = Interceptor.singleton(MyInterceptor) - expect(setup).not.toHaveBeenCalled() - - interceptor.apply() - expect(setup).toHaveBeenCalledOnce() - - { - const interceptor = Interceptor.singleton(MyInterceptor) - expect(setup).toHaveBeenCalledOnce() - - interceptor.dispose() - expect(dispose).toHaveBeenCalledOnce() - } - - interceptor.dispose() - expect(dispose).toHaveBeenCalledTimes(2) -}) - -it('removes all listeners when the interceptor is disposed', () => { - class MyInterceptor extends Interceptor<{ test: TypedEvent }> { - protected predicate(): boolean { - return true - } - - protected setup(): void {} - } - - const interceptor = new MyInterceptor() - interceptor.apply() - - const listener = vi.fn() - interceptor.on('test', listener) - - interceptor.dispose() - - interceptor['emitter'].emit(new TypedEvent('test')) - expect(listener).not.toHaveBeenCalled() -}) diff --git a/src/interceptor.test.ts b/src/interceptor.test.ts index c6de737d3..cfd5c2932 100644 --- a/src/interceptor.test.ts +++ b/src/interceptor.test.ts @@ -1,5 +1,5 @@ import { TypedEvent } from 'rettime' -import { Interceptor } from './interceptor' +import { Interceptor, InterceptorReadyState } from './interceptor' it('nesting interceptors', async () => { const socketSetup = vi.fn() @@ -192,3 +192,23 @@ it('removes all listeners when the interceptor is disposed', () => { interceptor['emitter'].emit(new TypedEvent('test')) expect(listener).not.toHaveBeenCalled() }) + +it('applies the interceptor after disposal', () => { + class MyInterceptor extends Interceptor<{}> { + protected predicate(): boolean { + return true + } + + protected setup(): void {} + } + const interceptor = new MyInterceptor() + + interceptor.apply() + expect(interceptor.readyState).toBe(InterceptorReadyState.ACTIVE) + + interceptor.dispose() + expect(interceptor.readyState).toBe(InterceptorReadyState.DISPOSED) + + interceptor.apply() + expect(interceptor.readyState).toBe(InterceptorReadyState.ACTIVE) +}) diff --git a/src/interceptor.ts b/src/interceptor.ts index be261ccf5..2a88b1ed4 100644 --- a/src/interceptor.ts +++ b/src/interceptor.ts @@ -54,7 +54,7 @@ export abstract class Interceptor< protected abstract setup(): void public apply(): void { - if (this.readyState === InterceptorReadyState.DISPOSED) { + if (this.readyState === InterceptorReadyState.ACTIVE) { return } diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 648e2794f..f079f1605 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -318,6 +318,7 @@ export class HttpRequestInterceptor extends Interceptor { callback(null) } + responseSocket.on('drain', () => serverResponse.emit('drain')) serverResponse.assignSocket(responseSocket) serverResponse.removeHeader('connection') diff --git a/src/interceptors/net/utils/address-info.ts b/src/interceptors/net/utils/address-info.ts index 024a50e40..d3bfabfca 100644 --- a/src/interceptors/net/utils/address-info.ts +++ b/src/interceptors/net/utils/address-info.ts @@ -18,6 +18,6 @@ export function getAddressInfoByConnectionOptions( return { address: ipAddress || isIPv6 ? '::1' : '127.0.0.1', port: options.port || (options.protocol === 'https:' ? 443 : 80), - family: isIPv6 ? 'ipv6' : 'ipv4', + family: isIPv6 ? 'IPv6' : 'IPv4', } } diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index 366fc8590..619539832 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -180,7 +180,7 @@ it('returns socket address for a mocked IPv4 request', async () => { await expect(addressPromise).resolves.toEqual({ address: '127.0.0.1', - family: 'ipv4', + family: 'IPv4', port: 80, }) }) @@ -204,7 +204,7 @@ it('returns socket address for a mocked IPv6 request', async () => { await expect(addressPromise).resolves.toEqual({ address: '::1', - family: 'ipv6', + family: 'IPv6', port: 80, }) }) @@ -224,7 +224,7 @@ it('returns socket address for a mocked request with IPv6 hostname', async () => await expect(addressPromise).resolves.toEqual({ address: '::1', - family: 'ipv6', + family: 'IPv6', port: 80, }) }) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 9faca1ace..b3a8389e9 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -9,7 +9,7 @@ const server = getTestServer() const interceptor = new HttpRequestInterceptor() -beforeAll(() => { +beforeEach(() => { interceptor.apply() }) @@ -146,3 +146,18 @@ it('bypasses any request after the interceptor was restored', async () => { expect(response.statusText).toBe('OK') await expect(response.text()).resolves.toBe('original-response') }) + +it('responds with a body larger than the high watermark', async () => { + const responseBody = new Uint8Array(1024 * 1024) + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response(responseBody)) + }) + const request = http.get('http://any.localhost/non-existing') + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + await expect + .soft(response.arrayBuffer()) + .resolves.toEqual(responseBody.buffer) +})