From b0f0e37a2add5ac7457b206488c2b98bea5741f8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 Mar 2024 13:19:03 +0100 Subject: [PATCH 01/69] feat: implement socket interceptor --- src/interceptors/Socket/SocketInterceptor.ts | 356 +++++++++++++++++++ test/modules/http/intercept/http.get.test.ts | 10 +- 2 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 src/interceptors/Socket/SocketInterceptor.ts diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts new file mode 100644 index 00000000..615eb2dc --- /dev/null +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -0,0 +1,356 @@ +import { randomUUID } from 'node:crypto' +import net from 'node:net' +import { Readable } from 'node:stream' +import { until } from '@open-draft/until' +import { Interceptor } from '../../Interceptor' +import { toInteractiveRequest } from '../../utils/toInteractiveRequest' +import { emitAsync } from '../../utils/emitAsync' + +const HTTPParser = process.binding('http_parser').HTTPParser + +export interface SocketEventMap { + request: [ + args: { + requestId: string + request: Request + }, + ] + response: [ + args: { + requestId: string + request: Request + response: Response + isMockedResponse: boolean + }, + ] +} + +export class SocketInterceptor extends Interceptor { + static symbol = Symbol('socket') + + constructor() { + super(SocketInterceptor.symbol) + } + + protected setup(): void { + const self = this + const originalConnect = net.Socket.prototype.connect + + net.Socket.prototype.connect = function (normalizedOptions) { + const socket = originalConnect.apply(this, normalizedOptions as any) + + const controller = new SocketController(socket, ...normalizedOptions) + const requestId = randomUUID() + controller.onRequest = (request) => { + const { interactiveRequest, requestController } = + toInteractiveRequest(request) + + self.emitter.once('request', ({ requestId: pendingRequestId }) => { + if (pendingRequestId !== requestId) { + return + } + + if (requestController.responsePromise.state === 'pending') { + requestController.responsePromise.resolve(undefined) + } + }) + + until(async () => { + await emitAsync(self.emitter, 'request', { + request: interactiveRequest, + requestId, + }) + + const mockedResponse = await requestController.responsePromise + return mockedResponse + }).then((resolverResult) => { + if (resolverResult.error) { + socket.emit('error', resolverResult.error) + return + } + + const mockedResponse = resolverResult.data + + if (mockedResponse) { + const responseClone = mockedResponse.clone() + controller.respondWith(mockedResponse) + + self.emitter.emit('response', { + requestId, + response: responseClone, + request, + isMockedResponse: true, + }) + + return + } + + // Otherwise, listen to the original response + // and forward it to the interceptor. + controller.onResponse = (response) => { + self.emitter.emit('response', { + requestId, + request, + response, + isMockedResponse: false, + }) + } + }) + } + + return socket + } + + this.subscriptions.push(() => { + net.Socket.prototype.connect = originalConnect + }) + } +} + +type CommonSocketConnectOptions = { + noDelay: boolean + encoding: BufferEncoding | null + servername: string +} + +type NormalizedSocketConnectOptions = + | (CommonSocketConnectOptions & URL) + | (CommonSocketConnectOptions & { + host: string + port: number + path: string | null + }) + +class SocketController { + public onRequest: (request: Request) => void = () => null + public onResponse: (response: Response) => void = () => null + + private url: URL + private mode: 'bypass' | 'mock' = 'bypass' + private errors: Array<[event: string, error: Error]> = [] + private requestParser: typeof HTTPParser + private requestStream?: Readable + private responseParser: typeof HTTPParser + private responseStream?: Readable + + constructor( + private readonly socket: net.Socket, + private readonly normalizedOptions: NormalizedSocketConnectOptions, + callback?: (error?: Error) => void + ) { + this.url = parseSocketConnectionUrl(normalizedOptions) + + this.requestParser = new HTTPParser() + this.requestParser[HTTPParser.kOnHeadersComplete] = ( + verionMajor: number, + versionMinor: number, + headers: Array, + idk: number, + path: string, + idk2: undefined, + idk3: undefined, + idk4: boolean + ) => { + this.onRequestStart(path, headers) + } + this.requestParser[HTTPParser.kOnBody] = (chunk: Buffer) => { + this.onRequestData(chunk) + } + this.requestParser[HTTPParser.kOnMessageComplete] = () => { + this.onRequestEnd() + } + this.requestParser.initialize(HTTPParser.REQUEST, {}) + + this.responseParser = new HTTPParser() + this.responseParser[HTTPParser.kOnHeadersComplete] = ( + verionMajor: number, + versionMinor: number, + headers: Array, + method: string | undefined, + url: string | undefined, + status: number, + statusText: string, + upgrade: boolean, + shouldKeepAlive: boolean + ) => { + this.onResponseStart(status, statusText, headers) + } + this.responseParser[HTTPParser.kOnBody] = (chunk: Buffer) => { + this.onResponseData(chunk) + } + this.responseParser[HTTPParser.kOnMessageComplete] = () => { + this.onResponseEnd() + } + this.responseParser.initialize( + HTTPParser.RESPONSE, + // Don't create any async resources here. + // This has to be "HTTPINCOMINGMESSAGE" in practice. + // @see https://github.com/nodejs/llhttp/issues/44#issuecomment-582499320 + // new HTTPServerAsyncResource('INTERCEPTORINCOMINGMESSAGE', socket) + {} + ) + + socket.emit = new Proxy(socket.emit, { + apply: (target, thisArg, args) => { + // The lookup phase will error first when requesting + // non-existing address. If that happens, switch to + // the mock mode and emulate successful connection. + if (args[0] === 'lookup' && args[1] instanceof Error) { + this.mode = 'mock' + this.errors.push(['lookup', args[1]]) + queueMicrotask(() => { + this.mockConnect(callback) + }) + return true + } + + if (this.mode === 'mock') { + if (args[0] === 'error') { + Reflect.set(this.socket, '_hadError', false) + this.errors.push(['error', args[1]]) + return true + } + + // Suppress close events for errored mocked connections. + if (args[0] === 'close') { + return true + } + } + + return Reflect.apply(target, thisArg, args) + }, + }) + + // Intercept the outgoing (request) data. + socket.write = new Proxy(socket.write, { + apply: (target, thisArg, args) => { + if (args[0] !== null) { + this.requestParser.execute( + Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]) + ) + } + return Reflect.apply(target, thisArg, args) + }, + }) + + // Intercept the incoming (response) data. + socket.push = new Proxy(socket.push, { + apply: (target, thisArg, args) => { + if (args[0] !== null) { + this.responseParser.execute( + Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]) + ) + } + return Reflect.apply(target, thisArg, args) + }, + }) + } + + private mockConnect(callback?: (error?: Error) => void) { + /** + * @todo We may want to push these events until AFTER + * the "request" interceptor event is awaited. This will + * prevent the "lookup" from being emitted twice. + */ + this.socket.emit('lookup', null, '::1', 6, '') + + Reflect.set(this.socket, 'connecting', false) + // Don't forger about "secureConnect" for TLS connections. + this.socket.emit('connect') + callback?.() + this.socket.emit('ready') + } + + public respondWith(response: Response): void { + // Use the given mocked Response instance to + // send its headers/data to this socket. + throw new Error('Not implemented') + } + + private replayErrors() { + if (this.errors.length === 0) { + return + } + + Reflect.set(this.socket, '_hadError', true) + for (const [event, error] of this.errors) { + this.socket.emit(event, error) + } + } + + private onRequestStart(path: string, rawHeaders: Array) { + // Depending on how the request object is constructed, + // its path may be available only from the parsed HTTP message. + const requestUrl = new URL(path, this.url) + + this.requestStream = new Readable() + const method = 'GET' // todo + const request = new Request(requestUrl, { + headers: parseRawHeaders(rawHeaders), + body: + method === 'HEAD' || method === 'GET' + ? null + : Readable.toWeb(this.requestStream), + }) + this.onRequest(request) + } + + private onRequestData(chunk: Buffer) { + this.requestStream?.push(chunk) + } + + private onRequestEnd() { + this.requestStream?.push(null) + this.requestParser.free() + } + + private onResponseStart( + status: number, + statusText: string, + rawHeaders: Array + ) { + this.responseStream = new Readable() + const response = new Response(Readable.toWeb(this.responseStream), { + status, + statusText, + headers: parseRawHeaders(rawHeaders), + }) + this.onResponse(response) + } + + private onResponseData(chunk: Buffer) { + this.responseStream?.push(chunk) + } + + private onResponseEnd() { + this.responseStream?.push(null) + this.responseParser.free() + } +} + +function parseSocketConnectionUrl( + options: NormalizedSocketConnectOptions +): URL { + if ('href' in options) { + return new URL(options.href) + } + + const url = new URL(`http://${options.host}`) + + if (options.port) { + url.port = options.port.toString() + } + if (options.path) { + url.pathname = options.path + } + + return url +} + +function 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/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 624e1f74..d361c87a 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,9 +1,11 @@ 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 { + SocketInterceptor, + SocketEventMap, +} from '../../../../src/interceptors/Socket/SocketInterceptor' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -11,9 +13,9 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn() +const resolver = vi.fn() -const interceptor = new ClientRequestInterceptor() +const interceptor = new SocketInterceptor() interceptor.on('request', resolver) beforeAll(async () => { From 4f48ecdcd93d25ffe1d7d41eee66a658997b91c2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 Mar 2024 13:46:17 +0100 Subject: [PATCH 02/69] chore: guard against missing streams --- src/interceptors/Socket/SocketInterceptor.ts | 27 +++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 615eb2dc..b753fb0a 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -5,6 +5,7 @@ import { until } from '@open-draft/until' import { Interceptor } from '../../Interceptor' import { toInteractiveRequest } from '../../utils/toInteractiveRequest' import { emitAsync } from '../../utils/emitAsync' +import { invariant } from 'outvariant' const HTTPParser = process.binding('http_parser').HTTPParser @@ -296,12 +297,21 @@ class SocketController { } private onRequestData(chunk: Buffer) { - this.requestStream?.push(chunk) + invariant( + this.requestStream, + 'Failed to push the chunk to the request stream: request stream is missing' + ) + this.requestStream.push(chunk) } private onRequestEnd() { - this.requestStream?.push(null) this.requestParser.free() + + invariant( + this.requestStream, + 'Failed to handle the request end: request stream is missing' + ) + this.requestStream.push(null) } private onResponseStart( @@ -319,12 +329,21 @@ class SocketController { } private onResponseData(chunk: Buffer) { - this.responseStream?.push(chunk) + invariant( + this.responseStream, + 'Failed to push the chunk to the response stream: response stream is missing' + ) + this.responseStream.push(chunk) } private onResponseEnd() { - this.responseStream?.push(null) this.responseParser.free() + + invariant( + this.responseStream, + 'Failed to handle the response end: response stream is missing' + ) + this.responseStream.push(null) } } From ac28a3c9c6909b480c63e7cd559fb7d733d83ff0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 Mar 2024 13:49:42 +0100 Subject: [PATCH 03/69] chore: replay all suppressed events --- src/interceptors/Socket/SocketInterceptor.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index b753fb0a..c3b78020 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -128,7 +128,7 @@ class SocketController { private url: URL private mode: 'bypass' | 'mock' = 'bypass' - private errors: Array<[event: string, error: Error]> = [] + private suppressedEvents: Array<[event: string, ...args: Array]> = [] private requestParser: typeof HTTPParser private requestStream?: Readable private responseParser: typeof HTTPParser @@ -198,7 +198,7 @@ class SocketController { // the mock mode and emulate successful connection. if (args[0] === 'lookup' && args[1] instanceof Error) { this.mode = 'mock' - this.errors.push(['lookup', args[1]]) + this.suppressedEvents.push(['lookup', args.slice(1)]) queueMicrotask(() => { this.mockConnect(callback) }) @@ -208,12 +208,13 @@ class SocketController { if (this.mode === 'mock') { if (args[0] === 'error') { Reflect.set(this.socket, '_hadError', false) - this.errors.push(['error', args[1]]) + this.suppressedEvents.push(['error', args.slice(1)]) return true } // Suppress close events for errored mocked connections. if (args[0] === 'close') { + this.suppressedEvents.push(['close', args.slice(1)]) return true } } @@ -269,12 +270,15 @@ class SocketController { } private replayErrors() { - if (this.errors.length === 0) { + if (this.suppressedEvents.length === 0) { return } - Reflect.set(this.socket, '_hadError', true) - for (const [event, error] of this.errors) { + for (const [event, error] of this.suppressedEvents) { + if (event === 'error') { + Reflect.set(this.socket, '_hadError', true) + } + this.socket.emit(event, error) } } From 6053cfceca7aa3a30a1d694ca2bef5dd77e8a06a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 Mar 2024 14:29:24 +0100 Subject: [PATCH 04/69] fix: respect auth, content-length assumption --- src/interceptors/Socket/SocketInterceptor.ts | 50 +++++++++++++++---- .../http/intercept/http.request.test.ts | 25 ++++++---- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index c3b78020..16ac836a 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -3,17 +3,20 @@ import net from 'node:net' import { Readable } from 'node:stream' import { until } from '@open-draft/until' import { Interceptor } from '../../Interceptor' -import { toInteractiveRequest } from '../../utils/toInteractiveRequest' +import { + InteractiveRequest, + toInteractiveRequest, +} from '../../utils/toInteractiveRequest' import { emitAsync } from '../../utils/emitAsync' import { invariant } from 'outvariant' -const HTTPParser = process.binding('http_parser').HTTPParser +const { HTTPParser } = process.binding('http_parser') export interface SocketEventMap { request: [ args: { requestId: string - request: Request + request: InteractiveRequest }, ] response: [ @@ -109,6 +112,8 @@ export class SocketInterceptor extends Interceptor { } type CommonSocketConnectOptions = { + method?: string + auth?: string noDelay: boolean encoding: BufferEncoding | null servername: string @@ -286,16 +291,34 @@ class SocketController { private onRequestStart(path: string, rawHeaders: Array) { // Depending on how the request object is constructed, // its path may be available only from the parsed HTTP message. - const requestUrl = new URL(path, this.url) + const url = new URL(path, this.url) + const headers = parseRawHeaders(rawHeaders) + + if (url.username || url.password) { + if (!headers.has('authorization')) { + headers.set( + 'authorization', + `Basic ${btoa(`${url.username}:${url.password}`)}` + ) + } + url.username = '' + url.password = '' + } this.requestStream = new Readable() - const method = 'GET' // todo - const request = new Request(requestUrl, { - headers: parseRawHeaders(rawHeaders), - body: - method === 'HEAD' || method === 'GET' - ? null - : Readable.toWeb(this.requestStream), + const method = this.normalizedOptions.method || 'GET' + const methodWithBody = method !== 'HEAD' && method !== 'GET' + // Request must specify the "Content-Length" header. + // Otherwise, no way for us to know if we should set + // the body stream. E.g. a "DELETE" request without a body. + const contentLength = headers.has('content-length') + const hasBody = methodWithBody && contentLength + + const request = new Request(url, { + method, + headers, + body: hasBody ? Readable.toWeb(this.requestStream) : null, + duplex: hasBody ? 'half' : undefined, }) this.onRequest(request) } @@ -366,6 +389,11 @@ function parseSocketConnectionUrl( 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/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 27de1361..e7426e2b 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -3,8 +3,10 @@ import http from 'http' import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { HttpRequestEventMap } from '../../../../src' +import { + SocketInterceptor, + SocketEventMap, +} from '../../../../src/interceptors/Socket/SocketInterceptor' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -17,8 +19,8 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn() -const interceptor = new ClientRequestInterceptor() +const resolver = vi.fn() +const interceptor = new SocketInterceptor() interceptor.on('request', resolver) beforeAll(async () => { @@ -52,7 +54,7 @@ it('intercepts a HEAD request', async () => { expect(request.method).toBe('HEAD') expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toEqual({ + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ host: new URL(url).host, 'x-custom-header': 'yes', }) @@ -80,7 +82,7 @@ it('intercepts a GET request', async () => { expect(request.method).toBe('GET') expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toEqual({ + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ host: new URL(url).host, 'x-custom-header': 'yes', }) @@ -96,6 +98,7 @@ it('intercepts a POST request', async () => { const req = http.request(url, { method: 'POST', headers: { + 'content-length': '12', 'x-custom-header': 'yes', }, }) @@ -109,7 +112,7 @@ it('intercepts a POST request', async () => { expect(request.method).toBe('POST') expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toEqual({ + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ host: new URL(url).host, 'x-custom-header': 'yes', }) @@ -125,6 +128,7 @@ it('intercepts a PUT request', async () => { const req = http.request(url, { method: 'PUT', headers: { + 'content-length': '11', 'x-custom-header': 'yes', }, }) @@ -138,7 +142,7 @@ it('intercepts a PUT request', async () => { expect(request.method).toBe('PUT') expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toEqual({ + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ host: new URL(url).host, 'x-custom-header': 'yes', }) @@ -154,6 +158,7 @@ it('intercepts a PATCH request', async () => { const req = http.request(url, { method: 'PATCH', headers: { + 'content-length': '13', 'x-custom-header': 'yes', }, }) @@ -167,7 +172,7 @@ it('intercepts a PATCH request', async () => { expect(request.method).toBe('PATCH') expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toEqual({ + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ host: new URL(url).host, 'x-custom-header': 'yes', }) @@ -195,7 +200,7 @@ it('intercepts a DELETE request', async () => { expect(request.method).toBe('DELETE') expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toEqual({ + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ host: new URL(url).host, 'x-custom-header': 'yes', }) From f8294ad55215fd111bdb235f533669b9379eec9d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 Mar 2024 14:30:24 +0100 Subject: [PATCH 05/69] fix: set "credentials" to "same-origin" --- src/interceptors/Socket/SocketInterceptor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 16ac836a..d14145ab 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -319,6 +319,7 @@ class SocketController { headers, body: hasBody ? Readable.toWeb(this.requestStream) : null, duplex: hasBody ? 'half' : undefined, + credentials: 'same-origin', }) this.onRequest(request) } From b9be9e6b246ed7eefb81a9bcf1b7de951bbaa4a9 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 Mar 2024 14:31:00 +0100 Subject: [PATCH 06/69] chore: import order --- src/interceptors/Socket/SocketInterceptor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index d14145ab..8152c79d 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import net from 'node:net' import { Readable } from 'node:stream' +import { invariant } from 'outvariant' import { until } from '@open-draft/until' import { Interceptor } from '../../Interceptor' import { @@ -8,7 +9,6 @@ import { toInteractiveRequest, } from '../../utils/toInteractiveRequest' import { emitAsync } from '../../utils/emitAsync' -import { invariant } from 'outvariant' const { HTTPParser } = process.binding('http_parser') From d8a60750dd109af0e049c84480bd681dec00e4b6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 Mar 2024 14:34:17 +0100 Subject: [PATCH 07/69] fix: always set request body stream if allowed --- src/interceptors/Socket/SocketInterceptor.ts | 9 ++------- test/modules/http/intercept/http.request.test.ts | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 8152c79d..3ecdbdb7 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -308,17 +308,12 @@ class SocketController { this.requestStream = new Readable() const method = this.normalizedOptions.method || 'GET' const methodWithBody = method !== 'HEAD' && method !== 'GET' - // Request must specify the "Content-Length" header. - // Otherwise, no way for us to know if we should set - // the body stream. E.g. a "DELETE" request without a body. - const contentLength = headers.has('content-length') - const hasBody = methodWithBody && contentLength const request = new Request(url, { method, headers, - body: hasBody ? Readable.toWeb(this.requestStream) : null, - duplex: hasBody ? 'half' : undefined, + body: methodWithBody ? Readable.toWeb(this.requestStream) : null, + duplex: methodWithBody ? 'half' : undefined, credentials: 'same-origin', }) this.onRequest(request) diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index e7426e2b..a320ecac 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -205,7 +205,7 @@ it('intercepts a DELETE request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(await request.arrayBuffer()).toEqual(new ArrayBuffer(0)) expect(request.respondWith).toBeInstanceOf(Function) expect(requestId).toMatch(UUID_REGEXP) From 0a67263b054f3130c6569edfa7c1a970cf877507 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 3 Mar 2024 17:07:16 +0100 Subject: [PATCH 08/69] feat: implement "respondWith()" --- src/interceptors/Socket/SocketInterceptor.ts | 133 ++++++++++++------ .../http/intercept/http.request.test.ts | 4 +- 2 files changed, 93 insertions(+), 44 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 3ecdbdb7..4d273535 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -1,5 +1,5 @@ -import { randomUUID } from 'node:crypto' import net from 'node:net' +import { randomUUID } from 'node:crypto' import { Readable } from 'node:stream' import { invariant } from 'outvariant' import { until } from '@open-draft/until' @@ -42,10 +42,10 @@ export class SocketInterceptor extends Interceptor { net.Socket.prototype.connect = function (normalizedOptions) { const socket = originalConnect.apply(this, normalizedOptions as any) - const controller = new SocketController(socket, ...normalizedOptions) - const requestId = randomUUID() + controller.onRequest = (request) => { + const requestId = randomUUID() const { interactiveRequest, requestController } = toInteractiveRequest(request) @@ -76,30 +76,25 @@ export class SocketInterceptor extends Interceptor { const mockedResponse = resolverResult.data if (mockedResponse) { - const responseClone = mockedResponse.clone() controller.respondWith(mockedResponse) - - self.emitter.emit('response', { - requestId, - response: responseClone, - request, - isMockedResponse: true, - }) - return } - // Otherwise, listen to the original response - // and forward it to the interceptor. - controller.onResponse = (response) => { - self.emitter.emit('response', { - requestId, - request, - response, - isMockedResponse: false, - }) - } + controller.passthrough() }) + + // Otherwise, listen to the original response + // and forward it to the interceptor. + controller.onResponse = (response, isMockedResponse) => { + console.log('onResponse callback') + + self.emitter.emit('response', { + requestId, + request, + response, + isMockedResponse, + }) + } } return socket @@ -129,11 +124,13 @@ type NormalizedSocketConnectOptions = class SocketController { public onRequest: (request: Request) => void = () => null - public onResponse: (response: Response) => void = () => null + public onResponse: (response: Response, isMockedResponse: boolean) => void = + () => null private url: URL - private mode: 'bypass' | 'mock' = 'bypass' + private shouldSuppressEvents = false private suppressedEvents: Array<[event: string, ...args: Array]> = [] + private request: Request private requestParser: typeof HTTPParser private requestStream?: Readable private responseParser: typeof HTTPParser @@ -146,6 +143,9 @@ class SocketController { ) { this.url = parseSocketConnectionUrl(normalizedOptions) + // Create the parser later on because a single + // socket can be *reused* for multiple requests. + // The same way, don't free the parser. this.requestParser = new HTTPParser() this.requestParser[HTTPParser.kOnHeadersComplete] = ( verionMajor: number, @@ -202,15 +202,12 @@ class SocketController { // non-existing address. If that happens, switch to // the mock mode and emulate successful connection. if (args[0] === 'lookup' && args[1] instanceof Error) { - this.mode = 'mock' - this.suppressedEvents.push(['lookup', args.slice(1)]) - queueMicrotask(() => { - this.mockConnect(callback) - }) + this.shouldSuppressEvents = true + this.mockConnect(callback) return true } - if (this.mode === 'mock') { + if (this.shouldSuppressEvents) { if (args[0] === 'error') { Reflect.set(this.socket, '_hadError', false) this.suppressedEvents.push(['error', args.slice(1)]) @@ -228,6 +225,12 @@ class SocketController { }, }) + socket.once('ready', () => { + // Notify the interceptor once the socket is ready. + // The HTTP parser triggers BEFORE that. + this.onRequest(this.request) + }) + // Intercept the outgoing (request) data. socket.write = new Proxy(socket.write, { apply: (target, thisArg, args) => { @@ -254,24 +257,51 @@ class SocketController { } private mockConnect(callback?: (error?: Error) => void) { - /** - * @todo We may want to push these events until AFTER - * the "request" interceptor event is awaited. This will - * prevent the "lookup" from being emitted twice. - */ this.socket.emit('lookup', null, '::1', 6, '') Reflect.set(this.socket, 'connecting', false) // Don't forger about "secureConnect" for TLS connections. this.socket.emit('connect') callback?.() + this.socket.emit('ready') } - public respondWith(response: Response): void { - // Use the given mocked Response instance to - // send its headers/data to this socket. - throw new Error('Not implemented') + public async respondWith(response: Response): Promise { + this.onResponse(response, true) + + this.socket.push(`HTTP/1.1 ${response.status} ${response.statusText}\r\n`) + + for (const [name, value] of response.headers) { + this.socket.push(`${name}: ${value}\r\n`) + } + + if (response.body) { + this.socket.push('\r\n') + + const reader = response.body.getReader() + const readNextChunk = async () => { + const { done, value } = await reader.read() + + if (done) { + this.socket.push(null) + return + } + + this.socket.push(value) + await readNextChunk() + } + + readNextChunk() + return + } + + this.socket.push(null) + } + + public async passthrough(): Promise { + this.shouldSuppressEvents = false + this.replayErrors() } private replayErrors() { @@ -279,12 +309,12 @@ class SocketController { return } - for (const [event, error] of this.suppressedEvents) { + for (const [event, ...args] of this.suppressedEvents) { if (event === 'error') { Reflect.set(this.socket, '_hadError', true) } - this.socket.emit(event, error) + this.socket.emit(event, ...args) } } @@ -309,14 +339,13 @@ class SocketController { const method = this.normalizedOptions.method || 'GET' const methodWithBody = method !== 'HEAD' && method !== 'GET' - const request = new Request(url, { + this.request = new Request(url, { method, headers, body: methodWithBody ? Readable.toWeb(this.requestStream) : null, duplex: methodWithBody ? 'half' : undefined, credentials: 'same-origin', }) - this.onRequest(request) } private onRequestData(chunk: Buffer) { @@ -401,3 +430,21 @@ function parseRawHeaders(rawHeaders: Array): Headers { } return headers } + +// MOCKED REQUEST: +// 1. lookup // mock that's OK +// 2. connect +// 3. ready +// HAS MOCK? +// -> Y: data -> close +// -> N (no response, non-existing host): +// -> replayErrors() +// -> lookup (error), error, close + +// BYPASSED REQUEST TO EXISTING HOST: +// 1. lookup (no errors) +// 2. (skip mockConnect), forward all socket events. +// 3. emit "request" on the interceptor. +// 4. HAS MOCK? +// -> Y: respondWith: data -> close +// -> N: do nothing diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index a320ecac..3454f459 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -93,7 +93,7 @@ it('intercepts a GET request', async () => { expect(requestId).toMatch(UUID_REGEXP) }) -it('intercepts a POST request', async () => { +it.only('intercepts a POST request', async () => { const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'POST', @@ -102,6 +102,8 @@ it('intercepts a POST request', async () => { 'x-custom-header': 'yes', }, }) + console.log('write (Test):', new Date()) + req.write('post-payload') req.end() await waitForClientRequest(req) From cff7820c1a3be2b890cdc5e0e9e18c8682d07fd1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 4 Mar 2024 12:39:15 +0100 Subject: [PATCH 09/69] chore: import "HTTPParser" from "node:_http_common" --- src/interceptors/Socket/SocketInterceptor.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 4d273535..facb8aef 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -1,4 +1,5 @@ import net from 'node:net' +import { HTTPParser } from 'node:_http_common' import { randomUUID } from 'node:crypto' import { Readable } from 'node:stream' import { invariant } from 'outvariant' @@ -10,8 +11,6 @@ import { } from '../../utils/toInteractiveRequest' import { emitAsync } from '../../utils/emitAsync' -const { HTTPParser } = process.binding('http_parser') - export interface SocketEventMap { request: [ args: { From 5d9a81268ee8d14512a2eacc37854ea646dfe92a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 4 Mar 2024 13:25:21 +0100 Subject: [PATCH 10/69] chore: abstract http message parser --- src/interceptors/Socket/SocketInterceptor.ts | 175 ++++++++++--------- 1 file changed, 91 insertions(+), 84 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index facb8aef..3466a660 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -85,8 +85,6 @@ export class SocketInterceptor extends Interceptor { // Otherwise, listen to the original response // and forward it to the interceptor. controller.onResponse = (response, isMockedResponse) => { - console.log('onResponse callback') - self.emitter.emit('response', { requestId, request, @@ -130,9 +128,7 @@ class SocketController { private shouldSuppressEvents = false private suppressedEvents: Array<[event: string, ...args: Array]> = [] private request: Request - private requestParser: typeof HTTPParser private requestStream?: Readable - private responseParser: typeof HTTPParser private responseStream?: Readable constructor( @@ -142,58 +138,35 @@ class SocketController { ) { this.url = parseSocketConnectionUrl(normalizedOptions) - // Create the parser later on because a single - // socket can be *reused* for multiple requests. - // The same way, don't free the parser. - this.requestParser = new HTTPParser() - this.requestParser[HTTPParser.kOnHeadersComplete] = ( - verionMajor: number, - versionMinor: number, - headers: Array, - idk: number, - path: string, - idk2: undefined, - idk3: undefined, - idk4: boolean - ) => { - this.onRequestStart(path, headers) - } - this.requestParser[HTTPParser.kOnBody] = (chunk: Buffer) => { - this.onRequestData(chunk) - } - this.requestParser[HTTPParser.kOnMessageComplete] = () => { - this.onRequestEnd() - } - this.requestParser.initialize(HTTPParser.REQUEST, {}) - - this.responseParser = new HTTPParser() - this.responseParser[HTTPParser.kOnHeadersComplete] = ( - verionMajor: number, - versionMinor: number, - headers: Array, - method: string | undefined, - url: string | undefined, - status: number, - statusText: string, - upgrade: boolean, - shouldKeepAlive: boolean - ) => { - this.onResponseStart(status, statusText, headers) - } - this.responseParser[HTTPParser.kOnBody] = (chunk: Buffer) => { - this.onResponseData(chunk) - } - this.responseParser[HTTPParser.kOnMessageComplete] = () => { - this.onResponseEnd() - } - this.responseParser.initialize( - HTTPParser.RESPONSE, - // Don't create any async resources here. - // This has to be "HTTPINCOMINGMESSAGE" in practice. - // @see https://github.com/nodejs/llhttp/issues/44#issuecomment-582499320 - // new HTTPServerAsyncResource('INTERCEPTORINCOMINGMESSAGE', socket) - {} - ) + const requestParser = new HttpMessageParser('request', { + onHeadersComplete: (major, minor, headers, _, path) => { + this.onRequestStart(path, headers) + }, + onBody: (chunk) => { + this.onRequestData(chunk) + }, + onMessageComplete: this.onRequestEnd.bind(this), + }) + + const responseParser = new HttpMessageParser('response', { + onHeadersComplete: ( + versionMajor, + versionMinor, + headers, + method, + url, + status, + statusText, + upgrade, + keepalive + ) => { + this.onResponseStart(status, statusText, headers) + }, + onBody: (chunk) => { + this.onResponseData(chunk) + }, + onMessageComplete: this.onResponseEnd.bind(this), + }) socket.emit = new Proxy(socket.emit, { apply: (target, thisArg, args) => { @@ -209,13 +182,13 @@ class SocketController { if (this.shouldSuppressEvents) { if (args[0] === 'error') { Reflect.set(this.socket, '_hadError', false) - this.suppressedEvents.push(['error', args.slice(1)]) + this.suppressedEvents.push(['error', ...args.slice(1)]) return true } // Suppress close events for errored mocked connections. if (args[0] === 'close') { - this.suppressedEvents.push(['close', args.slice(1)]) + this.suppressedEvents.push(['close', ...args.slice(1)]) return true } } @@ -224,7 +197,7 @@ class SocketController { }, }) - socket.once('ready', () => { + socket.once('connect', () => { // Notify the interceptor once the socket is ready. // The HTTP parser triggers BEFORE that. this.onRequest(this.request) @@ -234,7 +207,7 @@ class SocketController { socket.write = new Proxy(socket.write, { apply: (target, thisArg, args) => { if (args[0] !== null) { - this.requestParser.execute( + requestParser.push( Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]) ) } @@ -246,7 +219,7 @@ class SocketController { socket.push = new Proxy(socket.push, { apply: (target, thisArg, args) => { if (args[0] !== null) { - this.responseParser.execute( + responseParser.push( Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]) ) } @@ -304,11 +277,15 @@ class SocketController { } private replayErrors() { + console.log('replay errors...', this.suppressedEvents) + if (this.suppressedEvents.length === 0) { return } for (const [event, ...args] of this.suppressedEvents) { + console.log('replaying event', event, ...args) + if (event === 'error') { Reflect.set(this.socket, '_hadError', true) } @@ -342,6 +319,7 @@ class SocketController { method, headers, body: methodWithBody ? Readable.toWeb(this.requestStream) : null, + // @ts-expect-error Not documented fetch property. duplex: methodWithBody ? 'half' : undefined, credentials: 'same-origin', }) @@ -356,8 +334,6 @@ class SocketController { } private onRequestEnd() { - this.requestParser.free() - invariant( this.requestStream, 'Failed to handle the request end: request stream is missing' @@ -376,7 +352,7 @@ class SocketController { statusText, headers: parseRawHeaders(rawHeaders), }) - this.onResponse(response) + this.onResponse(response, false) } private onResponseData(chunk: Buffer) { @@ -388,8 +364,6 @@ class SocketController { } private onResponseEnd() { - this.responseParser.free() - invariant( this.responseStream, 'Failed to handle the response end: response stream is missing' @@ -398,6 +372,57 @@ class SocketController { } } +type HttpMessageParserMessageType = 'request' | 'response' +interface HttpMessageParserCallbacks { + onHeadersComplete?: T extends 'request' + ? ( + versionMajor: number, + versionMinor: number, + headers: Array, + idk: number, + path: string + ) => void + : ( + versionMajor: number, + versionMinor: number, + headers: Array, + method: string | undefined, + url: string | undefined, + status: number, + statusText: string, + upgrade: boolean, + shouldKeepAlive: boolean + ) => void + onBody?: (chunk: Buffer) => void + onMessageComplete?: () => void +} + +class HttpMessageParser { + private parser: HTTPParser + + constructor(messageType: T, callbacks: HttpMessageParserCallbacks) { + this.parser = new HTTPParser() + this.parser.initialize( + messageType === 'request' ? HTTPParser.REQUEST : HTTPParser.RESPONSE, + // Don't create any async resources here. + // This has to be "HTTPINCOMINGMESSAGE" in practice. + // @see https://github.com/nodejs/llhttp/issues/44#issuecomment-582499320 + // new HTTPServerAsyncResource('INTERCEPTORINCOMINGMESSAGE', socket) + {} + ) + this.parser[HTTPParser.kOnHeadersComplete] = callbacks.onHeadersComplete + this.parser[HTTPParser.kOnMessageComplete] = callbacks.onMessageComplete + } + + public push(chunk: Buffer): void { + this.parser.execute(chunk) + } + + public destroy(): void { + this.parser.free() + } +} + function parseSocketConnectionUrl( options: NormalizedSocketConnectOptions ): URL { @@ -429,21 +454,3 @@ function parseRawHeaders(rawHeaders: Array): Headers { } return headers } - -// MOCKED REQUEST: -// 1. lookup // mock that's OK -// 2. connect -// 3. ready -// HAS MOCK? -// -> Y: data -> close -// -> N (no response, non-existing host): -// -> replayErrors() -// -> lookup (error), error, close - -// BYPASSED REQUEST TO EXISTING HOST: -// 1. lookup (no errors) -// 2. (skip mockConnect), forward all socket events. -// 3. emit "request" on the interceptor. -// 4. HAS MOCK? -// -> Y: respondWith: data -> close -// -> N: do nothing From f7fded868cf34572424951f019d2cdaeb2286af3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Mar 2024 11:46:40 +0100 Subject: [PATCH 11/69] chore: introduce SocketWrap class --- src/interceptors/Socket/SocketInterceptor.ts | 403 ++++++++---------- .../http/intercept/http.request.test.ts | 4 +- 2 files changed, 181 insertions(+), 226 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 3466a660..a492369e 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -2,7 +2,6 @@ import net from 'node:net' import { HTTPParser } from 'node:_http_common' import { randomUUID } from 'node:crypto' import { Readable } from 'node:stream' -import { invariant } from 'outvariant' import { until } from '@open-draft/until' import { Interceptor } from '../../Interceptor' import { @@ -39,11 +38,16 @@ export class SocketInterceptor extends Interceptor { const self = this const originalConnect = net.Socket.prototype.connect - net.Socket.prototype.connect = function (normalizedOptions) { - const socket = originalConnect.apply(this, normalizedOptions as any) - const controller = new SocketController(socket, ...normalizedOptions) + net.Socket.prototype.connect = function (options, callback) { + // const socket = originalConnect.apply(this, normalizedOptions as any) - controller.onRequest = (request) => { + const createConnection = () => { + return originalConnect.apply(this, options) + } + + const socketWrap = new SocketWrap(options, createConnection) + + socketWrap.on('request', async (request) => { const requestId = randomUUID() const { interactiveRequest, requestController } = toInteractiveRequest(request) @@ -58,43 +62,38 @@ export class SocketInterceptor extends Interceptor { } }) - until(async () => { + const resolverResult = await until(async () => { await emitAsync(self.emitter, 'request', { - request: interactiveRequest, requestId, + request: interactiveRequest, }) - const mockedResponse = await requestController.responsePromise - return mockedResponse - }).then((resolverResult) => { - if (resolverResult.error) { - socket.emit('error', resolverResult.error) - return - } + return await requestController.responsePromise + }) - const mockedResponse = resolverResult.data + if (resolverResult.error) { + throw new Error('Implement error handling') + } - if (mockedResponse) { - controller.respondWith(mockedResponse) - return - } + const mockedResponse = resolverResult.data - controller.passthrough() - }) + if (mockedResponse) { + socketWrap.respondWith(mockedResponse) + } else { + socketWrap.passthrough() + } - // Otherwise, listen to the original response - // and forward it to the interceptor. - controller.onResponse = (response, isMockedResponse) => { + socketWrap.once('response', (response) => { self.emitter.emit('response', { requestId, request, response, - isMockedResponse, + isMockedResponse: false, }) - } - } + }) + }) - return socket + return socketWrap } this.subscriptions.push(() => { @@ -103,203 +102,99 @@ export class SocketInterceptor extends Interceptor { } } -type CommonSocketConnectOptions = { - method?: string - auth?: string - noDelay: boolean - encoding: BufferEncoding | null - servername: string -} +class SocketWrap extends net.Socket { + public url: URL + public onRequest?: (request: Request) => void + public onResponse?: (response: Response) => void -type NormalizedSocketConnectOptions = - | (CommonSocketConnectOptions & URL) - | (CommonSocketConnectOptions & { - host: string - port: number - path: string | null - }) + private connectionOptions: NormalizedSocketConnectOptions + private requestParser: HttpMessageParser<'request'> + private responseParser: HttpMessageParser<'response'> + private requestStream: Readable + private responseStream: Readable -class SocketController { - public onRequest: (request: Request) => void = () => null - public onResponse: (response: Response, isMockedResponse: boolean) => void = - () => null - - private url: URL - private shouldSuppressEvents = false - private suppressedEvents: Array<[event: string, ...args: Array]> = [] - private request: Request - private requestStream?: Readable - private responseStream?: Readable + private requestChunks: Array = [] constructor( - private readonly socket: net.Socket, - private readonly normalizedOptions: NormalizedSocketConnectOptions, - callback?: (error?: Error) => void + readonly info: [ + options: NormalizedSocketConnectOptions, + callback: () => void | undefined, + ], + private createConnection: () => net.Socket ) { - this.url = parseSocketConnectionUrl(normalizedOptions) + super() + + this.connectionOptions = info[0] + this.url = parseSocketConnectionUrl(this.connectionOptions) - const requestParser = new HttpMessageParser('request', { - onHeadersComplete: (major, minor, headers, _, path) => { + this.requestStream = new Readable() + this.requestParser = new HttpMessageParser('request', { + onHeadersComplete: (major, minor, headers, method, path) => { this.onRequestStart(path, headers) }, onBody: (chunk) => { - this.onRequestData(chunk) + this.requestStream.push(chunk) + }, + onMessageComplete: () => { + this.requestStream.push(null) + this.requestParser.destroy() }, - onMessageComplete: this.onRequestEnd.bind(this), }) - const responseParser = new HttpMessageParser('response', { + this.responseStream = new Readable() + this.responseParser = new HttpMessageParser('response', { onHeadersComplete: ( - versionMajor, - versionMinor, + major, + minor, headers, method, url, status, - statusText, - upgrade, - keepalive + statusText ) => { this.onResponseStart(status, statusText, headers) }, onBody: (chunk) => { - this.onResponseData(chunk) + this.responseStream.push(chunk) }, - onMessageComplete: this.onResponseEnd.bind(this), - }) - - socket.emit = new Proxy(socket.emit, { - apply: (target, thisArg, args) => { - // The lookup phase will error first when requesting - // non-existing address. If that happens, switch to - // the mock mode and emulate successful connection. - if (args[0] === 'lookup' && args[1] instanceof Error) { - this.shouldSuppressEvents = true - this.mockConnect(callback) - return true - } - - if (this.shouldSuppressEvents) { - if (args[0] === 'error') { - Reflect.set(this.socket, '_hadError', false) - this.suppressedEvents.push(['error', ...args.slice(1)]) - return true - } - - // Suppress close events for errored mocked connections. - if (args[0] === 'close') { - this.suppressedEvents.push(['close', ...args.slice(1)]) - return true - } - } - - return Reflect.apply(target, thisArg, args) - }, - }) - - socket.once('connect', () => { - // Notify the interceptor once the socket is ready. - // The HTTP parser triggers BEFORE that. - this.onRequest(this.request) - }) - - // Intercept the outgoing (request) data. - socket.write = new Proxy(socket.write, { - apply: (target, thisArg, args) => { - if (args[0] !== null) { - requestParser.push( - Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]) - ) - } - return Reflect.apply(target, thisArg, args) - }, - }) - - // Intercept the incoming (response) data. - socket.push = new Proxy(socket.push, { - apply: (target, thisArg, args) => { - if (args[0] !== null) { - responseParser.push( - Buffer.isBuffer(args[0]) ? args[0] : Buffer.from(args[0]) - ) - } - return Reflect.apply(target, thisArg, args) + onMessageComplete: () => { + this.responseStream.push(null) + this.responseParser.destroy() }, }) } - private mockConnect(callback?: (error?: Error) => void) { - this.socket.emit('lookup', null, '::1', 6, '') - - Reflect.set(this.socket, 'connecting', false) - // Don't forger about "secureConnect" for TLS connections. - this.socket.emit('connect') - callback?.() - - this.socket.emit('ready') - } - - public async respondWith(response: Response): Promise { - this.onResponse(response, true) - - this.socket.push(`HTTP/1.1 ${response.status} ${response.statusText}\r\n`) + public write(chunk: Buffer) { + this.requestChunks.push(chunk) - for (const [name, value] of response.headers) { - this.socket.push(`${name}: ${value}\r\n`) + if (chunk !== null) { + this.requestParser.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ) } - if (response.body) { - this.socket.push('\r\n') - - const reader = response.body.getReader() - const readNextChunk = async () => { - const { done, value } = await reader.read() - - if (done) { - this.socket.push(null) - return - } - - this.socket.push(value) - await readNextChunk() - } - - readNextChunk() - return - } - - this.socket.push(null) + return true } - public async passthrough(): Promise { - this.shouldSuppressEvents = false - this.replayErrors() - } - - private replayErrors() { - console.log('replay errors...', this.suppressedEvents) - - if (this.suppressedEvents.length === 0) { - return + public push(chunk: any, encoding?: BufferEncoding) { + if (chunk !== null) { + const chunkBuffer = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk, encoding) + this.responseParser.push(chunkBuffer) + this.emit('data', chunkBuffer) + } else { + this.emit('end') } - for (const [event, ...args] of this.suppressedEvents) { - console.log('replaying event', event, ...args) - - if (event === 'error') { - Reflect.set(this.socket, '_hadError', true) - } - - this.socket.emit(event, ...args) - } + return true } - private onRequestStart(path: string, rawHeaders: Array) { - // Depending on how the request object is constructed, - // its path may be available only from the parsed HTTP message. + private onRequestStart(path: string, rawHeaders: Array): void { const url = new URL(path, this.url) const headers = parseRawHeaders(rawHeaders) + // Translate URL auth into the authorization request header. if (url.username || url.password) { if (!headers.has('authorization')) { headers.set( @@ -311,34 +206,19 @@ class SocketController { url.password = '' } - this.requestStream = new Readable() - const method = this.normalizedOptions.method || 'GET' - const methodWithBody = method !== 'HEAD' && method !== 'GET' + const method = this.connectionOptions.method || 'GET' + const isBodyAllowed = method !== 'HEAD' && method !== 'GET' - this.request = new Request(url, { + const request = new Request(url, { method, headers, - body: methodWithBody ? Readable.toWeb(this.requestStream) : null, - // @ts-expect-error Not documented fetch property. - duplex: methodWithBody ? 'half' : undefined, credentials: 'same-origin', + body: isBodyAllowed ? Readable.toWeb(this.requestStream) : null, + // @ts-expect-error Not documented fetch property. + duplex: isBodyAllowed ? 'half' : undefined, }) - } - private onRequestData(chunk: Buffer) { - invariant( - this.requestStream, - 'Failed to push the chunk to the request stream: request stream is missing' - ) - this.requestStream.push(chunk) - } - - private onRequestEnd() { - invariant( - this.requestStream, - 'Failed to handle the request end: request stream is missing' - ) - this.requestStream.push(null) + this.emit('request', request) } private onResponseStart( @@ -346,32 +226,69 @@ class SocketController { statusText: string, rawHeaders: Array ) { - this.responseStream = new Readable() const response = new Response(Readable.toWeb(this.responseStream), { status, statusText, headers: parseRawHeaders(rawHeaders), }) - this.onResponse(response, false) + + this.emit('response', response) } - private onResponseData(chunk: Buffer) { - invariant( - this.responseStream, - 'Failed to push the chunk to the response stream: response stream is missing' - ) - this.responseStream.push(chunk) + /** + * Passthrough this Socket connection. + * Performs the connection as-is, flushing the request body + * and forwarding any events and response stream to this instance. + */ + public passthrough(): void { + const socket = this.createConnection() + + /** + * @fixme This is not ideal. I'd love not to introduce another + * place where we store the request body stream. Alas, we cannot + * read from "this.requestStream.read()" as it returns null + * (at this point, the stream is drained). + */ + if (this.requestChunks.length > 0) { + this.requestChunks.forEach((chunk) => socket.write(chunk)) + } + + socket + .once('connect', () => this.emit('connect')) + .once('ready', () => { + this.emit('ready') + socket.on('data', (chunk) => { + this.emit('data', chunk) + }) + }) + .on('error', (error) => this.emit('error', error)) + .on('timeout', () => this.emit('timeout')) + .on('drain', () => this.emit('drain')) + .on('close', (...args) => this.emit('close', ...args)) + .on('end', () => this.emit('end')) } - private onResponseEnd() { - invariant( - this.responseStream, - 'Failed to handle the response end: response stream is missing' - ) - this.responseStream.push(null) + public respondWith(response: Response): void { + pipeResponse(response, this) } } +type CommonSocketConnectOptions = { + method?: string + auth?: string + noDelay: boolean + encoding: BufferEncoding | null + servername: string +} + +type NormalizedSocketConnectOptions = + | (CommonSocketConnectOptions & URL) + | (CommonSocketConnectOptions & { + host: string + port: number + path: string | null + }) + type HttpMessageParserMessageType = 'request' | 'response' interface HttpMessageParserCallbacks { onHeadersComplete?: T extends 'request' @@ -411,6 +328,7 @@ class HttpMessageParser { {} ) this.parser[HTTPParser.kOnHeadersComplete] = callbacks.onHeadersComplete + this.parser[HTTPParser.kOnBody] = callbacks.onBody this.parser[HTTPParser.kOnMessageComplete] = callbacks.onMessageComplete } @@ -419,6 +337,7 @@ class HttpMessageParser { } public destroy(): void { + this.parser.finish() this.parser.free() } } @@ -454,3 +373,41 @@ function parseRawHeaders(rawHeaders: Array): Headers { } return headers } + +/** + * Pipes the entire HTTP message from the given Fetch API `Response` + * instance to the socket. + */ +async function pipeResponse( + response: Response, + socket: net.Socket +): Promise { + socket.push(`HTTP/1.1 ${response.status} ${response.statusText}\r\n`) + + for (const [name, value] of response.headers) { + socket.push(`${name}: ${value}\r\n`) + } + + if (response.body) { + socket.push('\r\n') + + const encoding = response.headers.get('content-encoding') as + | BufferEncoding + | undefined + const reader = response.body.getReader() + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + socket.push(value, encoding) + } + + reader.releaseLock() + } + + socket.push(null) +} diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 3454f459..a320ecac 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -93,7 +93,7 @@ it('intercepts a GET request', async () => { expect(requestId).toMatch(UUID_REGEXP) }) -it.only('intercepts a POST request', async () => { +it('intercepts a POST request', async () => { const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'POST', @@ -102,8 +102,6 @@ it.only('intercepts a POST request', async () => { 'x-custom-header': 'yes', }, }) - console.log('write (Test):', new Date()) - req.write('post-payload') req.end() await waitForClientRequest(req) From 405808095c3d05ffe0fd88b9960529f6a719165e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Mar 2024 11:46:49 +0100 Subject: [PATCH 12/69] test: add http compliance test suite --- test/modules/http/compliance/http.test.ts | 162 ++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 test/modules/http/compliance/http.test.ts diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts new file mode 100644 index 00000000..88bf33c1 --- /dev/null +++ b/test/modules/http/compliance/http.test.ts @@ -0,0 +1,162 @@ +/** + * @vitest-environment node + */ +import http from 'node:http' +import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' +import { HttpServer } from '@open-draft/test-server/http' +import express from 'express' +import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' +import { waitForClientRequest } from '../../../helpers' +import { DeferredPromise } from '@open-draft/deferred-promise' + +const interceptor = new SocketInterceptor() + +const httpServer = new HttpServer((app) => { + app.use(express.json()) + app.post('/user', (req, res) => { + res.set({ 'x-custom-header': 'yes' }).send(`hello, ${req.body.name}`) + }) +}) + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('bypasses a request to the existing host', async () => { + const requestListener = vi.fn() + interceptor.on('request', ({ request }) => requestListener(request)) + + const request = http.request(httpServer.http.url('/user'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + }) + request.write(JSON.stringify({ name: 'john' })) + request.end() + const { text, res } = await waitForClientRequest(request) + + // Must expose the request reference to the listener. + const [requestFromListener] = requestListener.mock.calls[0] + + expect(requestFromListener.method).toBe('POST') + expect(requestFromListener.headers.get('content-type')).toBe( + 'application/json' + ) + expect(await requestFromListener.json()).toEqual({ name: 'john' }) + + // Must receive the correct response. + expect(res.headers).toHaveProperty('x-custom-header', 'yes') + expect(await text()).toBe('hello, john') + expect(requestListener).toHaveBeenCalledTimes(1) +}) + +it('errors on a request to a non-existing host', async () => { + const responseListener = vi.fn() + const errorPromise = new DeferredPromise() + const request = http.request('http://abc123-non-existing.lol', { + method: 'POST', + }) + request.on('response', responseListener) + request.on('error', (error) => errorPromise.resolve(error)) + request.end() + + await expect(() => waitForClientRequest(request)).rejects.toThrow( + 'getaddrinfo ENOTFOUND abc123-non-existing.lol' + ) + + // Must emit the "error" event on the request. + expect(await errorPromise).toEqual( + new Error('getaddrinfo ENOTFOUND abc123-non-existing.lol') + ) + // Must not call the "response" event. + expect(responseListener).not.toHaveBeenCalled() +}) + +it('mocked request to an existing host', async () => { + const requestListener = vi.fn() + interceptor.on('request', async ({ request }) => { + requestListener(request.clone()) + + const data = await request.json() + request.respondWith( + new Response(`howdy, ${data.name}`, { + headers: { + 'x-custom-header': 'mocked', + }, + }) + ) + }) + + const request = http.request(httpServer.http.url('/user'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + }) + request.write(JSON.stringify({ name: 'john' })) + request.end() + const { text, res } = await waitForClientRequest(request) + + // Must expose the request reference to the listener. + const [requestFromListener] = requestListener.mock.calls[0] + expect(requestFromListener.method).toBe('POST') + expect(requestFromListener.headers.get('content-type')).toBe( + 'application/json' + ) + expect(await requestFromListener.json()).toEqual({ name: 'john' }) + + // Must receive the correct response. + expect(res.headers).toHaveProperty('x-custom-header', 'mocked') + expect(await text()).toBe('howdy, john') + expect(requestListener).toHaveBeenCalledTimes(1) +}) + +it('mocks response to a non-existing host', async () => { + const requestListener = vi.fn() + interceptor.on('request', async ({ request }) => { + requestListener(request.clone()) + + const data = await request.json() + request.respondWith( + new Response(`howdy, ${data.name}`, { + headers: { + 'x-custom-header': 'mocked', + }, + }) + ) + }) + + const request = http.request('http://foo.example.com', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + }) + request.write(JSON.stringify({ name: 'john' })) + request.end() + const { text, res } = await waitForClientRequest(request) + + // Must expose the request reference to the listener. + const [requestFromListener] = requestListener.mock.calls[0] + expect(requestFromListener.method).toBe('POST') + expect(requestFromListener.headers.get('content-type')).toBe( + 'application/json' + ) + expect(await requestFromListener.json()).toEqual({ name: 'john' }) + + // Must receive the correct response. + expect(res.headers).toHaveProperty('x-custom-header', 'mocked') + expect(await text()).toBe('howdy, john') + expect(requestListener).toHaveBeenCalledTimes(1) +}) From 1c74f2399f9af87c8ef3800568e2757db07eaadd Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Mar 2024 13:40:50 +0100 Subject: [PATCH 13/69] fix: improve Socket compliance, add events tests --- src/interceptors/Socket/SocketInterceptor.ts | 209 ++++++++++++++---- .../Socket/compliance/socket.events.test.ts | 136 ++++++++++++ test/modules/http/compliance/http.test.ts | 3 + 3 files changed, 304 insertions(+), 44 deletions(-) create mode 100644 test/modules/Socket/compliance/socket.events.test.ts diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index a492369e..473cb016 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -9,6 +9,22 @@ import { toInteractiveRequest, } from '../../utils/toInteractiveRequest' import { emitAsync } from '../../utils/emitAsync' +import { STATUS_CODES } from 'node:http' + +type NormalizedSocketConnectArgs = [ + options: NormalizedSocketConnectOptions, + connectionListener: (() => void) | null, +] + +declare module 'node:net' { + /** + * Internal `new Socket().connect()` arguments normalization function. + * @see https://github.com/nodejs/node/blob/29ec7e9331c4944006ffe28e126cc31cc3de271b/lib/net.js#L272 + */ + export var _normalizeArgs: ( + args: Array + ) => NormalizedSocketConnectArgs +} export interface SocketEventMap { request: [ @@ -38,16 +54,29 @@ export class SocketInterceptor extends Interceptor { const self = this const originalConnect = net.Socket.prototype.connect - net.Socket.prototype.connect = function (options, callback) { - // const socket = originalConnect.apply(this, normalizedOptions as any) + net.Socket.prototype.connect = function mockConnect( + ...args: Array + ) { + /** + * @note In some cases, "Socket.prototype.connect" will receive already + * normalized arguments. The call signature of that method will differ: + * .connect(port, host, cb) // unnormalized + * .connect([options, cb, normalizedSymbol]) // normalized + * Check that and unwrap the arguments to have a consistent format. + */ + const unwrappedArgs = Array.isArray(args[0]) ? args[0] : args + const normalizedSocketConnectArgs = net._normalizeArgs(unwrappedArgs) const createConnection = () => { - return originalConnect.apply(this, options) + return originalConnect.apply(this, args) } - const socketWrap = new SocketWrap(options, createConnection) + const socketWrap = new SocketWrap( + normalizedSocketConnectArgs, + createConnection + ) - socketWrap.on('request', async (request) => { + socketWrap.onRequest = async (request) => { const requestId = randomUUID() const { interactiveRequest, requestController } = toInteractiveRequest(request) @@ -72,7 +101,8 @@ export class SocketInterceptor extends Interceptor { }) if (resolverResult.error) { - throw new Error('Implement error handling') + socketWrap.errorWith(resolverResult.error) + return } const mockedResponse = resolverResult.data @@ -83,15 +113,15 @@ export class SocketInterceptor extends Interceptor { socketWrap.passthrough() } - socketWrap.once('response', (response) => { + socketWrap.onResponse = (response) => { self.emitter.emit('response', { requestId, request, response, isMockedResponse: false, }) - }) - }) + } + } return socketWrap } @@ -107,29 +137,40 @@ class SocketWrap extends net.Socket { public onRequest?: (request: Request) => void public onResponse?: (response: Response) => void - private connectionOptions: NormalizedSocketConnectOptions + private connectionOptions: NormalizedSocketConnectArgs[0] + private connectionListener: NormalizedSocketConnectArgs[1] private requestParser: HttpMessageParser<'request'> private responseParser: HttpMessageParser<'response'> private requestStream: Readable private responseStream: Readable - private requestChunks: Array = [] + private shouldKeepAlive?: boolean constructor( - readonly info: [ - options: NormalizedSocketConnectOptions, - callback: () => void | undefined, - ], + readonly socketConnectArgs: ReturnType, private createConnection: () => net.Socket ) { super() - this.connectionOptions = info[0] + this.connectionOptions = socketConnectArgs[0] + this.connectionListener = socketConnectArgs[1] + this.url = parseSocketConnectionUrl(this.connectionOptions) this.requestStream = new Readable() this.requestParser = new HttpMessageParser('request', { - onHeadersComplete: (major, minor, headers, method, path) => { + onHeadersComplete: ( + major, + minor, + headers, + method, + path, + _, + __, + ___, + shouldKeepAlive + ) => { + this.shouldKeepAlive = shouldKeepAlive this.onRequestStart(path, headers) }, onBody: (chunk) => { @@ -162,6 +203,39 @@ class SocketWrap extends net.Socket { this.responseParser.destroy() }, }) + + this.mockConnect() + } + + private mockConnect() { + if (this.connectionListener) { + this.once('connect', this.connectionListener) + } + + Reflect.set(this, 'connecting', true) + + queueMicrotask(() => { + this.emit('lookup', null, '127.0.0.1', 6, this.connectionOptions.host) + Reflect.set(this, 'connecting', false) + + this.emit('connect') + this.emit('ready') + }) + } + + public destroy(error?: Error) { + queueMicrotask(() => { + if (error) { + this.emit('error', error) + } + // Override the ".destroy()" method in order to + // emit the "hadError" argument with the "close" event. + // For some reason, relying on "super.destroy()" doesn't + // include that argument, it's undefined. + this.emit('close', !!error) + }) + + return this } public write(chunk: Buffer) { @@ -218,7 +292,7 @@ class SocketWrap extends net.Socket { duplex: isBodyAllowed ? 'half' : undefined, }) - this.emit('request', request) + this.onRequest?.(request) } private onResponseStart( @@ -232,7 +306,7 @@ class SocketWrap extends net.Socket { headers: parseRawHeaders(rawHeaders), }) - this.emit('response', response) + this.onResponse?.(response) } /** @@ -264,21 +338,42 @@ class SocketWrap extends net.Socket { .on('error', (error) => this.emit('error', error)) .on('timeout', () => this.emit('timeout')) .on('drain', () => this.emit('drain')) - .on('close', (...args) => this.emit('close', ...args)) + .on('close', (hadError) => this.emit('close', hadError)) .on('end', () => this.emit('end')) } - public respondWith(response: Response): void { - pipeResponse(response, this) + public async respondWith(response: Response): Promise { + this.emit('resume') + await pipeResponse(response, this) + + // If the request did not specify the "Connection" header, + // the socket will be kept alive. We mustn't close its + // readable stream in that case as more clients can write to it. + if (!this.shouldKeepAlive) { + /** + * Socket (I suspect the underlying stream) emits the + * "readable" event for non-keepalive connections + * before closing them. If you are a magician who knows + * why it does so, let us know if we're doing it right here. + */ + this.emit('readable') + this.push(null) + } + } + + public errorWith(error?: Error): void { + Reflect.set(this, '_hadError', true) + this.emit('error', error) + this.emit('close', true) } } type CommonSocketConnectOptions = { method?: string auth?: string - noDelay: boolean - encoding: BufferEncoding | null - servername: string + noDelay?: boolean + encoding?: BufferEncoding | null + servername?: string } type NormalizedSocketConnectOptions = @@ -286,7 +381,7 @@ type NormalizedSocketConnectOptions = | (CommonSocketConnectOptions & { host: string port: number - path: string | null + path?: string | null }) type HttpMessageParserMessageType = 'request' | 'response' @@ -297,7 +392,11 @@ interface HttpMessageParserCallbacks { versionMinor: number, headers: Array, idk: number, - path: string + path: string, + idk2: unknown, + idk3: unknown, + idk4: unknown, + shouldKeepAlive: boolean ) => void : ( versionMajor: number, @@ -349,14 +448,19 @@ function parseSocketConnectionUrl( return new URL(options.href) } - const url = new URL(`http://${options.host}`) + 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 @@ -382,32 +486,49 @@ async function pipeResponse( response: Response, socket: net.Socket ): Promise { - socket.push(`HTTP/1.1 ${response.status} ${response.statusText}\r\n`) + const httpHeaders: Array = [] + // Status text is optional in Response but required in the HTTP message. + const statusText = response.statusText || STATUS_CODES[response.status] || '' + + httpHeaders.push(Buffer.from(`HTTP/1.1 ${response.status} ${statusText}\r\n`)) for (const [name, value] of response.headers) { - socket.push(`${name}: ${value}\r\n`) + httpHeaders.push(Buffer.from(`${name}: ${value}\r\n`)) } - if (response.body) { - socket.push('\r\n') + if (!response.body) { + socket.push(Buffer.concat(httpHeaders)) + return + } - const encoding = response.headers.get('content-encoding') as - | BufferEncoding - | undefined - const reader = response.body.getReader() + httpHeaders.push(Buffer.from('\r\n')) - while (true) { - const { done, value } = await reader.read() + const encoding = response.headers.get('content-encoding') as + | BufferEncoding + | undefined + const reader = response.body.getReader() - if (done) { - break - } + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } - socket.push(value, encoding) + // Send the whole HTTP message headers buffer, + // including the first body chunk at once. This will + // be triggered for all non-stream response bodies. + if (httpHeaders.length > 0) { + httpHeaders.push(Buffer.from(value)) + socket.push(Buffer.concat(httpHeaders)) + httpHeaders.length = 0 + continue } - reader.releaseLock() + // If the response body keeps streaming, + // pipe it to the socket as we receive the chunks. + socket.push(value, encoding) } - socket.push(null) + reader.releaseLock() } diff --git a/test/modules/Socket/compliance/socket.events.test.ts b/test/modules/Socket/compliance/socket.events.test.ts new file mode 100644 index 00000000..04c21006 --- /dev/null +++ b/test/modules/Socket/compliance/socket.events.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment node + */ +import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' +import net from 'node:net' +import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +function spyOnEvents(socket: net.Socket): Array { + const events: Array = [] + + socket.emit = new Proxy(socket.emit, { + apply(target, thisArg, args) { + events.push(args) + return Reflect.apply(target, thisArg, args) + }, + }) + + return events +} + +function waitForSocketEvent( + socket: net.Socket, + event: string +): Promise { + return new Promise((resolve, reject) => { + socket + .once(event, (data) => resolve(data)) + .once('error', (error) => reject(error)) + }) +} + +it('emits correct events for an HTTP connection', async () => { + const connectionCallback = vi.fn() + const socket = new net.Socket().connect(80, 'example.com', connectionCallback) + const events = spyOnEvents(socket) + + await waitForSocketEvent(socket, 'connect') + expect(events).toEqual([ + ['lookup', null, expect.any(String), 6, 'example.com'], + ['connect'], + ['ready'], + ]) + + socket.destroy() + await waitForSocketEvent(socket, 'close') + + expect(events.slice(3)).toEqual([['close', false]]) + expect(connectionCallback).toHaveBeenCalledTimes(1) +}) + +it('emits correct events for a mocked keepalive HTTP request', async () => { + interceptor.on('request', ({ request }) => { + request.respondWith(new Response('hello world')) + }) + + const connectionCallback = vi.fn() + const socket = new net.Socket().connect(80, 'example.com', connectionCallback) + const events = spyOnEvents(socket) + + await waitForSocketEvent(socket, 'connect') + expect(events).toEqual([ + ['lookup', null, expect.any(String), 6, 'example.com'], + ['connect'], + ['ready'], + ]) + + socket.write('HEAD / HTTP/1.1\r\n') + // Intentionally construct a keepalive request + // (no "Connection: close" request header). + socket.write('Host: example.com\r\n') + socket.write('\r\n') + + await waitForSocketEvent(socket, 'data') + + expect(events.slice(3)).toEqual([ + ['resume'], + [ + 'data', + Buffer.from( + `HTTP/1.1 200 OK\r\ncontent-type: text/plain;charset=UTF-8\r\n\r\nhello world` + ), + ], + ]) +}) + +it('emits correct events for a mocked HTTP request', async () => { + interceptor.on('request', ({ request }) => { + request.respondWith(new Response('hello world')) + }) + + const connectionCallback = vi.fn() + const socket = new net.Socket().connect(80, 'example.com', connectionCallback) + const events = spyOnEvents(socket) + + await waitForSocketEvent(socket, 'connect') + expect(events).toEqual([ + ['lookup', null, expect.any(String), 6, 'example.com'], + ['connect'], + ['ready'], + ]) + + socket.write('HEAD / HTTP/1.1\r\n') + // Instruct the socket to close the connection + // as soon as the response is received. + socket.write('Connection: close\r\n') + socket.write('Host: example.com\r\n') + socket.write('\r\n') + + await waitForSocketEvent(socket, 'data') + expect(events.slice(3)).toEqual([ + ['resume'], + [ + 'data', + Buffer.from( + `HTTP/1.1 200 OK\r\ncontent-type: text/plain;charset=UTF-8\r\n\r\nhello world` + ), + ], + ]) + + await waitForSocketEvent(socket, 'end') + expect(events.slice(5)).toEqual([['readable'], ['end']]) +}) diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index 88bf33c1..6e221db1 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -49,6 +49,7 @@ it('bypasses a request to the existing host', async () => { // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] + expect(requestFromListener.url).toBe(httpServer.http.url('/user')) expect(requestFromListener.method).toBe('POST') expect(requestFromListener.headers.get('content-type')).toBe( 'application/json' @@ -110,6 +111,7 @@ it('mocked request to an existing host', async () => { // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] + expect(requestFromListener.url).toBe(httpServer.http.url('/user')) expect(requestFromListener.method).toBe('POST') expect(requestFromListener.headers.get('content-type')).toBe( 'application/json' @@ -149,6 +151,7 @@ it('mocks response to a non-existing host', async () => { // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] + expect(requestFromListener.url).toBe('http://foo.example.com/') expect(requestFromListener.method).toBe('POST') expect(requestFromListener.headers.get('content-type')).toBe( 'application/json' From a755028997ffd8f2a02e91b316e67ec9084bdb51 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Mar 2024 13:50:10 +0100 Subject: [PATCH 14/69] test: migrate other http tests to socket interceptor --- src/interceptors/Socket/SocketInterceptor.ts | 12 +++++++++++- test/modules/http/http-performance.test.ts | 5 +++-- .../http/response/http-response-delay.test.ts | 6 +++--- .../http/response/http-response-error.test.ts | 4 ++-- .../http/response/http-response-patching.test.ts | 8 ++------ 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 473cb016..64f126bc 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -1,4 +1,5 @@ import net from 'node:net' +import { STATUS_CODES } from 'node:http' import { HTTPParser } from 'node:_http_common' import { randomUUID } from 'node:crypto' import { Readable } from 'node:stream' @@ -9,7 +10,7 @@ import { toInteractiveRequest, } from '../../utils/toInteractiveRequest' import { emitAsync } from '../../utils/emitAsync' -import { STATUS_CODES } from 'node:http' +import { isPropertyAccessible } from '../../utils/isPropertyAccessible' type NormalizedSocketConnectArgs = [ options: NormalizedSocketConnectOptions, @@ -108,6 +109,15 @@ export class SocketInterceptor extends Interceptor { const mockedResponse = resolverResult.data if (mockedResponse) { + // Handle mocked "Response.error()" instances. + if ( + isPropertyAccessible(mockedResponse, 'type') && + mockedResponse.type === 'error' + ) { + socketWrap.errorWith(new TypeError('Network error')) + return + } + socketWrap.respondWith(mockedResponse) } else { socketWrap.passthrough() diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index f0a1956f..1637300f 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,6 +1,6 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' +import { SocketInterceptor } from '../../../src/interceptors/Socket/SocketInterceptor' import { httpGet, PromisifiedResponse, useCors } from '../../helpers' function arrayWith(length: number, mapFn: (index: number) => V): V[] { @@ -28,7 +28,8 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new SocketInterceptor() + interceptor.on('request', ({ request }) => { const url = new URL(request.url) diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 06a7ddc1..7fc1ac2d 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 { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' -const interceptor = new ClientRequestInterceptor() +const interceptor = new SocketInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { @@ -29,7 +29,7 @@ it('supports custom delay before responding with a mock', async () => { }) const requestStart = Date.now() - const request = http.get('https://non-existing-host.com') + const request = http.get('http://non-existing-host.com') const { res, text } = await waitForClientRequest(request) const requestEnd = Date.now() diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index 71638925..f2c0b390 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 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' import { DeferredPromise } from '@open-draft/deferred-promise' -const interceptor = new ClientRequestInterceptor() +const interceptor = new SocketInterceptor() interceptor.on('request', ({ request }) => { request.respondWith(Response.error()) diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 9ecb5a8b..af82f40f 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,8 +1,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'http' import { HttpServer } from '@open-draft/test-server/http' -import { BatchInterceptor } from '../../../../src' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' import { sleep, waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -11,10 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new BatchInterceptor({ - name: 'response-patching', - interceptors: [new ClientRequestInterceptor()], -}) +const interceptor = new SocketInterceptor() async function getResponse(request: Request): Promise { const url = new URL(request.url) From 095548c6b85aea5fcbff46528e3187e25de9a2d4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 14:05:30 +0100 Subject: [PATCH 15/69] feat(wip): agent-based socket interception --- .../ClientRequest/MockHttpSocket.ts | 341 ++++++++++++++++++ src/interceptors/ClientRequest/agents.ts | 60 +++ src/interceptors/ClientRequest/index-new.ts | 130 +++++++ src/interceptors/Socket/MockSocket.ts | 70 ++++ src/interceptors/Socket/SocketInterceptor.ts | 88 ++++- src/interceptors/Socket/parsers/HttpParser.ts | 53 +++ .../utils/baseUrlFromConnectionOptions.ts | 26 ++ .../Socket/utils/normalizeWriteArgs.ts | 31 ++ .../Socket/utils/parseRawHeaders.ts | 10 + test/modules/http/intercept/http.get.test.ts | 10 +- .../http/intercept/http.request.test.ts | 31 +- test/modules/http/intercept/https.get.test.ts | 50 ++- .../http-concurrent-same-host.test.ts | 4 +- test/vitest.config.js | 1 + 14 files changed, 872 insertions(+), 33 deletions(-) create mode 100644 src/interceptors/ClientRequest/MockHttpSocket.ts create mode 100644 src/interceptors/ClientRequest/agents.ts create mode 100644 src/interceptors/ClientRequest/index-new.ts create mode 100644 src/interceptors/Socket/MockSocket.ts create mode 100644 src/interceptors/Socket/parsers/HttpParser.ts create mode 100644 src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts create mode 100644 src/interceptors/Socket/utils/normalizeWriteArgs.ts create mode 100644 src/interceptors/Socket/utils/parseRawHeaders.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts new file mode 100644 index 00000000..f32bdfc1 --- /dev/null +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -0,0 +1,341 @@ +import net from 'node:net' +import { STATUS_CODES } from 'node:http' +import { Readable } from 'node:stream' +import { invariant } from 'outvariant' +import { MockSocket } from '../Socket/MockSocket' +import type { NormalizedWriteArgs } from '../Socket/utils/normalizeWriteArgs' +import { + HTTPParser, + RequestHeadersCompleteCallback, + ResponseHeadersCompleteCallback, + type NodeHttpParser, +} from '../Socket/parsers/HttpParser' +import { isPropertyAccessible } from '../../utils/isPropertyAccessible' +import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' +import { parseRawHeaders } from '../Socket/utils/parseRawHeaders' +import { RESPONSE_STATUS_CODES_WITHOUT_BODY } from '../../utils/responseUtils' + +type HttpConnectionOptions = any + +interface MockHttpSocketOptions { + connectionOptions: HttpConnectionOptions + createConnection: () => net.Socket + onRequest?: (request: Request) => void + onResponse?: (response: Response) => void +} + +export class MockHttpSocket extends MockSocket { + private connectionOptions: HttpConnectionOptions + private createConnection: () => net.Socket + private baseUrl: URL + + private onRequest?: (request: Request) => void + private onResponse?: (response: Response) => void + + private writeBuffer: Array = [] + private requestParser: NodeHttpParser<0> + private requestStream?: Readable + private shouldKeepAlive?: boolean + + private responseParser: NodeHttpParser<1> + private responseStream?: Readable + + constructor(options: MockHttpSocketOptions) { + super({ + write: (chunk, encoding, callback) => { + this.writeBuffer.push([chunk, encoding, callback]) + + if (chunk !== '') { + this.requestParser.execute( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) + ) + } + }, + read: (chunk) => { + // console.log('MockHttpSocket.read()', chunk) + // 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.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.kOnHeadersComplete] = + this.onResponseStart.bind(this) + this.responseParser[HTTPParser.kOnBody] = this.onResponseBody.bind(this) + this.responseParser[HTTPParser.kOnMessageComplete] = + this.onResponseEnd.bind(this) + } + + public destroy(error?: Error | undefined): this { + console.log('MockHttpSocket.destroy()') + + this.requestParser.free() + this.responseParser.free() + return super.destroy(error) + } + + /** + * Establish this Socket connection as-is and pipe + * its data/events through this Socket. + */ + public passthrough(): void { + console.log('MockHttpSocket.passthrough()') + + if (this.writable) { + console.log('SOCKET IS STILL WRITABLE') + + this.once('end', () => { + console.log('FINISHED') + // this.passthrough() + }) + + return + } + + const socket = this.createConnection() + + console.log('buffered chunks:', this.writeBuffer) + + // Write the buffered request body chunks. + // Exhaust the "requestBuffer" in case this Socket + // gets reused for different requests. + let writeArgs: NormalizedWriteArgs | undefined + + while ((writeArgs = this.writeBuffer.shift())) { + console.log('writing onto original socket:', writeArgs) + + if (writeArgs !== undefined) { + socket.write(...writeArgs) + } + } + + 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) => { + this.emit('data', 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)) + } + + /** + * Convert the given Fetch API `Response` instance to an + * HTTP message and push it to the socket. + */ + public async respondWith(response: Response): Promise { + // Handle "type: error" responses. + if (isPropertyAccessible(response, 'type') && response.type === 'error') { + this.errorWith(new Error('Failed to fetch')) + return + } + + // First, emit all the connection events + // to emulate a successful connection. + this.mockConnect() + + const httpHeaders: Array = [] + + httpHeaders.push( + Buffer.from( + `HTTP/1.1 ${response.status} ${response.statusText || STATUS_CODES[response.status]}\r\n` + ) + ) + + for (const [name, value] of response.headers) { + httpHeaders.push(Buffer.from(`${name}: ${value}\r\n`)) + } + + if (response.body) { + httpHeaders.push(Buffer.from('\r\n')) + const reader = response.body.getReader() + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + // The first body chunk flushes the entire headers. + if (httpHeaders.length > 0) { + httpHeaders.push(Buffer.from(value)) + this.push(Buffer.concat(httpHeaders)) + httpHeaders.length = 0 + continue + } + + // Subsequent body chukns are push to the stream. + this.push(value) + } + } + + this.push('\r\n') + this.push(null) + + // Close the socket if the connection wasn't marked as keep-alive. + if (!this.shouldKeepAlive) { + this.emit('readable') + this.push(null) + } + } + + /** + * Close this Socket connection with the given error. + */ + public errorWith(error: Error): void { + this.destroy(error) + } + + private mockConnect(): void { + this.emit('lookup', null, '::1', 6, this.connectionOptions.host) + this.emit('connect') + this.emit('ready') + + // TODO: Also emit "secure" -> "secureConnect" -> "session" events + // for TLS sockets. + } + + private onRequestStart: RequestHeadersCompleteCallback = ( + versionMajor, + versionMinor, + rawHeaders, + _, + path, + __, + ___, + ____, + shouldKeepAlive + ) => { + this.shouldKeepAlive = shouldKeepAlive + + const url = new URL(path, this.baseUrl) + const method = this.connectionOptions.method || 'GET' + const headers = parseRawHeaders(rawHeaders) + const canHaveBody = method !== 'GET' && method !== 'HEAD' + + 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. + if (canHaveBody) { + this.requestStream = new Readable() + } + + const request = new Request(url, { + method, + headers, + credentials: 'same-origin', + // @ts-expect-error: Undocumented fetch option. + duplex: canHaveBody ? 'half' : undefined, + body: canHaveBody ? Readable.toWeb(this.requestStream) : null, + }) + + this.onRequest?.(request) + } + + 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 { + if (this.requestStream) { + this.requestStream.push(null) + this.requestStream.destroy() + this.requestStream = undefined + } + } + + private onResponseStart: ResponseHeadersCompleteCallback = ( + versionMajor, + versionMinor, + rawHeaders, + method, + url, + status, + statusText, + upgrade, + shouldKeepAlive + ) => { + const headers = parseRawHeaders(rawHeaders) + const canHaveBody = !RESPONSE_STATUS_CODES_WITHOUT_BODY.has(status) + + if (canHaveBody) { + this.responseStream = new Readable() + } + + const response = new Response( + canHaveBody ? Readable.toWeb(this.responseStream) : null, + { + status, + statusText, + headers, + } + ) + + this.onResponse?.(response) + } + + 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 { + if (this.responseStream) { + this.responseStream.push(null) + this.responseStream.destroy() + } + } +} diff --git a/src/interceptors/ClientRequest/agents.ts b/src/interceptors/ClientRequest/agents.ts new file mode 100644 index 00000000..a2e68d41 --- /dev/null +++ b/src/interceptors/ClientRequest/agents.ts @@ -0,0 +1,60 @@ +import http from 'node:http' +import https from 'node:https' +import net from 'node:net' +import { MockHttpSocket } from './MockHttpSocket' + +declare module 'node:http' { + interface Agent { + createConnection(options: any, callback: any): net.Socket + } +} + +export type MockAgentOnRequestCallback = (args: { + request: Request + socket: MockHttpSocket +}) => void + +export type MockAgentOnResponseCallback = (args: { response: Response }) => void + +interface MockAgentInterface { + onRequest?: MockAgentOnRequestCallback + onResponse?: MockAgentOnResponseCallback +} + +export class MockAgent extends http.Agent implements MockAgentInterface { + public onRequest?: MockAgentOnRequestCallback + public onResponse?: MockAgentOnResponseCallback + + constructor(options?: http.AgentOptions) { + super(options) + } + + public createConnection(options: any, callback: any) { + const createConnection = super.createConnection.bind( + this, + options, + callback + ) + + const socket = new MockHttpSocket({ + connectionOptions: options, + createConnection, + onRequest: (request) => { + this.onRequest?.({ request, socket }) + }, + onResponse: (response) => { + this.onResponse?.({ response }) + }, + }) + + return socket + } +} + +export class MockHttpsAgent extends https.Agent implements MockAgentInterface { + public onRequest?: MockAgentOnRequestCallback + public onResponse?: MockAgentOnResponseCallback + + // TODO: The same implementation as for the HTTP. + // Just need to extend Https.Agent. +} diff --git a/src/interceptors/ClientRequest/index-new.ts b/src/interceptors/ClientRequest/index-new.ts new file mode 100644 index 00000000..a688a0aa --- /dev/null +++ b/src/interceptors/ClientRequest/index-new.ts @@ -0,0 +1,130 @@ +import http from 'node:http' +import https from 'node:https' +import { randomUUID } from 'node:crypto' +import { until } from '@open-draft/until' +import { Interceptor } from '../../Interceptor' +import type { HttpRequestEventMap } from '../../glossary' +import { + MockAgent, + MockHttpsAgent, + type MockAgentOnRequestCallback, + type MockAgentOnResponseCallback, +} from './agents' +import { emitAsync } from '../../utils/emitAsync' +import { toInteractiveRequest } from '../../utils/toInteractiveRequest' +import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' + +export class _ClientRequestInterceptor extends Interceptor { + static symbol = Symbol('client-request-interceptor') + + constructor() { + super(_ClientRequestInterceptor.symbol) + } + + protected setup(): void { + const { get: originalGet, request: originalRequest } = http + const { get: originalHttpsGet, request: originalHttpsRequest } = http + + http.request = new Proxy(http.request, { + apply: (target, context, args: Parameters) => { + const normalizedArgs = normalizeClientRequestArgs('http:', ...args) + + const agent = new MockAgent() + agent.onRequest = this.onRequest.bind(this) + agent.onResponse = this.onResponse.bind(this) + + normalizedArgs[1].agent = agent + return Reflect.apply(target, context, normalizedArgs) + }, + }) + + http.get = new Proxy(http.get, { + apply: (target, context, args: Parameters) => { + const normalizedArgs = normalizeClientRequestArgs('http:', ...args) + + const agent = new MockAgent() + agent.onRequest = this.onRequest.bind(this) + agent.onResponse = this.onResponse.bind(this) + + normalizedArgs[1].agent = agent + return Reflect.apply(target, context, normalizedArgs) + }, + }) + + // + // HTTPS. + // + + https.request = new Proxy(https.request, { + apply: (target, context, args: Parameters) => { + const normalizedArgs = normalizeClientRequestArgs('https:', ...args) + + const httpsAgent = new MockHttpsAgent() + httpsAgent.onRequest = this.onRequest.bind(this) + httpsAgent.onResponse = this.onResponse.bind(this) + + normalizedArgs[1].agent = httpsAgent + return Reflect.apply(target, context, normalizedArgs) + }, + }) + + this.subscriptions.push(() => { + http.get = originalGet + http.request = originalRequest + + https.get = originalHttpsGet + https.request = originalHttpsRequest + }) + } + + private onRequest: MockAgentOnRequestCallback = async ({ + request, + socket, + }) => { + const requestId = randomUUID() + const { interactiveRequest, requestController } = + toInteractiveRequest(request) + + // TODO: Abstract this bit. We are using it everywhere. + this.emitter.once('request', ({ requestId: pendingRequestId }) => { + if (pendingRequestId !== requestId) { + return + } + + if (requestController.responsePromise.state === 'pending') { + this.logger.info( + 'request has not been handled in listeners, executing fail-safe listener...' + ) + + requestController.responsePromise.resolve(undefined) + } + }) + + const listenerResult = await until(async () => { + await emitAsync(this.emitter, 'request', { + requestId, + request: interactiveRequest, + }) + + return await requestController.responsePromise + }) + + if (listenerResult.error) { + socket.errorWith(listenerResult.error) + return + } + + const mockedResponse = listenerResult.data + + if (mockedResponse) { + socket.respondWith(mockedResponse) + return + } + + socket.passthrough() + } + + public onResponse: MockAgentOnResponseCallback = async ({ response }) => { + console.log('RESPONSE:', response.status, response.statusText) + } +} diff --git a/src/interceptors/Socket/MockSocket.ts b/src/interceptors/Socket/MockSocket.ts new file mode 100644 index 00000000..34745a6e --- /dev/null +++ b/src/interceptors/Socket/MockSocket.ts @@ -0,0 +1,70 @@ +import net from 'node:net' +import { + normalizeWriteArgs, + type WriteCallback, +} from './utils/normalizeWriteArgs' + +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() + } + + public connect() { + console.log('MockSocket.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] = normalizeWriteArgs(args) + console.log('MockSocket.write()', chunk.toString()) + + this.options.write(chunk, encoding, callback) + return false + } + + public end(...args: Array) { + console.log('MockSocket.end()', args) + + const [chunk, encoding, callback] = normalizeWriteArgs(args) + this.options.write(chunk, encoding, callback) + + return super.end.apply(this, args) + } + + public push(chunk: any, encoding?: BufferEncoding): boolean { + console.log( + 'MockSocket.push()', + { chunk, encoding }, + this.writable, + this.writableFinished + ) + + this.options.read(chunk, encoding) + + if (chunk !== null) { + this.emit('data', chunk) + } else { + this.emit('end') + } + + return true + } +} diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 64f126bc..7a03732b 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -1,8 +1,10 @@ import net from 'node:net' +import https from 'node:https' +import tls from 'node:tls' import { STATUS_CODES } from 'node:http' import { HTTPParser } from 'node:_http_common' import { randomUUID } from 'node:crypto' -import { Readable } from 'node:stream' +import { Duplex, Readable } from 'node:stream' import { until } from '@open-draft/until' import { Interceptor } from '../../Interceptor' import { @@ -11,6 +13,7 @@ import { } from '../../utils/toInteractiveRequest' import { emitAsync } from '../../utils/emitAsync' import { isPropertyAccessible } from '../../utils/isPropertyAccessible' +import EventEmitter from 'node:events' type NormalizedSocketConnectArgs = [ options: NormalizedSocketConnectOptions, @@ -54,6 +57,7 @@ export class SocketInterceptor extends Interceptor { protected setup(): void { const self = this const originalConnect = net.Socket.prototype.connect + const originalTlsConnect = tls.TLSSocket.prototype.connect net.Socket.prototype.connect = function mockConnect( ...args: Array @@ -136,8 +140,42 @@ export class SocketInterceptor extends Interceptor { return socketWrap } + // + // + // + + // tls.TLSSocket.prototype.connect = function mockTlsConnect(...args) { + // console.log('tls.TLSSocket.connect') + + // const normalizedArgs = net._normalizeArgs(args) + + // const createSocketConnection = () => { + // /** @todo Have doubts about this. Bypassed request tests will reveal the truth. */ + // return originalConnect.apply(this, normalizedArgs[0], normalizedArgs[1]) + // } + + // // const socketWrap = new SocketWrap(normalizedArgs, createSocketConnection) + + // // const tlsSocket = new TlsSocketWrap(socketWrap, normalizedArgs[0]) + // // const tlsSocket = new tls.TLSSocket(socketWrap, normalizedArgs[0]) + // // + + // // socketWrap.onRequest = (request) => { + // // console.log('intercepted', request.method, request.url) + // // } + + // const e = new EventEmitter() + // queueMicrotask(() => { + // e.emit('secureConnect') + // e.emit('connect') + // }) + + // return e + // } + this.subscriptions.push(() => { net.Socket.prototype.connect = originalConnect + tls.TLSSocket.prototype.connect = originalTlsConnect }) } } @@ -158,7 +196,8 @@ class SocketWrap extends net.Socket { constructor( readonly socketConnectArgs: ReturnType, - private createConnection: () => net.Socket + private createConnection: () => net.Socket, + private isSecure = false ) { super() @@ -218,6 +257,8 @@ class SocketWrap extends net.Socket { } private mockConnect() { + console.log('SocketWrap.mockConnect()', this.connectionListener) + if (this.connectionListener) { this.once('connect', this.connectionListener) } @@ -228,6 +269,10 @@ class SocketWrap extends net.Socket { this.emit('lookup', null, '127.0.0.1', 6, this.connectionOptions.host) Reflect.set(this, 'connecting', false) + if (this.isSecure) { + this.emit('secureConnect') + } + this.emit('connect') this.emit('ready') }) @@ -378,6 +423,30 @@ class SocketWrap extends net.Socket { } } +class TlsSocketWrap extends tls.TLSSocket { + constructor( + private readonly socket: SocketWrap, + _tlsOptions: NormalizedSocketConnectOptions + ) { + socket._tlsOptions = {} + + super(socket) + + this.mockSecureConnect() + } + + private mockSecureConnect() { + console.log('TlsSocketWrap: mockSecureConnect()') + // console.log(this.ssl) + + // this.ssl.onhandshakedone() + + this._secureEstablished = true + this.emit('secure') + this.emit('secureConnect') + } +} + type CommonSocketConnectOptions = { method?: string auth?: string @@ -542,3 +611,18 @@ async function pipeResponse( reader.releaseLock() } + +export class MockAgent extends https.Agent { + createConnection( + options, + onCreate: (error: Error | null, socket: net.Socket) => void + ) { + const normalizedOptions = net._normalizeArgs([options, onCreate]) + + const createRealConnection = () => { + throw new Error('Not implemented') + } + + return new SocketWrap(normalizedOptions, createRealConnection, true) + } +} diff --git a/src/interceptors/Socket/parsers/HttpParser.ts b/src/interceptors/Socket/parsers/HttpParser.ts new file mode 100644 index 00000000..3b2a0921 --- /dev/null +++ b/src/interceptors/Socket/parsers/HttpParser.ts @@ -0,0 +1,53 @@ +// @ts-expect-error Tapping into Node.js internals. +import httpCommon from 'node:_http_common' + +const HTTPParser = httpCommon.HTTPParser as NodeHttpParser + +export interface NodeHttpParser { + REQUEST: 0 + RESPONSE: 1 + readonly kOnHeadersComplete: unique symbol + readonly kOnBody: unique symbol + readonly kOnMessageComplete: unique symbol + + new (): NodeHttpParser + + // Headers complete callback has different + // signatures for REQUEST and RESPONSE parsers. + [HTTPParser.kOnHeadersComplete]: Type extends 0 + ? RequestHeadersCompleteCallback + : ResponseHeadersCompleteCallback + [HTTPParser.kOnBody]: (chunk: Buffer) => void + [HTTPParser.kOnMessageComplete]: () => void + + initialize(type: Type, asyncResource: object): void + execute(buffer: Buffer): void + finish(): void + free(): void +} + +export type RequestHeadersCompleteCallback = ( + versionMajor: number, + versionMinor: number, + headers: 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 + +export { HTTPParser } diff --git a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts new file mode 100644 index 00000000..6a4f33ad --- /dev/null +++ b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts @@ -0,0 +1,26 @@ +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/Socket/utils/normalizeWriteArgs.ts b/src/interceptors/Socket/utils/normalizeWriteArgs.ts new file mode 100644 index 00000000..733060e8 --- /dev/null +++ b/src/interceptors/Socket/utils/normalizeWriteArgs.ts @@ -0,0 +1,31 @@ +export type WriteCallback = (error?: Error | null) => void + +export type WriteArgs = + | [chunk: unknown, callback?: WriteCallback] + | [chunk: unknown, encoding: BufferEncoding, callback?: WriteCallback] + +export type NormalizedWriteArgs = [ + chunk: any, + encoding?: BufferEncoding, + callback?: WriteCallback, +] + +/** + * Normalizes the arguments provided to the `Writable.prototype.write()` + * and `Writable.prototype.end()`. + */ +export function normalizeWriteArgs(args: WriteArgs): NormalizedWriteArgs { + const normalized: NormalizedWriteArgs = [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 +} diff --git a/src/interceptors/Socket/utils/parseRawHeaders.ts b/src/interceptors/Socket/utils/parseRawHeaders.ts new file mode 100644 index 00000000..c66ee2f8 --- /dev/null +++ b/src/interceptors/Socket/utils/parseRawHeaders.ts @@ -0,0 +1,10 @@ +/** + * Create a Fetch API `Headers` instance from the given raw headers list. + */ +export function 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/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index d361c87a..095dfc73 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,11 +1,9 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'http' import { HttpServer } from '@open-draft/test-server/http' -import { - SocketInterceptor, - SocketEventMap, -} from '../../../../src/interceptors/Socket/SocketInterceptor' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' +import { HttpRequestEventMap } from '../../../../src/glossary' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -13,9 +11,9 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn() +const resolver = vi.fn() -const interceptor = new SocketInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index a320ecac..31d8bb69 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -3,10 +3,8 @@ import http from 'http' import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' -import { - SocketInterceptor, - SocketEventMap, -} from '../../../../src/interceptors/Socket/SocketInterceptor' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { HttpRequestEventMap } from '../../../../src/glossary' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -19,8 +17,8 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn() -const interceptor = new SocketInterceptor() +const resolver = vi.fn() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { @@ -93,7 +91,7 @@ it('intercepts a GET request', async () => { expect(requestId).toMatch(UUID_REGEXP) }) -it('intercepts a POST request', async () => { +it.only('intercepts a POST request', async () => { const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'POST', @@ -103,8 +101,25 @@ it('intercepts a POST request', async () => { }, }) req.write('post-payload') + + console.log('[test] calling req.end()...') req.end() - await waitForClientRequest(req) + + req.on('abort', () => { + console.trace('REQUEST ABORTED') + }) + + req.on('socket', (socket) => { + console.log('req socket!', socket.constructor.name) + + socket.on('error', (error) => { + console.log('socket error', error) + }) + }) + + const { res } = await waitForClientRequest(req) + + console.log('[test] RESPONSE:', res.statusCode, res.statusMessage) 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 207ba382..5b746ff0 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -2,8 +2,11 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'https' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { HttpRequestEventMap } from '../../../../src' +import { + SocketInterceptor, + SocketEventMap, + MockAgent, +} from '../../../../src/interceptors/Socket/SocketInterceptor' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -11,8 +14,8 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn() -const interceptor = new ClientRequestInterceptor() +const resolver = vi.fn() +const interceptor = new SocketInterceptor() interceptor.on('request', resolver) beforeAll(async () => { @@ -29,28 +32,45 @@ afterAll(async () => { await httpServer.close() }) -it('intercepts a GET request', async () => { +it.only('intercepts a GET request', async () => { const url = httpServer.https.url('/user?id=123') - const req = https.get(url, { - agent: httpsAgent, + const request = https.get(url, { + agent: new MockAgent(), headers: { 'x-custom-header': 'yes', }, }) - await waitForClientRequest(req) + + request + .on('socket', (socket) => { + console.log('[test] request "socket" event:', socket.constructor.name) + + socket.on('secureConnect', () => console.log('secureConnect')) + socket.on('error', (e) => console.error(e)) + socket.on('timeout', () => console.error('timeout')) + socket.on('close', (e) => console.error(e)) + socket.on('end', () => console.error('end')) + }) + .on('error', (e) => console.error(e)) + .on('end', () => console.log('end')) + .on('close', () => console.log('close')) + + await waitForClientRequest(request) expect(resolver).toHaveBeenCalledTimes(1) - const [{ request, requestId }] = resolver.mock.calls[0] + const [{ request: requestFromListener, requestId }] = resolver.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + expect(requestFromListener.method).toBe('GET') + expect(requestFromListener.url).toBe(url) + expect( + Object.fromEntries(requestFromListener.headers.entries()) + ).toMatchObject({ 'x-custom-header': 'yes', }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(request.respondWith).toBeInstanceOf(Function) + expect(requestFromListener.credentials).toBe('same-origin') + expect(requestFromListener.body).toBe(null) + expect(requestFromListener.respondWith).toBeInstanceOf(Function) expect(requestId).toMatch(UUID_REGEXP) }) 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 43b662b6..d98fdbdc 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -3,11 +3,11 @@ */ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' let requests: Array = [] -const interceptor = new ClientRequestInterceptor() +const interceptor = new SocketInterceptor() interceptor.on('request', ({ request }) => { requests.push(request) request.respondWith(new Response()) diff --git a/test/vitest.config.js b/test/vitest.config.js index fba879b8..fedbe2e6 100644 --- a/test/vitest.config.js +++ b/test/vitest.config.js @@ -9,5 +9,6 @@ export default defineConfig({ 'vitest-environment-node-with-websocket': './envs/node-with-websocket', 'vitest-environment-react-native-like': './envs/react-native-like', }, + testTimeout: 2000, }, }) From d3df8e0e4ddec2a9de59ac298ad4a8d2c14bc4b5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 18:56:45 +0100 Subject: [PATCH 16/69] fix(MockHttpSocket): do not destroy the request stream --- .../ClientRequest/MockHttpSocket.ts | 35 +++++-------------- .../http/intercept/http.request.test.ts | 2 +- 2 files changed, 9 insertions(+), 28 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index f32bdfc1..44f5a3b0 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -52,10 +52,9 @@ export class MockHttpSocket extends MockSocket { } }, read: (chunk) => { - // console.log('MockHttpSocket.read()', chunk) - // this.responseParser.execute( - // Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - // ) + this.responseParser.execute( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ) }, }) @@ -86,8 +85,7 @@ export class MockHttpSocket extends MockSocket { } public destroy(error?: Error | undefined): this { - console.log('MockHttpSocket.destroy()') - + // Free the parsers once this socket is destroyed. this.requestParser.free() this.responseParser.free() return super.destroy(error) @@ -98,31 +96,14 @@ export class MockHttpSocket extends MockSocket { * its data/events through this Socket. */ public passthrough(): void { - console.log('MockHttpSocket.passthrough()') - - if (this.writable) { - console.log('SOCKET IS STILL WRITABLE') - - this.once('end', () => { - console.log('FINISHED') - // this.passthrough() - }) - - return - } - const socket = this.createConnection() - console.log('buffered chunks:', this.writeBuffer) - // Write the buffered request body chunks. // Exhaust the "requestBuffer" in case this Socket // gets reused for different requests. let writeArgs: NormalizedWriteArgs | undefined while ((writeArgs = this.writeBuffer.shift())) { - console.log('writing onto original socket:', writeArgs) - if (writeArgs !== undefined) { socket.write(...writeArgs) } @@ -160,7 +141,7 @@ export class MockHttpSocket extends MockSocket { public async respondWith(response: Response): Promise { // Handle "type: error" responses. if (isPropertyAccessible(response, 'type') && response.type === 'error') { - this.errorWith(new Error('Failed to fetch')) + this.errorWith(new TypeError('Failed to fetch')) return } @@ -286,10 +267,9 @@ export class MockHttpSocket extends MockSocket { } private onRequestEnd(): void { + // Request end can be called for requests without body. if (this.requestStream) { this.requestStream.push(null) - this.requestStream.destroy() - this.requestStream = undefined } } @@ -307,6 +287,7 @@ export class MockHttpSocket extends MockSocket { const headers = parseRawHeaders(rawHeaders) const canHaveBody = !RESPONSE_STATUS_CODES_WITHOUT_BODY.has(status) + // Similarly, create a new stream for each response. if (canHaveBody) { this.responseStream = new Readable() } @@ -333,9 +314,9 @@ export class MockHttpSocket extends MockSocket { } private onResponseEnd(): void { + // Response end can be called for responses without body. if (this.responseStream) { this.responseStream.push(null) - this.responseStream.destroy() } } } diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 31d8bb69..39731be6 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -91,7 +91,7 @@ it('intercepts a GET request', async () => { expect(requestId).toMatch(UUID_REGEXP) }) -it.only('intercepts a POST request', async () => { +it('intercepts a POST request', async () => { const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'POST', From cdf6c254175835afff5d7e72fdff567a73795227 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 18:58:14 +0100 Subject: [PATCH 17/69] fix(MockHttpSocket): handle undefined writes on .end() --- src/interceptors/ClientRequest/MockHttpSocket.ts | 2 +- test/modules/http/intercept/https.get.test.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 44f5a3b0..8aa5b75d 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -45,7 +45,7 @@ export class MockHttpSocket extends MockSocket { write: (chunk, encoding, callback) => { this.writeBuffer.push([chunk, encoding, callback]) - if (chunk !== '') { + if (chunk) { this.requestParser.execute( Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) ) diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 5b746ff0..f13ccd4a 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -2,11 +2,8 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'https' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' -import { - SocketInterceptor, - SocketEventMap, - MockAgent, -} from '../../../../src/interceptors/Socket/SocketInterceptor' +import { HttpRequestEventMap } from '../../../../src/glossary' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -14,8 +11,8 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn() -const interceptor = new SocketInterceptor() +const resolver = vi.fn() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { @@ -32,10 +29,10 @@ afterAll(async () => { await httpServer.close() }) -it.only('intercepts a GET request', async () => { +it('intercepts a GET request', async () => { const url = httpServer.https.url('/user?id=123') const request = https.get(url, { - agent: new MockAgent(), + agent: httpsAgent, headers: { 'x-custom-header': 'yes', }, From 3364c8ef66c54a03a9a985ae5b5f6e64cb16c8b0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 19:48:03 +0100 Subject: [PATCH 18/69] feat: implement MockHttpsAgent --- src/interceptors/ClientRequest/agents.ts | 73 ++++++++++++----- src/interceptors/ClientRequest/index-new.ts | 81 +++++++++++++------ .../http/intercept/http.request.test.ts | 20 +---- test/modules/http/intercept/https.get.test.ts | 4 +- .../http/intercept/https.request.test.ts | 22 ++--- 5 files changed, 124 insertions(+), 76 deletions(-) diff --git a/src/interceptors/ClientRequest/agents.ts b/src/interceptors/ClientRequest/agents.ts index a2e68d41..2fbb390d 100644 --- a/src/interceptors/ClientRequest/agents.ts +++ b/src/interceptors/ClientRequest/agents.ts @@ -1,6 +1,6 @@ +import net from 'node:net' import http from 'node:http' import https from 'node:https' -import net from 'node:net' import { MockHttpSocket } from './MockHttpSocket' declare module 'node:http' { @@ -16,34 +16,38 @@ export type MockAgentOnRequestCallback = (args: { export type MockAgentOnResponseCallback = (args: { response: Response }) => void -interface MockAgentInterface { - onRequest?: MockAgentOnRequestCallback - onResponse?: MockAgentOnResponseCallback +interface MockAgentOptions { + customAgent?: http.RequestOptions['agent'] + onRequest: MockAgentOnRequestCallback + onResponse: MockAgentOnResponseCallback } -export class MockAgent extends http.Agent implements MockAgentInterface { - public onRequest?: MockAgentOnRequestCallback - public onResponse?: MockAgentOnResponseCallback +export class MockAgent extends http.Agent { + private customAgent?: http.RequestOptions['agent'] + private onRequest: MockAgentOnRequestCallback + private onResponse: MockAgentOnResponseCallback - constructor(options?: http.AgentOptions) { - super(options) + constructor(options: MockAgentOptions) { + super() + this.customAgent = options.customAgent + this.onRequest = options.onRequest + this.onResponse = options.onResponse } public createConnection(options: any, callback: any) { - const createConnection = super.createConnection.bind( - this, - options, - callback - ) + const createConnection = + (this.customAgent instanceof http.Agent && + this.customAgent.createConnection) || + super.createConnection const socket = new MockHttpSocket({ connectionOptions: options, - createConnection, + createConnection: createConnection.bind(this, options, callback), onRequest: (request) => { - this.onRequest?.({ request, socket }) + this.onRequest({ request, socket }) }, onResponse: (response) => { - this.onResponse?.({ response }) + this.onResponse({ response }) }, }) @@ -51,10 +55,35 @@ export class MockAgent extends http.Agent implements MockAgentInterface { } } -export class MockHttpsAgent extends https.Agent implements MockAgentInterface { - public onRequest?: MockAgentOnRequestCallback - public onResponse?: MockAgentOnResponseCallback +export class MockHttpsAgent extends https.Agent { + private customAgent?: https.RequestOptions['agent'] + private onRequest: MockAgentOnRequestCallback + private onResponse: MockAgentOnResponseCallback + + constructor(options: MockAgentOptions) { + super() + this.customAgent = options.customAgent + this.onRequest = options.onRequest + this.onResponse = options.onResponse + } + + public createConnection(options: any, callback: any) { + const createConnection = + (this.customAgent instanceof https.Agent && + this.customAgent.createConnection) || + super.createConnection + + const socket = new MockHttpSocket({ + connectionOptions: options, + createConnection: createConnection.bind(this, options, callback), + onRequest: (request) => { + this.onRequest({ request, socket }) + }, + onResponse: (response) => { + this.onResponse({ response }) + }, + }) - // TODO: The same implementation as for the HTTP. - // Just need to extend Https.Agent. + return socket + } } diff --git a/src/interceptors/ClientRequest/index-new.ts b/src/interceptors/ClientRequest/index-new.ts index a688a0aa..ba78f29d 100644 --- a/src/interceptors/ClientRequest/index-new.ts +++ b/src/interceptors/ClientRequest/index-new.ts @@ -25,29 +25,41 @@ export class _ClientRequestInterceptor extends Interceptor const { get: originalGet, request: originalRequest } = http const { get: originalHttpsGet, request: originalHttpsRequest } = http - http.request = new Proxy(http.request, { - apply: (target, context, args: Parameters) => { - const normalizedArgs = normalizeClientRequestArgs('http:', ...args) - - const agent = new MockAgent() - agent.onRequest = this.onRequest.bind(this) - agent.onResponse = this.onResponse.bind(this) + const onRequest = this.onRequest.bind(this) + const onResponse = this.onResponse.bind(this) - normalizedArgs[1].agent = agent - return Reflect.apply(target, context, normalizedArgs) + 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, context, args: Parameters) => { - const normalizedArgs = normalizeClientRequestArgs('http:', ...args) + apply: (target, thisArg, args: Parameters) => { + const [url, options, callback] = normalizeClientRequestArgs( + 'http:', + ...args + ) - const agent = new MockAgent() - agent.onRequest = this.onRequest.bind(this) - agent.onResponse = this.onResponse.bind(this) + const mockAgent = new MockAgent({ + customAgent: options.agent, + onRequest, + onResponse, + }) + options.agent = mockAgent - normalizedArgs[1].agent = agent - return Reflect.apply(target, context, normalizedArgs) + return Reflect.apply(target, thisArg, [url, options, callback]) }, }) @@ -56,15 +68,38 @@ export class _ClientRequestInterceptor extends Interceptor // https.request = new Proxy(https.request, { - apply: (target, context, args: Parameters) => { - const normalizedArgs = normalizeClientRequestArgs('https:', ...args) + 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 httpsAgent = new MockHttpsAgent() - httpsAgent.onRequest = this.onRequest.bind(this) - httpsAgent.onResponse = this.onResponse.bind(this) + const mockAgent = new MockHttpsAgent({ + customAgent: options.agent, + onRequest, + onResponse, + }) + options.agent = mockAgent - normalizedArgs[1].agent = httpsAgent - return Reflect.apply(target, context, normalizedArgs) + return Reflect.apply(target, thisArg, [url, options, callback]) }, }) diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 39731be6..3b617e00 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -3,8 +3,8 @@ import http from 'http' import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { HttpRequestEventMap } from '../../../../src/glossary' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -101,25 +101,9 @@ it('intercepts a POST request', async () => { }, }) req.write('post-payload') - - console.log('[test] calling req.end()...') req.end() - req.on('abort', () => { - console.trace('REQUEST ABORTED') - }) - - req.on('socket', (socket) => { - console.log('req socket!', socket.constructor.name) - - socket.on('error', (error) => { - console.log('socket error', error) - }) - }) - - const { res } = await waitForClientRequest(req) - - console.log('[test] RESPONSE:', res.statusCode, res.statusMessage) + await waitForClientRequest(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 f13ccd4a..e674103d 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,6 +1,6 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'https' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src/glossary' import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' @@ -32,7 +32,7 @@ afterAll(async () => { it('intercepts a GET request', async () => { const url = httpServer.https.url('/user?id=123') const request = https.get(url, { - agent: httpsAgent, + rejectUnauthorized: false, headers: { 'x-custom-header': 'yes', }, diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index 85cbeccb..b4dd6698 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -1,10 +1,10 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'https' import { RequestHandler } from 'express' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { HttpRequestEventMap } from '../../../../src' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (req, res) => { @@ -20,7 +20,7 @@ const httpServer = new HttpServer((app) => { }) const resolver = vi.fn() -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { @@ -40,7 +40,7 @@ afterAll(async () => { it('intercepts a HEAD request', async () => { const url = httpServer.https.url('/user?id=123') const req = https.request(url, { - agent: httpsAgent, + rejectUnauthorized: false, method: 'HEAD', headers: { 'x-custom-header': 'yes', @@ -65,7 +65,7 @@ it('intercepts a HEAD request', async () => { it('intercepts a GET request', async () => { const url = httpServer.https.url('/user?id=123') const req = https.request(url, { - agent: httpsAgent, + rejectUnauthorized: false, method: 'GET', headers: { 'x-custom-header': 'yes', @@ -90,7 +90,7 @@ it('intercepts a GET request', async () => { it('intercepts a POST request', async () => { const url = httpServer.https.url('/user?id=123') const req = https.request(url, { - agent: httpsAgent, + rejectUnauthorized: false, method: 'POST', headers: { 'x-custom-header': 'yes', @@ -116,7 +116,7 @@ it('intercepts a POST request', async () => { it('intercepts a PUT request', async () => { const url = httpServer.https.url('/user?id=123') const req = https.request(url, { - agent: httpsAgent, + rejectUnauthorized: false, method: 'PUT', headers: { 'x-custom-header': 'yes', @@ -142,7 +142,7 @@ it('intercepts a PUT request', async () => { it('intercepts a PATCH request', async () => { const url = httpServer.https.url('/user?id=123') const req = https.request(url, { - agent: httpsAgent, + rejectUnauthorized: false, method: 'PATCH', headers: { 'x-custom-header': 'yes', @@ -168,7 +168,7 @@ it('intercepts a PATCH request', async () => { it('intercepts a DELETE request', async () => { const url = httpServer.https.url('/user?id=123') const req = https.request(url, { - agent: httpsAgent, + rejectUnauthorized: false, method: 'DELETE', headers: { 'x-custom-header': 'yes', @@ -184,7 +184,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(request.body).toBe(null) + expect(await request.text()).toBe('') expect(request.respondWith).toBeInstanceOf(Function) expect(requestId).toMatch(UUID_REGEXP) @@ -192,7 +192,7 @@ it('intercepts a DELETE request', async () => { it('intercepts an http.request request given RequestOptions without a protocol', async () => { const req = https.request({ - agent: httpsAgent, + rejectUnauthorized: false, host: httpServer.https.address.host, port: httpServer.https.address.port, path: '/user?id=123', From 7904858cd739fa6d73769d6513ec7b2d79c4ba11 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 21:24:20 +0100 Subject: [PATCH 19/69] fix: free the request parser on socket finish --- src/interceptors/ClientRequest/MockHttpSocket.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 8aa5b75d..5e571ec1 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -82,6 +82,12 @@ export class MockHttpSocket extends MockSocket { this.responseParser[HTTPParser.kOnBody] = this.onResponseBody.bind(this) this.responseParser[HTTPParser.kOnMessageComplete] = this.onResponseEnd.bind(this) + + // Once the socket is marked as finished, + // no requests can be written to it, so free the parser. + this.once('finish', () => { + this.requestParser.free() + }) } public destroy(error?: Error | undefined): this { From 9c7794e7a2de5cac8977ec04152c00efc67fbd99 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 21:32:09 +0100 Subject: [PATCH 20/69] test: use new interceptor in response tests --- .../ClientRequest/MockHttpSocket.ts | 11 +++++------ test/modules/http/response/http-https.test.ts | 18 +++++++++--------- .../http/response/http-response-delay.test.ts | 4 ++-- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 5e571ec1..8c4b4990 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -52,9 +52,11 @@ export class MockHttpSocket extends MockSocket { } }, read: (chunk) => { - this.responseParser.execute( - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - ) + if (chunk) { + this.responseParser.execute( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + ) + } }, }) @@ -191,9 +193,6 @@ export class MockHttpSocket extends MockSocket { } } - this.push('\r\n') - this.push(null) - // Close the socket if the connection wasn't marked as keep-alive. if (!this.shouldKeepAlive) { this.emit('readable') diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index e5562e02..663c1065 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,9 +1,9 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'http' import https from 'https' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -14,7 +14,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const url = new URL(request.url) @@ -61,7 +61,7 @@ it('responds to a handled request issued by "http.get"', async () => { }) it('responds to a handled request issued by "https.get"', async () => { - const req = https.get('https://any.thing/non-existing', { agent: httpsAgent }) + const req = https.get('https://any.thing/non-existing') const { res, text } = await waitForClientRequest(req) expect(res).toMatchObject>({ @@ -86,7 +86,9 @@ it('bypasses an unhandled request issued by "http.get"', async () => { }) it('bypasses an unhandled request issued by "https.get"', async () => { - const req = https.get(httpServer.https.url('/get'), { agent: httpsAgent }) + const req = https.get(httpServer.https.url('/get'), { + rejectUnauthorized: false, + }) const { res, text } = await waitForClientRequest(req) expect(res).toMatchObject>({ @@ -108,9 +110,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', { - agent: httpsAgent, - }) + const req = https.request('https://any.thing/non-existing') req.end() const { res, text } = await waitForClientRequest(req) @@ -139,7 +139,7 @@ it('bypasses an unhandled request issued by "http.request"', async () => { it('bypasses an unhandled request issued by "https.request"', async () => { const req = https.request(httpServer.https.url('/get'), { - agent: httpsAgent, + rejectUnauthorized: false, }) req.end() const { res, text } = await waitForClientRequest(req) diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 7fc1ac2d..de77ad5a 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 { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' -const interceptor = new SocketInterceptor() +const interceptor = new _ClientRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { From fa5827f5e0315153918fb70e0e6ad2d32fc53013 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 21:32:22 +0100 Subject: [PATCH 21/69] fix(MockHttpAgent): error with "Network error" on Response.error() --- src/interceptors/ClientRequest/MockHttpSocket.ts | 2 +- test/modules/http/response/http-response-error.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 8c4b4990..2b94257b 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -149,7 +149,7 @@ export class MockHttpSocket extends MockSocket { public async respondWith(response: Response): Promise { // Handle "type: error" responses. if (isPropertyAccessible(response, 'type') && response.type === 'error') { - this.errorWith(new TypeError('Failed to fetch')) + this.errorWith(new TypeError('Network error')) return } diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index f2c0b390..022cbf40 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 @@ import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' -import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' +import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' -const interceptor = new SocketInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { request.respondWith(Response.error()) From 48b30361dd807fe655bb36119f660c94860aedd3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 21:33:53 +0100 Subject: [PATCH 22/69] test: use the new interceptor in rest of response tests --- test/modules/http/response/http-response-patching.test.ts | 6 +++--- test/modules/http/response/readable-stream.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index af82f40f..5bd6acb2 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 'http' +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { sleep, waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -10,7 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new SocketInterceptor() +const interceptor = new _ClientRequestInterceptor() async function getResponse(request: Request): Promise { const url = new URL(request.url) diff --git a/test/modules/http/response/readable-stream.test.ts b/test/modules/http/response/readable-stream.test.ts index a554abdb..b1fd9c14 100644 --- a/test/modules/http/response/readable-stream.test.ts +++ b/test/modules/http/response/readable-stream.test.ts @@ -1,14 +1,14 @@ import { it, expect, beforeAll, afterAll } from 'vitest' -import https from 'https' +import https from 'node:https' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { sleep } from '../../../helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> const encoder = new TextEncoder() -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const stream = new ReadableStream({ async start(controller) { From 5d9d42276e79cad3eec0d1198ec7537e944022f7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 21:49:24 +0100 Subject: [PATCH 23/69] test: add all http tests --- .../ClientRequest/MockHttpSocket.ts | 4 +--- .../http/compliance/http-errors.test.ts | 13 ++++++++----- .../compliance/http-modify-request.test.ts | 8 ++++---- .../http/compliance/http-rate-limit.test.ts | 9 ++++++--- .../http/compliance/http-req-callback.test.ts | 13 ++++++++----- .../http/compliance/http-req-write.test.ts | 19 +++++++------------ .../http-request-without-options.test.ts | 9 ++++++--- .../compliance/http-res-raw-headers.test.ts | 9 ++++++--- .../http-res-read-multiple-times.test.ts | 7 ++++--- .../compliance/http-res-set-encoding.test.ts | 9 ++++++--- test/modules/http/compliance/http.test.ts | 10 +++++----- .../http/compliance/https-constructor.test.ts | 13 +++++++------ test/modules/http/http-performance.test.ts | 7 +++++-- ...ncurrent-different-response-source.test.ts | 7 +++++-- .../http-concurrent-same-host.test.ts | 7 ++++--- .../regressions/http-socket-timeout.test.ts | 3 +++ .../http/regressions/http-socket-timeout.ts | 7 ++++--- test/vitest.config.js | 1 - 18 files changed, 89 insertions(+), 66 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 2b94257b..de43c1ab 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -128,9 +128,7 @@ export class MockHttpSocket extends MockSocket { .on('session', (session) => this.emit('session', session)) .on('ready', () => this.emit('ready')) .on('drain', () => this.emit('drain')) - .on('data', (chunk) => { - this.emit('data', chunk) - }) + .on('data', (chunk) => this.emit('data', chunk)) .on('error', (error) => { Reflect.set(this, '_hadError', Reflect.get(socket, '_hadError')) this.emit('error', error) diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index 38061c7e..ce1e9696 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -1,10 +1,10 @@ import { vi, it, expect, beforeAll, afterAll } from 'vitest' import http from 'http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { sleep, waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interface NotFoundError extends NodeJS.ErrnoException { hostname: string @@ -73,7 +73,7 @@ it('suppresses ENOTFOUND error given a mocked response', async () => { request.respondWith(new Response('Mocked')) }) - const request = http.get('https://non-existing-url.com') + const request = http.get('http://non-existing-url.com') const errorListener = vi.fn() request.on('error', errorListener) @@ -85,7 +85,7 @@ it('suppresses ENOTFOUND error given a mocked response', async () => { }) it('forwards ENOTFOUND error for a bypassed request', async () => { - const request = http.get('https://non-existing-url.com') + const request = http.get('http://non-existing-url.com') const errorPromise = new DeferredPromise() request.on('error', (error: NotFoundError) => { errorPromise.resolve(error) @@ -144,7 +144,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 diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index d13c414b..7c625840 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -1,7 +1,7 @@ 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 { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -10,7 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() beforeAll(async () => { await server.listen() @@ -28,7 +28,7 @@ it('allows modifying the outgoing request headers', async () => { }) const req = http.get(server.http.url('/user')) - const { text, res } = await waitForClientRequest(req) + const { res } = await waitForClientRequest(req) expect(res.headers['x-appended-header']).toBe('modified') }) diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index c7ff3d4d..061f27f0 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -1,8 +1,11 @@ +/** + * @vitest-environment node + */ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +import http from 'node:http' import rateLimit from 'express-rate-limit' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' const httpServer = new HttpServer((app) => { app.use( @@ -21,7 +24,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const url = new URL(request.url) diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts index 4c834709..00582731 100644 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ b/test/modules/http/compliance/http-req-callback.test.ts @@ -1,10 +1,13 @@ +/** + * @vitest-environment jsdom + */ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { IncomingMessage } from 'http' -import https from 'https' +import { IncomingMessage } from 'node:http' +import https from 'node:https' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' -import { getRequestOptionsByUrl } from '../../../../src/utils/getRequestOptionsByUrl' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { DeferredPromise } from '@open-draft/deferred-promise' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { getRequestOptionsByUrl } from '../../../../src/utils/getRequestOptionsByUrl' const httpServer = new HttpServer((app) => { app.get('/get', (req, res) => { @@ -12,7 +15,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { if ([httpServer.https.url('/get')].includes(request.url)) { return diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 48f8fda7..3de5d2cd 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,10 +1,12 @@ +/** + * @vitest-environment node + */ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +import http from 'node:http' import express from 'express' import { HttpServer } from '@open-draft/test-server/http' -import { NodeClientRequest } from '../../../../src/interceptors/ClientRequest/NodeClientRequest' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.post('/resource', express.text({ type: '*/*' }), (req, res) => { @@ -14,15 +16,11 @@ const httpServer = new HttpServer((app) => { const interceptedRequestBody = vi.fn() -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', async ({ request }) => { interceptedRequestBody(await request.clone().text()) }) -function getInternalRequestBody(req: http.ClientRequest): Buffer { - return Buffer.from((req as NodeClientRequest).requestBuffer || '') -} - beforeAll(async () => { interceptor.apply() await httpServer.listen() @@ -54,7 +52,6 @@ it('writes string request body', async () => { const expectedBody = 'onetwothree' expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(getInternalRequestBody(req).toString()).toEqual(expectedBody) expect(await text()).toEqual(expectedBody) }) @@ -70,11 +67,10 @@ it('writes JSON request body', async () => { req.write(':"value"') req.end('}') - const { res, text } = await waitForClientRequest(req) + const { text } = await waitForClientRequest(req) const expectedBody = `{"key":"value"}` expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(getInternalRequestBody(req).toString()).toEqual(expectedBody) expect(await text()).toEqual(expectedBody) }) @@ -94,7 +90,6 @@ it('writes Buffer request body', async () => { const expectedBody = `{"key":"value"}` expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(getInternalRequestBody(req).toString()).toEqual(expectedBody) expect(await text()).toEqual(expectedBody) }) 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 ceee8c05..d2c15e28 100644 --- a/test/modules/http/compliance/http-request-without-options.test.ts +++ b/test/modules/http/compliance/http-request-without-options.test.ts @@ -1,9 +1,12 @@ +/** + * @vitest-environment node + */ import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import http from 'node:http' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() beforeAll(() => { interceptor.apply() 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 f65167ad..b6b79b92 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -1,9 +1,12 @@ +/** + * @vitest-environment node + */ import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import http from 'node:http' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() beforeAll(() => { 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 0f346fcf..fe59dbf5 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 @@ -1,13 +1,14 @@ /** + * @vitest-environment node * Ensure that reading the response body stream for the internal "response" * 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 'http' +import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestEventMap } from '../../../../src' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -17,7 +18,7 @@ const httpServer = new HttpServer((app) => { const resolver = vi.fn() -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() 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 c1c50102..d4222c36 100644 --- a/test/modules/http/compliance/http-res-set-encoding.test.ts +++ b/test/modules/http/compliance/http-res-set-encoding.test.ts @@ -1,8 +1,11 @@ +/** + * @vitest-environment node + */ import { it, expect, describe, beforeAll, afterAll } from 'vitest' -import http, { IncomingMessage } from 'http' +import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' const httpServer = new HttpServer((app) => { app.get('/resource', (request, res) => { @@ -10,7 +13,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const url = new URL(request.url) diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index 6e221db1..f289dbcf 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -1,15 +1,15 @@ /** * @vitest-environment node */ -import http from 'node:http' import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' +import http from 'node:http' import express from 'express' -import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' -import { waitForClientRequest } from '../../../helpers' +import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { waitForClientRequest } from '../../../helpers' -const interceptor = new SocketInterceptor() +const interceptor = new _ClientRequestInterceptor() 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 89d13f3d..434501b6 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -1,21 +1,22 @@ /** + * @vitest-environment node * @see https://github.com/mswjs/interceptors/issues/131 */ import { it, expect, beforeAll, afterAll } from 'vitest' -import { IncomingMessage } from 'http' -import https from 'https' -import { URL } from 'url' +import { IncomingMessage } from 'node:http' +import https from 'node:https' +import { URL } from 'node:url' +import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' import { getIncomingMessageBody } from '../../../../src/interceptors/ClientRequest/utils/getIncomingMessageBody' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { DeferredPromise } from '@open-draft/deferred-promise' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { res.status(200).send('hello') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() beforeAll(async () => { await httpServer.listen() diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index 1637300f..9219ff86 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,7 +1,10 @@ +/** + * @vitest-environment node + */ import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { SocketInterceptor } from '../../../src/interceptors/Socket/SocketInterceptor' import { httpGet, PromisifiedResponse, useCors } from '../../helpers' +import { _ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest/index-new' function arrayWith(length: number, mapFn: (index: number) => V): V[] { return new Array(length).fill(null).map((_, index) => mapFn(index)) @@ -28,7 +31,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new SocketInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { 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 cd6d9179..97f8c5f8 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,11 @@ +/** + * @vitest-environment node + */ import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' import { httpGet } from '../../../helpers' import { sleep } from '../../../../test/helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/', async (req, res) => { @@ -11,7 +14,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', async ({ request }) => { if (request.headers.get('x-bypass')) { return 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 d98fdbdc..12917447 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -1,13 +1,14 @@ /** + * @vitest-environment node * @see https://github.com/mswjs/interceptors/issues/2 */ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' -import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' +import http from 'node:http' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' let requests: Array = [] -const interceptor = new SocketInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { requests.push(request) request.respondWith(new Response()) diff --git a/test/modules/http/regressions/http-socket-timeout.test.ts b/test/modules/http/regressions/http-socket-timeout.test.ts index 88a86e83..6bc6f4fb 100644 --- a/test/modules/http/regressions/http-socket-timeout.test.ts +++ b/test/modules/http/regressions/http-socket-timeout.test.ts @@ -1,3 +1,6 @@ +/** + * @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 743b26af..f0889758 100644 --- a/test/modules/http/regressions/http-socket-timeout.ts +++ b/test/modules/http/regressions/http-socket-timeout.ts @@ -1,14 +1,15 @@ /** + * @vitest-environment node * @note This test is intentionally omitted in the test run. * It's meant to be spawned in a child process by the actual test * that asserts that this one doesn't leave the Jest runner hanging * due to the unterminated socket. */ import { it, expect, beforeAll, afterAll } from 'vitest' -import http, { IncomingMessage } from 'http' +import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { @@ -16,7 +17,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new _ClientRequestInterceptor() interceptor.on('request', ({ request }) => { request.respondWith(new Response('hello world', { status: 301 })) }) diff --git a/test/vitest.config.js b/test/vitest.config.js index fedbe2e6..fba879b8 100644 --- a/test/vitest.config.js +++ b/test/vitest.config.js @@ -9,6 +9,5 @@ export default defineConfig({ 'vitest-environment-node-with-websocket': './envs/node-with-websocket', 'vitest-environment-react-native-like': './envs/react-native-like', }, - testTimeout: 2000, }, }) From 00bb31a177e23c8a0b2ddb33c3f818a818f0202b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 9 Mar 2024 21:52:16 +0100 Subject: [PATCH 24/69] chore: delete old "ClientRequestInterceptor" --- .../ClientRequest/NodeClientRequest.test.ts | 211 ------- .../ClientRequest/NodeClientRequest.ts | 596 ------------------ src/interceptors/ClientRequest/http.get.ts | 30 - .../ClientRequest/http.request.ts | 27 - src/interceptors/ClientRequest/index-new.ts | 165 ----- src/interceptors/ClientRequest/index.ts | 188 ++++-- .../http/compliance/http-errors.test.ts | 4 +- .../compliance/http-modify-request.test.ts | 4 +- .../http/compliance/http-rate-limit.test.ts | 4 +- .../http/compliance/http-req-callback.test.ts | 4 +- .../http/compliance/http-req-write.test.ts | 4 +- .../http-request-without-options.test.ts | 4 +- .../compliance/http-res-raw-headers.test.ts | 4 +- .../http-res-read-multiple-times.test.ts | 4 +- .../compliance/http-res-set-encoding.test.ts | 4 +- test/modules/http/compliance/http.test.ts | 4 +- .../http/compliance/https-constructor.test.ts | 4 +- test/modules/http/http-performance.test.ts | 4 +- test/modules/http/intercept/http.get.test.ts | 4 +- .../http/intercept/http.request.test.ts | 4 +- test/modules/http/intercept/https.get.test.ts | 4 +- .../http/intercept/https.request.test.ts | 4 +- ...ncurrent-different-response-source.test.ts | 4 +- .../http-concurrent-same-host.test.ts | 4 +- .../http/regressions/http-socket-timeout.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 | 4 +- .../response/http-response-patching.test.ts | 4 +- .../http/response/readable-stream.test.ts | 4 +- 30 files changed, 194 insertions(+), 1119 deletions(-) delete mode 100644 src/interceptors/ClientRequest/NodeClientRequest.test.ts delete mode 100644 src/interceptors/ClientRequest/NodeClientRequest.ts delete mode 100644 src/interceptors/ClientRequest/http.get.ts delete mode 100644 src/interceptors/ClientRequest/http.request.ts delete mode 100644 src/interceptors/ClientRequest/index-new.ts diff --git a/src/interceptors/ClientRequest/NodeClientRequest.test.ts b/src/interceptors/ClientRequest/NodeClientRequest.test.ts deleted file mode 100644 index 6b603db0..00000000 --- a/src/interceptors/ClientRequest/NodeClientRequest.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import express from 'express' -import { IncomingMessage } from 'http' -import { Emitter } from 'strict-event-emitter' -import { Logger } from '@open-draft/logger' -import { HttpServer } from '@open-draft/test-server/http' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { NodeClientRequest } from './NodeClientRequest' -import { getIncomingMessageBody } from './utils/getIncomingMessageBody' -import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' -import { sleep } from '../../../test/helpers' -import { HttpRequestEventMap } from '../../glossary' - -interface ErrorConnectionRefused extends NodeJS.ErrnoException { - address: string - port: number -} - -const httpServer = new HttpServer((app) => { - app.post('/comment', (_req, res) => { - res.status(200).send('original-response') - }) - - app.post('/write', express.text(), (req, res) => { - res.status(200).send(req.body) - }) -}) - -const logger = new Logger('test') - -beforeAll(async () => { - await httpServer.listen() -}) - -afterAll(async () => { - await httpServer.close() -}) - -it('gracefully finishes the request when it has a mocked response', async () => { - const emitter = new Emitter() - const request = new NodeClientRequest( - normalizeClientRequestArgs('http:', 'http://any.thing', { - method: 'PUT', - }), - { - emitter, - logger, - } - ) - - emitter.on('request', ({ request }) => { - request.respondWith( - new Response('mocked-response', { - status: 301, - headers: { - 'x-custom-header': 'yes', - }, - }) - ) - }) - - request.end() - - const responseReceived = new DeferredPromise() - - request.on('response', async (response) => { - responseReceived.resolve(response) - }) - const response = await responseReceived - - // Request must be marked as finished as soon as it's sent. - expect(request.writableEnded).toBe(true) - expect(request.writableFinished).toBe(true) - expect(request.writableCorked).toBe(0) - - /** - * Consume the response body, which will handle the "data" and "end" - * events of the incoming message. After this point, the response is finished. - */ - const text = await getIncomingMessageBody(response) - - // Response must be marked as finished as soon as its done. - expect(request['response'].complete).toBe(true) - - expect(response.statusCode).toBe(301) - expect(response.headers).toHaveProperty('x-custom-header', 'yes') - expect(text).toBe('mocked-response') -}) - -it('responds with a mocked response when requesting an existing hostname', async () => { - const emitter = new Emitter() - const request = new NodeClientRequest( - normalizeClientRequestArgs('http:', httpServer.http.url('/comment')), - { - emitter, - logger, - } - ) - - emitter.on('request', ({ request }) => { - request.respondWith(new Response('mocked-response', { status: 201 })) - }) - - request.end() - - const responseReceived = new DeferredPromise() - request.on('response', async (response) => { - responseReceived.resolve(response) - }) - const response = await responseReceived - - expect(response.statusCode).toBe(201) - - const text = await getIncomingMessageBody(response) - expect(text).toBe('mocked-response') -}) - -it('performs the request as-is given resolver returned no mocked response', async () => { - const emitter = new Emitter() - const request = new NodeClientRequest( - normalizeClientRequestArgs('http:', httpServer.http.url('/comment'), { - method: 'POST', - }), - { - emitter, - logger, - } - ) - - request.end() - - const responseReceived = new DeferredPromise() - request.on('response', async (response) => { - responseReceived.resolve(response) - }) - const response = await responseReceived - - expect(request.finished).toBe(true) - expect(request.writableEnded).toBe(true) - - expect(response.statusCode).toBe(200) - expect(response.statusMessage).toBe('OK') - expect(response.headers).toHaveProperty('x-powered-by', 'Express') - - const text = await getIncomingMessageBody(response) - expect(text).toBe('original-response') -}) - -it('sends the request body to the server given no mocked response', async () => { - const emitter = new Emitter() - const request = new NodeClientRequest( - normalizeClientRequestArgs('http:', httpServer.http.url('/write'), { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - }, - }), - { - emitter, - logger, - } - ) - - request.write('one') - request.write('two') - request.end('three') - - const responseReceived = new DeferredPromise() - request.on('response', (response) => { - responseReceived.resolve(response) - }) - const response = await responseReceived - - expect(response.statusCode).toBe(200) - - const text = await getIncomingMessageBody(response) - expect(text).toBe('onetwothree') -}) - -it('does not send request body to the original server given mocked response', async () => { - const emitter = new Emitter() - const request = new NodeClientRequest( - normalizeClientRequestArgs('http:', httpServer.http.url('/write'), { - method: 'POST', - }), - { - emitter, - logger, - } - ) - - emitter.on('request', async ({ request }) => { - await sleep(200) - request.respondWith(new Response('mock created!', { status: 301 })) - }) - - request.write('one') - request.write('two') - request.end() - - const responseReceived = new DeferredPromise() - request.on('response', (response) => { - responseReceived.resolve(response) - }) - const response = await responseReceived - - expect(response.statusCode).toBe(301) - - const text = await getIncomingMessageBody(response) - expect(text).toBe('mock created!') -}) diff --git a/src/interceptors/ClientRequest/NodeClientRequest.ts b/src/interceptors/ClientRequest/NodeClientRequest.ts deleted file mode 100644 index 0bdec77f..00000000 --- a/src/interceptors/ClientRequest/NodeClientRequest.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { ClientRequest, IncomingMessage } from 'http' -import type { Logger } from '@open-draft/logger' -import { until } from '@open-draft/until' -import { DeferredPromise } from '@open-draft/deferred-promise' -import type { ClientRequestEmitter } from '.' -import { - ClientRequestEndCallback, - ClientRequestEndChunk, - normalizeClientRequestEndArgs, -} from './utils/normalizeClientRequestEndArgs' -import { NormalizedClientRequestArgs } from './utils/normalizeClientRequestArgs' -import { - ClientRequestWriteArgs, - normalizeClientRequestWriteArgs, -} from './utils/normalizeClientRequestWriteArgs' -import { cloneIncomingMessage } from './utils/cloneIncomingMessage' -import { createResponse } from './utils/createResponse' -import { createRequest } from './utils/createRequest' -import { toInteractiveRequest } from '../../utils/toInteractiveRequest' -import { uuidv4 } from '../../utils/uuid' -import { emitAsync } from '../../utils/emitAsync' -import { getRawFetchHeaders } from '../../utils/getRawFetchHeaders' -import { isPropertyAccessible } from '../../utils/isPropertyAccessible' - -export type Protocol = 'http' | 'https' - -enum HttpClientInternalState { - // Have the concept of an idle request because different - // request methods can kick off request sending - // (e.g. ".end()" or ".flushHeaders()"). - Idle, - Sending, - Sent, - MockLookupStart, - MockLookupEnd, - ResponseReceived, -} - -export interface NodeClientOptions { - emitter: ClientRequestEmitter - logger: Logger -} - -export class NodeClientRequest extends ClientRequest { - /** - * The list of internal Node.js errors to suppress while - * using the "mock" response source. - */ - static suppressErrorCodes = [ - 'ENOTFOUND', - 'ECONNREFUSED', - 'ECONNRESET', - 'EAI_AGAIN', - 'ENETUNREACH', - 'EHOSTUNREACH', - ] - - /** - * Internal state of the request. - */ - private state: HttpClientInternalState - private responseType?: 'mock' | 'passthrough' - private response: IncomingMessage - private emitter: ClientRequestEmitter - private logger: Logger - private chunks: Array<{ - chunk?: string | Buffer - encoding?: BufferEncoding - }> = [] - private capturedError?: NodeJS.ErrnoException - - public url: URL - public requestBuffer: Buffer | null - - constructor( - [url, requestOptions, callback]: NormalizedClientRequestArgs, - options: NodeClientOptions - ) { - super(requestOptions, callback) - - this.logger = options.logger.extend( - `request ${requestOptions.method} ${url.href}` - ) - - this.logger.info('constructing ClientRequest using options:', { - url, - requestOptions, - callback, - }) - - this.state = HttpClientInternalState.Idle - this.url = url - this.emitter = options.emitter - - // Set request buffer to null by default so that GET/HEAD requests - // without a body wouldn't suddenly get one. - this.requestBuffer = null - - // Construct a mocked response message. - this.response = new IncomingMessage(this.socket!) - } - - private writeRequestBodyChunk( - chunk: string | Buffer | null, - encoding?: BufferEncoding - ): void { - if (chunk == null) { - return - } - - if (this.requestBuffer == null) { - this.requestBuffer = Buffer.from([]) - } - - const resolvedChunk = Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(chunk, encoding) - - this.requestBuffer = Buffer.concat([this.requestBuffer, resolvedChunk]) - } - - write(...args: ClientRequestWriteArgs): boolean { - const [chunk, encoding, callback] = normalizeClientRequestWriteArgs(args) - this.logger.info('write:', { chunk, encoding, callback }) - this.chunks.push({ chunk, encoding }) - - // Write each request body chunk to the internal buffer. - this.writeRequestBodyChunk(chunk, encoding) - - this.logger.info( - 'chunk successfully stored!', - this.requestBuffer?.byteLength - ) - - /** - * Prevent invoking the callback if the written chunk is empty. - * @see https://nodejs.org/api/http.html#requestwritechunk-encoding-callback - */ - if (!chunk || chunk.length === 0) { - this.logger.info('written chunk is empty, skipping callback...') - } else { - callback?.() - } - - // Do not write the request body chunks to prevent - // the Socket from sending data to a potentially existing - // server when there is a mocked response defined. - return true - } - - end(...args: any): this { - this.logger.info('end', args) - - const requestId = uuidv4() - - const [chunk, encoding, callback] = normalizeClientRequestEndArgs(...args) - this.logger.info('normalized arguments:', { chunk, encoding, callback }) - - // Write the last request body chunk passed to the "end()" method. - this.writeRequestBodyChunk(chunk, encoding || undefined) - - /** - * @note Mark the request as sent immediately when invoking ".end()". - * In Node.js, calling ".end()" will flush the remaining request body - * and mark the request as "finished" immediately ("end" is synchronous) - * but we delegate that property update to: - * - * - respondWith(), in the case of mocked responses; - * - super.end(), in the case of bypassed responses. - * - * For that reason, we have to keep an internal flag for a finished request. - */ - this.state = HttpClientInternalState.Sent - - const capturedRequest = createRequest(this) - const { interactiveRequest, requestController } = - toInteractiveRequest(capturedRequest) - - /** - * @todo Remove this modification of the original request - * and expose the controller alongside it in the "request" - * listener argument. - */ - Object.defineProperty(capturedRequest, 'respondWith', { - value: requestController.respondWith.bind(requestController), - }) - - // Prevent handling this request if it has already been handled - // in another (parent) interceptor (like XMLHttpRequest -> ClientRequest). - // That means some interceptor up the chain has concluded that - // this request must be performed as-is. - if (this.getHeader('X-Request-Id') != null) { - this.removeHeader('X-Request-Id') - return this.passthrough(chunk, encoding, callback) - } - - // Add the last "request" listener that always resolves - // the pending response Promise. This way if the consumer - // hasn't handled the request themselves, we will prevent - // the response Promise from pending indefinitely. - this.emitter.once('request', ({ requestId: pendingRequestId }) => { - /** - * @note Ignore request events emitted by irrelevant - * requests. This happens when response patching. - */ - if (pendingRequestId !== requestId) { - return - } - - if (requestController.responsePromise.state === 'pending') { - this.logger.info( - 'request has not been handled in listeners, executing fail-safe listener...' - ) - - requestController.responsePromise.resolve(undefined) - } - }) - - // Execute the resolver Promise like a side-effect. - // Node.js 16 forces "ClientRequest.end" to be synchronous and return "this". - until(async () => { - // Notify the interceptor about the request. - // This will call any "request" listeners the users have. - this.logger.info( - 'emitting the "request" event for %d listener(s)...', - this.emitter.listenerCount('request') - ) - - this.state = HttpClientInternalState.MockLookupStart - - await emitAsync(this.emitter, 'request', { - request: interactiveRequest, - requestId, - }) - - this.logger.info('all "request" listeners done!') - - const mockedResponse = await requestController.responsePromise - this.logger.info('event.respondWith called with:', mockedResponse) - - return mockedResponse - }).then((resolverResult) => { - this.logger.info('the listeners promise awaited!') - - this.state = HttpClientInternalState.MockLookupEnd - - /** - * @fixme We are in the "end()" method that still executes in parallel - * to our mocking logic here. This can be solved by migrating to the - * Proxy-based approach and deferring the passthrough "end()" properly. - * @see https://github.com/mswjs/interceptors/issues/346 - */ - if (!this.headersSent) { - // Forward any request headers that the "request" listener - // may have modified before proceeding with this request. - for (const [headerName, headerValue] of capturedRequest.headers) { - this.setHeader(headerName, headerValue) - } - } - - // Halt the request whenever the resolver throws an exception. - if (resolverResult.error) { - this.logger.info( - 'encountered resolver exception, aborting request...', - resolverResult.error - ) - - this.destroyed = true - this.emit('error', resolverResult.error) - this.terminate() - - return this - } - - const mockedResponse = resolverResult.data - - if (mockedResponse) { - this.logger.info( - 'received mocked response:', - mockedResponse.status, - mockedResponse.statusText - ) - - /** - * @note Ignore this request being destroyed by TLS in Node.js - * due to connection errors. - */ - this.destroyed = false - - // Handle mocked "Response.error" network error responses. - if ( - /** - * @note Some environments, like Miniflare (Cloudflare) do not - * implement the "Response.type" property and throw on its access. - * Safely check if we can access "type" on "Response" before continuing. - * @see https://github.com/mswjs/msw/issues/1834 - */ - isPropertyAccessible(mockedResponse, 'type') && - mockedResponse.type === 'error' - ) { - this.logger.info( - 'received network error response, aborting request...' - ) - - /** - * There is no standardized error format for network errors - * in Node.js. Instead, emit a generic TypeError. - */ - this.emit('error', new TypeError('Network error')) - this.terminate() - - return this - } - - const responseClone = mockedResponse.clone() - - this.respondWith(mockedResponse) - this.logger.info( - mockedResponse.status, - mockedResponse.statusText, - '(MOCKED)' - ) - - callback?.() - - this.logger.info('emitting the custom "response" event...') - this.emitter.emit('response', { - response: responseClone, - isMockedResponse: true, - request: capturedRequest, - requestId, - }) - - this.logger.info('request (mock) is completed') - - return this - } - - this.logger.info('no mocked response received!') - - this.once('response-internal', (message: IncomingMessage) => { - this.logger.info(message.statusCode, message.statusMessage) - this.logger.info('original response headers:', message.headers) - - this.logger.info('emitting the custom "response" event...') - this.emitter.emit('response', { - response: createResponse(message), - isMockedResponse: false, - request: capturedRequest, - requestId, - }) - }) - - return this.passthrough(chunk, encoding, callback) - }) - - return this - } - - emit(event: string, ...data: any[]) { - this.logger.info('emit: %s', event) - - if (event === 'response') { - this.logger.info('found "response" event, cloning the response...') - - try { - /** - * Clone the response object when emitting the "response" event. - * This prevents the response body stream from locking - * and allows reading it twice: - * 1. Internal "response" event from the observer. - * 2. Any external response body listeners. - * @see https://github.com/mswjs/interceptors/issues/161 - */ - const response = data[0] as IncomingMessage - const firstClone = cloneIncomingMessage(response) - const secondClone = cloneIncomingMessage(response) - - this.emit('response-internal', secondClone) - - this.logger.info( - 'response successfully cloned, emitting "response" event...' - ) - return super.emit(event, firstClone, ...data.slice(1)) - } catch (error) { - this.logger.info('error when cloning response:', error) - return super.emit(event, ...data) - } - } - - if (event === 'error') { - const error = data[0] as NodeJS.ErrnoException - const errorCode = error.code || '' - - this.logger.info('error:\n', error) - - // Suppress only specific Node.js connection errors. - if (NodeClientRequest.suppressErrorCodes.includes(errorCode)) { - // Until we aren't sure whether the request will be - // passthrough, capture the first emitted connection - // error in case we have to replay it for this request. - if (this.state < HttpClientInternalState.MockLookupEnd) { - if (!this.capturedError) { - this.capturedError = error - this.logger.info('captured the first error:', this.capturedError) - } - return false - } - - // Ignore any connection errors once we know the request - // has been resolved with a mocked response. Don't capture - // them as they won't ever be replayed. - if ( - this.state === HttpClientInternalState.ResponseReceived && - this.responseType === 'mock' - ) { - return false - } - } - } - - return super.emit(event, ...data) - } - - /** - * Performs the intercepted request as-is. - * Replays the captured request body chunks, - * still emits the internal events, and wraps - * up the request with `super.end()`. - */ - private passthrough( - chunk: ClientRequestEndChunk | null, - encoding?: BufferEncoding | null, - callback?: ClientRequestEndCallback | null - ): this { - this.state = HttpClientInternalState.ResponseReceived - this.responseType = 'passthrough' - - // Propagate previously captured errors. - // For example, a ECONNREFUSED error when connecting to a non-existing host. - if (this.capturedError) { - this.emit('error', this.capturedError) - return this - } - - this.logger.info('writing request chunks...', this.chunks) - - // Write the request body chunks in the order of ".write()" calls. - // Note that no request body has been written prior to this point - // in order to prevent the Socket to communicate with a potentially - // existing server. - for (const { chunk, encoding } of this.chunks) { - if (encoding) { - super.write(chunk, encoding) - } else { - super.write(chunk) - } - } - - this.once('error', (error) => { - this.logger.info('original request error:', error) - }) - - this.once('abort', () => { - this.logger.info('original request aborted!') - }) - - this.once('response-internal', (message: IncomingMessage) => { - this.logger.info(message.statusCode, message.statusMessage) - this.logger.info('original response headers:', message.headers) - }) - - this.logger.info('performing original request...') - - // This call signature is way too dynamic. - return super.end(...[chunk, encoding as any, callback].filter(Boolean)) - } - - /** - * Responds to this request instance using a mocked response. - */ - private respondWith(mockedResponse: Response): void { - this.logger.info('responding with a mocked response...', mockedResponse) - - this.state = HttpClientInternalState.ResponseReceived - this.responseType = 'mock' - - /** - * Mark the request as finished right before streaming back the response. - * This is not entirely conventional but this will allow the consumer to - * modify the outoging request in the interceptor. - * - * The request is finished when its headers and bodies have been sent. - * @see https://nodejs.org/api/http.html#event-finish - */ - Object.defineProperties(this, { - writableFinished: { value: true }, - writableEnded: { value: true }, - }) - this.emit('finish') - - const { status, statusText, headers, body } = mockedResponse - this.response.statusCode = status - this.response.statusMessage = statusText - - // Try extracting the raw headers from the headers instance. - // If not possible, fallback to the headers instance as-is. - const rawHeaders = getRawFetchHeaders(headers) || headers - - if (rawHeaders) { - this.response.headers = {} - - rawHeaders.forEach((headerValue, headerName) => { - /** - * @note Make sure that multi-value headers are appended correctly. - */ - this.response.rawHeaders.push(headerName, headerValue) - - const insensitiveHeaderName = headerName.toLowerCase() - const prevHeaders = this.response.headers[insensitiveHeaderName] - this.response.headers[insensitiveHeaderName] = prevHeaders - ? Array.prototype.concat([], prevHeaders, headerValue) - : headerValue - }) - } - this.logger.info('mocked response headers ready:', headers) - - /** - * Set the internal "res" property to the mocked "OutgoingMessage" - * to make the "ClientRequest" instance think there's data received - * from the socket. - * @see https://github.com/nodejs/node/blob/9c405f2591f5833d0247ed0fafdcd68c5b14ce7a/lib/_http_client.js#L501 - * - * Set the response immediately so the interceptor could stream data - * chunks to the request client as they come in. - */ - // @ts-ignore - this.res = this.response - this.emit('response', this.response) - - const isResponseStreamFinished = new DeferredPromise() - - const finishResponseStream = () => { - this.logger.info('finished response stream!') - - // Push "null" to indicate that the response body is complete - // and shouldn't be written to anymore. - this.response.push(null) - this.response.complete = true - - isResponseStreamFinished.resolve() - } - - if (body) { - const bodyReader = body.getReader() - const readNextChunk = async (): Promise => { - const { done, value } = await bodyReader.read() - - if (done) { - finishResponseStream() - return - } - - this.response.emit('data', value) - - return readNextChunk() - } - - readNextChunk() - } else { - finishResponseStream() - } - - isResponseStreamFinished.then(() => { - this.logger.info('finalizing response...') - this.response.emit('end') - this.terminate() - - this.logger.info('request complete!') - }) - } - - /** - * Terminates a pending request. - */ - private terminate(): void { - /** - * @note Some request clients (e.g. Octokit) create a ClientRequest - * in a way that it has no Agent set. Now, whether that's correct is - * debatable, but we should still handle this case gracefully. - * @see https://github.com/mswjs/interceptors/issues/304 - */ - // @ts-ignore - this.agent?.destroy() - } -} diff --git a/src/interceptors/ClientRequest/http.get.ts b/src/interceptors/ClientRequest/http.get.ts deleted file mode 100644 index b6f3d684..00000000 --- a/src/interceptors/ClientRequest/http.get.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ClientRequest } from 'node:http' -import { - NodeClientOptions, - NodeClientRequest, - Protocol, -} from './NodeClientRequest' -import { - ClientRequestArgs, - normalizeClientRequestArgs, -} from './utils/normalizeClientRequestArgs' - -export function get(protocol: Protocol, options: NodeClientOptions) { - return function interceptorsHttpGet( - ...args: ClientRequestArgs - ): ClientRequest { - const clientRequestArgs = normalizeClientRequestArgs( - `${protocol}:`, - ...args - ) - const request = new NodeClientRequest(clientRequestArgs, options) - - /** - * @note https://nodejs.org/api/http.html#httpgetoptions-callback - * "http.get" sets the method to "GET" and calls "req.end()" automatically. - */ - request.end() - - return request - } -} diff --git a/src/interceptors/ClientRequest/http.request.ts b/src/interceptors/ClientRequest/http.request.ts deleted file mode 100644 index 4e581ee7..00000000 --- a/src/interceptors/ClientRequest/http.request.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ClientRequest } from 'http' -import { Logger } from '@open-draft/logger' -import { - NodeClientOptions, - NodeClientRequest, - Protocol, -} from './NodeClientRequest' -import { - normalizeClientRequestArgs, - ClientRequestArgs, -} from './utils/normalizeClientRequestArgs' - -const logger = new Logger('http request') - -export function request(protocol: Protocol, options: NodeClientOptions) { - return function interceptorsHttpRequest( - ...args: ClientRequestArgs - ): ClientRequest { - logger.info('request call (protocol "%s"):', protocol, args) - - const clientRequestArgs = normalizeClientRequestArgs( - `${protocol}:`, - ...args - ) - return new NodeClientRequest(clientRequestArgs, options) - } -} diff --git a/src/interceptors/ClientRequest/index-new.ts b/src/interceptors/ClientRequest/index-new.ts deleted file mode 100644 index ba78f29d..00000000 --- a/src/interceptors/ClientRequest/index-new.ts +++ /dev/null @@ -1,165 +0,0 @@ -import http from 'node:http' -import https from 'node:https' -import { randomUUID } from 'node:crypto' -import { until } from '@open-draft/until' -import { Interceptor } from '../../Interceptor' -import type { HttpRequestEventMap } from '../../glossary' -import { - MockAgent, - MockHttpsAgent, - type MockAgentOnRequestCallback, - type MockAgentOnResponseCallback, -} from './agents' -import { emitAsync } from '../../utils/emitAsync' -import { toInteractiveRequest } from '../../utils/toInteractiveRequest' -import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' - -export class _ClientRequestInterceptor extends Interceptor { - static symbol = Symbol('client-request-interceptor') - - constructor() { - super(_ClientRequestInterceptor.symbol) - } - - protected setup(): void { - const { get: originalGet, request: originalRequest } = http - const { get: originalHttpsGet, request: originalHttpsRequest } = http - - const onRequest = this.onRequest.bind(this) - const onResponse = this.onResponse.bind(this) - - 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]) - }, - }) - - this.subscriptions.push(() => { - http.get = originalGet - http.request = originalRequest - - https.get = originalHttpsGet - https.request = originalHttpsRequest - }) - } - - private onRequest: MockAgentOnRequestCallback = async ({ - request, - socket, - }) => { - const requestId = randomUUID() - const { interactiveRequest, requestController } = - toInteractiveRequest(request) - - // TODO: Abstract this bit. We are using it everywhere. - this.emitter.once('request', ({ requestId: pendingRequestId }) => { - if (pendingRequestId !== requestId) { - return - } - - if (requestController.responsePromise.state === 'pending') { - this.logger.info( - 'request has not been handled in listeners, executing fail-safe listener...' - ) - - requestController.responsePromise.resolve(undefined) - } - }) - - const listenerResult = await until(async () => { - await emitAsync(this.emitter, 'request', { - requestId, - request: interactiveRequest, - }) - - return await requestController.responsePromise - }) - - if (listenerResult.error) { - socket.errorWith(listenerResult.error) - return - } - - const mockedResponse = listenerResult.data - - if (mockedResponse) { - socket.respondWith(mockedResponse) - return - } - - socket.passthrough() - } - - public onResponse: MockAgentOnResponseCallback = async ({ response }) => { - console.log('RESPONSE:', response.status, response.statusText) - } -} diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index edc599cf..da7d880d 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,61 +1,165 @@ -import http from 'http' -import https from 'https' -import type { Emitter } from 'strict-event-emitter' -import { HttpRequestEventMap } from '../../glossary' +import http from 'node:http' +import https from 'node:https' +import { randomUUID } from 'node:crypto' +import { until } from '@open-draft/until' import { Interceptor } from '../../Interceptor' -import { get } from './http.get' -import { request } from './http.request' -import { NodeClientOptions, Protocol } from './NodeClientRequest' +import type { HttpRequestEventMap } from '../../glossary' +import { + MockAgent, + MockHttpsAgent, + type MockAgentOnRequestCallback, + type MockAgentOnResponseCallback, +} from './agents' +import { emitAsync } from '../../utils/emitAsync' +import { toInteractiveRequest } from '../../utils/toInteractiveRequest' +import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' -export type ClientRequestEmitter = Emitter - -export type ClientRequestModules = Map - -/** - * Intercept requests made via the `ClientRequest` class. - * Such requests include `http.get`, `https.request`, etc. - */ export class ClientRequestInterceptor extends Interceptor { - static interceptorSymbol = Symbol('http') - private modules: ClientRequestModules + static symbol = Symbol('client-request-interceptor') constructor() { - super(ClientRequestInterceptor.interceptorSymbol) - - this.modules = new Map() - this.modules.set('http', http) - this.modules.set('https', https) + super(ClientRequestInterceptor.symbol) } protected setup(): void { - const logger = this.logger.extend('setup') + const { get: originalGet, request: originalRequest } = http + const { get: originalHttpsGet, request: originalHttpsRequest } = http - for (const [protocol, requestModule] of this.modules) { - const { request: pureRequest, get: pureGet } = requestModule + const onRequest = this.onRequest.bind(this) + const onResponse = this.onResponse.bind(this) - this.subscriptions.push(() => { - requestModule.request = pureRequest - requestModule.get = pureGet + 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 - logger.info('native "%s" module restored!', protocol) - }) + 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]) + }, + }) - const options: NodeClientOptions = { - emitter: this.emitter, - logger: this.logger, + this.subscriptions.push(() => { + http.get = originalGet + http.request = originalRequest + + https.get = originalHttpsGet + https.request = originalHttpsRequest + }) + } + + private onRequest: MockAgentOnRequestCallback = async ({ + request, + socket, + }) => { + const requestId = randomUUID() + const { interactiveRequest, requestController } = + toInteractiveRequest(request) + + // TODO: Abstract this bit. We are using it everywhere. + this.emitter.once('request', ({ requestId: pendingRequestId }) => { + if (pendingRequestId !== requestId) { + return + } + + if (requestController.responsePromise.state === 'pending') { + this.logger.info( + 'request has not been handled in listeners, executing fail-safe listener...' + ) + + requestController.responsePromise.resolve(undefined) } + }) - // @ts-ignore - requestModule.request = - // Force a line break. - request(protocol, options) + const listenerResult = await until(async () => { + await emitAsync(this.emitter, 'request', { + requestId, + request: interactiveRequest, + }) + + return await requestController.responsePromise + }) + + if (listenerResult.error) { + socket.errorWith(listenerResult.error) + return + } - // @ts-ignore - requestModule.get = - // Force a line break. - get(protocol, options) + const mockedResponse = listenerResult.data - logger.info('native "%s" module patched!', protocol) + if (mockedResponse) { + socket.respondWith(mockedResponse) + return } + + socket.passthrough() + } + + public onResponse: MockAgentOnResponseCallback = async ({ response }) => { + console.log('RESPONSE:', response.status, response.statusText) } } diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index ce1e9696..ffe56877 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -1,10 +1,10 @@ import { vi, it, expect, beforeAll, afterAll } from 'vitest' import http from 'http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep, waitForClientRequest } from '../../../helpers' -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interface NotFoundError extends NodeJS.ErrnoException { hostname: string diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index 7c625840..59b3a1f4 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -10,7 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() beforeAll(async () => { await server.listen() diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index 061f27f0..c7377ee2 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -5,7 +5,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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.use( @@ -24,7 +24,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const url = new URL(request.url) diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts index 00582731..0cd8c4d0 100644 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ b/test/modules/http/compliance/http-req-callback.test.ts @@ -6,7 +6,7 @@ import { IncomingMessage } from 'node:http' import https from 'node:https' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { getRequestOptionsByUrl } from '../../../../src/utils/getRequestOptionsByUrl' const httpServer = new HttpServer((app) => { @@ -15,7 +15,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { if ([httpServer.https.url('/get')].includes(request.url)) { return diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 3de5d2cd..b3a55cd6 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -5,7 +5,7 @@ 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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -16,7 +16,7 @@ const httpServer = new HttpServer((app) => { const interceptedRequestBody = vi.fn() -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', async ({ request }) => { interceptedRequestBody(await request.clone().text()) }) 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 d2c15e28..878d066b 100644 --- a/test/modules/http/compliance/http-request-without-options.test.ts +++ b/test/modules/http/compliance/http-request-without-options.test.ts @@ -3,10 +3,10 @@ */ import { vi, it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() beforeAll(() => { interceptor.apply() 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 b6b79b92..841126bf 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -3,10 +3,10 @@ */ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() beforeAll(() => { 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 fe59dbf5..3fcd9a6f 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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -18,7 +18,7 @@ const httpServer = new HttpServer((app) => { const resolver = vi.fn() -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() 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 d4222c36..04899ef6 100644 --- a/test/modules/http/compliance/http-res-set-encoding.test.ts +++ b/test/modules/http/compliance/http-res-set-encoding.test.ts @@ -5,7 +5,7 @@ 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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/resource', (request, res) => { @@ -13,7 +13,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const url = new URL(request.url) diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index f289dbcf..b453d42d 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -6,10 +6,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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() 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 434501b6..918a19d0 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, httpsAgent } from '@open-draft/test-server/http' import { getIncomingMessageBody } from '../../../../src/interceptors/ClientRequest/utils/getIncomingMessageBody' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { res.status(200).send('hello') }) }) -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() beforeAll(async () => { await httpServer.listen() diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index 9219ff86..87614e3b 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -4,7 +4,7 @@ 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/index-new' +import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' function arrayWith(length: number, mapFn: (index: number) => V): V[] { return new Array(length).fill(null).map((_, index) => mapFn(index)) @@ -31,7 +31,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const url = new URL(request.url) diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 095dfc73..dd8d45b4 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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src/glossary' @@ -13,7 +13,7 @@ const httpServer = new HttpServer((app) => { const resolver = vi.fn() -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 3b617e00..1713e39f 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -4,7 +4,7 @@ import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src/glossary' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -18,7 +18,7 @@ const httpServer = new HttpServer((app) => { }) const resolver = vi.fn() -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index e674103d..73dc1a7b 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -3,7 +3,7 @@ import https from 'https' import { HttpServer } from '@open-draft/test-server/http' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src/glossary' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -12,7 +12,7 @@ const httpServer = new HttpServer((app) => { }) const resolver = vi.fn() -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() 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 b4dd6698..ab4c5a85 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -4,7 +4,7 @@ import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (req, res) => { @@ -20,7 +20,7 @@ const httpServer = new HttpServer((app) => { }) const resolver = vi.fn() -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { diff --git a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts index 97f8c5f8..a3ff2fb1 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 @@ -3,7 +3,7 @@ */ import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { httpGet } from '../../../helpers' import { sleep } from '../../../../test/helpers' @@ -14,7 +14,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', async ({ request }) => { if (request.headers.get('x-bypass')) { return 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 12917447..411fa30c 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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' let requests: Array = [] -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { requests.push(request) request.respondWith(new Response()) diff --git a/test/modules/http/regressions/http-socket-timeout.ts b/test/modules/http/regressions/http-socket-timeout.ts index f0889758..e92b6203 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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' 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 ClientRequestInterceptor() interceptor.on('request', ({ request }) => { request.respondWith(new Response('hello world', { status: 301 })) }) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 663c1065..53f5be65 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 'http' import https from 'https' import { HttpServer } from '@open-draft/test-server/http' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' 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 ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const url = new URL(request.url) diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index de77ad5a..ab20d2ed 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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index 022cbf40..39d7f258 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 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { request.respondWith(Response.error()) diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 5bd6acb2..ebf1127d 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/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep, waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -10,7 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() async function getResponse(request: Request): Promise { const url = new URL(request.url) diff --git a/test/modules/http/response/readable-stream.test.ts b/test/modules/http/response/readable-stream.test.ts index b1fd9c14..78ab552a 100644 --- a/test/modules/http/response/readable-stream.test.ts +++ b/test/modules/http/response/readable-stream.test.ts @@ -1,14 +1,14 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import https from 'node:https' import { DeferredPromise } from '@open-draft/deferred-promise' -import { _ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index-new' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep } from '../../../helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> const encoder = new TextEncoder() -const interceptor = new _ClientRequestInterceptor() +const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { const stream = new ReadableStream({ async start(controller) { From 3c7b9029a898ad6a008b88b7679c3a5e5cbcf8b5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 07:57:08 +0100 Subject: [PATCH 25/69] chore: add "_http_common.d.ts" --- .../HttpParser.ts => _http_common.d.ts | 20 +++++++------------ .../ClientRequest/MockHttpSocket.ts | 15 +++++++------- src/interceptors/Socket/SocketInterceptor.ts | 5 ++--- tsconfig.json | 6 +++++- 4 files changed, 21 insertions(+), 25 deletions(-) rename src/interceptors/Socket/parsers/HttpParser.ts => _http_common.d.ts (65%) diff --git a/src/interceptors/Socket/parsers/HttpParser.ts b/_http_common.d.ts similarity index 65% rename from src/interceptors/Socket/parsers/HttpParser.ts rename to _http_common.d.ts index 3b2a0921..0e5eb6aa 100644 --- a/src/interceptors/Socket/parsers/HttpParser.ts +++ b/_http_common.d.ts @@ -1,26 +1,22 @@ -// @ts-expect-error Tapping into Node.js internals. -import httpCommon from 'node:_http_common' - -const HTTPParser = httpCommon.HTTPParser as NodeHttpParser - -export interface NodeHttpParser { +declare var HTTPParser: { + new (): HTTPParser REQUEST: 0 RESPONSE: 1 readonly kOnHeadersComplete: unique symbol readonly kOnBody: unique symbol readonly kOnMessageComplete: unique symbol +} - new (): NodeHttpParser +export interface HTTPParser { + new (): HTTPParser - // Headers complete callback has different - // signatures for REQUEST and RESPONSE parsers. - [HTTPParser.kOnHeadersComplete]: Type extends 0 + [HTTPParser.kOnHeadersComplete]: ParserType extends 0 ? RequestHeadersCompleteCallback : ResponseHeadersCompleteCallback [HTTPParser.kOnBody]: (chunk: Buffer) => void [HTTPParser.kOnMessageComplete]: () => void - initialize(type: Type, asyncResource: object): void + initialize(type: ParserType, asyncResource: object): void execute(buffer: Buffer): void finish(): void free(): void @@ -49,5 +45,3 @@ export type ResponseHeadersCompleteCallback = ( upgrade: boolean, shouldKeepAlive: boolean ) => void - -export { HTTPParser } diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index de43c1ab..cad98d77 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -1,15 +1,14 @@ import net from 'node:net' +import { + HTTPParser, + type RequestHeadersCompleteCallback, + type ResponseHeadersCompleteCallback, +} from '_http_common' import { STATUS_CODES } from 'node:http' import { Readable } from 'node:stream' import { invariant } from 'outvariant' import { MockSocket } from '../Socket/MockSocket' import type { NormalizedWriteArgs } from '../Socket/utils/normalizeWriteArgs' -import { - HTTPParser, - RequestHeadersCompleteCallback, - ResponseHeadersCompleteCallback, - type NodeHttpParser, -} from '../Socket/parsers/HttpParser' import { isPropertyAccessible } from '../../utils/isPropertyAccessible' import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' import { parseRawHeaders } from '../Socket/utils/parseRawHeaders' @@ -33,11 +32,11 @@ export class MockHttpSocket extends MockSocket { private onResponse?: (response: Response) => void private writeBuffer: Array = [] - private requestParser: NodeHttpParser<0> + private requestParser: HTTPParser<0> private requestStream?: Readable private shouldKeepAlive?: boolean - private responseParser: NodeHttpParser<1> + private responseParser: HTTPParser<1> private responseStream?: Readable constructor(options: MockHttpSocketOptions) { diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts index 7a03732b..fb26b1de 100644 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ b/src/interceptors/Socket/SocketInterceptor.ts @@ -2,9 +2,9 @@ import net from 'node:net' import https from 'node:https' import tls from 'node:tls' import { STATUS_CODES } from 'node:http' -import { HTTPParser } from 'node:_http_common' +import { HTTPParser } from '_http_common' import { randomUUID } from 'node:crypto' -import { Duplex, Readable } from 'node:stream' +import { Readable } from 'node:stream' import { until } from '@open-draft/until' import { Interceptor } from '../../Interceptor' import { @@ -13,7 +13,6 @@ import { } from '../../utils/toInteractiveRequest' import { emitAsync } from '../../utils/emitAsync' import { isPropertyAccessible } from '../../utils/isPropertyAccessible' -import EventEmitter from 'node:events' type NormalizedSocketConnectArgs = [ options: NormalizedSocketConnectOptions, diff --git a/tsconfig.json b/tsconfig.json index e4500ebd..0c5b33cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,11 @@ "removeComments": false, "esModuleInterop": true, "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"] + "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], + "baseUrl": ".", + "paths": { + "_http_commons": ["./_http.common.d.ts"] + } }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "**/*.test.*"] From aaa59b8f5a35c59b5356f2d83b4bc65f9f8e6aa8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 09:14:21 +0100 Subject: [PATCH 26/69] test: http-req-callback --- .../ClientRequest/MockHttpSocket.ts | 8 +- src/interceptors/ClientRequest/index.ts | 10 +- .../utils/normalizeClientRequestArgs.test.ts | 172 ++++++++---------- .../utils/normalizeClientRequestArgs.ts | 33 ++-- .../http/compliance/http-req-callback.test.ts | 31 ++-- .../http/compliance/https-constructor.test.ts | 4 +- 6 files changed, 117 insertions(+), 141 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index cad98d77..4a70c264 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -19,8 +19,8 @@ type HttpConnectionOptions = any interface MockHttpSocketOptions { connectionOptions: HttpConnectionOptions createConnection: () => net.Socket - onRequest?: (request: Request) => void - onResponse?: (response: Response) => void + onRequest: (request: Request) => void + onResponse: (response: Response) => void } export class MockHttpSocket extends MockSocket { @@ -28,8 +28,8 @@ export class MockHttpSocket extends MockSocket { private createConnection: () => net.Socket private baseUrl: URL - private onRequest?: (request: Request) => void - private onResponse?: (response: Response) => void + private onRequest: (request: Request) => void + private onResponse: (response: Response) => void private writeBuffer: Array = [] private requestParser: HTTPParser<0> diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index da7d880d..94cd53d4 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -32,7 +32,7 @@ export class ClientRequestInterceptor extends Interceptor { apply: (target, thisArg, args: Parameters) => { const [url, options, callback] = normalizeClientRequestArgs( 'http:', - ...args + args ) const mockAgent = new MockAgent({ customAgent: options.agent, @@ -49,7 +49,7 @@ export class ClientRequestInterceptor extends Interceptor { apply: (target, thisArg, args: Parameters) => { const [url, options, callback] = normalizeClientRequestArgs( 'http:', - ...args + args ) const mockAgent = new MockAgent({ @@ -71,7 +71,7 @@ export class ClientRequestInterceptor extends Interceptor { apply: (target, thisArg, args: Parameters) => { const [url, options, callback] = normalizeClientRequestArgs( 'https:', - ...args + args ) const mockAgent = new MockHttpsAgent({ @@ -86,10 +86,10 @@ export class ClientRequestInterceptor extends Interceptor { }) https.get = new Proxy(https.get, { - apply: (target, thisArg, args: Parameters) => { + apply: (target, thisArg, args: Parameters) => { const [url, options, callback] = normalizeClientRequestArgs( 'https:', - ...args + args ) const mockAgent = new MockHttpsAgent({ diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts index c5bef850..3e4da1b9 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts @@ -6,11 +6,10 @@ import { getUrlByRequestOptions } from '../../../utils/getUrlByRequestOptions' import { normalizeClientRequestArgs } from './normalizeClientRequestArgs' it('handles [string, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', + const [url, options, callback] = normalizeClientRequestArgs('https:', [ 'https://mswjs.io/resource', - function cb() {} - ) + function cb() {}, + ]) // URL string must be converted to a URL instance. expect(url.href).toEqual('https://mswjs.io/resource') @@ -31,12 +30,11 @@ it('handles [string, RequestOptions, callback] input', () => { 'Content-Type': 'text/plain', }, } - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', + const [url, options, callback] = normalizeClientRequestArgs('https:', [ 'https://mswjs.io/resource', initialOptions, - function cb() {} - ) + function cb() {}, + ]) // URL must be created from the string. expect(url.href).toEqual('https://mswjs.io/resource') @@ -49,11 +47,10 @@ it('handles [string, RequestOptions, callback] input', () => { }) it('handles [URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', + const [url, options, callback] = normalizeClientRequestArgs('https:', [ new URL('https://mswjs.io/resource'), - function cb() {} - ) + function cb() {}, + ]) // URL must be preserved. expect(url.href).toEqual('https://mswjs.io/resource') @@ -69,11 +66,10 @@ it('handles [URL, callback] input', () => { }) it('handles [Absolute Legacy URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', + const [url, options, callback] = normalizeClientRequestArgs('https:', [ parse('https://cherry:durian@mswjs.io:12345/resource?apple=banana'), - function cb() {} - ) + function cb() {}, + ]) // URL must be preserved. expect(url.toJSON()).toEqual( @@ -95,12 +91,11 @@ it('handles [Absolute Legacy URL, callback] input', () => { }) it('handles [Relative Legacy URL, RequestOptions without path set, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs( - 'http:', + const [url, options, callback] = normalizeClientRequestArgs('http:', [ parse('/resource?apple=banana'), { host: 'mswjs.io' }, - function cb() {} - ) + function cb() {}, + ]) // Correct WHATWG URL generated. expect(url.toJSON()).toEqual( @@ -117,12 +112,11 @@ it('handles [Relative Legacy URL, RequestOptions without path set, callback] inp }) it('handles [Relative Legacy URL, RequestOptions with path set, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs( - 'http:', + const [url, options, callback] = normalizeClientRequestArgs('http:', [ parse('/resource?apple=banana'), { host: 'mswjs.io', path: '/other?cherry=durian' }, - function cb() {} - ) + function cb() {}, + ]) // Correct WHATWG URL generated. expect(url.toJSON()).toEqual( @@ -139,11 +133,10 @@ it('handles [Relative Legacy URL, RequestOptions with path set, callback] input' }) it('handles [Relative Legacy URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs( - 'http:', + const [url, options, callback] = normalizeClientRequestArgs('http:', [ parse('/resource?apple=banana'), - function cb() {} - ) + function cb() {}, + ]) // Correct WHATWG URL generated. expect(url.toJSON()).toMatch( @@ -155,14 +148,14 @@ it('handles [Relative Legacy URL, callback] input', () => { 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') - ) + const [url, options, callback] = normalizeClientRequestArgs('http:', [ + parse('/resource?apple=banana'), + ]) // Correct WHATWG URL generated. expect(url.toJSON()).toMatch( @@ -178,8 +171,7 @@ it('handles [Relative Legacy URL] input', () => { }) it('handles [URL, RequestOptions, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', + const [url, options, callback] = normalizeClientRequestArgs('https:', [ new URL('https://mswjs.io/resource'), { agent: false, @@ -187,8 +179,8 @@ it('handles [URL, RequestOptions, callback] input', () => { 'Content-Type': 'text/plain', }, }, - function cb() {} - ) + function cb() {}, + ]) // URL must be preserved. expect(url.href).toEqual('https://mswjs.io/resource') @@ -209,17 +201,17 @@ it('handles [URL, RequestOptions, callback] input', () => { }) // 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:', + 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({ host: 'host-from-options.com', @@ -228,15 +220,14 @@ it('handles [URL, RequestOptions] where options have custom "hostname"', () => { }) it('handles [URL, RequestOptions] where options contain "host" and "path" and "port"', () => { - const [url, options] = normalizeClientRequestArgs( - 'http:', + 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({ @@ -247,13 +238,12 @@ it('handles [URL, RequestOptions] where options contain "host" and "path" and "p }) it('handles [URL, RequestOptions] where options contain "path" with query string', () => { - const [url, options] = normalizeClientRequestArgs( - 'http:', + 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({ host: 'example.com', @@ -275,11 +265,10 @@ it('handles [RequestOptions, callback] input', () => { 'Content-Type': 'text/plain', }, } - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', + const [url, options, callback] = normalizeClientRequestArgs('https:', [ initialOptions, - function cb() {} - ) + function cb() {}, + ]) // URL must be derived from request options. expect(url.href).toEqual('https://mswjs.io/resource') @@ -288,15 +277,15 @@ it('handles [RequestOptions, callback] input', () => { expect(options).toEqual(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:', + const [_, options, callback] = normalizeClientRequestArgs('https:', [ {}, - function cb() {} - ) + function cb() {}, + ]) expect(options.protocol).toEqual('https:') @@ -320,11 +309,10 @@ it('handles [PartialRequestOptions, callback] input', () => { passphrase: undefined, agent: false, } - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', + const [url, options, callback] = normalizeClientRequestArgs('https:', [ initialOptions, - function cb() {} - ) + function cb() {}, + ]) // URL must be derived from request options. expect(url.toJSON()).toEqual( @@ -338,14 +326,14 @@ it('handles [PartialRequestOptions, callback] input', () => { expect(options.protocol).toEqual('https:') // Callback must be preserved. + expect(callback).toBeTypeOf('function') expect(callback?.name).toEqual('cb') }) it('sets fallback Agent based on the URL protocol', () => { - const [url, options] = normalizeClientRequestArgs( - 'https:', - 'https://github.com' - ) + const [url, options] = normalizeClientRequestArgs('https:', [ + 'https://github.com', + ]) const agent = options.agent as HttpsAgent expect(agent).toBeInstanceOf(HttpsAgent) @@ -354,59 +342,54 @@ it('sets fallback Agent based on the URL protocol', () => { }) it('does not set any fallback Agent given "agent: false" option', () => { - const [, options] = normalizeClientRequestArgs( - 'https:', + const [, options] = normalizeClientRequestArgs('https:', [ 'https://github.com', - { agent: false } - ) + { agent: false }, + ]) expect(options.agent).toEqual(false) }) it('sets the default Agent for HTTP request', () => { - const [, options] = normalizeClientRequestArgs( - 'http:', + const [, options] = normalizeClientRequestArgs('http:', [ 'http://github.com', - {} - ) + {}, + ]) expect(options._defaultAgent).toEqual(httpGlobalAgent) }) it('sets the default Agent for HTTPS request', () => { - const [, options] = normalizeClientRequestArgs( - 'https:', + const [, options] = normalizeClientRequestArgs('https:', [ 'https://github.com', - {} - ) + {}, + ]) expect(options._defaultAgent).toEqual(httpsGlobalAgent) }) it('preserves a custom default Agent when set', () => { - const [, options] = normalizeClientRequestArgs( - 'https:', + 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:', + const [url, options] = normalizeClientRequestArgs('https:', [ 'https://github.com/graphql', { method: 'GET', pfx: 'PFX_KEY', - } - ) + }, + ]) expect(url.href).toEqual('https://github.com/graphql') @@ -422,13 +405,12 @@ it('merges URL-based RequestOptions with the custom RequestOptions', () => { }) it('respects custom "options.path" over URL path', () => { - const [url, options] = normalizeClientRequestArgs( - 'http:', + 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:') @@ -438,13 +420,12 @@ it('respects custom "options.path" over URL path', () => { }) it('respects custom "options.path" over URL path with query string', () => { - const [url, options] = normalizeClientRequestArgs( - 'http:', + 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') @@ -455,10 +436,9 @@ it('respects custom "options.path" over URL path with query string', () => { }) it('preserves URL query string', () => { - const [url, options] = normalizeClientRequestArgs( - 'http:', - new URL('http://example.com/resource?a=b&c=d') - ) + const [url, options] = normalizeClientRequestArgs('http:', [ + new URL('http://example.com/resource?a=b&c=d'), + ]) expect(url.href).toBe('http://example.com/resource?a=b&c=d') expect(options.protocol).toBe('http:') diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts index cba58ebe..a8bd4815 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts @@ -93,7 +93,7 @@ function resolveCallback( export type NormalizedClientRequestArgs = [ url: URL, options: ResolvedRequestOptions, - callback?: HttpRequestCallback + callback?: HttpRequestCallback, ] /** @@ -102,7 +102,7 @@ export type NormalizedClientRequestArgs = [ */ export function normalizeClientRequestArgs( defaultProtocol: string, - ...args: ClientRequestArgs + args: ClientRequestArgs ): NormalizedClientRequestArgs { let url: URL let options: ResolvedRequestOptions @@ -172,16 +172,14 @@ export function normalizeClientRequestArgs( logger.info('given legacy URL is relative (no hostname)') return isObject(args[1]) - ? normalizeClientRequestArgs( - defaultProtocol, + ? normalizeClientRequestArgs(defaultProtocol, [ { path: legacyUrl.path, ...args[1] }, - args[2] - ) - : normalizeClientRequestArgs( - defaultProtocol, + args[2], + ]) + : normalizeClientRequestArgs(defaultProtocol, [ { path: legacyUrl.path }, - args[1] as HttpRequestCallback - ) + args[1] as HttpRequestCallback, + ]) } logger.info('given legacy url is absolute') @@ -190,15 +188,14 @@ export function normalizeClientRequestArgs( const resolvedUrl = new URL(legacyUrl.href) return args[1] === undefined - ? normalizeClientRequestArgs(defaultProtocol, resolvedUrl) + ? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl]) : typeof args[1] === 'function' - ? normalizeClientRequestArgs(defaultProtocol, resolvedUrl, args[1]) - : normalizeClientRequestArgs( - defaultProtocol, - resolvedUrl, - args[1], - args[2] - ) + ? 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. diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts index 0cd8c4d0..28b68a80 100644 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ b/test/modules/http/compliance/http-req-callback.test.ts @@ -1,17 +1,16 @@ /** - * @vitest-environment jsdom + * @vitest-environment node */ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { IncomingMessage } from 'node:http' import https from 'node:https' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { getRequestOptionsByUrl } from '../../../../src/utils/getRequestOptionsByUrl' const httpServer = new HttpServer((app) => { app.get('/get', (req, res) => { - res.status(200).send('/').end() + res.status(200).send('/') }) }) @@ -47,16 +46,16 @@ it('calls a custom callback once when the request is bypassed', async () => { let text: string = '' const responseReceived = new DeferredPromise() - const responseCallback = vi.fn<[IncomingMessage]>((res) => { - res.on('data', (chunk) => (text += chunk)) - res.on('end', () => responseReceived.resolve()) - res.on('error', (error) => responseReceived.reject(error)) + const responseCallback = vi.fn<[IncomingMessage]>((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'), { - ...getRequestOptionsByUrl(new URL(httpServer.https.url('/get'))), - agent: httpsAgent, + rejectUnauthorized: false, }, responseCallback ) @@ -74,16 +73,16 @@ it('calls a custom callback once when the response is mocked', async () => { let text: string = '' const responseReceived = new DeferredPromise() - const responseCallback = vi.fn<[IncomingMessage]>((res) => { - res.on('data', (chunk) => (text += chunk)) - res.on('end', () => responseReceived.resolve()) - res.on('error', (error) => responseReceived.reject(error)) + const responseCallback = vi.fn<[IncomingMessage]>((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'), { - ...getRequestOptionsByUrl(new URL(httpServer.https.url('/arbitrary'))), - agent: httpsAgent, + rejectUnauthorized: false, }, responseCallback ) diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index 918a19d0..6732e62b 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -7,7 +7,7 @@ import { IncomingMessage } from 'node:http' import https from 'node:https' import { URL } from 'node:url' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' import { getIncomingMessageBody } from '../../../../src/interceptors/ClientRequest/utils/getIncomingMessageBody' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' @@ -35,7 +35,7 @@ it('performs the original HTTPS request', async () => { new URL(httpServer.https.url('/resource')), { method: 'GET', - agent: httpsAgent, + rejectUnauthorized: false, }, async (response) => { responseReceived.resolve(response) From a81e94773bba9e9efc4ae2c3c77e28c59e5f1913 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 09:40:55 +0100 Subject: [PATCH 27/69] fix(MockHttpAgent): support modifying request headers --- .../ClientRequest/MockHttpSocket.ts | 27 +++++++++++++++++-- .../compliance/http-modify-request.test.ts | 25 ++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 4a70c264..75e97b0f 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -32,6 +32,7 @@ export class MockHttpSocket extends MockSocket { private onResponse: (response: Response) => void private writeBuffer: Array = [] + private request?: Request private requestParser: HTTPParser<0> private requestStream?: Readable private shouldKeepAlive?: boolean @@ -109,9 +110,31 @@ export class MockHttpSocket extends MockSocket { // Exhaust the "requestBuffer" in case this Socket // gets reused for different requests. let writeArgs: NormalizedWriteArgs | 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 requestHeadersString = Array.from( + this.request!.headers.entries() + ) + .map(([name, value]) => `${name}: ${value}`) + .join('\r\n') + const headersChunk = `${chunkBeforeRequestHeaders}${requestHeadersString}${chunkAfterRequestHeaders}` + socket.write(headersChunk, encoding, callback) + headersWritten = true + continue + } + socket.write(...writeArgs) } } @@ -247,7 +270,7 @@ export class MockHttpSocket extends MockSocket { this.requestStream = new Readable() } - const request = new Request(url, { + this.request = new Request(url, { method, headers, credentials: 'same-origin', @@ -256,7 +279,7 @@ export class MockHttpSocket extends MockSocket { body: canHaveBody ? Readable.toWeb(this.requestStream) : null, }) - this.onRequest?.(request) + this.onRequest?.(this.request) } private onRequestBody(chunk: Buffer): void { diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index 59b3a1f4..b47e8ffb 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -1,3 +1,6 @@ +/** + * @vitest-environment node + */ import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' @@ -5,7 +8,7 @@ import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientReq import { waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { - app.get('/user', (req, res) => { + app.use('/user', (req, res) => { res.set('x-appended-header', req.headers['x-appended-header']).end() }) }) @@ -24,11 +27,25 @@ afterAll(async () => { it('allows modifying the outgoing request headers', async () => { interceptor.on('request', ({ request }) => { - request.headers.set('X-Appended-Header', 'modified') + request.headers.set('x-appended-header', 'modified') }) - const req = http.get(server.http.url('/user')) - const { res } = await waitForClientRequest(req) + 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 request headers in a request with body', async () => { + interceptor.on('request', ({ request }) => { + request.headers.set('x-appended-header', 'modified') + }) + + const request = http.request(server.http.url('/user'), { method: 'POST' }) + request.write('post-payload') + request.end() + + const { res } = await waitForClientRequest(request) expect(res.headers['x-appended-header']).toBe('modified') }) From 3dac2a0a581d997580a1f09ad0b6682b37d020da Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 09:44:58 +0100 Subject: [PATCH 28/69] test: fix invalid http-req-write callback tests --- .../http/compliance/http-req-write.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index b3a55cd6..a1a308c2 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -93,29 +93,29 @@ it('writes Buffer request body', async () => { expect(await text()).toEqual(expectedBody) }) -it('does not call the write callback when writing an empty string', async () => { - const req = http.request(httpServer.http.url('/resource'), { +it('calls the callback when writing an empty string', async () => { + const request = http.request(httpServer.http.url('/resource'), { method: 'POST', }) const writeCallback = vi.fn() - req.write('', writeCallback) - req.end() - await waitForClientRequest(req) + request.write('', writeCallback) + request.end() + await waitForClientRequest(request) - expect(writeCallback).not.toHaveBeenCalled() + expect(writeCallback).toHaveBeenCalledTimes(1) }) -it('does not call the write callback when writing an empty Buffer', async () => { - const req = http.request(httpServer.http.url('/resource'), { +it('calls the callback when writing an empty Buffer', async () => { + const request = http.request(httpServer.http.url('/resource'), { method: 'POST', }) const writeCallback = vi.fn() - req.write(Buffer.from(''), writeCallback) - req.end() + request.write(Buffer.from(''), writeCallback) + request.end() - await waitForClientRequest(req) + await waitForClientRequest(request) - expect(writeCallback).not.toHaveBeenCalled() + expect(writeCallback).toHaveBeenCalledTimes(1) }) From bfa651be0bea568d424ccd4df43fc980972b87c6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 09:51:45 +0100 Subject: [PATCH 29/69] test(http-request-without-options): remove non-mocked cases --- .../http-request-without-options.test.ts | 64 +++++-------------- 1 file changed, 15 insertions(+), 49 deletions(-) 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 878d066b..70e24ed2 100644 --- a/test/modules/http/compliance/http-request-without-options.test.ts +++ b/test/modules/http/compliance/http-request-without-options.test.ts @@ -1,13 +1,19 @@ /** * @vitest-environment node */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' const interceptor = new ClientRequestInterceptor() +interceptor.on('request', ({ request }) => { + if (request.url === 'http://localhost/') { + request.respondWith(new Response('Mocked')) + } +}) + beforeAll(() => { interceptor.apply() }) @@ -16,7 +22,7 @@ afterAll(() => { interceptor.dispose() }) -it('supports "http.request()" without any options', async () => { +it('supports "http.request()" without any arguments', async () => { const responseListener = vi.fn() const errorListener = vi.fn() @@ -28,34 +34,14 @@ it('supports "http.request()" without any options', async () => { request.on('response', responseListener) request.on('error', errorListener) + const { res, text } = await waitForClientRequest(request) + expect(errorListener).not.toHaveBeenCalled() - expect(responseListener).not.toHaveBeenCalled() + expect(responseListener).toHaveBeenCalledTimes(1) expect(request.path).toBe('/') expect(request.method).toBe('GET') expect(request.protocol).toBe('http:') expect(request.host).toBe('localhost') -}) - -it('responds with a mocked response for "http.request()" without any options', async () => { - interceptor.once('request', ({ request }) => { - if (request.url === 'http://localhost/') { - request.respondWith(new Response('Mocked')) - } - }) - - const request = http - // @ts-ignore It's possible to make a request without any options. - // This will result in a "GET http://localhost" request in Node.js. - .request() - request.end() - - const errorListener = vi.fn() - request.on('error', errorListener) - - const { res, text } = await waitForClientRequest(request) - - expect(errorListener).not.toHaveBeenCalled() - expect(res.statusCode).toBe(200) expect(await text()).toBe('Mocked') }) @@ -68,38 +54,18 @@ it('supports "http.get()" without any argumenst', async () => { // @ts-ignore It's possible to make a request without any options. // This will result in a "GET http://localhost" request in Node.js. .get() - request.end() + .end() request.on('response', responseListener) request.on('error', errorListener) + const { res, text } = await waitForClientRequest(request) + expect(errorListener).not.toHaveBeenCalled() - expect(responseListener).not.toHaveBeenCalled() + expect(responseListener).toHaveBeenCalledTimes(1) expect(request.path).toBe('/') expect(request.method).toBe('GET') expect(request.protocol).toBe('http:') expect(request.host).toBe('localhost') -}) - -it('responds with a mocked response for "http.get()" without any options', async () => { - interceptor.once('request', ({ request }) => { - if (request.url === 'http://localhost/') { - request.respondWith(new Response('Mocked')) - } - }) - - const request = http - // @ts-ignore It's possible to make a request without any options. - // This will result in a "GET http://localhost" request in Node.js. - .get() - request.end() - - const errorListener = vi.fn() - request.on('error', errorListener) - - const { res, text } = await waitForClientRequest(request) - - expect(errorListener).not.toHaveBeenCalled() - expect(res.statusCode).toBe(200) expect(await text()).toBe('Mocked') }) From 01ad60aa3e0ef9edf0fff8bbda44d715202b4eaa Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 10:00:04 +0100 Subject: [PATCH 30/69] fix(MockHttpAgent): write headers for responses without body --- .../ClientRequest/MockHttpSocket.ts | 8 ++++- src/interceptors/Socket/MockSocket.ts | 7 +---- .../http-concurrent-same-host.test.ts | 22 +++++++------ .../http/response/http-empty-response.test.ts | 31 +++++++++++++++++++ 4 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 test/modules/http/response/http-empty-response.test.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 75e97b0f..e6d60724 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -189,8 +189,10 @@ export class MockHttpSocket extends MockSocket { httpHeaders.push(Buffer.from(`${name}: ${value}\r\n`)) } + // An empty line separating headers from the body. + httpHeaders.push(Buffer.from('\r\n')) + if (response.body) { - httpHeaders.push(Buffer.from('\r\n')) const reader = response.body.getReader() while (true) { @@ -211,6 +213,10 @@ export class MockHttpSocket extends MockSocket { // Subsequent body chukns are push to the stream. this.push(value) } + } else { + // If the response has no body, write the headers immediately. + this.push(Buffer.concat(httpHeaders)) + httpHeaders.length = 0 } // Close the socket if the connection wasn't marked as keep-alive. diff --git a/src/interceptors/Socket/MockSocket.ts b/src/interceptors/Socket/MockSocket.ts index 34745a6e..2e6a447f 100644 --- a/src/interceptors/Socket/MockSocket.ts +++ b/src/interceptors/Socket/MockSocket.ts @@ -50,12 +50,7 @@ export class MockSocket extends net.Socket { } public push(chunk: any, encoding?: BufferEncoding): boolean { - console.log( - 'MockSocket.push()', - { chunk, encoding }, - this.writable, - this.writableFinished - ) + console.log('MockSocket.push()', { chunk: chunk?.toString(), encoding }) this.options.read(chunk, encoding) 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 411fa30c..1706aa2b 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -10,6 +10,8 @@ let requests: Array = [] const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { + console.log('REQUEST', request.method, request.url) + requests.push(request) request.respondWith(new Response()) }) @@ -42,16 +44,16 @@ it('resolves multiple concurrent requests to the same host independently', async promisifyClientRequest(() => { return http.get('http://httpbin.org/get') }), - promisifyClientRequest(() => { - return http.get('http://httpbin.org/get?header=abc', { - headers: { 'x-custom-header': 'abc' }, - }) - }), - promisifyClientRequest(() => { - return http.get('http://httpbin.org/get?header=123', { - headers: { 'x-custom-header': '123' }, - }) - }), + // promisifyClientRequest(() => { + // return http.get('http://httpbin.org/get?header=abc', { + // headers: { 'x-custom-header': 'abc' }, + // }) + // }), + // promisifyClientRequest(() => { + // return http.get('http://httpbin.org/get?header=123', { + // headers: { 'x-custom-header': '123' }, + // }) + // }), ]) for (const request of requests) { diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts new file mode 100644 index 00000000..3069b6a0 --- /dev/null +++ b/test/modules/http/response/http-empty-response.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ +import { it, expect, beforeAll, afterAll } from 'vitest' +import http from 'node:http' +import { waitForClientRequest } from '../../../helpers' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' + +const interceptor = new ClientRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('supports responding with an empty mocked response', async () => { + interceptor.once('request', ({ request }) => { + // Responding with an empty response must + // translate to 200 OK with an empty body. + request.respondWith(new Response()) + }) + + const request = http.get('http://localhost') + const { res, text } = await waitForClientRequest(request) + + expect(res.statusCode).toBe(200) + expect(await text()).toBe('') +}) From 81f4cfc3836c048579b61c8f76a1f4d5048e9b6a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 10:07:54 +0100 Subject: [PATCH 31/69] fix(MockHttpSocket): preserve mocked response header name casing --- .../ClientRequest/MockHttpSocket.ts | 5 +++- .../compliance/http-res-raw-headers.test.ts | 23 +++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index e6d60724..d635f8e1 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -12,6 +12,7 @@ import type { NormalizedWriteArgs } from '../Socket/utils/normalizeWriteArgs' import { isPropertyAccessible } from '../../utils/isPropertyAccessible' import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' import { parseRawHeaders } from '../Socket/utils/parseRawHeaders' +import { getRawFetchHeaders } from '../../utils/getRawFetchHeaders' import { RESPONSE_STATUS_CODES_WITHOUT_BODY } from '../../utils/responseUtils' type HttpConnectionOptions = any @@ -185,7 +186,9 @@ export class MockHttpSocket extends MockSocket { ) ) - for (const [name, value] of response.headers) { + // Get the raw headers stored behind the symbol to preserve name casing. + const headers = getRawFetchHeaders(response.headers) || response.headers + for (const [name, value] of headers) { httpHeaders.push(Buffer.from(`${name}: ${value}\r\n`)) } 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 841126bf..ee5866dc 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -3,17 +3,28 @@ */ 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 { waitForClientRequest } from '../../../helpers' +// The actual server is here for A/B purpose only. +const httpServer = new HttpServer((app) => { + app.get('/', (req, res) => { + res.writeHead(200, { 'X-CustoM-HeadeR': 'Yes' }) + res.end() + }) +}) + const interceptor = new ClientRequestInterceptor() -beforeAll(() => { +beforeAll(async () => { interceptor.apply() + await httpServer.listen() }) -afterAll(() => { +afterAll(async () => { interceptor.dispose() + await httpServer.close() }) it('preserves the original mocked response headers casing in "rawHeaders"', async () => { @@ -27,9 +38,11 @@ it('preserves the original mocked response headers casing in "rawHeaders"', asyn ) }) - const request = http.get('http://any.thing') + const request = http.get(httpServer.http.url('/')) const { res } = await waitForClientRequest(request) - expect(res.rawHeaders).toStrictEqual(['X-CustoM-HeadeR', 'Yes']) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(res.rawHeaders).toEqual( + expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) + ) + expect(res.headers).toMatchObject({ 'x-custom-header': 'Yes' }) }) From d41d16ff05b5443e84da12d1f36557674a1d6aa2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 11:15:42 +0100 Subject: [PATCH 32/69] fix: implement the "response" event --- .../ClientRequest/MockHttpSocket.ts | 93 +++++++--- src/interceptors/ClientRequest/agents.ts | 41 ++--- src/interceptors/ClientRequest/index.ts | 27 ++- test/modules/http/compliance/events.test.ts | 164 ++++++++++++++++++ 4 files changed, 270 insertions(+), 55 deletions(-) create mode 100644 test/modules/http/compliance/events.test.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index d635f8e1..0d8f7726 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -6,6 +6,7 @@ import { } from '_http_common' import { STATUS_CODES } from 'node:http' import { Readable } from 'node:stream' +import { randomUUID } from 'node:crypto' import { invariant } from 'outvariant' import { MockSocket } from '../Socket/MockSocket' import type { NormalizedWriteArgs } from '../Socket/utils/normalizeWriteArgs' @@ -17,20 +18,36 @@ import { RESPONSE_STATUS_CODES_WITHOUT_BODY } from '../../utils/responseUtils' 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 +}) => void + interface MockHttpSocketOptions { connectionOptions: HttpConnectionOptions createConnection: () => net.Socket - onRequest: (request: Request) => void - onResponse: (response: Response) => void + onRequest: MockHttpSocketRequestCallback + onResponse: MockHttpSocketResponseCallback } +const kRequestId = Symbol('kRequestId') + export class MockHttpSocket extends MockSocket { private connectionOptions: HttpConnectionOptions private createConnection: () => net.Socket private baseUrl: URL - private onRequest: (request: Request) => void - private onResponse: (response: Response) => void + private onRequest: MockHttpSocketRequestCallback + private onResponse: MockHttpSocketResponseCallback private writeBuffer: Array = [] private request?: Request @@ -38,6 +55,7 @@ export class MockHttpSocket extends MockSocket { private requestStream?: Readable private shouldKeepAlive?: boolean + private responseType: 'mock' | 'bypassed' = 'bypassed' private responseParser: HTTPParser<1> private responseStream?: Readable @@ -53,7 +71,7 @@ export class MockHttpSocket extends MockSocket { } }, read: (chunk) => { - if (chunk) { + if (chunk !== null) { this.responseParser.execute( Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) ) @@ -88,14 +106,10 @@ export class MockHttpSocket extends MockSocket { // Once the socket is marked as finished, // no requests can be written to it, so free the parser. - this.once('finish', () => { - this.requestParser.free() - }) + this.once('finish', () => this.requestParser.free()) } public destroy(error?: Error | undefined): this { - // Free the parsers once this socket is destroyed. - this.requestParser.free() this.responseParser.free() return super.destroy(error) } @@ -125,11 +139,15 @@ export class MockHttpSocket extends MockSocket { const chunkAfterRequestHeaders = chunkString.slice( chunk.indexOf('\r\n\r\n') ) - const requestHeadersString = Array.from( - this.request!.headers.entries() - ) + const requestHeaders = + getRawFetchHeaders(this.request!.headers) || this.request!.headers + const requestHeadersString = Array.from(requestHeaders.entries()) .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 @@ -151,7 +169,12 @@ export class MockHttpSocket extends MockSocket { .on('session', (session) => this.emit('session', session)) .on('ready', () => this.emit('ready')) .on('drain', () => this.emit('drain')) - .on('data', (chunk) => this.emit('data', chunk)) + .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) @@ -177,6 +200,7 @@ export class MockHttpSocket extends MockSocket { // First, emit all the connection events // to emulate a successful connection. this.mockConnect() + this.responseType = 'mock' const httpHeaders: Array = [] @@ -217,7 +241,7 @@ export class MockHttpSocket extends MockSocket { this.push(value) } } else { - // If the response has no body, write the headers immediately. + // If the response has no body, write its headers immediately. this.push(Buffer.concat(httpHeaders)) httpHeaders.length = 0 } @@ -225,6 +249,15 @@ export class MockHttpSocket extends MockSocket { // 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) } } @@ -263,6 +296,8 @@ export class MockHttpSocket extends MockSocket { const headers = parseRawHeaders(rawHeaders) 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}`) @@ -279,6 +314,7 @@ export class MockHttpSocket extends MockSocket { this.requestStream = new Readable() } + const requestId = randomUUID() this.request = new Request(url, { method, headers, @@ -288,7 +324,13 @@ export class MockHttpSocket extends MockSocket { body: canHaveBody ? Readable.toWeb(this.requestStream) : null, }) - this.onRequest?.(this.request) + Reflect.set(this.request, kRequestId, requestId) + + this.onRequest({ + requestId, + request: this.request, + socket: this, + }) } private onRequestBody(chunk: Buffer): void { @@ -314,9 +356,7 @@ export class MockHttpSocket extends MockSocket { method, url, status, - statusText, - upgrade, - shouldKeepAlive + statusText ) => { const headers = parseRawHeaders(rawHeaders) const canHaveBody = !RESPONSE_STATUS_CODES_WITHOUT_BODY.has(status) @@ -335,7 +375,18 @@ export class MockHttpSocket extends MockSocket { } ) - this.onResponse?.(response) + invariant( + this.request, + 'Failed to handle a response: request does not exist' + ) + + this.onResponse({ + response, + isMockedResponse: this.responseType === 'mock', + requestId: Reflect.get(this.request, kRequestId), + request: this.request, + socket: this, + }) } private onResponseBody(chunk: Buffer) { @@ -348,6 +399,8 @@ export class MockHttpSocket extends MockSocket { } private onResponseEnd(): void { + console.log('------ on response end') + // 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 index 2fbb390d..994e084e 100644 --- a/src/interceptors/ClientRequest/agents.ts +++ b/src/interceptors/ClientRequest/agents.ts @@ -1,7 +1,11 @@ import net from 'node:net' import http from 'node:http' import https from 'node:https' -import { MockHttpSocket } from './MockHttpSocket' +import { + MockHttpSocket, + type MockHttpSocketRequestCallback, + type MockHttpSocketResponseCallback, +} from './MockHttpSocket' declare module 'node:http' { interface Agent { @@ -9,23 +13,16 @@ declare module 'node:http' { } } -export type MockAgentOnRequestCallback = (args: { - request: Request - socket: MockHttpSocket -}) => void - -export type MockAgentOnResponseCallback = (args: { response: Response }) => void - interface MockAgentOptions { customAgent?: http.RequestOptions['agent'] - onRequest: MockAgentOnRequestCallback - onResponse: MockAgentOnResponseCallback + onRequest: MockHttpSocketRequestCallback + onResponse: MockHttpSocketResponseCallback } export class MockAgent extends http.Agent { private customAgent?: http.RequestOptions['agent'] - private onRequest: MockAgentOnRequestCallback - private onResponse: MockAgentOnResponseCallback + private onRequest: MockHttpSocketRequestCallback + private onResponse: MockHttpSocketResponseCallback constructor(options: MockAgentOptions) { super() @@ -43,12 +40,8 @@ export class MockAgent extends http.Agent { const socket = new MockHttpSocket({ connectionOptions: options, createConnection: createConnection.bind(this, options, callback), - onRequest: (request) => { - this.onRequest({ request, socket }) - }, - onResponse: (response) => { - this.onResponse({ response }) - }, + onRequest: this.onRequest.bind(this), + onResponse: this.onResponse.bind(this), }) return socket @@ -57,8 +50,8 @@ export class MockAgent extends http.Agent { export class MockHttpsAgent extends https.Agent { private customAgent?: https.RequestOptions['agent'] - private onRequest: MockAgentOnRequestCallback - private onResponse: MockAgentOnResponseCallback + private onRequest: MockHttpSocketRequestCallback + private onResponse: MockHttpSocketResponseCallback constructor(options: MockAgentOptions) { super() @@ -76,12 +69,8 @@ export class MockHttpsAgent extends https.Agent { const socket = new MockHttpSocket({ connectionOptions: options, createConnection: createConnection.bind(this, options, callback), - onRequest: (request) => { - this.onRequest({ request, socket }) - }, - onResponse: (response) => { - this.onResponse({ response }) - }, + onRequest: this.onRequest.bind(this), + onResponse: this.onResponse.bind(this), }) return socket diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 94cd53d4..e3f5862e 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -4,12 +4,11 @@ import { randomUUID } from 'node:crypto' import { until } from '@open-draft/until' import { Interceptor } from '../../Interceptor' import type { HttpRequestEventMap } from '../../glossary' -import { - MockAgent, - MockHttpsAgent, - type MockAgentOnRequestCallback, - type MockAgentOnResponseCallback, -} from './agents' +import type { + MockHttpSocketRequestCallback, + MockHttpSocketResponseCallback, +} from './MockHttpSocket' +import { MockAgent, MockHttpsAgent } from './agents' import { emitAsync } from '../../utils/emitAsync' import { toInteractiveRequest } from '../../utils/toInteractiveRequest' import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' @@ -112,7 +111,7 @@ export class ClientRequestInterceptor extends Interceptor { }) } - private onRequest: MockAgentOnRequestCallback = async ({ + private onRequest: MockHttpSocketRequestCallback = async ({ request, socket, }) => { @@ -159,7 +158,17 @@ export class ClientRequestInterceptor extends Interceptor { socket.passthrough() } - public onResponse: MockAgentOnResponseCallback = async ({ response }) => { - console.log('RESPONSE:', response.status, response.statusText) + public onResponse: MockHttpSocketResponseCallback = async ({ + requestId, + request, + response, + isMockedResponse, + }) => { + this.emitter.emit('response', { + requestId, + request, + response, + isMockedResponse, + }) } } diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts new file mode 100644 index 00000000..f94f5483 --- /dev/null +++ b/test/modules/http/compliance/events.test.ts @@ -0,0 +1,164 @@ +/** + * @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 { HttpRequestEventMap } from '../../../../src/glossary' +import { UUID_REGEXP, waitForClientRequest } from '../../../helpers' + +const httpServer = new HttpServer((app) => { + app.get('/', (req, res) => { + res.send('original-response') + }) +}) + +const interceptor = new ClientRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('emits the "request" event for an outgoing request without body', async () => { + const requestListener = vi.fn() + interceptor.once('request', requestListener) + + await waitForClientRequest( + http.get(httpServer.http.url('/'), { + headers: { + 'x-custom-header': 'yes', + }, + }) + ) + + expect(requestListener).toHaveBeenCalledTimes(1) + + const { request } = requestListener.mock.calls[0][0] + expect(request).toBeInstanceOf(Request) + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.http.url('/')) + expect(Object.fromEntries(request.headers.entries())).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(request.body).toBe(null) +}) + +it('emits the "request" event for an outgoing request with a body', async () => { + const requestListener = vi.fn() + interceptor.once('request', requestListener) + + const request = http.request(httpServer.http.url('/'), { + method: 'POST', + headers: { + 'content-type': 'text/plain', + 'x-custom-header': 'yes', + }, + }) + request.write('post-payload') + request.end() + await waitForClientRequest(request) + + expect(requestListener).toHaveBeenCalledTimes(1) + + const { request: requestFromListener } = requestListener.mock.calls[0][0] + expect(requestFromListener).toBeInstanceOf(Request) + expect(requestFromListener.method).toBe('POST') + expect(requestFromListener.url).toBe(httpServer.http.url('/')) + expect( + Object.fromEntries(requestFromListener.headers.entries()) + ).toMatchObject({ + 'content-type': 'text/plain', + 'x-custom-header': 'yes', + }) + expect(await requestFromListener.text()).toBe('post-payload') +}) + +it('emits the "response" event for a mocked response', async () => { + const responseListener = vi.fn() + interceptor.once('request', ({ request }) => { + request.respondWith(new Response('hello world')) + }) + interceptor.once('response', responseListener) + + const request = http.get('http://localhost', { + headers: { + 'x-custom-header': 'yes', + }, + }) + const { res, text } = await waitForClientRequest(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(UUID_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') +}) + +it('emits the "response" event for a bypassed response', async () => { + const responseListener = vi.fn() + interceptor.once('response', responseListener) + + const request = http.get(httpServer.http.url('/'), { + headers: { + 'x-custom-header': 'yes', + }, + }) + const { res, text } = await waitForClientRequest(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(UUID_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') +}) From 6e303ec5755e8ca5f1bf39b40440ebe81a314c0c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 11:16:35 +0100 Subject: [PATCH 33/69] chore: remove logs --- src/interceptors/ClientRequest/MockHttpSocket.ts | 2 -- src/interceptors/Socket/MockSocket.ts | 8 -------- 2 files changed, 10 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 0d8f7726..50382527 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -399,8 +399,6 @@ export class MockHttpSocket extends MockSocket { } private onResponseEnd(): void { - console.log('------ on response end') - // Response end can be called for responses without body. if (this.responseStream) { this.responseStream.push(null) diff --git a/src/interceptors/Socket/MockSocket.ts b/src/interceptors/Socket/MockSocket.ts index 2e6a447f..fcbd4e0b 100644 --- a/src/interceptors/Socket/MockSocket.ts +++ b/src/interceptors/Socket/MockSocket.ts @@ -24,8 +24,6 @@ export class MockSocket extends net.Socket { } public connect() { - console.log('MockSocket.connect()') - // The connection will remain pending until // the consumer decides to handle it. this.connecting = true @@ -34,15 +32,11 @@ export class MockSocket extends net.Socket { public write(...args: Array): boolean { const [chunk, encoding, callback] = normalizeWriteArgs(args) - console.log('MockSocket.write()', chunk.toString()) - this.options.write(chunk, encoding, callback) return false } public end(...args: Array) { - console.log('MockSocket.end()', args) - const [chunk, encoding, callback] = normalizeWriteArgs(args) this.options.write(chunk, encoding, callback) @@ -50,8 +44,6 @@ export class MockSocket extends net.Socket { } public push(chunk: any, encoding?: BufferEncoding): boolean { - console.log('MockSocket.push()', { chunk: chunk?.toString(), encoding }) - this.options.read(chunk, encoding) if (chunk !== null) { From 6b4d06c0ebfd6487846340a44b90a398d7da0393 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 11:17:29 +0100 Subject: [PATCH 34/69] test(ClientRequest): adjust statusMessage assertion for inferred code --- src/interceptors/ClientRequest/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 4cde38b5..935b73e0 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -54,7 +54,7 @@ it('forbids calling "respondWith" multiple times for the same request', async () const response = await responseReceived expect(response.statusCode).toBe(200) - expect(response.statusMessage).toBe('') + expect(response.statusMessage).toBe('OK') }) it('abort the request if the abort signal is emitted', async () => { From 640c9bf85bde5817d6f5b2626141ea75d6c8258c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 11:37:04 +0100 Subject: [PATCH 35/69] fix(ClientRequest): restore https from https module --- src/interceptors/ClientRequest/index.ts | 2 +- test/modules/fetch/response/fetch.test.ts | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index e3f5862e..b9540e39 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -22,7 +22,7 @@ export class ClientRequestInterceptor extends Interceptor { protected setup(): void { const { get: originalGet, request: originalRequest } = http - const { get: originalHttpsGet, request: originalHttpsRequest } = http + const { get: originalHttpsGet, request: originalHttpsRequest } = https const onRequest = this.onRequest.bind(this) const onResponse = this.onResponse.bind(this) diff --git a/test/modules/fetch/response/fetch.test.ts b/test/modules/fetch/response/fetch.test.ts index de6cd4b9..a82c52b6 100644 --- a/test/modules/fetch/response/fetch.test.ts +++ b/test/modules/fetch/response/fetch.test.ts @@ -1,8 +1,13 @@ +/** + * @vitest-environment node + */ import { it, expect, beforeAll, afterAll } from 'vitest' import fetch from 'node-fetch' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { res.status(500).json({ error: 'must use mock' }) @@ -56,9 +61,7 @@ it('bypasses an HTTP request not handled in the middleware', async () => { }) it('responds to an HTTPS request that is handled in the middleware', async () => { - const res = await fetch(httpServer.https.url('/'), { - agent: httpsAgent, - }) + const res = await fetch(httpServer.https.url('/')) const body = await res.json() expect(res.status).toEqual(201) @@ -67,9 +70,7 @@ it('responds to an HTTPS request that is handled in the middleware', async () => }) it('bypasses an HTTPS request not handled in the middleware', async () => { - const res = await fetch(httpServer.https.url('/get'), { - agent: httpsAgent, - }) + const res = await fetch(httpServer.https.url('/get')) const body = await res.json() expect(res.status).toEqual(200) @@ -78,14 +79,13 @@ it('bypasses an HTTPS request not handled in the middleware', async () => { it('bypasses any request when the interceptor is restored', async () => { interceptor.dispose() + const httpRes = await fetch(httpServer.http.url('/')) const httpBody = await httpRes.json() expect(httpRes.status).toEqual(500) expect(httpBody).toEqual({ error: 'must use mock' }) - const httpsRes = await fetch(httpServer.https.url('/'), { - agent: httpsAgent, - }) + const httpsRes = await fetch(httpServer.https.url('/')) const httpsBody = await httpsRes.json() expect(httpsRes.status).toEqual(500) expect(httpsBody).toEqual({ error: 'must use mock' }) @@ -95,7 +95,7 @@ it('does not throw an error if there are multiple interceptors', async () => { const secondInterceptor = new ClientRequestInterceptor() secondInterceptor.apply() - let res = await fetch(httpServer.https.url('/get'), { agent: httpsAgent }) + let res = await fetch(httpServer.http.url('/get')) let body = await res.json() expect(res.status).toEqual(200) From dd53781b71853791a42a6de952d021235aac967d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 11:42:53 +0100 Subject: [PATCH 36/69] chore: remove unused "createRequest" --- .../ClientRequest/utils/createRequest.test.ts | 144 ------------------ .../ClientRequest/utils/createRequest.ts | 49 ------ 2 files changed, 193 deletions(-) delete mode 100644 src/interceptors/ClientRequest/utils/createRequest.test.ts delete mode 100644 src/interceptors/ClientRequest/utils/createRequest.ts diff --git a/src/interceptors/ClientRequest/utils/createRequest.test.ts b/src/interceptors/ClientRequest/utils/createRequest.test.ts deleted file mode 100644 index 45211276..00000000 --- a/src/interceptors/ClientRequest/utils/createRequest.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { it, expect } from 'vitest' -import { Logger } from '@open-draft/logger' -import { HttpRequestEventMap } from '../../..' -import { NodeClientRequest } from '../NodeClientRequest' -import { createRequest } from './createRequest' -import { Emitter } from 'strict-event-emitter' - -const emitter = new Emitter() -const logger = new Logger('test') - -it('creates a fetch Request with a JSON body', async () => { - const clientRequest = new NodeClientRequest( - [ - new URL('https://api.github.com'), - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - }, - () => {}, - ], - { - emitter, - logger, - } - ) - clientRequest.write(JSON.stringify({ firstName: 'John' })) - - const request = createRequest(clientRequest) - - expect(request.method).toBe('POST') - expect(request.url).toBe('https://api.github.com/') - expect(request.headers.get('Content-Type')).toBe('application/json') - expect(await request.json()).toEqual({ firstName: 'John' }) -}) - -it('creates a fetch Request with an empty body', async () => { - const clientRequest = new NodeClientRequest( - [ - new URL('https://api.github.com'), - { - method: 'GET', - headers: { - Accept: 'application/json', - }, - }, - () => {}, - ], - { - emitter, - logger, - } - ) - - const request = createRequest(clientRequest) - - expect(request.method).toBe('GET') - expect(request.url).toBe('https://api.github.com/') - expect(request.headers.get('Accept')).toBe('application/json') - expect(request.body).toBe(null) -}) - -it('creates a fetch Request with an empty string body', async () => { - const clientRequest = new NodeClientRequest( - [ - new URL('https://api.github.com'), - { - method: 'HEAD', - }, - () => {}, - ], - { - emitter, - logger, - } - ) - clientRequest.write('') - - const request = createRequest(clientRequest) - - expect(request.method).toBe('HEAD') - expect(request.url).toBe('https://api.github.com/') - expect(request.body).toBe(null) -}) - -it('creates a fetch Request with an empty password', async () => { - const clientRequest = new NodeClientRequest( - [ - new URL('https://api.github.com'), - { auth: 'username:' }, - () => {}, - ], - { - emitter, - logger, - } - ) - clientRequest.write('') - - const request = createRequest(clientRequest) - - expect(request.headers.get('Authorization')).toBe(`Basic ${btoa('username:')}`) - expect(request.url).toBe('https://api.github.com/') -}) - -it('creates a fetch Request with an empty username', async () => { - const clientRequest = new NodeClientRequest( - [ - new URL('https://api.github.com'), - { auth: ':password' }, - () => {}, - ], - { - emitter, - logger, - } - ) - clientRequest.write('') - - const request = createRequest(clientRequest) - - expect(request.headers.get('Authorization')).toBe(`Basic ${btoa(':password')}`) - expect(request.url).toBe('https://api.github.com/') -}) - -it('creates a fetch Request with falsy headers', async () => { - const clientRequest = new NodeClientRequest( - [ - new URL('https://api.github.com'), - { headers: { 'foo': 0, 'empty': '' }} - ], - { - emitter, - logger, - } - ) - clientRequest.write('') - - const request = createRequest(clientRequest) - - expect(request.headers.get('foo')).toBe('0') - expect(request.headers.get('empty')).toBe('') -}) \ No newline at end of file diff --git a/src/interceptors/ClientRequest/utils/createRequest.ts b/src/interceptors/ClientRequest/utils/createRequest.ts deleted file mode 100644 index fbc41651..00000000 --- a/src/interceptors/ClientRequest/utils/createRequest.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { NodeClientRequest } from '../NodeClientRequest' - -/** - * Creates a Fetch API `Request` instance from the given `http.ClientRequest`. - */ -export function createRequest(clientRequest: NodeClientRequest): Request { - const headers = new Headers() - - const outgoingHeaders = clientRequest.getHeaders() - for (const headerName in outgoingHeaders) { - const headerValue = outgoingHeaders[headerName] - - if (typeof headerValue === 'undefined') { - continue - } - - const valuesList = Array.prototype.concat([], headerValue) - for (const value of valuesList) { - headers.append(headerName, value.toString()) - } - } - - /** - * Translate the authentication from the request URL to - * the request "Authorization" header. - * @see https://github.com/mswjs/interceptors/issues/438 - */ - if (clientRequest.url.username || clientRequest.url.password) { - const auth = `${clientRequest.url.username || ''}:${clientRequest.url.password || ''}` - headers.set('Authorization', `Basic ${btoa(auth)}`) - - // Remove the credentials from the URL since you cannot - // construct a Request instance with such a URL. - clientRequest.url.username = '' - clientRequest.url.password = '' - } - - const method = clientRequest.method || 'GET' - - return new Request(clientRequest.url, { - method, - headers, - credentials: 'same-origin', - body: - method === 'HEAD' || method === 'GET' - ? null - : clientRequest.requestBuffer, - }) -} From 4da4341fe09be2785fff8ef7e7ad5a3652f5448e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 11:43:02 +0100 Subject: [PATCH 37/69] test: try fixing other tests --- test/features/events/response.test.ts | 3 ++- test/modules/XMLHttpRequest/features/events.test.ts | 4 +++- test/third-party/axios.test.ts | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 2e9d988d..3c1546b1 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -3,7 +3,7 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import https from 'https' import fetch from 'node-fetch' import waitForExpect from 'wait-for-expect' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestEventMap } from '../../../src' import { createXMLHttpRequest, @@ -63,6 +63,7 @@ interceptor.on('request', ({ request }) => { 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() diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index 0e8403d7..c3745926 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -1,4 +1,6 @@ -// @vitest-environment jsdom +/** + * @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' diff --git a/test/third-party/axios.test.ts b/test/third-party/axios.test.ts index 14ea917a..48ad1971 100644 --- a/test/third-party/axios.test.ts +++ b/test/third-party/axios.test.ts @@ -1,4 +1,6 @@ -// @vitest-environment jsdom +/** + * @vitest-environment jsdom + */ import { it, expect, beforeAll, afterAll } from 'vitest' import axios from 'axios' import { HttpServer } from '@open-draft/test-server/http' From 635d12fbdc4f220af4c3df40d87587a294de7773 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 11:47:44 +0100 Subject: [PATCH 38/69] chore: use "NODE_TLS_REJECT_UNAUTHORIZED" flag for testing vs "httpsAgent" (outdated) --- test/features/events/response.test.ts | 2 -- test/modules/fetch/intercept/fetch.test.ts | 10 +++------- test/third-party/follow-redirect-http.test.ts | 5 +++-- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 3c1546b1..b5546c0d 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -124,7 +124,6 @@ it('ClientRequest: emits the "response" event upon the original response', async headers: { 'x-request-custom': 'yes', }, - agent: httpsAgent, }) req.write('request-body') req.end() @@ -261,7 +260,6 @@ it('fetch: emits the "response" event upon the original response', async () => { interceptor.on('response', responseListener) await fetch(httpServer.https.url('/account'), { - agent: httpsAgent, method: 'POST', headers: { 'x-request-custom': 'yes', diff --git a/test/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index e9bc952c..d0abdd8e 100644 --- a/test/modules/fetch/intercept/fetch.test.ts +++ b/test/modules/fetch/intercept/fetch.test.ts @@ -1,12 +1,14 @@ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import fetch from 'node-fetch' import { RequestHandler } from 'express' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestEventMap } from '../../../../src' import { UUID_REGEXP } from '../../../helpers' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { encodeBuffer } from '../../../../src/utils/bufferUtils' +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { res.status(200).send('user-body').end() @@ -184,7 +186,6 @@ it('intercepts an HTTP PATCH request', async () => { it('intercepts an HTTPS HEAD request', async () => { await fetch(httpServer.https.url('/user?id=123'), { - agent: httpsAgent, method: 'HEAD', headers: { 'x-custom-header': 'yes', @@ -209,7 +210,6 @@ it('intercepts an HTTPS HEAD request', async () => { it('intercepts an HTTPS GET request', async () => { await fetch(httpServer.https.url('/user?id=123'), { - agent: httpsAgent, headers: { 'x-custom-header': 'yes', }, @@ -233,7 +233,6 @@ it('intercepts an HTTPS GET request', async () => { it('intercepts an HTTPS POST request', async () => { await fetch(httpServer.https.url('/user?id=123'), { - agent: httpsAgent, method: 'POST', headers: { 'x-custom-header': 'yes', @@ -259,7 +258,6 @@ it('intercepts an HTTPS POST request', async () => { it('intercepts an HTTPS PUT request', async () => { await fetch(httpServer.https.url('/user?id=123'), { - agent: httpsAgent, method: 'PUT', headers: { 'x-custom-header': 'yes', @@ -285,7 +283,6 @@ it('intercepts an HTTPS PUT request', async () => { it('intercepts an HTTPS DELETE request', async () => { await fetch(httpServer.https.url('/user?id=123'), { - agent: httpsAgent, method: 'DELETE', headers: { 'x-custom-header': 'yes', @@ -310,7 +307,6 @@ it('intercepts an HTTPS DELETE request', async () => { it('intercepts an HTTPS PATCH request', async () => { await fetch(httpServer.https.url('/user?id=123'), { - agent: httpsAgent, method: 'PATCH', headers: { 'x-custom-header': 'yes', diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index db34f6fb..48ed2f73 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -1,11 +1,13 @@ // @vitest-environment jsdom import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { https } from 'follow-redirects' -import { httpsAgent, HttpServer } from '@open-draft/test-server/http' +import { HttpServer } from '@open-draft/test-server/http' import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' import type { HttpRequestEventMap } from '../../src/glossary' import { waitForClientRequest } from '../helpers' +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + const resolver = vi.fn() const interceptor = new ClientRequestInterceptor() @@ -55,7 +57,6 @@ it('intercepts a POST request issued by "follow-redirects"', async () => { 'Content-Type': 'application/json', 'Content-Length': payload.length, }, - agent: httpsAgent, }, (res) => { catchResponseUrl(res.responseUrl) From 497201919c5e1f7ad77a6ff1e80c59664bd49f70 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 12:02:34 +0100 Subject: [PATCH 39/69] fix: update @types/node to support Readable.toWeb() --- package.json | 2 +- pnpm-lock.yaml | 82 +++++++------------ .../ClientRequest/MockHttpSocket.ts | 6 +- src/interceptors/Socket/MockSocket.ts | 7 +- tsconfig.json | 1 + 5 files changed, 39 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 36d84893..50973259 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "@types/express-rate-limit": "^6.0.0", "@types/follow-redirects": "^1.14.1", "@types/jest": "^27.0.3", - "@types/node": "^16.11.26", + "@types/node": "18", "@types/node-fetch": "2.5.12", "@types/supertest": "^2.0.11", "@types/ws": "^8.5.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9f37355..93c5c7d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ specifiers: '@types/express-rate-limit': ^6.0.0 '@types/follow-redirects': ^1.14.1 '@types/jest': ^27.0.3 - '@types/node': ^16.11.26 + '@types/node': '18' '@types/node-fetch': 2.5.12 '@types/supertest': ^2.0.11 '@types/ws': ^8.5.10 @@ -75,7 +75,7 @@ devDependencies: '@types/express-rate-limit': 6.0.0_express@4.18.2 '@types/follow-redirects': 1.14.1 '@types/jest': 27.5.2 - '@types/node': 16.18.46 + '@types/node': 18.19.22 '@types/node-fetch': 2.5.12 '@types/supertest': 2.0.12 '@types/ws': 8.5.10 @@ -104,7 +104,7 @@ devDependencies: tsup: 6.7.0_typescript@4.9.5 typescript: 4.9.5 undici: 6.6.2 - vitest: 1.2.2_tzd3dabz5yko6x5zyblyxnclxa + vitest: 1.2.2_maoo2dpmvbrbq426wkrerk455q vitest-environment-miniflare: 2.14.1_vitest@1.2.2 wait-for-expect: 3.0.2 web-encoding: 1.1.5 @@ -540,10 +540,10 @@ packages: '@commitlint/execute-rule': 16.2.1 '@commitlint/resolve-extends': 16.2.1 '@commitlint/types': 16.2.1 - '@types/node': 16.18.46 + '@types/node': 20.4.7 chalk: 4.1.2 cosmiconfig: 7.1.0 - cosmiconfig-typescript-loader: 2.0.2_3re4vlo6afiexbbu7bcncr655a + cosmiconfig-typescript-loader: 2.0.2_rfwflknpvvnwtwekos4ljeaw7m lodash: 4.17.21 resolve-from: 5.0.0 typescript: 4.9.5 @@ -1825,7 +1825,7 @@ packages: /@types/cors/2.8.13: resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} dependencies: - '@types/node': 16.18.46 + '@types/node': 20.4.7 dev: true /@types/eslint-scope/3.7.4: @@ -1880,7 +1880,7 @@ packages: /@types/follow-redirects/1.14.1: resolution: {integrity: sha512-THBEFwqsLuU/K62B5JRwab9NW97cFmL4Iy34NTMX0bMycQVzq2q7PKOkhfivIwxdpa/J72RppgC42vCHfwKJ0Q==} dependencies: - '@types/node': 16.18.46 + '@types/node': 20.4.7 dev: true /@types/graceful-fs/4.1.6: @@ -1953,7 +1953,7 @@ packages: /@types/node-fetch/2.5.12: resolution: {integrity: sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==} dependencies: - '@types/node': 16.18.46 + '@types/node': 20.4.7 form-data: 3.0.1 dev: true @@ -1961,6 +1961,12 @@ packages: resolution: {integrity: sha512-Mnq3O9Xz52exs3mlxMcQuA7/9VFe/dXcrgAyfjLkABIqxXKOgBRjyazTxUbjsxDa4BP7hhPliyjVTP9RDP14xg==} dev: true + /@types/node/18.19.22: + resolution: {integrity: sha512-p3pDIfuMg/aXBmhkyanPshdfJuX5c5+bQjYLIikPLXAUycEogij/c50n/C+8XOA5L93cU4ZRXtn+dNQGi0IZqQ==} + dependencies: + undici-types: 5.26.5 + dev: true + /@types/node/20.4.7: resolution: {integrity: sha512-bUBrPjEry2QUTsnuEjzjbS7voGWCc30W0qzgMf90GPeDGFRakvrz47ju+oqDAKCXLUCe39u57/ORMl/O/04/9g==} dev: true @@ -3020,16 +3026,16 @@ packages: vary: 1.1.2 dev: true - /cosmiconfig-typescript-loader/2.0.2_3re4vlo6afiexbbu7bcncr655a: + /cosmiconfig-typescript-loader/2.0.2_rfwflknpvvnwtwekos4ljeaw7m: resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@types/node': '*' typescript: '>=3' dependencies: - '@types/node': 16.18.46 + '@types/node': 20.4.7 cosmiconfig: 7.1.0 - ts-node: 10.9.1_3re4vlo6afiexbbu7bcncr655a + ts-node: 10.9.1_rfwflknpvvnwtwekos4ljeaw7m typescript: 4.9.5 transitivePeerDependencies: - '@swc/core' @@ -6927,37 +6933,6 @@ packages: yargs-parser: 20.2.9 dev: true - /ts-node/10.9.1_3re4vlo6afiexbbu7bcncr655a: - resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.9 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 16.18.46 - acorn: 8.11.3 - acorn-walk: 8.3.2 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 4.9.5 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - dev: true - /ts-node/10.9.1_rfwflknpvvnwtwekos4ljeaw7m: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true @@ -6988,7 +6963,6 @@ packages: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 dev: true - optional: true /tslib/2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} @@ -7079,6 +7053,10 @@ packages: resolution: {integrity: sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==} dev: true + /undici-types/5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + dev: true + /undici/5.20.0: resolution: {integrity: sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==} engines: {node: '>=12.18'} @@ -7187,7 +7165,7 @@ packages: engines: {node: '>= 0.8'} dev: true - /vite-node/1.2.2_@types+node@16.18.46: + /vite-node/1.2.2_@types+node@18.19.22: resolution: {integrity: sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -7196,7 +7174,7 @@ packages: debug: 4.3.4 pathe: 1.1.1 picocolors: 1.0.0 - vite: 5.1.2_@types+node@16.18.46 + vite: 5.1.2_@types+node@18.19.22 transitivePeerDependencies: - '@types/node' - less @@ -7208,7 +7186,7 @@ packages: - terser dev: true - /vite/5.1.2_@types+node@16.18.46: + /vite/5.1.2_@types+node@18.19.22: resolution: {integrity: sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -7236,7 +7214,7 @@ packages: terser: optional: true dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.22 esbuild: 0.19.12 postcss: 8.4.35 rollup: 4.11.0 @@ -7255,13 +7233,13 @@ packages: '@miniflare/shared': 2.14.1 '@miniflare/shared-test-environment': 2.14.1 undici: 5.20.0 - vitest: 1.2.2_tzd3dabz5yko6x5zyblyxnclxa + vitest: 1.2.2_maoo2dpmvbrbq426wkrerk455q transitivePeerDependencies: - bufferutil - utf-8-validate dev: true - /vitest/1.2.2_tzd3dabz5yko6x5zyblyxnclxa: + /vitest/1.2.2_maoo2dpmvbrbq426wkrerk455q: resolution: {integrity: sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -7286,7 +7264,7 @@ packages: jsdom: optional: true dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.22 '@vitest/expect': 1.2.2 '@vitest/runner': 1.2.2 '@vitest/snapshot': 1.2.2 @@ -7306,8 +7284,8 @@ packages: strip-literal: 1.3.0 tinybench: 2.6.0 tinypool: 0.8.2 - vite: 5.1.2_@types+node@16.18.46 - vite-node: 1.2.2_@types+node@16.18.46 + vite: 5.1.2_@types+node@18.19.22 + vite-node: 1.2.2_@types+node@18.19.22 why-is-node-running: 2.2.2 transitivePeerDependencies: - less diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 50382527..769f8db3 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -319,9 +319,9 @@ export class MockHttpSocket extends MockSocket { method, headers, credentials: 'same-origin', - // @ts-expect-error: Undocumented fetch option. + // @ts-expect-error Undocumented Fetch property. duplex: canHaveBody ? 'half' : undefined, - body: canHaveBody ? Readable.toWeb(this.requestStream) : null, + body: canHaveBody ? (Readable.toWeb(this.requestStream!) as any) : null, }) Reflect.set(this.request, kRequestId, requestId) @@ -367,7 +367,7 @@ export class MockHttpSocket extends MockSocket { } const response = new Response( - canHaveBody ? Readable.toWeb(this.responseStream) : null, + canHaveBody ? (Readable.toWeb(this.responseStream!) as any) : null, { status, statusText, diff --git a/src/interceptors/Socket/MockSocket.ts b/src/interceptors/Socket/MockSocket.ts index fcbd4e0b..d35976c9 100644 --- a/src/interceptors/Socket/MockSocket.ts +++ b/src/interceptors/Socket/MockSocket.ts @@ -1,6 +1,7 @@ import net from 'node:net' import { normalizeWriteArgs, + type WriteArgs, type WriteCallback, } from './utils/normalizeWriteArgs' @@ -31,16 +32,16 @@ export class MockSocket extends net.Socket { } public write(...args: Array): boolean { - const [chunk, encoding, callback] = normalizeWriteArgs(args) + const [chunk, encoding, callback] = normalizeWriteArgs(args as WriteArgs) this.options.write(chunk, encoding, callback) return false } public end(...args: Array) { - const [chunk, encoding, callback] = normalizeWriteArgs(args) + const [chunk, encoding, callback] = normalizeWriteArgs(args as WriteArgs) this.options.write(chunk, encoding, callback) - return super.end.apply(this, args) + return super.end.apply(this, args as any) } public push(chunk: any, encoding?: BufferEncoding): boolean { diff --git a/tsconfig.json b/tsconfig.json index 0c5b33cb..a6dc9747 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "downlevelIteration": true, "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], + "types": ["@types/node"], "baseUrl": ".", "paths": { "_http_commons": ["./_http.common.d.ts"] From 46e51e41f93732edb8c117fafd576078f75f2a25 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 13:40:25 +0100 Subject: [PATCH 40/69] test: fix "intecept/fetch" body assertions --- test/modules/fetch/intercept/fetch.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index d0abdd8e..ca2349e6 100644 --- a/test/modules/fetch/intercept/fetch.test.ts +++ b/test/modules/fetch/intercept/fetch.test.ts @@ -153,7 +153,7 @@ it('intercepts an HTTP DELETE request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(await request.arrayBuffer()).toEqual(new ArrayBuffer(0)) expect(request.respondWith).toBeInstanceOf(Function) expect(requestId).toMatch(UUID_REGEXP) @@ -299,7 +299,7 @@ it('intercepts an HTTPS DELETE request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(await request.arrayBuffer()).toEqual(new ArrayBuffer(0)) expect(request.respondWith).toBeInstanceOf(Function) expect(requestId).toMatch(UUID_REGEXP) @@ -323,7 +323,7 @@ it('intercepts an HTTPS PATCH request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(await request.arrayBuffer()).toEqual(new ArrayBuffer(0)) expect(request.respondWith).toBeInstanceOf(Function) expect(requestId).toMatch(UUID_REGEXP) From 78d4ed32b01986d5e2251e183d924b3ab074704a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 10 Mar 2024 13:59:19 +0100 Subject: [PATCH 41/69] fix: use "URL" from "node:url" and cast to string --- .../utils/normalizeClientRequestArgs.ts | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts index a8bd4815..1f4e4e5b 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts @@ -2,13 +2,23 @@ import { Agent as HttpAgent, globalAgent as httpGlobalAgent, IncomingMessage, -} from 'http' +} from 'node:http' import { RequestOptions, Agent as HttpsAgent, globalAgent as httpsGlobalAgent, -} from 'https' -import { Url as LegacyURL, parse as parseUrl } from 'url' +} 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 { getRequestOptionsByUrl } from '../../../utils/getRequestOptionsByUrl' import { @@ -114,6 +124,7 @@ export function normalizeClientRequestArgs( // Support "http.request()" calls without any arguments. // That call results in a "GET http://localhost" request. if (args.length === 0) { + console.log('THIS?') const url = new URL('http://localhost') const options = resolveRequestOptions(args, url) return [url, options] @@ -263,5 +274,16 @@ export function normalizeClientRequestArgs( 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] } From 8fdb4b5609c39660d628b8dfb7f8c533dee23620 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 13:37:58 +0100 Subject: [PATCH 42/69] fix(MockHttpSocket): rely on "x-request-id" for event deduplication --- .../ClientRequest/MockHttpSocket.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 769f8db3..9937a7ce 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -326,6 +326,16 @@ export class MockHttpSocket extends MockSocket { Reflect.set(this.request, kRequestId, requestId) + /** + * @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('x-request-id')) { + this.passthrough() + return + } + this.onRequest({ requestId, request: this.request, @@ -380,6 +390,15 @@ export class MockHttpSocket extends MockSocket { 'Failed to handle a response: request does not exist' ) + /** + * @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('x-request-id')) { + return + } + this.onResponse({ response, isMockedResponse: this.responseType === 'mock', From caeb168cde8a9911dbd46b89a63d3389d89cbee1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 13:39:19 +0100 Subject: [PATCH 43/69] chore: remove "SocketInterceptor" --- src/interceptors/Socket/SocketInterceptor.ts | 627 ------------------ .../Socket/compliance/socket.events.test.ts | 136 ---- 2 files changed, 763 deletions(-) delete mode 100644 src/interceptors/Socket/SocketInterceptor.ts delete mode 100644 test/modules/Socket/compliance/socket.events.test.ts diff --git a/src/interceptors/Socket/SocketInterceptor.ts b/src/interceptors/Socket/SocketInterceptor.ts deleted file mode 100644 index fb26b1de..00000000 --- a/src/interceptors/Socket/SocketInterceptor.ts +++ /dev/null @@ -1,627 +0,0 @@ -import net from 'node:net' -import https from 'node:https' -import tls from 'node:tls' -import { STATUS_CODES } from 'node:http' -import { HTTPParser } from '_http_common' -import { randomUUID } from 'node:crypto' -import { Readable } from 'node:stream' -import { until } from '@open-draft/until' -import { Interceptor } from '../../Interceptor' -import { - InteractiveRequest, - toInteractiveRequest, -} from '../../utils/toInteractiveRequest' -import { emitAsync } from '../../utils/emitAsync' -import { isPropertyAccessible } from '../../utils/isPropertyAccessible' - -type NormalizedSocketConnectArgs = [ - options: NormalizedSocketConnectOptions, - connectionListener: (() => void) | null, -] - -declare module 'node:net' { - /** - * Internal `new Socket().connect()` arguments normalization function. - * @see https://github.com/nodejs/node/blob/29ec7e9331c4944006ffe28e126cc31cc3de271b/lib/net.js#L272 - */ - export var _normalizeArgs: ( - args: Array - ) => NormalizedSocketConnectArgs -} - -export interface SocketEventMap { - request: [ - args: { - requestId: string - request: InteractiveRequest - }, - ] - response: [ - args: { - requestId: string - request: Request - response: Response - isMockedResponse: boolean - }, - ] -} - -export class SocketInterceptor extends Interceptor { - static symbol = Symbol('socket') - - constructor() { - super(SocketInterceptor.symbol) - } - - protected setup(): void { - const self = this - const originalConnect = net.Socket.prototype.connect - const originalTlsConnect = tls.TLSSocket.prototype.connect - - net.Socket.prototype.connect = function mockConnect( - ...args: Array - ) { - /** - * @note In some cases, "Socket.prototype.connect" will receive already - * normalized arguments. The call signature of that method will differ: - * .connect(port, host, cb) // unnormalized - * .connect([options, cb, normalizedSymbol]) // normalized - * Check that and unwrap the arguments to have a consistent format. - */ - const unwrappedArgs = Array.isArray(args[0]) ? args[0] : args - const normalizedSocketConnectArgs = net._normalizeArgs(unwrappedArgs) - - const createConnection = () => { - return originalConnect.apply(this, args) - } - - const socketWrap = new SocketWrap( - normalizedSocketConnectArgs, - createConnection - ) - - socketWrap.onRequest = async (request) => { - const requestId = randomUUID() - const { interactiveRequest, requestController } = - toInteractiveRequest(request) - - self.emitter.once('request', ({ requestId: pendingRequestId }) => { - if (pendingRequestId !== requestId) { - return - } - - if (requestController.responsePromise.state === 'pending') { - requestController.responsePromise.resolve(undefined) - } - }) - - const resolverResult = await until(async () => { - await emitAsync(self.emitter, 'request', { - requestId, - request: interactiveRequest, - }) - - return await requestController.responsePromise - }) - - if (resolverResult.error) { - socketWrap.errorWith(resolverResult.error) - return - } - - const mockedResponse = resolverResult.data - - if (mockedResponse) { - // Handle mocked "Response.error()" instances. - if ( - isPropertyAccessible(mockedResponse, 'type') && - mockedResponse.type === 'error' - ) { - socketWrap.errorWith(new TypeError('Network error')) - return - } - - socketWrap.respondWith(mockedResponse) - } else { - socketWrap.passthrough() - } - - socketWrap.onResponse = (response) => { - self.emitter.emit('response', { - requestId, - request, - response, - isMockedResponse: false, - }) - } - } - - return socketWrap - } - - // - // - // - - // tls.TLSSocket.prototype.connect = function mockTlsConnect(...args) { - // console.log('tls.TLSSocket.connect') - - // const normalizedArgs = net._normalizeArgs(args) - - // const createSocketConnection = () => { - // /** @todo Have doubts about this. Bypassed request tests will reveal the truth. */ - // return originalConnect.apply(this, normalizedArgs[0], normalizedArgs[1]) - // } - - // // const socketWrap = new SocketWrap(normalizedArgs, createSocketConnection) - - // // const tlsSocket = new TlsSocketWrap(socketWrap, normalizedArgs[0]) - // // const tlsSocket = new tls.TLSSocket(socketWrap, normalizedArgs[0]) - // // - - // // socketWrap.onRequest = (request) => { - // // console.log('intercepted', request.method, request.url) - // // } - - // const e = new EventEmitter() - // queueMicrotask(() => { - // e.emit('secureConnect') - // e.emit('connect') - // }) - - // return e - // } - - this.subscriptions.push(() => { - net.Socket.prototype.connect = originalConnect - tls.TLSSocket.prototype.connect = originalTlsConnect - }) - } -} - -class SocketWrap extends net.Socket { - public url: URL - public onRequest?: (request: Request) => void - public onResponse?: (response: Response) => void - - private connectionOptions: NormalizedSocketConnectArgs[0] - private connectionListener: NormalizedSocketConnectArgs[1] - private requestParser: HttpMessageParser<'request'> - private responseParser: HttpMessageParser<'response'> - private requestStream: Readable - private responseStream: Readable - private requestChunks: Array = [] - private shouldKeepAlive?: boolean - - constructor( - readonly socketConnectArgs: ReturnType, - private createConnection: () => net.Socket, - private isSecure = false - ) { - super() - - this.connectionOptions = socketConnectArgs[0] - this.connectionListener = socketConnectArgs[1] - - this.url = parseSocketConnectionUrl(this.connectionOptions) - - this.requestStream = new Readable() - this.requestParser = new HttpMessageParser('request', { - onHeadersComplete: ( - major, - minor, - headers, - method, - path, - _, - __, - ___, - shouldKeepAlive - ) => { - this.shouldKeepAlive = shouldKeepAlive - this.onRequestStart(path, headers) - }, - onBody: (chunk) => { - this.requestStream.push(chunk) - }, - onMessageComplete: () => { - this.requestStream.push(null) - this.requestParser.destroy() - }, - }) - - this.responseStream = new Readable() - this.responseParser = new HttpMessageParser('response', { - onHeadersComplete: ( - major, - minor, - headers, - method, - url, - status, - statusText - ) => { - this.onResponseStart(status, statusText, headers) - }, - onBody: (chunk) => { - this.responseStream.push(chunk) - }, - onMessageComplete: () => { - this.responseStream.push(null) - this.responseParser.destroy() - }, - }) - - this.mockConnect() - } - - private mockConnect() { - console.log('SocketWrap.mockConnect()', this.connectionListener) - - if (this.connectionListener) { - this.once('connect', this.connectionListener) - } - - Reflect.set(this, 'connecting', true) - - queueMicrotask(() => { - this.emit('lookup', null, '127.0.0.1', 6, this.connectionOptions.host) - Reflect.set(this, 'connecting', false) - - if (this.isSecure) { - this.emit('secureConnect') - } - - this.emit('connect') - this.emit('ready') - }) - } - - public destroy(error?: Error) { - queueMicrotask(() => { - if (error) { - this.emit('error', error) - } - // Override the ".destroy()" method in order to - // emit the "hadError" argument with the "close" event. - // For some reason, relying on "super.destroy()" doesn't - // include that argument, it's undefined. - this.emit('close', !!error) - }) - - return this - } - - public write(chunk: Buffer) { - this.requestChunks.push(chunk) - - if (chunk !== null) { - this.requestParser.push( - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - ) - } - - return true - } - - public push(chunk: any, encoding?: BufferEncoding) { - if (chunk !== null) { - const chunkBuffer = Buffer.isBuffer(chunk) - ? chunk - : Buffer.from(chunk, encoding) - this.responseParser.push(chunkBuffer) - this.emit('data', chunkBuffer) - } else { - this.emit('end') - } - - return true - } - - private onRequestStart(path: string, rawHeaders: Array): void { - const url = new URL(path, this.url) - const headers = parseRawHeaders(rawHeaders) - - // Translate URL auth into the authorization request header. - if (url.username || url.password) { - if (!headers.has('authorization')) { - headers.set( - 'authorization', - `Basic ${btoa(`${url.username}:${url.password}`)}` - ) - } - url.username = '' - url.password = '' - } - - const method = this.connectionOptions.method || 'GET' - const isBodyAllowed = method !== 'HEAD' && method !== 'GET' - - const request = new Request(url, { - method, - headers, - credentials: 'same-origin', - body: isBodyAllowed ? Readable.toWeb(this.requestStream) : null, - // @ts-expect-error Not documented fetch property. - duplex: isBodyAllowed ? 'half' : undefined, - }) - - this.onRequest?.(request) - } - - private onResponseStart( - status: number, - statusText: string, - rawHeaders: Array - ) { - const response = new Response(Readable.toWeb(this.responseStream), { - status, - statusText, - headers: parseRawHeaders(rawHeaders), - }) - - this.onResponse?.(response) - } - - /** - * Passthrough this Socket connection. - * Performs the connection as-is, flushing the request body - * and forwarding any events and response stream to this instance. - */ - public passthrough(): void { - const socket = this.createConnection() - - /** - * @fixme This is not ideal. I'd love not to introduce another - * place where we store the request body stream. Alas, we cannot - * read from "this.requestStream.read()" as it returns null - * (at this point, the stream is drained). - */ - if (this.requestChunks.length > 0) { - this.requestChunks.forEach((chunk) => socket.write(chunk)) - } - - socket - .once('connect', () => this.emit('connect')) - .once('ready', () => { - this.emit('ready') - socket.on('data', (chunk) => { - this.emit('data', chunk) - }) - }) - .on('error', (error) => this.emit('error', error)) - .on('timeout', () => this.emit('timeout')) - .on('drain', () => this.emit('drain')) - .on('close', (hadError) => this.emit('close', hadError)) - .on('end', () => this.emit('end')) - } - - public async respondWith(response: Response): Promise { - this.emit('resume') - await pipeResponse(response, this) - - // If the request did not specify the "Connection" header, - // the socket will be kept alive. We mustn't close its - // readable stream in that case as more clients can write to it. - if (!this.shouldKeepAlive) { - /** - * Socket (I suspect the underlying stream) emits the - * "readable" event for non-keepalive connections - * before closing them. If you are a magician who knows - * why it does so, let us know if we're doing it right here. - */ - this.emit('readable') - this.push(null) - } - } - - public errorWith(error?: Error): void { - Reflect.set(this, '_hadError', true) - this.emit('error', error) - this.emit('close', true) - } -} - -class TlsSocketWrap extends tls.TLSSocket { - constructor( - private readonly socket: SocketWrap, - _tlsOptions: NormalizedSocketConnectOptions - ) { - socket._tlsOptions = {} - - super(socket) - - this.mockSecureConnect() - } - - private mockSecureConnect() { - console.log('TlsSocketWrap: mockSecureConnect()') - // console.log(this.ssl) - - // this.ssl.onhandshakedone() - - this._secureEstablished = true - this.emit('secure') - this.emit('secureConnect') - } -} - -type CommonSocketConnectOptions = { - method?: string - auth?: string - noDelay?: boolean - encoding?: BufferEncoding | null - servername?: string -} - -type NormalizedSocketConnectOptions = - | (CommonSocketConnectOptions & URL) - | (CommonSocketConnectOptions & { - host: string - port: number - path?: string | null - }) - -type HttpMessageParserMessageType = 'request' | 'response' -interface HttpMessageParserCallbacks { - onHeadersComplete?: T extends 'request' - ? ( - versionMajor: number, - versionMinor: number, - headers: Array, - idk: number, - path: string, - idk2: unknown, - idk3: unknown, - idk4: unknown, - shouldKeepAlive: boolean - ) => void - : ( - versionMajor: number, - versionMinor: number, - headers: Array, - method: string | undefined, - url: string | undefined, - status: number, - statusText: string, - upgrade: boolean, - shouldKeepAlive: boolean - ) => void - onBody?: (chunk: Buffer) => void - onMessageComplete?: () => void -} - -class HttpMessageParser { - private parser: HTTPParser - - constructor(messageType: T, callbacks: HttpMessageParserCallbacks) { - this.parser = new HTTPParser() - this.parser.initialize( - messageType === 'request' ? HTTPParser.REQUEST : HTTPParser.RESPONSE, - // Don't create any async resources here. - // This has to be "HTTPINCOMINGMESSAGE" in practice. - // @see https://github.com/nodejs/llhttp/issues/44#issuecomment-582499320 - // new HTTPServerAsyncResource('INTERCEPTORINCOMINGMESSAGE', socket) - {} - ) - this.parser[HTTPParser.kOnHeadersComplete] = callbacks.onHeadersComplete - this.parser[HTTPParser.kOnBody] = callbacks.onBody - this.parser[HTTPParser.kOnMessageComplete] = callbacks.onMessageComplete - } - - public push(chunk: Buffer): void { - this.parser.execute(chunk) - } - - public destroy(): void { - this.parser.finish() - this.parser.free() - } -} - -function parseSocketConnectionUrl( - options: NormalizedSocketConnectOptions -): 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 -} - -function 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 -} - -/** - * Pipes the entire HTTP message from the given Fetch API `Response` - * instance to the socket. - */ -async function pipeResponse( - response: Response, - socket: net.Socket -): Promise { - const httpHeaders: Array = [] - // Status text is optional in Response but required in the HTTP message. - const statusText = response.statusText || STATUS_CODES[response.status] || '' - - httpHeaders.push(Buffer.from(`HTTP/1.1 ${response.status} ${statusText}\r\n`)) - - for (const [name, value] of response.headers) { - httpHeaders.push(Buffer.from(`${name}: ${value}\r\n`)) - } - - if (!response.body) { - socket.push(Buffer.concat(httpHeaders)) - return - } - - httpHeaders.push(Buffer.from('\r\n')) - - const encoding = response.headers.get('content-encoding') as - | BufferEncoding - | undefined - const reader = response.body.getReader() - - while (true) { - const { done, value } = await reader.read() - - if (done) { - break - } - - // Send the whole HTTP message headers buffer, - // including the first body chunk at once. This will - // be triggered for all non-stream response bodies. - if (httpHeaders.length > 0) { - httpHeaders.push(Buffer.from(value)) - socket.push(Buffer.concat(httpHeaders)) - httpHeaders.length = 0 - continue - } - - // If the response body keeps streaming, - // pipe it to the socket as we receive the chunks. - socket.push(value, encoding) - } - - reader.releaseLock() -} - -export class MockAgent extends https.Agent { - createConnection( - options, - onCreate: (error: Error | null, socket: net.Socket) => void - ) { - const normalizedOptions = net._normalizeArgs([options, onCreate]) - - const createRealConnection = () => { - throw new Error('Not implemented') - } - - return new SocketWrap(normalizedOptions, createRealConnection, true) - } -} diff --git a/test/modules/Socket/compliance/socket.events.test.ts b/test/modules/Socket/compliance/socket.events.test.ts deleted file mode 100644 index 04c21006..00000000 --- a/test/modules/Socket/compliance/socket.events.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * @vitest-environment node - */ -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' -import net from 'node:net' -import { SocketInterceptor } from '../../../../src/interceptors/Socket/SocketInterceptor' - -const interceptor = new SocketInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -function spyOnEvents(socket: net.Socket): Array { - const events: Array = [] - - socket.emit = new Proxy(socket.emit, { - apply(target, thisArg, args) { - events.push(args) - return Reflect.apply(target, thisArg, args) - }, - }) - - return events -} - -function waitForSocketEvent( - socket: net.Socket, - event: string -): Promise { - return new Promise((resolve, reject) => { - socket - .once(event, (data) => resolve(data)) - .once('error', (error) => reject(error)) - }) -} - -it('emits correct events for an HTTP connection', async () => { - const connectionCallback = vi.fn() - const socket = new net.Socket().connect(80, 'example.com', connectionCallback) - const events = spyOnEvents(socket) - - await waitForSocketEvent(socket, 'connect') - expect(events).toEqual([ - ['lookup', null, expect.any(String), 6, 'example.com'], - ['connect'], - ['ready'], - ]) - - socket.destroy() - await waitForSocketEvent(socket, 'close') - - expect(events.slice(3)).toEqual([['close', false]]) - expect(connectionCallback).toHaveBeenCalledTimes(1) -}) - -it('emits correct events for a mocked keepalive HTTP request', async () => { - interceptor.on('request', ({ request }) => { - request.respondWith(new Response('hello world')) - }) - - const connectionCallback = vi.fn() - const socket = new net.Socket().connect(80, 'example.com', connectionCallback) - const events = spyOnEvents(socket) - - await waitForSocketEvent(socket, 'connect') - expect(events).toEqual([ - ['lookup', null, expect.any(String), 6, 'example.com'], - ['connect'], - ['ready'], - ]) - - socket.write('HEAD / HTTP/1.1\r\n') - // Intentionally construct a keepalive request - // (no "Connection: close" request header). - socket.write('Host: example.com\r\n') - socket.write('\r\n') - - await waitForSocketEvent(socket, 'data') - - expect(events.slice(3)).toEqual([ - ['resume'], - [ - 'data', - Buffer.from( - `HTTP/1.1 200 OK\r\ncontent-type: text/plain;charset=UTF-8\r\n\r\nhello world` - ), - ], - ]) -}) - -it('emits correct events for a mocked HTTP request', async () => { - interceptor.on('request', ({ request }) => { - request.respondWith(new Response('hello world')) - }) - - const connectionCallback = vi.fn() - const socket = new net.Socket().connect(80, 'example.com', connectionCallback) - const events = spyOnEvents(socket) - - await waitForSocketEvent(socket, 'connect') - expect(events).toEqual([ - ['lookup', null, expect.any(String), 6, 'example.com'], - ['connect'], - ['ready'], - ]) - - socket.write('HEAD / HTTP/1.1\r\n') - // Instruct the socket to close the connection - // as soon as the response is received. - socket.write('Connection: close\r\n') - socket.write('Host: example.com\r\n') - socket.write('\r\n') - - await waitForSocketEvent(socket, 'data') - expect(events.slice(3)).toEqual([ - ['resume'], - [ - 'data', - Buffer.from( - `HTTP/1.1 200 OK\r\ncontent-type: text/plain;charset=UTF-8\r\n\r\nhello world` - ), - ], - ]) - - await waitForSocketEvent(socket, 'end') - expect(events.slice(5)).toEqual([['readable'], ['end']]) -}) From deff61832d69205f0fe06fa4eaca6a84d44a2833 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 13:44:41 +0100 Subject: [PATCH 44/69] test: add mocked response body for HEAD request --- .../http-head-response-body.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/modules/http/compliance/http-head-response-body.test.ts diff --git a/test/modules/http/compliance/http-head-response-body.test.ts b/test/modules/http/compliance/http-head-response-body.test.ts new file mode 100644 index 00000000..4af8e3d2 --- /dev/null +++ b/test/modules/http/compliance/http-head-response-body.test.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment node + */ +import { it, expect, beforeAll, afterAll } from 'vitest' +import http from 'node:http' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' + +const interceptor = new ClientRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('ignores response body in a mocked response to a HEAD request', async () => { + interceptor.once('request', ({ request }) => { + request.respondWith( + new Response('hello world', { + headers: { + 'x-custom-header': 'yes', + }, + }) + ) + }) + + const request = http.request('http://example.com', { method: 'HEAD' }).end() + const { res, text } = await waitForClientRequest(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('') +}) From 298d4ee0b5ef54bb31d6be7ba4fd6c1bf26958ba Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 13:53:14 +0100 Subject: [PATCH 45/69] fix(MockHttpSocket): support empty ReadableStream in mocked responses --- .../ClientRequest/MockHttpSocket.ts | 32 ++++++++++++----- ...ttp-empty-readable-stream-response.test.ts | 35 +++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 test/modules/http/regressions/http-empty-readable-stream-response.test.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 9937a7ce..e9f6dbd4 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -219,6 +219,19 @@ export class MockHttpSocket extends MockSocket { // An empty line separating headers from the body. httpHeaders.push(Buffer.from('\r\n')) + const flushHeaders = (value?: Uint8Array) => { + if (httpHeaders.length === 0) { + return + } + + if (typeof value !== 'undefined') { + httpHeaders.push(Buffer.from(value)) + } + + this.push(Buffer.concat(httpHeaders)) + httpHeaders.length = 0 + } + if (response.body) { const reader = response.body.getReader() @@ -229,21 +242,24 @@ export class MockHttpSocket extends MockSocket { break } - // The first body chunk flushes the entire headers. + // Flush the headers upon the first chunk in the stream. + // This ensures the consumer will start receiving the response + // as it streams in (subsequent chunks are pushed). if (httpHeaders.length > 0) { - httpHeaders.push(Buffer.from(value)) - this.push(Buffer.concat(httpHeaders)) - httpHeaders.length = 0 + flushHeaders(value) continue } // Subsequent body chukns are push to the stream. this.push(value) } - } else { - // If the response has no body, write its headers immediately. - this.push(Buffer.concat(httpHeaders)) - httpHeaders.length = 0 + } + + // If the headers were not flushed up to this point, + // this means the response either had no body or had + // an empty body stream. Flush the headers. + if (httpHeaders.length > 0) { + flushHeaders() } // Close the socket if the connection wasn't marked as keep-alive. 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 new file mode 100644 index 00000000..8a66b81d --- /dev/null +++ b/test/modules/http/regressions/http-empty-readable-stream-response.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { it, expect, beforeAll, afterAll } from 'vitest' +import http from 'node:http' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' + +const interceptor = new ClientRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('responds to a request with an empty ReadableStream', async () => { + interceptor.once('request', ({ request }) => { + const stream = new ReadableStream({ + start(controller) { + controller.close() + }, + }) + request.respondWith(new Response(stream)) + }) + + const request = http.get('http://example.com') + const { res, text } = await waitForClientRequest(request) + + expect(res.statusCode).toBe(200) + expect(res.statusMessage).toBe('OK') + expect(await text()).toBe('') +}) From 13aea5b623b19ec8f467e02afc808888da02b947 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 14:20:23 +0100 Subject: [PATCH 46/69] fix(MockHttpSocket): emit correct events for TLS connections --- .../ClientRequest/MockHttpSocket.ts | 9 +- test/modules/http/compliance/https.test.ts | 98 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 test/modules/http/compliance/https.test.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index e9f6dbd4..8bb23724 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -290,8 +290,13 @@ export class MockHttpSocket extends MockSocket { this.emit('connect') this.emit('ready') - // TODO: Also emit "secure" -> "secureConnect" -> "session" events - // for TLS sockets. + if (this.baseUrl.protocol === 'https:') { + this.emit('secure') + this.emit('secureConnect') + // A single TLS connection is represented by two "session" events. + this.emit('session', Buffer.from('mock-session-renegotiate')) + this.emit('session', Buffer.from('mock-session-resume')) + } } private onRequestStart: RequestHeadersCompleteCallback = ( diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts new file mode 100644 index 00000000..a05b0e2c --- /dev/null +++ b/test/modules/http/compliance/https.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { vi, it, expect, beforeAll, afterAll } from 'vitest' +import https from 'node:https' +import { HttpServer } from '@open-draft/test-server/http' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' + +const httpServer = new HttpServer((app) => { + app.get('/', (req, res) => { + res.send('hello') + }) +}) + +const interceptor = new ClientRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('emits correct events for a mocked HTTPS request', async () => { + interceptor.once('request', ({ request }) => { + request.respondWith(new Response()) + }) + + const request = https.get('https://example.com') + + const socketListener = vi.fn() + const socketReadyListener = vi.fn() + const socketSecureListener = vi.fn() + const socketSecureConnectListener = vi.fn() + const socketSessionListener = vi.fn() + const socketErrorListener = vi.fn() + + 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) + }) + + 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() +}) + +it('emits correct events for a passthrough HTTPS request', async () => { + const request = https.get(httpServer.https.url('/'), { + rejectUnauthorized: false, + }) + + const socketListener = vi.fn() + const socketReadyListener = vi.fn() + const socketSecureListener = vi.fn() + const socketSecureConnectListener = vi.fn() + const socketSessionListener = vi.fn() + const socketErrorListener = vi.fn() + + 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) + }) + + 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() +}) From 68ad5298e29f8832470a352a3b14a3f757445f9c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 14:20:32 +0100 Subject: [PATCH 47/69] chore: remove console.logs --- .../utils/normalizeClientRequestArgs.ts | 1 - test/modules/http/intercept/https.get.test.ts | 14 -------------- 2 files changed, 15 deletions(-) diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts index 1f4e4e5b..de0ff7d1 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts @@ -124,7 +124,6 @@ export function normalizeClientRequestArgs( // Support "http.request()" calls without any arguments. // That call results in a "GET http://localhost" request. if (args.length === 0) { - console.log('THIS?') const url = new URL('http://localhost') const options = resolveRequestOptions(args, url) return [url, options] diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 73dc1a7b..3616447f 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -38,20 +38,6 @@ it('intercepts a GET request', async () => { }, }) - request - .on('socket', (socket) => { - console.log('[test] request "socket" event:', socket.constructor.name) - - socket.on('secureConnect', () => console.log('secureConnect')) - socket.on('error', (e) => console.error(e)) - socket.on('timeout', () => console.error('timeout')) - socket.on('close', (e) => console.error(e)) - socket.on('end', () => console.error('end')) - }) - .on('error', (e) => console.error(e)) - .on('end', () => console.log('end')) - .on('close', () => console.log('close')) - await waitForClientRequest(request) expect(resolver).toHaveBeenCalledTimes(1) From 2cb79f3435054a0bfc3c7462f81cc3eab53be79b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 17:37:03 +0100 Subject: [PATCH 48/69] test(MockSocket): add unit tests --- src/interceptors/Socket/MockSocket.test.ts | 214 +++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/interceptors/Socket/MockSocket.test.ts diff --git a/src/interceptors/Socket/MockSocket.test.ts b/src/interceptors/Socket/MockSocket.test.ts new file mode 100644 index 00000000..2ce91a0c --- /dev/null +++ b/src/interceptors/Socket/MockSocket.test.ts @@ -0,0 +1,214 @@ +/** + * @vitest-environment node + */ +import { vi, it, expect } from 'vitest' +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('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 readable/writable state on "socket.end()"', async () => { + const socket = new MockSocket({ + write: vi.fn(), + read: vi.fn(), + }) + + expect(socket.writable).toBe(true) + expect(socket.writableEnded).toBe(false) + expect(socket.writableFinished).toBe(false) + expect(socket.readable).toBe(true) + expect(socket.readableEnded).toBe(false) + + socket.write('hello') + socket.end() + + expect(socket.writable).toBe(false) + expect(socket.writableEnded).toBe(true) + expect(socket.readable).toBe(true) + + await vi.waitFor(() => { + socket.once('finish', () => { + expect(socket.writableFinished).toBe(true) + }) + }) + + await vi.waitFor(() => { + socket.once('end', () => { + expect(socket.readableEnded).toBe(true) + }) + }) +}) + +it('updates the readable/writable state on "socket.destroy()"', async () => { + const socket = new MockSocket({ + write: vi.fn(), + read: vi.fn(), + }) + + 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.readable).toBe(false) + + await vi.waitFor(() => { + socket.once('finish', () => { + expect(socket.writableFinished).toBe(true) + }) + }) + + await vi.waitFor(() => { + socket.once('end', () => { + expect(socket.readableEnded).toBe(true) + }) + }) +}) From 848ef000a3a27ee9e52c69274c03f6a32cdbe51f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 19:27:51 +0100 Subject: [PATCH 49/69] docs: elaborate on when we free the parsers --- src/interceptors/ClientRequest/MockHttpSocket.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 8bb23724..9b969ff1 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -104,12 +104,15 @@ export class MockHttpSocket extends MockSocket { this.responseParser[HTTPParser.kOnMessageComplete] = this.onResponseEnd.bind(this) - // Once the socket is marked as finished, - // no requests can be written to it, so free the parser. + // Once the socket is finished, nothing can write to it + // anymore. It has also flushed any buffered chunks. this.once('finish', () => this.requestParser.free()) } public destroy(error?: Error | undefined): this { + // Destroy the response parser when the socket gets destroyed. + // Normally, we shoud listen to the "close" event but it + // can be suppressed by using the "emitClose: false" option. this.responseParser.free() return super.destroy(error) } From ac36cc6dcbb0f4e2042cb821e4215a3f7b9cf661 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 11 Mar 2024 19:28:27 +0100 Subject: [PATCH 50/69] docs: remove jest mention from the test --- test/modules/http/regressions/http-socket-timeout.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/modules/http/regressions/http-socket-timeout.test.ts b/test/modules/http/regressions/http-socket-timeout.test.ts index 6bc6f4fb..364ea7b1 100644 --- a/test/modules/http/regressions/http-socket-timeout.test.ts +++ b/test/modules/http/regressions/http-socket-timeout.test.ts @@ -13,7 +13,6 @@ beforeAll(() => { `--config=${require.resolve('./http-socket-timeout.vitest.config.js')}`, ]) - // Jest writes its output into "stderr". child.stderr?.on('data', (buffer: Buffer) => { /** * @note @fixme Skip Vite's CJS build deprecation message. From 700d34fcb7f2ac0eae39cb92c5a369280afeb206 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 21 Mar 2024 19:37:36 +0100 Subject: [PATCH 51/69] test: increase timeout for http-socket-timeout test --- test/modules/http/regressions/http-socket-timeout.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/modules/http/regressions/http-socket-timeout.test.ts b/test/modules/http/regressions/http-socket-timeout.test.ts index 364ea7b1..8e138fb1 100644 --- a/test/modules/http/regressions/http-socket-timeout.test.ts +++ b/test/modules/http/regressions/http-socket-timeout.test.ts @@ -39,4 +39,4 @@ it('does not leave the test process hanging due to the custom socket timeout', a expect(testErrors).toBe('') expect(exitCode).toEqual(0) -}) +}, 10_000) From 230c238ee86806688df57829cb4827a3b8b8f8c0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 21 Mar 2024 20:00:14 +0100 Subject: [PATCH 52/69] chore: skip follow-redirect-http test --- test/third-party/follow-redirect-http.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index 48ed2f73..ae329de1 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -6,8 +6,6 @@ import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' import type { HttpRequestEventMap } from '../../src/glossary' import { waitForClientRequest } from '../helpers' -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - const resolver = vi.fn() const interceptor = new ClientRequestInterceptor() @@ -42,7 +40,7 @@ afterAll(async () => { await server.close() }) -it('intercepts a POST request issued by "follow-redirects"', async () => { +it.skip('intercepts a POST request issued by "follow-redirects"', async () => { const { address } = server.https const payload = JSON.stringify({ todo: 'Buy the milk' }) @@ -55,8 +53,9 @@ it('intercepts a POST request issued by "follow-redirects"', async () => { path: '/resource', headers: { 'Content-Type': 'application/json', - 'Content-Length': payload.length, + 'Content-Length': Buffer.from(payload).byteLength, }, + rejectUnauthorized: false, }, (res) => { catchResponseUrl(res.responseUrl) From 2b1a5d71610b5c5c7f11cf2951e3d5c73fc21fb5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 21 Mar 2024 20:04:51 +0100 Subject: [PATCH 53/69] fix: support Readable as request body (#527) Co-authored-by: Michael Solomon --- .../ClientRequest/MockHttpSocket.ts | 9 +++++- src/interceptors/Socket/MockSocket.ts | 2 +- .../http/compliance/http-req-write.test.ts | 28 ++++++++++++++++++- .../http-concurrent-same-host.test.ts | 2 -- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 9b969ff1..b21863f4 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -335,7 +335,14 @@ export class MockHttpSocket extends MockSocket { // this ensures that each request gets its own stream. // One Socket instance can only handle one request at a time. if (canHaveBody) { - this.requestStream = new Readable() + 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: () => {}, + }) } const requestId = randomUUID() diff --git a/src/interceptors/Socket/MockSocket.ts b/src/interceptors/Socket/MockSocket.ts index d35976c9..3dc31fe7 100644 --- a/src/interceptors/Socket/MockSocket.ts +++ b/src/interceptors/Socket/MockSocket.ts @@ -34,7 +34,7 @@ export class MockSocket extends net.Socket { public write(...args: Array): boolean { const [chunk, encoding, callback] = normalizeWriteArgs(args as WriteArgs) this.options.write(chunk, encoding, callback) - return false + return true } public end(...args: Array) { diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index a1a308c2..5335ec9b 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,12 +1,13 @@ /** * @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 { waitForClientRequest } from '../../../helpers' +import { sleep, waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { app.post('/resource', express.text({ type: '*/*' }), (req, res) => { @@ -93,6 +94,31 @@ it('writes Buffer request body', async () => { expect(await text()).toEqual(expectedBody) }) +it('supports Readable as the request body', async () => { + const req = http.request(httpServer.http.url('/resource'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + const input = ['hello', ' ', 'world', null] + const readable = new Readable({ + read: async function() { + await sleep(10) + this.push(input.shift()) + }, + }) + + readable.pipe(req) + + const { text } = await waitForClientRequest(req) + const expectedBody = 'hello world' + + expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) + expect(await text()).toEqual(expectedBody) +}) + it('calls the callback when writing an empty string', async () => { const request = http.request(httpServer.http.url('/resource'), { method: 'POST', 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 1706aa2b..09b8b1dd 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -10,8 +10,6 @@ let requests: Array = [] const interceptor = new ClientRequestInterceptor() interceptor.on('request', ({ request }) => { - console.log('REQUEST', request.method, request.url) - requests.push(request) request.respondWith(new Response()) }) From 3348dd78fbecc874758094ff3122aec7de44b291 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 30 Mar 2024 12:08:51 +0100 Subject: [PATCH 54/69] fix(ClientRequest): spread object args to support "follow-redirects" (#541) Co-authored-by: Michael Solomon --- .../ClientRequest/utils/normalizeClientRequestArgs.test.ts | 4 ++-- .../ClientRequest/utils/normalizeClientRequestArgs.ts | 2 +- test/third-party/follow-redirect-http.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts index 3e4da1b9..3080c7aa 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts @@ -274,7 +274,7 @@ it('handles [RequestOptions, callback] input', () => { expect(url.href).toEqual('https://mswjs.io/resource') // Request options must be preserved. - expect(options).toEqual(initialOptions) + expect(options).toMatchObject(initialOptions) // Callback must be preserved. expect(callback).toBeTypeOf('function') @@ -320,7 +320,7 @@ it('handles [PartialRequestOptions, callback] input', () => { ) // Request options must be preserved. - expect(options).toEqual(initialOptions) + expect(options).toMatchObject(initialOptions) // Options protocol must be inferred from the request issuing module. expect(options.protocol).toEqual('https:') diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts index de0ff7d1..b8d4c750 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts @@ -210,7 +210,7 @@ export function normalizeClientRequestArgs( // Handle a given "RequestOptions" object as-is // and derive the URL instance from it. else if (isObject(args[0])) { - options = args[0] as any + options = { ... args[0] as any } logger.info('first argument is RequestOptions:', options) // When handling a "RequestOptions" object without an explicit "protocol", diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index ae329de1..f26bef11 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -40,7 +40,7 @@ afterAll(async () => { await server.close() }) -it.skip('intercepts a POST request issued by "follow-redirects"', async () => { +it('intercepts a POST request issued by "follow-redirects"', async () => { const { address } = server.https const payload = JSON.stringify({ todo: 'Buy the milk' }) From 92343e64d6fcdf4737d93056ed8514f80ba7b172 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 31 Mar 2024 23:27:45 +0200 Subject: [PATCH 55/69] fix(MockHttpSocket): rely on internal request id header name --- src/interceptors/ClientRequest/MockHttpSocket.ts | 12 ++++++++++-- test/features/events/request.test.ts | 14 ++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 42e1679a..7a28b76a 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -7,6 +7,7 @@ import { import { STATUS_CODES } 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 { NormalizedWriteArgs } from '../Socket/utils/normalizeWriteArgs' import { isPropertyAccessible } from '../../utils/isPropertyAccessible' @@ -145,6 +146,8 @@ export class MockHttpSocket extends MockSocket { const requestHeaders = getRawFetchHeaders(this.request!.headers) || this.request!.headers const requestHeadersString = Array.from(requestHeaders.entries()) + // Skip the internal request ID deduplication header. + .filter(([name]) => name !== INTERNAL_REQUEST_ID_HEADER_NAME) .map(([name, value]) => `${name}: ${value}`) .join('\r\n') @@ -357,12 +360,17 @@ export class MockHttpSocket extends MockSocket { Reflect.set(this.request, kRequestId, requestId) + // 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('x-request-id')) { + if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) { this.passthrough() return } @@ -426,7 +434,7 @@ export class MockHttpSocket extends MockSocket { * to figure out if one interceptor has been invoked within another. * @see https://github.com/mswjs/interceptors/issues/378 */ - if (this.request.headers.has('x-request-id')) { + if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) { return } diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 13809157..6cd94f36 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -80,10 +80,16 @@ it('XMLHttpRequest: emits the "request" event upon the request', async () => { }) /** - * @note There are two "request" events emitted because XMLHttpRequest - * is polyfilled by "http.ClientRequest" in JSDOM. When this request gets - * bypassed by XMLHttpRequest interceptor, JSDOM constructs "http.ClientRequest" - * to perform it as-is. This issues an additional OPTIONS request first. + * @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. */ expect(requestListener).toHaveBeenCalledTimes(2) From 824998bb67afedc5428a325cc7459681163d872f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 12 Apr 2024 15:26:07 -0600 Subject: [PATCH 56/69] fix(MockHttpSocket): exhaust .write() callbacks for mocked requests (#542) --- package.json | 2 +- .../ClientRequest/MockHttpSocket.ts | 23 ++++- src/interceptors/Socket/MockSocket.test.ts | 86 +++++++++++++++---- src/interceptors/Socket/MockSocket.ts | 13 ++- .../http/compliance/http-req-write.test.ts | 70 ++++++++++++--- 5 files changed, 155 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 04ba3578..ed100691 100644 --- a/package.json +++ b/package.json @@ -186,4 +186,4 @@ "path": "./node_modules/cz-conventional-changelog" } } -} \ No newline at end of file +} diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 7a28b76a..d925a603 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -125,7 +125,8 @@ export class MockHttpSocket extends MockSocket { public passthrough(): void { const socket = this.createConnection() - // Write the buffered request body chunks. + // 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: NormalizedWriteArgs | undefined @@ -190,6 +191,7 @@ export class MockHttpSocket extends MockSocket { .on('prefinish', () => this.emit('prefinish')) .on('finish', () => this.emit('finish')) .on('close', (hadError) => this.emit('close', hadError)) + .on('end', () => this.emit('end')) } /** @@ -208,6 +210,10 @@ export class MockHttpSocket extends MockSocket { this.mockConnect() this.responseType = '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() + const httpHeaders: Array = [] httpHeaders.push( @@ -305,6 +311,13 @@ export class MockHttpSocket extends MockSocket { } } + private flushWriteBuffer(): void { + let args: NormalizedWriteArgs | undefined + while ((args = this.writeBuffer.shift())) { + args?.[2]?.() + } + } + private onRequestStart: RequestHeadersCompleteCallback = ( versionMajor, versionMinor, @@ -344,7 +357,13 @@ export class MockHttpSocket extends MockSocket { * used as the actual request body (the stream calls "read()"). * We control the queue in the onRequestBody/End functions. */ - read: () => {}, + read: () => { + // If the user attempts to read the request body, + // flush the write buffer to trigger the callbacks. + // This way, if the request stream ends in the write callback, + // it will indeed end correctly. + this.flushWriteBuffer() + }, }) } diff --git a/src/interceptors/Socket/MockSocket.test.ts b/src/interceptors/Socket/MockSocket.test.ts index 2ce91a0c..61235cf3 100644 --- a/src/interceptors/Socket/MockSocket.test.ts +++ b/src/interceptors/Socket/MockSocket.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { Socket } from 'node:net' import { vi, it, expect } from 'vitest' import { MockSocket } from './MockSocket' @@ -117,6 +118,20 @@ it('calls the "write" on "socket.end()" without any arguments', () => { 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({ @@ -150,43 +165,74 @@ it('calls the "read" on "socket.read(null)"', () => { expect(readCallback).toHaveBeenCalledWith(null, undefined) }) -it('updates the readable/writable state on "socket.end()"', async () => { +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) - expect(socket.readable).toBe(true) - expect(socket.readableEnded).toBe(false) socket.write('hello') + // Finish the writable stream. socket.end() expect(socket.writable).toBe(false) expect(socket.writableEnded).toBe(true) - expect(socket.readable).toBe(true) + // The "finish" event is emitted when writable is done. + // I.e. "socket.end()" is called. await vi.waitFor(() => { - socket.once('finish', () => { - expect(socket.writableFinished).toBe(true) - }) + 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(() => { - socket.once('end', () => { - expect(socket.readableEnded).toBe(true) - }) + 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) @@ -198,17 +244,21 @@ it('updates the readable/writable state on "socket.destroy()"', async () => { 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(() => { - socket.once('finish', () => { - expect(socket.writableFinished).toBe(true) - }) + expect(closeListener).toHaveBeenCalledTimes(1) }) - await vi.waitFor(() => { - socket.once('end', () => { - expect(socket.readableEnded).toBe(true) - }) - }) + // 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 index 3dc31fe7..4aff9f26 100644 --- a/src/interceptors/Socket/MockSocket.ts +++ b/src/interceptors/Socket/MockSocket.ts @@ -22,6 +22,10 @@ export class MockSocket extends net.Socket { super() this.connecting = false this.connect() + + this._final = (callback) => { + callback(null) + } } public connect() { @@ -46,13 +50,6 @@ export class MockSocket extends net.Socket { public push(chunk: any, encoding?: BufferEncoding): boolean { this.options.read(chunk, encoding) - - if (chunk !== null) { - this.emit('data', chunk) - } else { - this.emit('end') - } - - return true + return super.push(chunk, encoding) } } diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 5335ec9b..80fec0f5 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -95,7 +95,7 @@ it('writes Buffer request body', async () => { }) it('supports Readable as the request body', async () => { - const req = http.request(httpServer.http.url('/resource'), { + const request = http.request(httpServer.http.url('/resource'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -104,22 +104,19 @@ it('supports Readable as the request body', async () => { const input = ['hello', ' ', 'world', null] const readable = new Readable({ - read: async function() { + read: async function () { await sleep(10) this.push(input.shift()) }, }) - readable.pipe(req) - - const { text } = await waitForClientRequest(req) - const expectedBody = 'hello world' + readable.pipe(request) - expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(await text()).toEqual(expectedBody) + await waitForClientRequest(request) + expect(interceptedRequestBody).toHaveBeenCalledWith('hello world') }) -it('calls the callback when writing an empty string', async () => { +it('calls the write callback when writing an empty string', async () => { const request = http.request(httpServer.http.url('/resource'), { method: 'POST', }) @@ -132,7 +129,7 @@ it('calls the callback when writing an empty string', async () => { expect(writeCallback).toHaveBeenCalledTimes(1) }) -it('calls the callback when writing an empty Buffer', async () => { +it('calls the write callback when writing an empty Buffer', async () => { const request = http.request(httpServer.http.url('/resource'), { method: 'POST', }) @@ -145,3 +142,56 @@ it('calls the callback when writing an empty Buffer', async () => { expect(writeCallback).toHaveBeenCalledTimes(1) }) + +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) +}) + +it('emits "finish" for a mocked request', async () => { + interceptor.once('request', ({ request }) => { + request.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) +}) + +it('calls all write callbacks before the mocked response', async () => { + const requestBodyCallback = vi.fn() + interceptor.once('request', async ({ request }) => { + requestBodyCallback(await request.text()) + request.respondWith(new Response('hello world')) + }) + + const request = http.request(httpServer.http.url('/resource'), { + method: 'POST', + }) + request.write('one', () => { + console.log('write callback!') + request.end() + }) + + const { text } = await waitForClientRequest(request) + + expect(requestBodyCallback).toHaveBeenCalledWith('one') + expect(await text()).toBe('hello world') +}) From 66c60465f888a6b57ae434e0ae0c2c978c83aad4 Mon Sep 17 00:00:00 2001 From: Michael Solomon Date: Tue, 16 Apr 2024 18:37:58 +0300 Subject: [PATCH 57/69] fix: add "address()" on mock Socket (#549) Co-authored-by: Artem Zakharchenko --- .../ClientRequest/MockHttpSocket.ts | 20 ++++++++- test/modules/http/compliance/http.test.ts | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index d925a603..66696563 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -124,6 +124,7 @@ export class MockHttpSocket extends MockSocket { */ public passthrough(): void { const socket = this.createConnection() + this.address = socket.address.bind(socket) // Flush the buffered "socket.write()" calls onto // the original socket instance (i.e. write request body). @@ -218,7 +219,9 @@ export class MockHttpSocket extends MockSocket { httpHeaders.push( Buffer.from( - `HTTP/1.1 ${response.status} ${response.statusText || STATUS_CODES[response.status]}\r\n` + `HTTP/1.1 ${response.status} ${ + response.statusText || STATUS_CODES[response.status] + }\r\n` ) ) @@ -298,7 +301,20 @@ export class MockHttpSocket extends MockSocket { } private mockConnect(): void { - this.emit('lookup', null, '::1', 6, this.connectionOptions.host) + const addressInfo = { + address: '127.0.0.1', + family: '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') diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index b453d42d..42e29d22 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -163,3 +163,45 @@ it('mocks response to a non-existing host', async () => { expect(await text()).toBe('howdy, john') expect(requestListener).toHaveBeenCalledTimes(1) }) + +it('returns socket address for a mocked request', async () => { + interceptor.on('request', async ({ request }) => { + request.respondWith(new Response()) + }) + + const addressPromise = new DeferredPromise() + const request = http.get('http://example.com') + request.once('socket', (socket) => { + socket.once('connect', () => { + addressPromise.resolve(socket.address()) + }) + }) + + await expect(addressPromise).resolves.toEqual({ + address: '127.0.0.1', + family: 'IPv4', + port: 80, + }) +}) + +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', () => { + addressPromise.resolve(socket.address()) + }) + }) + + await waitForClientRequest(request) + + await expect(addressPromise).resolves.toEqual({ + address: httpServer.http.address.host, + family: 'IPv4', + /** + * @fixme Looks like every "http" request has an agent set. + * That agent, for some reason, wants to connect to a different port. + */ + port: expect.any(Number), + }) +}) From ab8656e92e2a2bb57ef6581f8d8204ba37848afb Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Apr 2024 18:42:10 +0200 Subject: [PATCH 58/69] docs: edit the algorithms section --- README.md | 95 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 56 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8bc5adb5..166fda0a 100644 --- a/README.md +++ b/README.md @@ -11,81 +11,98 @@ This library supports intercepting the following protocols: ## Motivation -While there are a lot of network communication mocking libraries, they tend to use request interception as an implementation detail, giving you a high-level API that includes request matching, timeouts, retries, and so forth. +While there are a lot of network mocking libraries, they tend to use request interception as an implementation detail, giving you a high-level API that includes request matching, timeouts, recording, and so forth. -This library is a strip-to-bone implementation that provides as little abstraction as possible to execute arbitrary logic upon any request. It's primarily designed as an underlying component for high-level API mocking solutions such as [Mock Service Worker](https://github.com/mswjs/msw). +This library is a barebones implementation that provides as little abstraction as possible to execute arbitrary logic upon any request. It's primarily designed as an underlying component for high-level API mocking solutions such as [Mock Service Worker](https://github.com/mswjs/msw). ### How is this library different? A traditional API mocking implementation in Node.js looks roughly like this: ```js -import http from 'http' - -function applyMock() { - // Store the original request module. - const originalHttpRequest = http.request - - // Rewrite the request module entirely. - http.request = function (...args) { - // Decide whether to handle this request before - // the actual request happens. - if (shouldMock(args)) { - // If so, never create a request, respond to it - // using the mocked response from this blackbox. - return coerceToResponse.bind(this, mock) - } - - // Otherwise, construct the original request - // and perform it as-is (receives the original response). - return originalHttpRequest(...args) +import http from 'node:http' + +// Store the original request function. +const originalHttpRequest = http.request + +// Override the request function entirely. +http.request = function (...args) { + // Decide if the outgoing request matches a predicate. + if (predicate(args)) { + // If it does, never create a request, respond to it + // using the mocked response from this blackbox. + return coerceToResponse.bind(this, mock) } + + // Otherwise, construct the original request + // and perform it as-is. + return originalHttpRequest(...args) } ``` -This library deviates from such implementation and uses _class extensions_ instead of module rewrites. Such deviation is necessary because, unlike other solutions that include request matching and can determine whether to mock requests _before_ they actually happen, this library is not opinionated about the mocked/bypassed nature of the requests. Instead, it _intercepts all requests_ and delegates the decision of mocking to the end consumer. +The core philosophy of Interceptors is to _run as much of the underlying network code as possible_. Strange for a network mocking library, isn't it? Turns out, respecting the system's integrity and executing more of the network code leads to more resilient tests and also helps to uncover bugs in the code that would otherwise go unnoticed. + +Interceptors heavily rely on _class extension_ instead of function and module overrides. By extending the native network code, it can surgically insert the interception and mocking pieces only where necessary, leaving the rest of the system intact. ```js -class NodeClientRequest extends ClientRequest { - async end(...args) { - // Check if there's a mocked response for this request. - // You control this in the "resolver" function. - const mockedResponse = await resolver(request) - - // If there is a mocked response, use it to respond to this - // request, finalizing it afterward as if it received that - // response from the actual server it connected to. +class XMLHttpRequestProxy extends XMLHttpRequest { + async send() { + // Call the request listeners and see if any of them + // returns a mocked response for this request. + const mockedResponse = await waitForRequestListeners({ request }) + + // If there is a mocked response, use it. This actually + // transitions the XMLHttpRequest instance into the correct + // response state (below is a simplified illustration). if (mockedResponse) { - this.respondWith(mockedResponse) - this.finish() + // Handle the response headers. + this.request.status = mockedResponse.status + this.request.statusText = mockedResponse.statusText + this.request.responseUrl = mockedResponse.url + this.readyState = 2 + this.trigger('readystatechange') + + // Start streaming the response body. + this.trigger('loadstart') + this.readyState = 3 + this.trigger('readystatechange') + await streamResponseBody(mockedResponse) + + // Finish the response. + this.trigger('load') + this.trigger('loadend') + this.readyState = 4 return } - // Otherwise, perform the original "ClientRequest.prototype.end" call. - return super.end(...args) + // Otherwise, perform the original "XMLHttpRequest.prototype.send" call. + return super.send(...args) } } ``` -By extending the native modules, this library actually constructs requests as soon as they are constructed by the consumer. This enables all the request input validation and transformations done natively by Node.js—something that traditional solutions simply cannot do (they replace `http.ClientRequest` entirely). The class extension allows to fully utilize Node.js internals instead of polyfilling them, which results in more resilient mocks. +> The request interception algorithms differ dramatically based on the request API. Interceptors acommodate for them all, bringing the intercepted requests to a common ground—the Fetch API `Request` instance. The same applies for responses, where a Fetch API `Response` instance is translated to the appropriate response format. + +This library aims to provide _full specification compliance_ with the APIs and protocols it extends. ## What this library does -This library extends (or patches, where applicable) the following native modules: +This library extends the following native modules: - `http.get`/`http.request` - `https.get`/`https.request` - `XMLHttpRequest` - `fetch` +- `WebSocket` Once extended, it intercepts and normalizes all requests to the Fetch API `Request` instances. This way, no matter the request source (`http.ClientRequest`, `XMLHttpRequest`, `window.Request`, etc), you always get a specification-compliant request instance to work with. -You can respond to the intercepted request by constructing a Fetch API Response instance. Instead of designing custom abstractions, this library respects the Fetch API specification and takes the responsibility to coerce a single response declaration to the appropriate response formats based on the request-issuing modules (like `http.OutgoingMessage` to respond to `http.ClientRequest`, or updating `XMLHttpRequest` response-related properties). +You can respond to the intercepted HTTP request by constructing a Fetch API Response instance. Instead of designing custom abstractions, this library respects the Fetch API specification and takes the responsibility to coerce a single response declaration to the appropriate response formats based on the request-issuing modules (like `http.OutgoingMessage` to respond to `http.ClientRequest`, or updating `XMLHttpRequest` response-related properties). ## What this library doesn't do - Does **not** provide any request matching logic; -- Does **not** decide how to handle requests. +- Does **not** handle requests by default. ## Getting started From 085d1ec2fd709cf62b97bf4996475c83419fc5c4 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Apr 2024 18:47:04 +0200 Subject: [PATCH 59/69] fix(MockHttpSocket): handle response stream errors (#548) --- .../ClientRequest/MockHttpSocket.ts | 52 ++++--- src/interceptors/ClientRequest/index.ts | 23 ++- .../http/compliance/http-req-write.test.ts | 1 - .../http-response-readable-stream.test.ts | 145 ++++++++++++++++++ .../http/response/readable-stream.test.ts | 78 ---------- test/third-party/miniflare.test.ts | 24 +-- 6 files changed, 212 insertions(+), 111 deletions(-) create mode 100644 test/modules/http/response/http-response-readable-stream.test.ts delete mode 100644 test/modules/http/response/readable-stream.test.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 66696563..bdc13586 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -14,7 +14,10 @@ import { isPropertyAccessible } from '../../utils/isPropertyAccessible' import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' import { parseRawHeaders } from '../Socket/utils/parseRawHeaders' import { getRawFetchHeaders } from '../../utils/getRawFetchHeaders' -import { RESPONSE_STATUS_CODES_WITHOUT_BODY } from '../../utils/responseUtils' +import { + createServerErrorResponse, + RESPONSE_STATUS_CODES_WITHOUT_BODY, +} from '../../utils/responseUtils' import { createRequestId } from '../../createRequestId' type HttpConnectionOptions = any @@ -248,34 +251,41 @@ export class MockHttpSocket extends MockSocket { } if (response.body) { - const reader = response.body.getReader() - - while (true) { - const { done, value } = await reader.read() - - if (done) { - break - } - - // Flush the headers upon the first chunk in the stream. - // This ensures the consumer will start receiving the response - // as it streams in (subsequent chunks are pushed). - if (httpHeaders.length > 0) { - flushHeaders(value) - continue + try { + const reader = response.body.getReader() + + while (true) { + const { done, value } = await reader.read() + + if (done) { + break + } + + // Flush the headers upon the first chunk in the stream. + // This ensures the consumer will start receiving the response + // as it streams in (subsequent chunks are pushed). + if (httpHeaders.length > 0) { + flushHeaders(value) + continue + } + + // Subsequent body chukns are push to the stream. + this.push(value) } + } catch (error) { + // Coerce response stream errors to 500 responses. + // Don't flush the original response headers because + // unhandled errors translate to 500 error responses forcefully. + this.respondWith(createServerErrorResponse(error)) - // Subsequent body chukns are push to the stream. - this.push(value) + return } } // If the headers were not flushed up to this point, // this means the response either had no body or had // an empty body stream. Flush the headers. - if (httpHeaders.length > 0) { - flushHeaders() - } + flushHeaders() // Close the socket if the connection wasn't marked as keep-alive. if (!this.shouldKeepAlive) { diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index a26b7d21..1e054182 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -12,6 +12,8 @@ import { MockAgent, MockHttpsAgent } from './agents' import { emitAsync } from '../../utils/emitAsync' import { toInteractiveRequest } from '../../utils/toInteractiveRequest' import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' +import { isNodeLikeError } from '../../utils/isNodeLikeError' +import { createServerErrorResponse } from '../../utils/responseUtils' export class ClientRequestInterceptor extends Interceptor { static symbol = Symbol('client-request-interceptor') @@ -144,13 +146,32 @@ export class ClientRequestInterceptor extends Interceptor { }) if (listenerResult.error) { - socket.errorWith(listenerResult.error) + // Treat thrown Responses as mocked responses. + if (listenerResult.error instanceof Response) { + socket.respondWith(listenerResult.error) + return + } + + // Allow mocking Node-like errors. + if (isNodeLikeError(listenerResult.error)) { + socket.errorWith(listenerResult.error) + return + } + + // Unhandled exceptions in the request listeners are + // synonymous to unhandled exceptions on the server. + // Those are represented as 500 error responses. + socket.respondWith(createServerErrorResponse(listenerResult.error)) return } const mockedResponse = listenerResult.data if (mockedResponse) { + /** + * @note The `.respondWith()` method will handle "Response.error()". + * Maybe we should make all interceptors do that? + */ socket.respondWith(mockedResponse) return } diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 80fec0f5..8eb64c48 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -186,7 +186,6 @@ it('calls all write callbacks before the mocked response', async () => { method: 'POST', }) request.write('one', () => { - console.log('write callback!') request.end() }) diff --git a/test/modules/http/response/http-response-readable-stream.test.ts b/test/modules/http/response/http-response-readable-stream.test.ts new file mode 100644 index 00000000..9aa330e9 --- /dev/null +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -0,0 +1,145 @@ +/** + * @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 { sleep, waitForClientRequest } from '../../../helpers' + +type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> + +const encoder = new TextEncoder() + +const interceptor = new ClientRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() +}) + +it('supports ReadableStream as a mocked response', async () => { + const encoder = new TextEncoder() + interceptor.once('request', ({ request }) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('hello')) + controller.enqueue(encoder.encode(' ')) + controller.enqueue(encoder.encode('world')) + controller.close() + }, + }) + request.respondWith(new Response(stream)) + }) + + const request = http.get('http://example.com/resource') + const { text } = await waitForClientRequest(request) + expect(await text()).toBe('hello world') +}) + +it('supports delays when enqueuing chunks', async () => { + interceptor.once('request', ({ request }) => { + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('first')) + await sleep(200) + + controller.enqueue(encoder.encode('second')) + await sleep(200) + + controller.enqueue(encoder.encode('third')) + await sleep(200) + + controller.close() + }, + }) + + request.respondWith( + new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + }, + }) + ) + }) + + const responseChunksPromise = new DeferredPromise() + + const request = https.get('https://api.example.com/stream', (response) => { + const chunks: ResponseChunks = [] + + response + .on('data', (data) => { + chunks.push({ + buffer: Buffer.from(data), + timestamp: performance.now(), + }) + }) + .on('end', () => { + responseChunksPromise.resolve(chunks) + }) + .on('error', responseChunksPromise.reject) + }) + + request.on('error', responseChunksPromise.reject) + + const responseChunks = await responseChunksPromise + const textChunks = responseChunks.map((chunk) => { + return chunk.buffer.toString('utf8') + }) + expect(textChunks).toEqual(['first', 'second', '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) +}) + +it('forwards ReadableStream errors to the request', async () => { + const requestErrorListener = vi.fn() + const responseErrorListener = vi.fn() + + interceptor.once('request', ({ request }) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('original')) + queueMicrotask(() => { + controller.error(new Error('stream error')) + }) + }, + }) + request.respondWith(new Response(stream)) + }) + + const request = http.get('http://localhost/resource') + request.on('error', requestErrorListener) + request.on('response', (response) => { + response.on('error', responseErrorListener) + }) + + const response = await vi.waitFor(() => { + return new Promise((resolve) => { + request.on('response', resolve) + }) + }) + + // Response stream errors are translated to unhandled exceptions, + // and then the server decides how to handle them. This is often + // done as returning a 500 response. + expect(response.statusCode).toBe(500) + expect(response.statusMessage).toBe('Unhandled Exception') + + // Response stream errors are not request errors. + expect(requestErrorListener).not.toHaveBeenCalled() + expect(request.destroyed).toBe(false) +}) diff --git a/test/modules/http/response/readable-stream.test.ts b/test/modules/http/response/readable-stream.test.ts deleted file mode 100644 index d3b99af9..00000000 --- a/test/modules/http/response/readable-stream.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { performance } from 'node:perf_hooks' -import { it, expect, beforeAll, afterAll } from 'vitest' -import https from 'node:https' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { sleep } from '../../../helpers' - -type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> - -const encoder = new TextEncoder() - -const interceptor = new ClientRequestInterceptor() -interceptor.on('request', ({ request }) => { - const stream = new ReadableStream({ - async start(controller) { - controller.enqueue(encoder.encode('first')) - await sleep(200) - - controller.enqueue(encoder.encode('second')) - await sleep(200) - - controller.enqueue(encoder.encode('third')) - await sleep(200) - - controller.close() - }, - }) - - request.respondWith( - new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - }, - }) - ) -}) - -beforeAll(async () => { - interceptor.apply() -}) - -afterAll(async () => { - interceptor.dispose() -}) - -it('supports delays when enqueuing chunks', async () => { - const responseChunksPromise = new DeferredPromise() - - const request = https.get('https://api.example.com/stream', (response) => { - const chunks: ResponseChunks = [] - - response - .on('data', (data) => { - chunks.push({ - buffer: Buffer.from(data), - timestamp: performance.now(), - }) - }) - .on('end', () => { - responseChunksPromise.resolve(chunks) - }) - .on('error', responseChunksPromise.reject) - }) - - request.on('error', responseChunksPromise.reject) - - const responseChunks = await responseChunksPromise - const textChunks = responseChunks.map((chunk) => { - return chunk.buffer.toString('utf8') - }) - expect(textChunks).toEqual(['first', 'second', '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) -}) diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index 19938ae7..25977625 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -15,17 +15,12 @@ const interceptor = new BatchInterceptor({ ], }) -const requestListener = vi.fn().mockImplementation(({ request }) => { - request.respondWith(new Response('mocked-body')) -}) - -interceptor.on('request', requestListener) - beforeAll(() => { interceptor.apply() }) afterEach(() => { + interceptor.removeAllListeners() vi.clearAllMocks() }) @@ -34,26 +29,35 @@ afterAll(() => { }) test('responds to fetch', async () => { + interceptor.once('request', ({ request }) => { + request.respondWith(new Response('mocked-body')) + }) + const response = await fetch('https://example.com') expect(response.status).toEqual(200) expect(await response.text()).toEqual('mocked-body') - expect(requestListener).toHaveBeenCalledTimes(1) }) test('responds to http.get', async () => { + interceptor.once('request', ({ request }) => { + request.respondWith(new Response('mocked-body')) + }) + const { resBody } = await httpGet('http://example.com') expect(resBody).toEqual('mocked-body') - expect(requestListener).toHaveBeenCalledTimes(1) }) test('responds to https.get', async () => { + interceptor.once('request', ({ request }) => { + request.respondWith(new Response('mocked-body')) + }) + const { resBody } = await httpsGet('https://example.com') expect(resBody).toEqual('mocked-body') - expect(requestListener).toHaveBeenCalledTimes(1) }) test('throws when responding with a network error', async () => { - requestListener.mockImplementationOnce(({ request }) => { + interceptor.once('request', ({ request }) => { /** * @note "Response.error()" static method is NOT implemented in Miniflare. * This expression will throw. From 430c65ed0d84dcb50153497c25df2bf6680b4b27 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Apr 2024 23:04:02 +0200 Subject: [PATCH 60/69] fix(MockHttpSocket): forward tls socket properties (#556) --- .../ClientRequest/MockHttpSocket.ts | 37 ++++++++++- .../http/compliance/http-ssl-socket.test.ts | 66 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 test/modules/http/compliance/http-ssl-socket.test.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index bdc13586..8bbb0716 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -169,6 +169,28 @@ export class MockHttpSocket extends MockSocket { } } + // 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', + ] + + 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', () => { @@ -331,9 +353,22 @@ export class MockHttpSocket extends MockSocket { if (this.baseUrl.protocol === 'https:') { this.emit('secure') this.emit('secureConnect') + // A single TLS connection is represented by two "session" events. - this.emit('session', Buffer.from('mock-session-renegotiate')) + this.emit( + 'session', + this.connectionOptions.session || + Buffer.from('mock-session-renegotiate') + ) this.emit('session', Buffer.from('mock-session-resume')) + + 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) } } diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts new file mode 100644 index 00000000..adf096a6 --- /dev/null +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -0,0 +1,66 @@ +/** + * @vitest-environment node + */ +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' + +const interceptor = new ClientRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('emits a correct TLS Socket instance for a handled HTTPS request', async () => { + interceptor.on('request', ({ request }) => { + request.respondWith(new Response('hello world')) + }) + + const request = https.get('https://example.com') + const socketPromise = new DeferredPromise() + request.on('socket', (socket) => { + socket.on('connect', () => socketPromise.resolve(socket as TLSSocket)) + }) + + const socket = await socketPromise + + // Must be a TLS socket. + expect(socket.encrypted).toBe(true) + // The server certificate wasn't signed by one of the CA + // specified in the Socket constructor. + expect(socket.authorized).toBe(false) + + expect(socket.getSession()).toBeUndefined() + expect(socket.getProtocol()).toBe('TLSv1.3') + expect(socket.isSessionReused()).toBe(false) +}) + +it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () => { + const request = https.get('https://example.com') + const socketPromise = new DeferredPromise() + request.on('socket', (socket) => { + socket.on('connect', () => socketPromise.resolve(socket as TLSSocket)) + }) + + const socket = await socketPromise + + // Must be a TLS socket. + expect(socket.encrypted).toBe(true) + // The server certificate wasn't signed by one of the CA + // specified in the Socket constructor. + expect(socket.authorized).toBe(false) + + expect(socket.getSession()).toBeUndefined() + expect(socket.getProtocol()).toBe('TLSv1.3') + expect(socket.isSessionReused()).toBe(false) +}) From fc736365dffc2bb60d541e6890596d9249a89ad6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 18 Apr 2024 11:11:44 +0200 Subject: [PATCH 61/69] chore: clean up ClientRequest utils (#557) --- .../ClientRequest/MockHttpSocket.ts | 8 +- .../utils/cloneIncomingMessage.test.ts | 26 ------- .../utils/cloneIncomingMessage.ts | 74 ------------------- .../utils/createResponse.test.ts | 53 ------------- .../ClientRequest/utils/createResponse.ts | 55 -------------- .../normalizeClientRequestEndArgs.test.ts | 41 ---------- .../utils/normalizeClientRequestEndArgs.ts | 53 ------------- .../normalizeClientRequestWriteArgs.test.ts | 36 --------- .../utils/normalizeClientRequestWriteArgs.ts | 39 ---------- src/interceptors/Socket/MockSocket.ts | 12 ++- .../utils/normalizeSocketWriteArgs.test.ts | 52 +++++++++++++ ...iteArgs.ts => normalizeSocketWriteArgs.ts} | 8 +- 12 files changed, 69 insertions(+), 388 deletions(-) delete mode 100644 src/interceptors/ClientRequest/utils/cloneIncomingMessage.test.ts delete mode 100644 src/interceptors/ClientRequest/utils/cloneIncomingMessage.ts delete mode 100644 src/interceptors/ClientRequest/utils/createResponse.test.ts delete mode 100644 src/interceptors/ClientRequest/utils/createResponse.ts delete mode 100644 src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.test.ts delete mode 100644 src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts delete mode 100644 src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.test.ts delete mode 100644 src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.ts create mode 100644 src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts rename src/interceptors/Socket/utils/{normalizeWriteArgs.ts => normalizeSocketWriteArgs.ts} (74%) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 8bbb0716..35a60d30 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -9,7 +9,7 @@ import { Readable } from 'node:stream' import { invariant } from 'outvariant' import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { MockSocket } from '../Socket/MockSocket' -import type { NormalizedWriteArgs } from '../Socket/utils/normalizeWriteArgs' +import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs' import { isPropertyAccessible } from '../../utils/isPropertyAccessible' import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' import { parseRawHeaders } from '../Socket/utils/parseRawHeaders' @@ -53,7 +53,7 @@ export class MockHttpSocket extends MockSocket { private onRequest: MockHttpSocketRequestCallback private onResponse: MockHttpSocketResponseCallback - private writeBuffer: Array = [] + private writeBuffer: Array = [] private request?: Request private requestParser: HTTPParser<0> private requestStream?: Readable @@ -133,7 +133,7 @@ export class MockHttpSocket extends MockSocket { // the original socket instance (i.e. write request body). // Exhaust the "requestBuffer" in case this Socket // gets reused for different requests. - let writeArgs: NormalizedWriteArgs | undefined + let writeArgs: NormalizedSocketWriteArgs | undefined let headersWritten = false while ((writeArgs = this.writeBuffer.shift())) { @@ -373,7 +373,7 @@ export class MockHttpSocket extends MockSocket { } private flushWriteBuffer(): void { - let args: NormalizedWriteArgs | undefined + let args: NormalizedSocketWriteArgs | undefined while ((args = this.writeBuffer.shift())) { args?.[2]?.() } diff --git a/src/interceptors/ClientRequest/utils/cloneIncomingMessage.test.ts b/src/interceptors/ClientRequest/utils/cloneIncomingMessage.test.ts deleted file mode 100644 index bfd473bc..00000000 --- a/src/interceptors/ClientRequest/utils/cloneIncomingMessage.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { it, expect } from 'vitest' -import { Socket } from 'net' -import { IncomingMessage } from 'http' -import { Stream, Readable, EventEmitter } from 'stream' -import { cloneIncomingMessage, IS_CLONE } from './cloneIncomingMessage' - -it('clones a given IncomingMessage', () => { - const message = new IncomingMessage(new Socket()) - message.statusCode = 200 - message.statusMessage = 'OK' - message.headers = { 'x-powered-by': 'msw' } - const clone = cloneIncomingMessage(message) - - // Prototypes must be preserved. - expect(clone).toBeInstanceOf(IncomingMessage) - expect(clone).toBeInstanceOf(EventEmitter) - expect(clone).toBeInstanceOf(Stream) - expect(clone).toBeInstanceOf(Readable) - - expect(clone.statusCode).toEqual(200) - expect(clone.statusMessage).toEqual('OK') - expect(clone.headers).toHaveProperty('x-powered-by', 'msw') - - // Cloned IncomingMessage must be marked respectively. - expect(clone[IS_CLONE]).toEqual(true) -}) diff --git a/src/interceptors/ClientRequest/utils/cloneIncomingMessage.ts b/src/interceptors/ClientRequest/utils/cloneIncomingMessage.ts deleted file mode 100644 index 35b21acf..00000000 --- a/src/interceptors/ClientRequest/utils/cloneIncomingMessage.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { IncomingMessage } from 'http' -import { PassThrough } from 'stream' - -export const IS_CLONE = Symbol('isClone') - -export interface ClonedIncomingMessage extends IncomingMessage { - [IS_CLONE]: boolean -} - -/** - * Clones a given `http.IncomingMessage` instance. - */ -export function cloneIncomingMessage( - message: IncomingMessage -): ClonedIncomingMessage { - const clone = message.pipe(new PassThrough()) - - // Inherit all direct "IncomingMessage" properties. - inheritProperties(message, clone) - - // Deeply inherit the message prototypes (Readable, Stream, EventEmitter, etc.). - const clonedPrototype = Object.create(IncomingMessage.prototype) - getPrototypes(clone).forEach((prototype) => { - inheritProperties(prototype, clonedPrototype) - }) - Object.setPrototypeOf(clone, clonedPrototype) - - Object.defineProperty(clone, IS_CLONE, { - enumerable: true, - value: true, - }) - - return clone as unknown as ClonedIncomingMessage -} - -/** - * Returns a list of all prototypes the given object extends. - */ -function getPrototypes(source: object): object[] { - const prototypes: object[] = [] - let current = source - - while ((current = Object.getPrototypeOf(current))) { - prototypes.push(current) - } - - return prototypes -} - -/** - * Inherits a given target object properties and symbols - * onto the given source object. - * @param source Object which should acquire properties. - * @param target Object to inherit the properties from. - */ -function inheritProperties(source: object, target: object): void { - const properties = [ - ...Object.getOwnPropertyNames(source), - ...Object.getOwnPropertySymbols(source), - ] - - for (const property of properties) { - if (target.hasOwnProperty(property)) { - continue - } - - const descriptor = Object.getOwnPropertyDescriptor(source, property) - if (!descriptor) { - continue - } - - Object.defineProperty(target, property, descriptor) - } -} diff --git a/src/interceptors/ClientRequest/utils/createResponse.test.ts b/src/interceptors/ClientRequest/utils/createResponse.test.ts deleted file mode 100644 index 13bc8cfa..00000000 --- a/src/interceptors/ClientRequest/utils/createResponse.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { it, expect } from 'vitest' -import { Socket } from 'net' -import * as http from 'http' -import { createResponse } from './createResponse' -import { RESPONSE_STATUS_CODES_WITHOUT_BODY } from '../../../utils/responseUtils' - -it('creates a fetch api response from http incoming message', async () => { - const message = new http.IncomingMessage(new Socket()) - message.statusCode = 201 - message.statusMessage = 'Created' - message.headers['content-type'] = 'application/json' - - const response = createResponse(message) - - message.emit('data', Buffer.from('{"firstName":')) - message.emit('data', Buffer.from('"John"}')) - message.emit('end') - - expect(response.status).toBe(201) - expect(response.statusText).toBe('Created') - expect(response.headers.get('content-type')).toBe('application/json') - expect(await response.json()).toEqual({ firstName: 'John' }) -}) - -/** - * @note Ignore 1xx response status code because those cannot - * be used as the init to the "Response" constructor. - */ -const CONSTRUCTABLE_RESPONSE_STATUS_CODES = Array.from( - RESPONSE_STATUS_CODES_WITHOUT_BODY -).filter((status) => status >= 200) - -it.each(CONSTRUCTABLE_RESPONSE_STATUS_CODES)( - 'ignores message body for %i response status', - (responseStatus) => { - const message = new http.IncomingMessage(new Socket()) - message.statusCode = responseStatus - - const response = createResponse(message) - - // These chunks will be ignored: this response - // cannot have body. We don't forward this error to - // the consumer because it's us who converts the - // internal stream to a Fetch API Response instance. - // Consumers will rely on the Response API when constructing - // mocked responses. - message.emit('data', Buffer.from('hello')) - message.emit('end') - - expect(response.status).toBe(responseStatus) - expect(response.body).toBe(null) - } -) diff --git a/src/interceptors/ClientRequest/utils/createResponse.ts b/src/interceptors/ClientRequest/utils/createResponse.ts deleted file mode 100644 index cf55b3c6..00000000 --- a/src/interceptors/ClientRequest/utils/createResponse.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { IncomingHttpHeaders, IncomingMessage } from 'http' -import { isResponseWithoutBody } from '../../../utils/responseUtils' - -/** - * Creates a Fetch API `Response` instance from the given - * `http.IncomingMessage` instance. - */ -export function createResponse(message: IncomingMessage): Response { - const responseBodyOrNull = isResponseWithoutBody(message.statusCode || 200) - ? null - : new ReadableStream({ - start(controller) { - message.on('data', (chunk) => controller.enqueue(chunk)) - message.on('end', () => controller.close()) - - /** - * @todo Should also listen to the "error" on the message - * and forward it to the controller. Otherwise the stream - * will pend indefinitely. - */ - }, - }) - - return new Response(responseBodyOrNull, { - status: message.statusCode, - statusText: message.statusMessage, - headers: createHeadersFromIncomingHttpHeaders(message.headers), - }) -} - -function createHeadersFromIncomingHttpHeaders( - httpHeaders: IncomingHttpHeaders -): Headers { - const headers = new Headers() - - for (const headerName in httpHeaders) { - const headerValues = httpHeaders[headerName] - - if (typeof headerValues === 'undefined') { - continue - } - - if (Array.isArray(headerValues)) { - headerValues.forEach((headerValue) => { - headers.append(headerName, headerValue) - }) - - continue - } - - headers.set(headerName, headerValues) - } - - return headers -} diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.test.ts deleted file mode 100644 index 63f0bb56..00000000 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { it, expect } from 'vitest' -import { normalizeClientRequestEndArgs } from './normalizeClientRequestEndArgs' - -it('returns [null, null, cb] given only the callback', () => { - const callback = () => {} - expect(normalizeClientRequestEndArgs(callback)).toEqual([ - null, - null, - callback, - ]) -}) - -it('returns [chunk, null, null] given only the chunk', () => { - expect(normalizeClientRequestEndArgs('chunk')).toEqual(['chunk', null, null]) -}) - -it('returns [chunk, cb] given the chunk and the callback', () => { - const callback = () => {} - expect(normalizeClientRequestEndArgs('chunk', callback)).toEqual([ - 'chunk', - null, - callback, - ]) -}) - -it('returns [chunk, encoding] given the chunk with the encoding', () => { - expect(normalizeClientRequestEndArgs('chunk', 'utf8')).toEqual([ - 'chunk', - 'utf8', - null, - ]) -}) - -it('returns [chunk, encoding, cb] given all three arguments', () => { - const callback = () => {} - expect(normalizeClientRequestEndArgs('chunk', 'utf8', callback)).toEqual([ - 'chunk', - 'utf8', - callback, - ]) -}) diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts deleted file mode 100644 index 137b15d5..00000000 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Logger } from '@open-draft/logger' - -const logger = new Logger('utils getUrlByRequestOptions') - -export type ClientRequestEndChunk = string | Buffer -export type ClientRequestEndCallback = () => void - -type HttpRequestEndArgs = - | [] - | [ClientRequestEndCallback] - | [ClientRequestEndChunk, ClientRequestEndCallback?] - | [ClientRequestEndChunk, BufferEncoding, ClientRequestEndCallback?] - -type NormalizedHttpRequestEndParams = [ - ClientRequestEndChunk | null, - BufferEncoding | null, - ClientRequestEndCallback | null -] - -/** - * Normalizes a list of arguments given to the `ClientRequest.end()` - * method to always include `chunk`, `encoding`, and `callback`. - */ -export function normalizeClientRequestEndArgs( - ...args: HttpRequestEndArgs -): NormalizedHttpRequestEndParams { - logger.info('arguments', args) - const normalizedArgs = new Array(3) - .fill(null) - .map((value, index) => args[index] || value) - - normalizedArgs.sort((a, b) => { - // If first element is a function, move it rightwards. - if (typeof a === 'function') { - return 1 - } - - // If second element is a function, move the first leftwards. - if (typeof b === 'function') { - return -1 - } - - // If both elements are strings, preserve their original index. - if (typeof a === 'string' && typeof b === 'string') { - return normalizedArgs.indexOf(a) - normalizedArgs.indexOf(b) - } - - return 0 - }) - - logger.info('normalized args', normalizedArgs) - return normalizedArgs as NormalizedHttpRequestEndParams -} diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.test.ts deleted file mode 100644 index 00e7cd3d..00000000 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { it, expect } from 'vitest' -import { normalizeClientRequestWriteArgs } from './normalizeClientRequestWriteArgs' - -it('returns a triplet of null given no chunk, encoding, or callback', () => { - expect( - normalizeClientRequestWriteArgs([ - // @ts-ignore - undefined, - undefined, - undefined, - ]) - ).toEqual([undefined, undefined, undefined]) -}) - -it('returns [chunk, null, null] given only a chunk', () => { - expect(normalizeClientRequestWriteArgs(['chunk', undefined])).toEqual([ - 'chunk', - undefined, - undefined, - ]) -}) - -it('returns [chunk, encoding] given only chunk and encoding', () => { - expect(normalizeClientRequestWriteArgs(['chunk', 'utf8'])).toEqual([ - 'chunk', - 'utf8', - undefined, - ]) -}) - -it('returns [chunk, encoding, cb] given all three arguments', () => { - const callbackFn = () => {} - expect( - normalizeClientRequestWriteArgs(['chunk', 'utf8', callbackFn]) - ).toEqual(['chunk', 'utf8', callbackFn]) -}) diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.ts deleted file mode 100644 index 0fee9aaa..00000000 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestWriteArgs.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Logger } from '@open-draft/logger' - -const logger = new Logger('http normalizeWriteArgs') - -export type ClientRequestWriteCallback = (error?: Error | null) => void -export type ClientRequestWriteArgs = [ - chunk: string | Buffer, - encoding?: BufferEncoding | ClientRequestWriteCallback, - callback?: ClientRequestWriteCallback -] - -export type NormalizedClientRequestWriteArgs = [ - chunk: string | Buffer, - encoding?: BufferEncoding, - callback?: ClientRequestWriteCallback -] - -export function normalizeClientRequestWriteArgs( - args: ClientRequestWriteArgs -): NormalizedClientRequestWriteArgs { - logger.info('normalizing ClientRequest.write arguments...', args) - - const chunk = args[0] - const encoding = - typeof args[1] === 'string' ? (args[1] as BufferEncoding) : undefined - const callback = typeof args[1] === 'function' ? args[1] : args[2] - - const writeArgs: NormalizedClientRequestWriteArgs = [ - chunk, - encoding, - callback, - ] - logger.info( - 'successfully normalized ClientRequest.write arguments:', - writeArgs - ) - - return writeArgs -} diff --git a/src/interceptors/Socket/MockSocket.ts b/src/interceptors/Socket/MockSocket.ts index 4aff9f26..412961ed 100644 --- a/src/interceptors/Socket/MockSocket.ts +++ b/src/interceptors/Socket/MockSocket.ts @@ -1,9 +1,9 @@ import net from 'node:net' import { - normalizeWriteArgs, + normalizeSocketWriteArgs, type WriteArgs, type WriteCallback, -} from './utils/normalizeWriteArgs' +} from './utils/normalizeSocketWriteArgs' export interface MockSocketOptions { write: ( @@ -36,13 +36,17 @@ export class MockSocket extends net.Socket { } public write(...args: Array): boolean { - const [chunk, encoding, callback] = normalizeWriteArgs(args as WriteArgs) + const [chunk, encoding, callback] = normalizeSocketWriteArgs( + args as WriteArgs + ) this.options.write(chunk, encoding, callback) return true } public end(...args: Array) { - const [chunk, encoding, callback] = normalizeWriteArgs(args as WriteArgs) + const [chunk, encoding, callback] = normalizeSocketWriteArgs( + args as WriteArgs + ) this.options.write(chunk, encoding, callback) return super.end.apply(this, args as any) diff --git a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts new file mode 100644 index 00000000..32f2e1d5 --- /dev/null +++ b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { it, expect } from 'vitest' +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/normalizeWriteArgs.ts b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts similarity index 74% rename from src/interceptors/Socket/utils/normalizeWriteArgs.ts rename to src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts index 733060e8..03a3e9c0 100644 --- a/src/interceptors/Socket/utils/normalizeWriteArgs.ts +++ b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts @@ -4,7 +4,7 @@ export type WriteArgs = | [chunk: unknown, callback?: WriteCallback] | [chunk: unknown, encoding: BufferEncoding, callback?: WriteCallback] -export type NormalizedWriteArgs = [ +export type NormalizedSocketWriteArgs = [ chunk: any, encoding?: BufferEncoding, callback?: WriteCallback, @@ -14,8 +14,10 @@ export type NormalizedWriteArgs = [ * Normalizes the arguments provided to the `Writable.prototype.write()` * and `Writable.prototype.end()`. */ -export function normalizeWriteArgs(args: WriteArgs): NormalizedWriteArgs { - const normalized: NormalizedWriteArgs = [args[0], undefined, undefined] +export function normalizeSocketWriteArgs( + args: WriteArgs +): NormalizedSocketWriteArgs { + const normalized: NormalizedSocketWriteArgs = [args[0], undefined, undefined] if (typeof args[1] === 'string') { normalized[1] = args[1] From e52851ce9f2b6e8fbe2482d2ec1557c147f3a857 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 18 Apr 2024 15:01:18 +0200 Subject: [PATCH 62/69] test: add `setTimeout` tests (#558) --- .../ClientRequest/MockHttpSocket.ts | 12 +- .../http/compliance/http-timeout.test.ts | 262 ++++++++++++++++++ 2 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 test/modules/http/compliance/http-timeout.test.ts diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 35a60d30..be8f674b 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -126,6 +126,10 @@ export class MockHttpSocket extends MockSocket { * its data/events through this Socket. */ public passthrough(): void { + if (this.destroyed) { + return + } + const socket = this.createConnection() this.address = socket.address.bind(socket) @@ -225,6 +229,12 @@ export class MockHttpSocket extends MockSocket { * 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 + } + // Handle "type: error" responses. if (isPropertyAccessible(response, 'type') && response.type === 'error') { this.errorWith(new TypeError('Network error')) @@ -326,7 +336,7 @@ export class MockHttpSocket extends MockSocket { } /** - * Close this Socket connection with the given error. + * Close this socket connection with the given error. */ public errorWith(error: Error): void { this.destroy(error) diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts new file mode 100644 index 00000000..e23397e0 --- /dev/null +++ b/test/modules/http/compliance/http-timeout.test.ts @@ -0,0 +1,262 @@ +/** + * @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 { DeferredPromise } from '@open-draft/deferred-promise' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { sleep } from '../../../helpers' + +const httpServer = new HttpServer((app) => { + app.get('/resource', async (req, res) => { + await sleep(200) + res.status(500).end() + }) +}) + +const interceptor = new ClientRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('respects the "timeout" option for a handled request', async () => { + interceptor.on('request', async ({ request }) => { + await sleep(200) + request.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 + + expect(request.destroyed).toBe(true) + expect(timeoutListener).toHaveBeenCalledTimes(1) + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ECONNRESET', + }) + ) + expect(responseListener).not.toHaveBeenCalled() +}) + +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 + + expect(request.destroyed).toBe(true) + expect(timeoutListener).toHaveBeenCalledTimes(1) + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ECONNRESET', + }) + ) + expect(responseListener).not.toHaveBeenCalled() +}) + +it('respects a "setTimeout()" on a handled request', async () => { + interceptor.on('request', async ({ request }) => { + const stream = new ReadableStream({ + async start(controller) { + // Emulate a long pending response stream + // to trigger the request timeout. + await sleep(200) + controller.enqueue(new TextEncoder().encode('hello')) + }, + }) + request.respondWith(new Response(stream)) + }) + + const errorListener = vi.fn() + const timeoutListener = vi.fn() + const setTimeoutCallback = vi.fn() + const responseListener = vi.fn() + const request = http.get('http://localhost/resource') + + /** + * @note `request.setTimeout(n)` is NOT equivalent to + * `{ timeout: n }` in request options. + * + * - { timeout: n } acts on the http.Agent level and + * sets the timeout on every socket once it's CREATED. + * + * - setTimeout(n) omits the http.Agent, and sets the + * timeout once the socket emits "connect". + * This timeout takes effect only after the connection, + * so in our case, the mock/bypassed response MUST start, + * and only if the response itself takes more than this timeout, + * the timeout will trigger. + */ + request.setTimeout(10, setTimeoutCallback) + + request.on('error', errorListener) + request.on('timeout', () => { + timeoutListener() + request.destroy() + }) + request.on('response', responseListener) + + const requestClosePromise = new DeferredPromise() + request.on('close', () => requestClosePromise.resolve()) + await requestClosePromise + + expect(request.destroyed).toBe(true) + expect(timeoutListener).toHaveBeenCalledTimes(1) + expect(setTimeoutCallback).toHaveBeenCalledTimes(1) + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ECONNRESET', + }) + ) + expect(responseListener).not.toHaveBeenCalled() +}) + +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 requestClosePromise = new DeferredPromise() + request.on('close', () => requestClosePromise.resolve()) + await requestClosePromise + + expect(request.destroyed).toBe(true) + expect(timeoutListener).toHaveBeenCalledTimes(1) + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ECONNRESET', + }) + ) + expect(responseListener).not.toHaveBeenCalled() +}) + +it('respects the "socket.setTimeout()" for a handled request', async () => { + interceptor.on('request', async ({ request }) => { + const stream = new ReadableStream({ + async start(controller) { + // Emulate a long pending response stream + // to trigger the request timeout. + await sleep(200) + controller.enqueue(new TextEncoder().encode('hello')) + }, + }) + request.respondWith(new Response(stream)) + }) + + const errorListener = vi.fn() + const setTimeoutCallback = vi.fn() + const responseListener = vi.fn() + const request = http.get('http://localhost/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() + }) + }) + + request.on('error', errorListener) + request.on('response', responseListener) + + const requestClosePromise = new DeferredPromise() + request.on('close', () => requestClosePromise.resolve()) + await requestClosePromise + + expect(request.destroyed).toBe(true) + expect(setTimeoutCallback).toHaveBeenCalledTimes(1) + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ECONNRESET', + }) + ) + expect(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) => { + /** + * @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() + }) + }) + + request.on('error', errorListener) + request.on('response', responseListener) + + const requestClosePromise = new DeferredPromise() + request.on('close', () => requestClosePromise.resolve()) + await requestClosePromise + + expect(request.destroyed).toBe(true) + expect(setTimeoutCallback).toHaveBeenCalledTimes(1) + expect(errorListener).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ECONNRESET', + }) + ) + expect(responseListener).not.toHaveBeenCalled() +}) From c362b3974143d58bf50ff9ce9ac44223389a9134 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 18 Apr 2024 15:38:15 +0200 Subject: [PATCH 63/69] test(MockHttpSocket): add "signal" tests (#559) --- .../http/compliance/http-signal.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 test/modules/http/compliance/http-signal.test.ts diff --git a/test/modules/http/compliance/http-signal.test.ts b/test/modules/http/compliance/http-signal.test.ts new file mode 100644 index 00000000..b69f212b --- /dev/null +++ b/test/modules/http/compliance/http-signal.test.ts @@ -0,0 +1,119 @@ +/** + * @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 { DeferredPromise } from '@open-draft/deferred-promise' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { sleep } from '../../../helpers' + +const httpServer = new HttpServer((app) => { + app.get('/resource', async (req, res) => { + await sleep(200) + res.status(500).end() + }) +}) + +const interceptor = new ClientRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('respects the "signal" for a handled request', async () => { + interceptor.on('request', ({ request }) => { + request.respondWith(new Response('hello world')) + }) + + const abortController = new AbortController() + const request = http.get( + httpServer.http.url('/resource'), + { + signal: abortController.signal, + }, + () => { + abortController.abort('abort reason') + } + ) + + // Must listen to the "close" event instead of "abort". + const requestClosePromise = new DeferredPromise() + request.on('close', () => requestClosePromise.resolve()) + await requestClosePromise + + // ClientRequest doesn't expose the destroy reason. + // It's kept in the kError symbol but we won't be going there. + expect(request.destroyed).toBe(true) +}) + +it('respects the "signal" for a bypassed request', async () => { + const abortController = new AbortController() + const request = http.get( + httpServer.http.url('/resource'), + { + signal: abortController.signal, + }, + () => { + abortController.abort('abort reason') + } + ) + + // Must listen to the "close" event instead of "abort". + const requestClosePromise = new DeferredPromise() + request.on('close', () => requestClosePromise.resolve()) + await requestClosePromise + + // ClientRequest doesn't expose the destroy reason. + // It's kept in the kError symbol but we won't be going there. + expect(request.destroyed).toBe(true) +}) + +it('respects "AbortSignal.timeout()" for a handled request', async () => { + interceptor.on('request', ({ request }) => { + request.respondWith(new Response('hello world')) + }) + + const timeoutListener = vi.fn() + const request = http.get('http://localhost/resource', { + signal: AbortSignal.timeout(10), + }) + request.on('timeout', timeoutListener) + + // Must listen to the "close" event instead of "abort". + const requestClosePromise = new DeferredPromise() + request.on('close', () => requestClosePromise.resolve()) + await requestClosePromise + + expect(request.destroyed).toBe(true) + // "AbortSignal.timeout()" indicates that it will create a + // timeout after which the request will be destroyed. It + // doesn't actually mean the request will time out. + expect(timeoutListener).not.toHaveBeenCalled() +}) + +it('respects "AbortSignal.timeout()" for a bypassed request', async () => { + const timeoutListener = vi.fn() + const request = http.get(httpServer.http.url('/resource'), { + signal: AbortSignal.timeout(10), + }) + request.on('timeout', timeoutListener) + + // Must listen to the "close" event instead of "abort". + const requestClosePromise = new DeferredPromise() + request.on('close', () => requestClosePromise.resolve()) + await requestClosePromise + + expect(request.destroyed).toBe(true) + expect(timeoutListener).not.toHaveBeenCalled() +}) From 8ff98db2c4ce8ccff34125cd4fafe344f7498b94 Mon Sep 17 00:00:00 2001 From: Michael Solomon Date: Sun, 21 Apr 2024 13:18:56 +0300 Subject: [PATCH 64/69] fix(MockHttpSocket): set tls socket properties on init (#561) --- .../ClientRequest/MockHttpSocket.ts | 18 ++++++++++-------- .../http/compliance/http-ssl-socket.test.ts | 8 ++------ 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index be8f674b..19ec18db 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -111,6 +111,16 @@ export class MockHttpSocket extends MockSocket { // Once the socket is finished, nothing can write to it // anymore. It has also flushed any buffered chunks. this.once('finish', () => this.requestParser.free()) + + 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) + } } public destroy(error?: Error | undefined): this { @@ -371,14 +381,6 @@ export class MockHttpSocket extends MockSocket { Buffer.from('mock-session-renegotiate') ) this.emit('session', Buffer.from('mock-session-resume')) - - 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) } } diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index adf096a6..7b315a96 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -28,9 +28,7 @@ it('emits a correct TLS Socket instance for a handled HTTPS request', async () = const request = https.get('https://example.com') const socketPromise = new DeferredPromise() - request.on('socket', (socket) => { - socket.on('connect', () => socketPromise.resolve(socket as TLSSocket)) - }) + request.on('socket', socketPromise.resolve) const socket = await socketPromise @@ -48,9 +46,7 @@ it('emits a correct TLS Socket instance for a handled HTTPS request', async () = it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () => { const request = https.get('https://example.com') const socketPromise = new DeferredPromise() - request.on('socket', (socket) => { - socket.on('connect', () => socketPromise.resolve(socket as TLSSocket)) - }) + request.on('socket', socketPromise.resolve) const socket = await socketPromise From 5bbf61a63a5ab0b9de59745c3a1731f5b1053a6f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 29 Apr 2024 15:15:29 +0200 Subject: [PATCH 65/69] fix(ClientRequest): support "unhandledException" event --- .../ClientRequest/MockHttpSocket.ts | 4 ++++ src/interceptors/ClientRequest/index.ts | 23 +++++++++++++++++++ .../http-unhandled-exception.test.ts | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 19ec18db..1f6e4060 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -353,6 +353,10 @@ export class MockHttpSocket extends MockSocket { } private mockConnect(): void { + // Calling this method immediately puts the socket + // into the connected state. + this.connecting = false + const addressInfo = { address: '127.0.0.1', family: 'IPv4', diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 1e054182..dc30b6d1 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -158,6 +158,29 @@ export class ClientRequestInterceptor extends Interceptor { return } + // Emit the "unhandledException" event to allow the client + // to opt-out from the default handling of exceptions + // as 500 error responses. + if (this.emitter.listenerCount('unhandledException') > 0) { + await emitAsync(this.emitter, 'unhandledException', { + error: listenerResult.error, + request, + requestId, + controller: { + respondWith: socket.respondWith.bind(socket), + errorWith: socket.errorWith.bind(socket), + }, + }) + + // After the listeners are done, if the socket is + // not connecting anymore, the response was mocked. + // If the socket has been destroyed, the error was mocked. + // Treat both as the result of the listener's call. + if (!socket.connecting || socket.destroyed) { + return + } + } + // Unhandled exceptions in the request listeners are // synonymous to unhandled exceptions on the server. // Those are represented as 500 error responses. diff --git a/test/modules/http/compliance/http-unhandled-exception.test.ts b/test/modules/http/compliance/http-unhandled-exception.test.ts index d833b13d..9dc75a55 100644 --- a/test/modules/http/compliance/http-unhandled-exception.test.ts +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -126,7 +126,7 @@ it('handles exceptions as instructed in "unhandledException" listener (request e throw new Error('Custom error') }) interceptor.on('unhandledException', (args) => { - const { request, controller } = args + const { controller } = args unhandledExceptionListener(args) // Handle exceptions as request errors. From b1481a3ad6728c050d2e2e21fb0f28124ac6e74a Mon Sep 17 00:00:00 2001 From: Michael Solomon Date: Tue, 30 Apr 2024 11:39:56 +0300 Subject: [PATCH 66/69] fix: respect IPv6 hostnames and family (#571) --- .../ClientRequest/MockHttpSocket.ts | 5 ++- test/modules/http/compliance/http.test.ts | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 1f6e4060..70836bd5 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -357,9 +357,10 @@ export class MockHttpSocket extends MockSocket { // into the connected state. this.connecting = false + const isIPv6 = net.isIPv6(this.connectionOptions.hostname) || this.connectionOptions.family === 6 const addressInfo = { - address: '127.0.0.1', - family: 'IPv4', + address: isIPv6 ? '::1' : '127.0.0.1', + family: isIPv6 ? 'IPv6' : 'IPv4', port: this.connectionOptions.port, } // Return fake address information for the socket. diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index 42e29d22..6d24477b 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -184,6 +184,46 @@ it('returns socket address for a mocked request', async () => { }) }) +it('returns socket address for a mocked request with family: 6', async () => { + interceptor.on('request', async ({ request }) => { + request.respondWith(new Response()) + }) + + const addressPromise = new DeferredPromise() + const request = http.get('http://example.com', { family: 6 }) + request.once('socket', (socket) => { + socket.once('connect', () => { + addressPromise.resolve(socket.address()) + }) + }) + + await expect(addressPromise).resolves.toEqual({ + address: '::1', + family: 'IPv6', + port: 80, + }) +}) + +it('returns socket address for a mocked request with IPv6 hostname', async () => { + interceptor.on('request', async ({ request }) => { + request.respondWith(new Response()) + }) + + const addressPromise = new DeferredPromise() + const request = http.get('http://[::1]') + request.once('socket', (socket) => { + socket.once('connect', () => { + addressPromise.resolve(socket.address()) + }) + }) + + await expect(addressPromise).resolves.toEqual({ + address: '::1', + family: 'IPv6', + port: 80, + }) +}) + it('returns socket address for a bypassed request', async () => { const addressPromise = new DeferredPromise() const request = http.get(httpServer.http.url('/user')) From 7ab764e45c0e029978f1fe1878e9f7ad78dd214f Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 7 Jun 2024 12:27:51 +0200 Subject: [PATCH 67/69] test(http): add "connect" and "secureConnect" tests (#576) --- .../compliance/http-event-connect.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test/modules/http/compliance/http-event-connect.test.ts diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts new file mode 100644 index 00000000..644391a4 --- /dev/null +++ b/test/modules/http/compliance/http-event-connect.test.ts @@ -0,0 +1,95 @@ +/** + * @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 { waitForClientRequest } from '../../../../test/helpers' + +const httpServer = new HttpServer((app) => { + app.get('/', (req, res) => { + res.send('original') + }) +}) + +const interceptor = new ClientRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('emits the "connect" event for a mocked request', async () => { + interceptor.on('request', ({ request }) => { + request.respondWith(new Response('hello world')) + }) + + const connectListener = vi.fn() + const request = http.get(httpServer.http.url('/')) + request.on('socket', (socket) => { + socket.on('connect', connectListener) + }) + + await waitForClientRequest(request) + + expect(connectListener).toHaveBeenCalledTimes(1) +}) + +it('emits the "connect" event for a bypassed request', async () => { + const connectListener = vi.fn() + const request = http.get(httpServer.http.url('/')) + request.on('socket', (socket) => { + socket.on('connect', connectListener) + }) + + await waitForClientRequest(request) + + expect(connectListener).toHaveBeenCalledTimes(1) +}) + +it('emits the "secureConnect" event for a mocked HTTPS request', async () => { + interceptor.on('request', ({ request }) => { + request.respondWith(new Response('hello world')) + }) + + const connectListener = vi.fn<[string]>() + const request = https.get(httpServer.https.url('/')) + request.on('socket', (socket) => { + socket.on('connect', () => connectListener('connect')) + socket.on('secureConnect', () => connectListener('secureConnect')) + }) + + await waitForClientRequest(request) + + expect(connectListener).toHaveBeenNthCalledWith(1, 'connect') + expect(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') + expect(connectListener).toHaveBeenCalledTimes(2) +}) + +it('emits the "secureConnect" event for a mocked HTTPS request', async () => { + const connectListener = vi.fn<[string]>() + const request = https.get(httpServer.https.url('/'), { + rejectUnauthorized: false, + }) + request.on('socket', (socket) => { + socket.on('connect', () => connectListener('connect')) + socket.on('secureConnect', () => connectListener('secureConnect')) + }) + + await waitForClientRequest(request) + + expect(connectListener).toHaveBeenNthCalledWith(1, 'connect') + expect(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') + expect(connectListener).toHaveBeenCalledTimes(2) +}) From 028190b5202235e0872415a39fb5a3ec46314a9c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 4 Jul 2024 15:55:27 +0200 Subject: [PATCH 68/69] chore: update @types/node to have Readable.toWeb() --- package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8e626566..ec6133bc 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "@types/express-rate-limit": "^6.0.0", "@types/follow-redirects": "^1.14.1", "@types/jest": "^27.0.3", - "@types/node": "18", + "@types/node": "^18.19.31", "@types/node-fetch": "2.5.12", "@types/supertest": "^2.0.11", "@types/ws": "^8.5.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f2c7961..2df3568d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,7 +59,7 @@ devDependencies: specifier: ^27.0.3 version: 27.5.2 '@types/node': - specifier: '18' + specifier: ^18.19.31 version: 18.19.31 '@types/node-fetch': specifier: 2.5.12 From ab9c49a97aee3c26b0564104aa62b2483305e67b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 4 Jul 2024 15:55:58 +0200 Subject: [PATCH 69/69] fix: defer response end/close until response listeners are done --- .../ClientRequest/MockHttpSocket.ts | 27 ++++++++++++++++--- src/interceptors/ClientRequest/index.ts | 4 ++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 70836bd5..5f7314c8 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -34,7 +34,7 @@ export type MockHttpSocketResponseCallback = (args: { response: Response isMockedResponse: boolean socket: MockHttpSocket -}) => void +}) => Promise interface MockHttpSocketOptions { connectionOptions: HttpConnectionOptions @@ -52,6 +52,7 @@ export class MockHttpSocket extends MockSocket { private onRequest: MockHttpSocketRequestCallback private onResponse: MockHttpSocketResponseCallback + private responseListenersPromise?: Promise private writeBuffer: Array = [] private request?: Request @@ -123,6 +124,17 @@ export class MockHttpSocket extends MockSocket { } } + 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 shoud listen to the "close" event but it @@ -357,7 +369,9 @@ export class MockHttpSocket extends MockSocket { // into the connected state. this.connecting = false - const isIPv6 = net.isIPv6(this.connectionOptions.hostname) || this.connectionOptions.family === 6 + const isIPv6 = + net.isIPv6(this.connectionOptions.hostname) || + this.connectionOptions.family === 6 const addressInfo = { address: isIPv6 ? '::1' : '127.0.0.1', family: isIPv6 ? 'IPv6' : 'IPv4', @@ -513,6 +527,13 @@ export class MockHttpSocket extends MockSocket { } const response = new Response( + /** + * @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. + */ canHaveBody ? (Readable.toWeb(this.responseStream!) as any) : null, { status, @@ -535,7 +556,7 @@ export class MockHttpSocket extends MockSocket { return } - this.onResponse({ + this.responseListenersPromise = this.onResponse({ response, isMockedResponse: this.responseType === 'mock', requestId: Reflect.get(this.request, kRequestId), diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index dc30b6d1..2e69c4a4 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -208,7 +208,9 @@ export class ClientRequestInterceptor extends Interceptor { response, isMockedResponse, }) => { - this.emitter.emit('response', { + // Return the promise to when all the response event listeners + // are finished. + return emitAsync(this.emitter, 'response', { requestId, request, response,