diff --git a/README.md b/README.md index 0f3d9896..ae7176bd 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 diff --git a/_http_common.d.ts b/_http_common.d.ts new file mode 100644 index 00000000..0e5eb6aa --- /dev/null +++ b/_http_common.d.ts @@ -0,0 +1,47 @@ +declare var HTTPParser: { + new (): HTTPParser + REQUEST: 0 + RESPONSE: 1 + readonly kOnHeadersComplete: unique symbol + readonly kOnBody: unique symbol + readonly kOnMessageComplete: unique symbol +} + +export interface HTTPParser { + new (): HTTPParser + + [HTTPParser.kOnHeadersComplete]: ParserType extends 0 + ? RequestHeadersCompleteCallback + : ResponseHeadersCompleteCallback + [HTTPParser.kOnBody]: (chunk: Buffer) => void + [HTTPParser.kOnMessageComplete]: () => void + + initialize(type: ParserType, 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 diff --git a/package.json b/package.json index f0b36167..618fdbe9 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": "^16.11.26", + "@types/node": "^18.19.31", "@types/node-fetch": "2.5.12", "@types/supertest": "^2.0.11", "@types/ws": "^8.5.10", @@ -198,4 +198,4 @@ "path": "./node_modules/cz-conventional-changelog" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2465d6e8..2df3568d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,8 +59,8 @@ devDependencies: specifier: ^27.0.3 version: 27.5.2 '@types/node': - specifier: ^16.11.26 - version: 16.18.46 + specifier: ^18.19.31 + version: 18.19.31 '@types/node-fetch': specifier: 2.5.12 version: 2.5.12 @@ -147,7 +147,7 @@ devDependencies: version: 6.6.2 vitest: specifier: ^1.2.2 - version: 1.2.2(@types/node@16.18.46)(happy-dom@12.10.3) + version: 1.2.2(@types/node@18.19.31)(happy-dom@12.10.3) vitest-environment-miniflare: specifier: ^2.14.1 version: 2.14.1(vitest@1.2.2) @@ -595,10 +595,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': 18.19.31 chalk: 4.1.2 cosmiconfig: 7.1.0 - cosmiconfig-typescript-loader: 2.0.2(@types/node@16.18.46)(cosmiconfig@7.1.0)(typescript@4.9.5) + cosmiconfig-typescript-loader: 2.0.2(@types/node@18.19.31)(cosmiconfig@7.1.0)(typescript@4.9.5) lodash: 4.17.21 resolve-from: 5.0.0 typescript: 4.9.5 @@ -724,7 +724,6 @@ packages: /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - requiresBuild: true dependencies: '@jridgewell/trace-mapping': 0.3.9 dev: true @@ -1164,7 +1163,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 chalk: 4.1.2 jest-message-util: 27.5.1 jest-util: 27.5.1 @@ -1185,7 +1184,7 @@ packages: '@jest/test-result': 27.5.1 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.8.1 @@ -1222,7 +1221,7 @@ packages: dependencies: '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 jest-mock: 27.5.1 dev: true @@ -1232,7 +1231,7 @@ packages: dependencies: '@jest/types': 27.5.1 '@sinonjs/fake-timers': 8.1.0 - '@types/node': 16.18.46 + '@types/node': 18.19.31 jest-message-util: 27.5.1 jest-mock: 27.5.1 jest-util: 27.5.1 @@ -1261,7 +1260,7 @@ packages: '@jest/test-result': 27.5.1 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -1352,7 +1351,7 @@ packages: dependencies: '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 '@types/yargs': 16.0.5 chalk: 4.1.2 dev: true @@ -1396,7 +1395,6 @@ packages: /@jridgewell/trace-mapping@0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - requiresBuild: true dependencies: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 @@ -1647,7 +1645,7 @@ packages: engines: {node: '>=16'} hasBin: true dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 playwright-core: 1.37.1 optionalDependencies: fsevents: 2.3.2 @@ -1796,22 +1794,18 @@ packages: /@tsconfig/node10@1.0.9: resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} - requiresBuild: true dev: true /@tsconfig/node12@1.0.11: resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - requiresBuild: true dev: true /@tsconfig/node14@1.0.3: resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - requiresBuild: true dev: true /@tsconfig/node16@1.0.4: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - requiresBuild: true dev: true /@types/babel__core@7.20.1: @@ -1846,14 +1840,14 @@ packages: /@types/better-sqlite3@7.6.7: resolution: {integrity: sha512-+c2YGPWY5831v3uj2/X0HRTK94u1GXU3sCnLqu7AKlxlSfawswnAiJR//TFzSL5azWsLQkG/uS+YnnqHtuZxPw==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/body-parser@1.19.2: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/cacheable-request@6.0.3: @@ -1861,20 +1855,20 @@ packages: dependencies: '@types/http-cache-semantics': 4.0.1 '@types/keyv': 3.1.4 - '@types/node': 16.18.46 + '@types/node': 18.19.31 '@types/responselike': 1.0.0 dev: true /@types/connect@3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/conventional-commits-parser@3.0.3: resolution: {integrity: sha512-aoUKfRQYvGMH+spFpOTX9jO4nZoz9/BKp4hlHPxL3Cj2r2Xj+jEcwlXtFIyZr5uL8bh1nbWynDEYaAota+XqPg==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/cookie@0.4.1: @@ -1888,7 +1882,7 @@ packages: /@types/cors@2.8.13: resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/eslint-scope@3.7.4: @@ -1925,7 +1919,7 @@ packages: /@types/express-serve-static-core@4.17.36: resolution: {integrity: sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 '@types/send': 0.17.1 @@ -1943,13 +1937,13 @@ packages: /@types/follow-redirects@1.14.1: resolution: {integrity: sha512-THBEFwqsLuU/K62B5JRwab9NW97cFmL4Iy34NTMX0bMycQVzq2q7PKOkhfivIwxdpa/J72RppgC42vCHfwKJ0Q==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/graceful-fs@4.1.6: resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/http-cache-semantics@4.0.1: @@ -1994,7 +1988,7 @@ packages: /@types/keyv@3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/mime@1.3.2: @@ -2016,7 +2010,7 @@ packages: /@types/node-fetch@2.5.12: resolution: {integrity: sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 form-data: 3.0.1 dev: true @@ -2024,6 +2018,12 @@ packages: resolution: {integrity: sha512-Mnq3O9Xz52exs3mlxMcQuA7/9VFe/dXcrgAyfjLkABIqxXKOgBRjyazTxUbjsxDa4BP7hhPliyjVTP9RDP14xg==} dev: true + /@types/node@18.19.31: + resolution: {integrity: sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==} + dependencies: + undici-types: 5.26.5 + dev: true + /@types/node@20.4.7: resolution: {integrity: sha512-bUBrPjEry2QUTsnuEjzjbS7voGWCc30W0qzgMf90GPeDGFRakvrz47ju+oqDAKCXLUCe39u57/ORMl/O/04/9g==} requiresBuild: true @@ -2063,7 +2063,7 @@ packages: /@types/responselike@1.0.0: resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/semver@7.5.1: @@ -2074,7 +2074,7 @@ packages: resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==} dependencies: '@types/mime': 1.3.2 - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/serve-static@1.15.2: @@ -2082,7 +2082,7 @@ packages: dependencies: '@types/http-errors': 2.0.1 '@types/mime': 3.0.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/stack-utils@2.0.1: @@ -2093,7 +2093,7 @@ packages: resolution: {integrity: sha512-LOWgpacIV8GHhrsQU+QMZuomfqXiqzz3ILLkCtKx3Us6AmomFViuzKT9D693QTKgyut2oCytMG8/efOop+DB+w==} dependencies: '@types/cookiejar': 2.1.2 - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/supertest@2.0.12: @@ -2105,7 +2105,7 @@ packages: /@types/ws@8.5.10: resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /@types/yargs-parser@21.0.0: @@ -2428,7 +2428,6 @@ packages: /arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - requiresBuild: true dev: true /argparse@1.0.10: @@ -3099,7 +3098,7 @@ packages: vary: 1.1.2 dev: true - /cosmiconfig-typescript-loader@2.0.2(@types/node@16.18.46)(cosmiconfig@7.1.0)(typescript@4.9.5): + /cosmiconfig-typescript-loader@2.0.2(@types/node@18.19.31)(cosmiconfig@7.1.0)(typescript@4.9.5): resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==} engines: {node: '>=12', npm: '>=6'} peerDependencies: @@ -3107,9 +3106,9 @@ packages: cosmiconfig: '>=7' typescript: '>=3' dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 cosmiconfig: 7.1.0 - ts-node: 10.9.1(@types/node@16.18.46)(typescript@4.9.5) + ts-node: 10.9.1(@types/node@18.19.31)(typescript@4.9.5) typescript: 4.9.5 transitivePeerDependencies: - '@swc/core' @@ -3158,7 +3157,6 @@ packages: /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - requiresBuild: true dev: true /cross-env@7.0.3: @@ -3361,7 +3359,6 @@ packages: /diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} - requiresBuild: true dev: true /dir-glob@3.0.1: @@ -3458,7 +3455,7 @@ packages: dependencies: '@types/cookie': 0.4.1 '@types/cors': 2.8.13 - '@types/node': 16.18.46 + '@types/node': 18.19.31 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.4.2 @@ -4601,7 +4598,7 @@ packages: '@jest/environment': 27.5.1 '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 chalk: 4.1.2 co: 4.6.0 dedent: 0.7.0 @@ -4684,7 +4681,7 @@ packages: pretty-format: 27.5.1 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.1(@types/node@16.18.46)(typescript@4.9.5) + ts-node: 10.9.1(@types/node@18.19.31)(typescript@4.9.5) transitivePeerDependencies: - bufferutil - canvas @@ -4727,7 +4724,7 @@ packages: '@jest/environment': 27.5.1 '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 jest-mock: 27.5.1 jest-util: 27.5.1 jsdom: 16.7.0 @@ -4745,7 +4742,7 @@ packages: '@jest/environment': 27.5.1 '@jest/fake-timers': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 jest-mock: 27.5.1 jest-util: 27.5.1 dev: true @@ -4761,7 +4758,7 @@ packages: dependencies: '@jest/types': 27.5.1 '@types/graceful-fs': 4.1.6 - '@types/node': 16.18.46 + '@types/node': 18.19.31 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -4783,7 +4780,7 @@ packages: '@jest/source-map': 27.5.1 '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 chalk: 4.1.2 co: 4.6.0 expect: 27.5.1 @@ -4838,7 +4835,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 dev: true /jest-pnp-resolver@1.2.3(jest-resolve@27.5.1): @@ -4894,7 +4891,7 @@ packages: '@jest/test-result': 27.5.1 '@jest/transform': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 chalk: 4.1.2 emittery: 0.8.1 graceful-fs: 4.2.11 @@ -4951,7 +4948,7 @@ packages: resolution: {integrity: sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 graceful-fs: 4.2.11 dev: true @@ -4990,7 +4987,7 @@ packages: engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} dependencies: '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 chalk: 4.1.2 ci-info: 3.8.0 graceful-fs: 4.2.11 @@ -5015,7 +5012,7 @@ packages: dependencies: '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 - '@types/node': 16.18.46 + '@types/node': 18.19.31 ansi-escapes: 4.3.2 chalk: 4.1.2 jest-util: 27.5.1 @@ -5026,7 +5023,7 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true @@ -5928,7 +5925,7 @@ packages: optional: true dependencies: lilconfig: 2.1.0 - ts-node: 10.9.1(@types/node@16.18.46)(typescript@4.9.5) + ts-node: 10.9.1(@types/node@18.19.31)(typescript@4.9.5) yaml: 1.10.2 dev: true @@ -7039,7 +7036,7 @@ packages: yargs-parser: 20.2.9 dev: true - /ts-node@10.9.1(@types/node@16.18.46)(typescript@4.9.5): + /ts-node@10.9.1(@types/node@18.19.31)(typescript@4.9.5): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -7058,7 +7055,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 16.18.46 + '@types/node': 18.19.31 acorn: 8.11.3 acorn-walk: 8.3.2 arg: 4.1.3 @@ -7191,6 +7188,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'} @@ -7269,7 +7270,6 @@ packages: /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - requiresBuild: true dev: true /v8-to-istanbul@8.1.1: @@ -7300,7 +7300,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.31): resolution: {integrity: sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -7309,7 +7309,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.31) transitivePeerDependencies: - '@types/node' - less @@ -7321,7 +7321,7 @@ packages: - terser dev: true - /vite@5.1.2(@types/node@16.18.46): + /vite@5.1.2(@types/node@18.19.31): resolution: {integrity: sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -7349,7 +7349,7 @@ packages: terser: optional: true dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 esbuild: 0.19.12 postcss: 8.4.35 rollup: 4.11.0 @@ -7368,13 +7368,13 @@ packages: '@miniflare/shared': 2.14.1 '@miniflare/shared-test-environment': 2.14.1 undici: 5.20.0 - vitest: 1.2.2(@types/node@16.18.46)(happy-dom@12.10.3) + vitest: 1.2.2(@types/node@18.19.31)(happy-dom@12.10.3) transitivePeerDependencies: - bufferutil - utf-8-validate dev: true - /vitest@1.2.2(@types/node@16.18.46)(happy-dom@12.10.3): + /vitest@1.2.2(@types/node@18.19.31)(happy-dom@12.10.3): resolution: {integrity: sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -7399,7 +7399,7 @@ packages: jsdom: optional: true dependencies: - '@types/node': 16.18.46 + '@types/node': 18.19.31 '@vitest/expect': 1.2.2 '@vitest/runner': 1.2.2 '@vitest/snapshot': 1.2.2 @@ -7419,8 +7419,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.31) + vite-node: 1.2.2(@types/node@18.19.31) why-is-node-running: 2.2.2 transitivePeerDependencies: - less @@ -7786,7 +7786,6 @@ packages: /yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} - requiresBuild: true dev: true /yocto-queue@0.1.0: diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts new file mode 100644 index 00000000..5f7314c8 --- /dev/null +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -0,0 +1,583 @@ +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 { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' +import { MockSocket } from '../Socket/MockSocket' +import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs' +import { isPropertyAccessible } from '../../utils/isPropertyAccessible' +import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' +import { parseRawHeaders } from '../Socket/utils/parseRawHeaders' +import { getRawFetchHeaders } from '../../utils/getRawFetchHeaders' +import { + createServerErrorResponse, + RESPONSE_STATUS_CODES_WITHOUT_BODY, +} from '../../utils/responseUtils' +import { createRequestId } from '../../createRequestId' + +type HttpConnectionOptions = any + +export type MockHttpSocketRequestCallback = (args: { + requestId: string + request: Request + socket: MockHttpSocket +}) => void + +export type MockHttpSocketResponseCallback = (args: { + requestId: string + request: Request + response: Response + isMockedResponse: boolean + socket: MockHttpSocket +}) => Promise + +interface MockHttpSocketOptions { + connectionOptions: HttpConnectionOptions + createConnection: () => net.Socket + onRequest: MockHttpSocketRequestCallback + onResponse: MockHttpSocketResponseCallback +} + +export const kRequestId = Symbol('kRequestId') + +export class MockHttpSocket extends MockSocket { + private connectionOptions: HttpConnectionOptions + private createConnection: () => net.Socket + private baseUrl: URL + + private onRequest: MockHttpSocketRequestCallback + private onResponse: MockHttpSocketResponseCallback + private responseListenersPromise?: Promise + + private writeBuffer: Array = [] + private request?: Request + private requestParser: HTTPParser<0> + private requestStream?: Readable + private shouldKeepAlive?: boolean + + private responseType: 'mock' | 'bypassed' = 'bypassed' + private responseParser: HTTPParser<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) => { + if (chunk !== null) { + 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) + + // 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 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 + // can be suppressed by using the "emitClose: false" option. + this.responseParser.free() + return super.destroy(error) + } + + /** + * Establish this Socket connection as-is and pipe + * its data/events through this Socket. + */ + public passthrough(): void { + if (this.destroyed) { + return + } + + 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). + // Exhaust the "requestBuffer" in case this Socket + // gets reused for different requests. + let writeArgs: NormalizedSocketWriteArgs | undefined + let headersWritten = false + + while ((writeArgs = this.writeBuffer.shift())) { + if (writeArgs !== undefined) { + if (!headersWritten) { + const [chunk, encoding, callback] = writeArgs + const chunkString = chunk.toString() + const chunkBeforeRequestHeaders = chunkString.slice( + 0, + chunkString.indexOf('\r\n') + 2 + ) + const chunkAfterRequestHeaders = chunkString.slice( + chunk.indexOf('\r\n\r\n') + ) + const 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') + + // Modify the HTTP request message headers + // to reflect any changes to the request headers + // from the "request" event listener. + const headersChunk = `${chunkBeforeRequestHeaders}${requestHeadersString}${chunkAfterRequestHeaders}` + socket.write(headersChunk, encoding, callback) + headersWritten = true + continue + } + + socket.write(...writeArgs) + } + } + + // Forward TLS Socket properties onto this Socket instance + // in the case of a TLS/SSL connection. + if (Reflect.get(socket, 'encrypted')) { + const tlsProperties = [ + 'encrypted', + 'authorized', + 'getProtocol', + 'getSession', + 'isSessionReused', + ] + + tlsProperties.forEach((propertyName) => { + Object.defineProperty(this, propertyName, { + enumerable: true, + get: () => { + const value = Reflect.get(socket, propertyName) + return typeof value === 'function' ? value.bind(socket) : value + }, + }) + }) + } + + socket + .on('lookup', (...args) => this.emit('lookup', ...args)) + .on('connect', () => { + this.connecting = socket.connecting + this.emit('connect') + }) + .on('secureConnect', () => this.emit('secureConnect')) + .on('secure', () => this.emit('secure')) + .on('session', (session) => this.emit('session', session)) + .on('ready', () => this.emit('ready')) + .on('drain', () => this.emit('drain')) + .on('data', (chunk) => { + // Push the original response to this socket + // so it triggers the HTTP response parser. This unifies + // the handling pipeline for original and mocked response. + this.push(chunk) + }) + .on('error', (error) => { + Reflect.set(this, '_hadError', Reflect.get(socket, '_hadError')) + this.emit('error', error) + }) + .on('resume', () => this.emit('resume')) + .on('timeout', () => this.emit('timeout')) + .on('prefinish', () => this.emit('prefinish')) + .on('finish', () => this.emit('finish')) + .on('close', (hadError) => this.emit('close', hadError)) + .on('end', () => this.emit('end')) + } + + /** + * Convert the given Fetch API `Response` instance to an + * HTTP message and push it to the socket. + */ + public async respondWith(response: Response): Promise { + // Ignore the mocked response if the socket has been destroyed + // (e.g. aborted or timed out), + if (this.destroyed) { + return + } + + // Handle "type: error" responses. + if (isPropertyAccessible(response, 'type') && response.type === 'error') { + this.errorWith(new TypeError('Network error')) + return + } + + // First, emit all the connection events + // to emulate a successful connection. + this.mockConnect() + this.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( + Buffer.from( + `HTTP/1.1 ${response.status} ${ + response.statusText || STATUS_CODES[response.status] + }\r\n` + ) + ) + + // 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`)) + } + + // 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) { + 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)) + + 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. + flushHeaders() + + // Close the socket if the connection wasn't marked as keep-alive. + if (!this.shouldKeepAlive) { + this.emit('readable') + + /** + * @todo @fixme This is likely a hack. + * Since we push null to the socket, it never propagates to the + * parser, and the parser never calls "onResponseEnd" to close + * the response stream. We are closing the stream here manually + * but that shouldn't be the case. + */ + this.responseStream?.push(null) + this.push(null) + } + } + + /** + * Close this socket connection with the given error. + */ + public errorWith(error: Error): void { + this.destroy(error) + } + + private mockConnect(): void { + // Calling this method immediately puts the socket + // into the connected state. + this.connecting = false + + const isIPv6 = + net.isIPv6(this.connectionOptions.hostname) || + this.connectionOptions.family === 6 + const addressInfo = { + address: isIPv6 ? '::1' : '127.0.0.1', + family: isIPv6 ? 'IPv6' : 'IPv4', + port: this.connectionOptions.port, + } + // Return fake address information for the socket. + this.address = () => addressInfo + this.emit( + 'lookup', + null, + addressInfo.address, + addressInfo.family === 'IPv6' ? 6 : 4, + this.connectionOptions.host + ) + this.emit('connect') + this.emit('ready') + + if (this.baseUrl.protocol === 'https:') { + this.emit('secure') + this.emit('secureConnect') + + // A single TLS connection is represented by two "session" events. + this.emit( + 'session', + this.connectionOptions.session || + Buffer.from('mock-session-renegotiate') + ) + this.emit('session', Buffer.from('mock-session-resume')) + } + } + + private flushWriteBuffer(): void { + let args: NormalizedSocketWriteArgs | undefined + while ((args = this.writeBuffer.shift())) { + args?.[2]?.() + } + } + + 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' + + // Translate the basic authorization in the URL to the request header. + // Constructing a Request instance with a URL containing auth is no-op. + if (url.username || url.password) { + if (!headers.has('authorization')) { + headers.set('authorization', `Basic ${url.username}:${url.password}`) + } + url.username = '' + url.password = '' + } + + // Create a new stream for each request. + // If this Socket is reused for multiple requests, + // this ensures that each request gets its own stream. + // One Socket instance can only handle one request at a time. + if (canHaveBody) { + this.requestStream = new Readable({ + /** + * @note Provide the `read()` method so a `Readable` could be + * used as the actual request body (the stream calls "read()"). + * We control the queue in the onRequestBody/End functions. + */ + read: () => { + // If the user attempts to read the request body, + // flush the write buffer to trigger the callbacks. + // This way, if the request stream ends in the write callback, + // it will indeed end correctly. + this.flushWriteBuffer() + }, + }) + } + + const requestId = createRequestId() + this.request = new Request(url, { + method, + headers, + credentials: 'same-origin', + // @ts-expect-error Undocumented Fetch property. + duplex: canHaveBody ? 'half' : undefined, + body: canHaveBody ? (Readable.toWeb(this.requestStream!) as any) : null, + }) + + Reflect.set(this.request, kRequestId, requestId) + + // Skip handling the request that's already being handled + // by another (parent) interceptor. For example, XMLHttpRequest + // is often implemented via ClientRequest in Node.js (e.g. JSDOM). + // In that case, XHR interceptor will bubble down to the ClientRequest + // interceptor. No need to try to handle that request again. + /** + * @fixme Stop relying on the "X-Request-Id" request header + * to figure out if one interceptor has been invoked within another. + * @see https://github.com/mswjs/interceptors/issues/378 + */ + if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) { + this.passthrough() + return + } + + this.onRequest({ + requestId, + request: this.request, + socket: this, + }) + } + + private onRequestBody(chunk: Buffer): void { + invariant( + this.requestStream, + 'Failed to write to a request stream: stream does not exist' + ) + + this.requestStream.push(chunk) + } + + private onRequestEnd(): void { + // Request end can be called for requests without body. + if (this.requestStream) { + this.requestStream.push(null) + } + } + + private onResponseStart: ResponseHeadersCompleteCallback = ( + versionMajor, + versionMinor, + rawHeaders, + method, + url, + status, + statusText + ) => { + 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() + } + + 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, + statusText, + headers, + } + ) + + invariant( + this.request, + '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(INTERNAL_REQUEST_ID_HEADER_NAME)) { + return + } + + this.responseListenersPromise = this.onResponse({ + response, + isMockedResponse: this.responseType === 'mock', + requestId: Reflect.get(this.request, kRequestId), + request: this.request, + socket: this, + }) + } + + private onResponseBody(chunk: Buffer) { + invariant( + this.responseStream, + 'Failed to write to a response stream: stream does not exist' + ) + + this.responseStream.push(chunk) + } + + private onResponseEnd(): void { + // Response end can be called for responses without body. + if (this.responseStream) { + this.responseStream.push(null) + } + } +} diff --git a/src/interceptors/ClientRequest/NodeClientRequest.test.ts b/src/interceptors/ClientRequest/NodeClientRequest.test.ts deleted file mode 100644 index e8358133..00000000 --- a/src/interceptors/ClientRequest/NodeClientRequest.test.ts +++ /dev/null @@ -1,206 +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' - -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 e98b4455..00000000 --- a/src/interceptors/ClientRequest/NodeClientRequest.ts +++ /dev/null @@ -1,680 +0,0 @@ -import { ClientRequest, IncomingMessage, STATUS_CODES } from 'node: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 { emitAsync } from '../../utils/emitAsync' -import { getRawFetchHeaders } from '../../utils/getRawFetchHeaders' -import { isNodeLikeError } from '../../utils/isNodeLikeError' -import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' -import { createRequestId } from '../../createRequestId' -import { - createServerErrorResponse, - isResponseError, -} from '../../utils/responseUtils' - -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 = createRequestId() - - 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.hasHeader(INTERNAL_REQUEST_ID_HEADER_NAME)) { - this.removeHeader(INTERNAL_REQUEST_ID_HEADER_NAME) - 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) - } - } - - if (resolverResult.error) { - this.logger.info( - 'unhandled resolver exception, coercing to an error response...', - resolverResult.error - ) - - // Handle thrown Response instances. - if (resolverResult.error instanceof Response) { - // Treat thrown Response.error() as a request error. - if (isResponseError(resolverResult.error)) { - this.logger.info( - 'received network error response, erroring request...' - ) - - this.errorWith(new TypeError('Network error')) - } else { - // Handle a thrown Response as a mocked response. - this.respondWith(resolverResult.error) - } - - return - } - - // Allow throwing Node.js-like errors, like connection rejection errors. - // Treat them as request errors. - if (isNodeLikeError(resolverResult.error)) { - this.errorWith(resolverResult.error) - return this - } - - until(async () => { - if (this.emitter.listenerCount('unhandledException') > 0) { - // Emit the "unhandledException" event to allow the client - // to opt-out from the default handling of exceptions - // as 500 error responses. - await emitAsync(this.emitter, 'unhandledException', { - error: resolverResult.error, - request: capturedRequest, - requestId, - controller: { - respondWith: this.respondWith.bind(this), - errorWith: this.errorWith.bind(this), - }, - }) - - // If after the "unhandledException" listeners are done, - // the request is either not writable (was mocked) or - // destroyed (has errored), do nothing. - if (this.writableEnded || this.destroyed) { - return - } - } - - // Unhandled exceptions in the request listeners are - // synonymous to unhandled exceptions on the server. - // Those are represented as 500 error responses. - this.respondWith(createServerErrorResponse(resolverResult.error)) - }) - - 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 (isResponseError(mockedResponse)) { - this.logger.info( - 'received network error response, erroring request...' - ) - - /** - * There is no standardized error format for network errors - * in Node.js. Instead, emit a generic TypeError. - */ - this.errorWith(new TypeError('Network error')) - - 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...') - - const responseListenersPromise = emitAsync(this.emitter, 'response', { - response: responseClone, - isMockedResponse: true, - request: capturedRequest, - requestId, - }) - - responseListenersPromise.then(() => { - this.logger.info('request (mock) is completed') - }) - - // Defer the end of the response until all the response - // event listeners are done (those can be async). - this.deferResponseEndUntil(responseListenersPromise, this.response) - - return this - } - - this.logger.info('no mocked response received!') - - this.once( - 'response-internal', - (message: IncomingMessage, originalMessage: IncomingMessage) => { - this.logger.info(message.statusCode, message.statusMessage) - this.logger.info('original response headers:', message.headers) - - this.logger.info('emitting the custom "response" event...') - - const responseListenersPromise = emitAsync(this.emitter, 'response', { - response: createResponse(message), - isMockedResponse: false, - request: capturedRequest, - requestId, - }) - - // Defer the end of the response until all the response - // event listeners are done (those can be async). - this.deferResponseEndUntil(responseListenersPromise, originalMessage) - } - ) - - 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, firstClone) - - 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 || STATUS_CODES[status] - - // 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!') - }) - } - - private errorWith(error: Error): void { - this.destroyed = true - this.emit('error', error) - this.terminate() - } - - /** - * Terminates a pending request. - */ - private terminate(): void { - /** - * @note Some request clients (e.g. Octokit, or proxy providers like - * `global-agent`) create a ClientRequest in a way that it has no Agent set, - * or does not have a destroy method on it. 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 "agent" is a private property. - this.agent?.destroy?.() - } - - private deferResponseEndUntil( - promise: Promise, - response: IncomingMessage - ): void { - response.emit = new Proxy(response.emit, { - apply: (target, thisArg, args) => { - const [event] = args - const callEmit = () => Reflect.apply(target, thisArg, args) - - if (event === 'end') { - promise.finally(() => callEmit()) - return this.listenerCount('end') > 0 - } - - return callEmit() - }, - }) - } -} diff --git a/src/interceptors/ClientRequest/agents.ts b/src/interceptors/ClientRequest/agents.ts new file mode 100644 index 00000000..994e084e --- /dev/null +++ b/src/interceptors/ClientRequest/agents.ts @@ -0,0 +1,78 @@ +import net from 'node:net' +import http from 'node:http' +import https from 'node:https' +import { + MockHttpSocket, + type MockHttpSocketRequestCallback, + type MockHttpSocketResponseCallback, +} from './MockHttpSocket' + +declare module 'node:http' { + interface Agent { + createConnection(options: any, callback: any): net.Socket + } +} + +interface MockAgentOptions { + customAgent?: http.RequestOptions['agent'] + onRequest: MockHttpSocketRequestCallback + onResponse: MockHttpSocketResponseCallback +} + +export class MockAgent extends http.Agent { + private customAgent?: http.RequestOptions['agent'] + private onRequest: MockHttpSocketRequestCallback + private onResponse: MockHttpSocketResponseCallback + + constructor(options: MockAgentOptions) { + super() + this.customAgent = options.customAgent + this.onRequest = options.onRequest + this.onResponse = options.onResponse + } + + public createConnection(options: any, callback: any) { + const createConnection = + (this.customAgent instanceof http.Agent && + this.customAgent.createConnection) || + super.createConnection + + const socket = new MockHttpSocket({ + connectionOptions: options, + createConnection: createConnection.bind(this, options, callback), + onRequest: this.onRequest.bind(this), + onResponse: this.onResponse.bind(this), + }) + + return socket + } +} + +export class MockHttpsAgent extends https.Agent { + private customAgent?: https.RequestOptions['agent'] + private onRequest: MockHttpSocketRequestCallback + private onResponse: MockHttpSocketResponseCallback + + constructor(options: MockAgentOptions) { + super() + this.customAgent = options.customAgent + this.onRequest = options.onRequest + this.onResponse = options.onResponse + } + + public createConnection(options: any, callback: any) { + 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: this.onRequest.bind(this), + onResponse: this.onResponse.bind(this), + }) + + return socket + } +} 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.ts b/src/interceptors/ClientRequest/index.ts index edc599cf..2e69c4a4 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,61 +1,220 @@ -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 { 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 { + kRequestId, + MockHttpSocketRequestCallback, + MockHttpSocketResponseCallback, +} from './MockHttpSocket' +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 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 } = https + + 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]) + }, + }) - for (const [protocol, requestModule] of this.modules) { - const { request: pureRequest, get: pureGet } = requestModule + // + // HTTPS. + // - this.subscriptions.push(() => { - requestModule.request = pureRequest - requestModule.get = pureGet + 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 + }) + } - logger.info('native "%s" module restored!', protocol) + private onRequest: MockHttpSocketRequestCallback = async ({ + request, + socket, + }) => { + const requestId = Reflect.get(request, kRequestId) + 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, }) - const options: NodeClientOptions = { - emitter: this.emitter, - logger: this.logger, + return await requestController.responsePromise + }) + + if (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 + } + + // 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 + } } - // @ts-ignore - requestModule.request = - // Force a line break. - request(protocol, options) + // 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 + } - // @ts-ignore - requestModule.get = - // Force a line break. - get(protocol, options) + const mockedResponse = listenerResult.data - logger.info('native "%s" module patched!', protocol) + if (mockedResponse) { + /** + * @note The `.respondWith()` method will handle "Response.error()". + * Maybe we should make all interceptors do that? + */ + socket.respondWith(mockedResponse) + return } + + socket.passthrough() + } + + public onResponse: MockHttpSocketResponseCallback = async ({ + requestId, + request, + response, + isMockedResponse, + }) => { + // Return the promise to when all the response event listeners + // are finished. + return emitAsync(this.emitter, 'response', { + requestId, + request, + response, + isMockedResponse, + }) } } 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/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 1312ed9c..00000000 --- a/src/interceptors/ClientRequest/utils/createRequest.ts +++ /dev/null @@ -1,51 +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 username = decodeURIComponent(clientRequest.url.username || '') - const password = decodeURIComponent(clientRequest.url.password || '') - const auth = `${username}:${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, - }) -} 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/normalizeClientRequestArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts index c5bef850..3080c7aa 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,28 +265,27 @@ 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') // Request options must be preserved. - expect(options).toEqual(initialOptions) + expect(options).toMatchObject(initialOptions) // Callback must be preserved. + expect(callback).toBeTypeOf('function') expect(callback?.name).toEqual('cb') }) it('handles [Empty RequestOptions, callback] input', () => { - const [_, options, callback] = normalizeClientRequestArgs( - 'https:', + 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( @@ -332,20 +320,20 @@ 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:') // 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..b8d4c750 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 { @@ -93,7 +103,7 @@ function resolveCallback( export type NormalizedClientRequestArgs = [ url: URL, options: ResolvedRequestOptions, - callback?: HttpRequestCallback + callback?: HttpRequestCallback, ] /** @@ -102,7 +112,7 @@ export type NormalizedClientRequestArgs = [ */ export function normalizeClientRequestArgs( defaultProtocol: string, - ...args: ClientRequestArgs + args: ClientRequestArgs ): NormalizedClientRequestArgs { let url: URL let options: ResolvedRequestOptions @@ -172,16 +182,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,20 +198,19 @@ 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. 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", @@ -266,5 +273,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] } 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.test.ts b/src/interceptors/Socket/MockSocket.test.ts new file mode 100644 index 00000000..61235cf3 --- /dev/null +++ b/src/interceptors/Socket/MockSocket.test.ts @@ -0,0 +1,264 @@ +/** + * @vitest-environment node + */ +import { Socket } from 'node:net' +import { vi, it, expect } from 'vitest' +import { MockSocket } from './MockSocket' + +it(`keeps the socket connecting until it's destroyed`, () => { + const socket = new MockSocket({ + write: vi.fn(), + read: vi.fn(), + }) + + expect(socket.connecting).toBe(true) + + socket.destroy() + expect(socket.connecting).toBe(false) +}) + +it('calls the "write" on "socket.write()"', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + socket.write() + expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined) +}) + +it('calls the "write" on "socket.write(chunk)"', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + socket.write('hello') + expect(writeCallback).toHaveBeenCalledWith('hello', undefined, undefined) +}) + +it('calls the "write" on "socket.write(chunk, encoding)"', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + socket.write('hello', 'utf8') + expect(writeCallback).toHaveBeenCalledWith('hello', 'utf8', undefined) +}) + +it('calls the "write" on "socket.write(chunk, encoding, callback)"', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + const callback = vi.fn() + socket.write('hello', 'utf8', callback) + expect(writeCallback).toHaveBeenCalledWith('hello', 'utf8', callback) +}) + +it('calls the "write" on "socket.end()"', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + socket.end() + expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined) +}) + +it('calls the "write" on "socket.end(chunk)"', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + socket.end('final') + expect(writeCallback).toHaveBeenCalledWith('final', undefined, undefined) +}) + +it('calls the "write" on "socket.end(chunk, encoding)"', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + socket.end('final', 'utf8') + expect(writeCallback).toHaveBeenCalledWith('final', 'utf8', undefined) +}) + +it('calls the "write" on "socket.end(chunk, encoding, callback)"', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + const callback = vi.fn() + socket.end('final', 'utf8', callback) + expect(writeCallback).toHaveBeenCalledWith('final', 'utf8', callback) +}) + +it('calls the "write" on "socket.end()" without any arguments', () => { + const writeCallback = vi.fn() + const socket = new MockSocket({ + write: writeCallback, + read: vi.fn(), + }) + + socket.end() + expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined) +}) + +it('emits "finished" on .end() without any arguments', async () => { + const finishListener = vi.fn() + const socket = new MockSocket({ + write: vi.fn(), + read: vi.fn(), + }) + socket.on('finish', finishListener) + socket.end() + + await vi.waitFor(() => { + expect(finishListener).toHaveBeenCalledTimes(1) + }) +}) + +it('calls the "read" on "socket.read(chunk)"', () => { + const readCallback = vi.fn() + const socket = new MockSocket({ + write: vi.fn(), + read: readCallback, + }) + + socket.push('hello') + expect(readCallback).toHaveBeenCalledWith('hello', undefined) +}) + +it('calls the "read" on "socket.read(chunk, encoding)"', () => { + const readCallback = vi.fn() + const socket = new MockSocket({ + write: vi.fn(), + read: readCallback, + }) + + socket.push('world', 'utf8') + expect(readCallback).toHaveBeenCalledWith('world', 'utf8') +}) + +it('calls the "read" on "socket.read(null)"', () => { + const readCallback = vi.fn() + const socket = new MockSocket({ + write: vi.fn(), + read: readCallback, + }) + + socket.push(null) + expect(readCallback).toHaveBeenCalledWith(null, undefined) +}) + +it('updates the writable state on "socket.end()"', async () => { + const finishListener = vi.fn() + const endListener = vi.fn() + const socket = new MockSocket({ + write: vi.fn(), + read: vi.fn(), + }) + socket.on('finish', finishListener) + socket.on('end', endListener) + + expect(socket.writable).toBe(true) + expect(socket.writableEnded).toBe(false) + expect(socket.writableFinished).toBe(false) + + socket.write('hello') + // Finish the writable stream. + socket.end() + + expect(socket.writable).toBe(false) + expect(socket.writableEnded).toBe(true) + + // The "finish" event is emitted when writable is done. + // I.e. "socket.end()" is called. + await vi.waitFor(() => { + expect(finishListener).toHaveBeenCalledTimes(1) + }) + expect(socket.writableFinished).toBe(true) +}) + +it('updates the readable state on "socket.push(null)"', async () => { + const endListener = vi.fn() + const socket = new MockSocket({ + write: vi.fn(), + read: vi.fn(), + }) + socket.on('end', endListener) + + expect(socket.readable).toBe(true) + expect(socket.readableEnded).toBe(false) + + socket.push('hello') + socket.push(null) + + expect(socket.readable).toBe(true) + expect(socket.readableEnded).toBe(false) + + // Read the data to free the buffer and + // make Socket emit "end". + socket.read() + + await vi.waitFor(() => { + expect(endListener).toHaveBeenCalledTimes(1) + }) + expect(socket.readable).toBe(false) + expect(socket.readableEnded).toBe(true) +}) + +it('updates the readable/writable state on "socket.destroy()"', async () => { + const finishListener = vi.fn() + const endListener = vi.fn() + const closeListener = vi.fn() + const socket = new MockSocket({ + write: vi.fn(), + read: vi.fn(), + }) + socket.on('finish', finishListener) + socket.on('end', endListener) + socket.on('close', closeListener) + + expect(socket.writable).toBe(true) + expect(socket.writableEnded).toBe(false) + expect(socket.writableFinished).toBe(false) + expect(socket.readable).toBe(true) + + socket.destroy() + + expect(socket.writable).toBe(false) + // The ".end()" wasn't called. + expect(socket.writableEnded).toBe(false) + expect(socket.writableFinished).toBe(false) + expect(socket.readable).toBe(false) + + await vi.waitFor(() => { + expect(closeListener).toHaveBeenCalledTimes(1) + }) + + // Neither "finish" nor "end" events are emitted + // when you destroy the stream. If you want those, + // call ".end()", then destroy the stream. + expect(finishListener).not.toHaveBeenCalled() + expect(endListener).not.toHaveBeenCalled() + expect(socket.writableFinished).toBe(false) + + // The "end" event was never emitted so "readableEnded" + // remains false. + expect(socket.readableEnded).toBe(false) +}) diff --git a/src/interceptors/Socket/MockSocket.ts b/src/interceptors/Socket/MockSocket.ts new file mode 100644 index 00000000..412961ed --- /dev/null +++ b/src/interceptors/Socket/MockSocket.ts @@ -0,0 +1,59 @@ +import net from 'node:net' +import { + normalizeSocketWriteArgs, + type WriteArgs, + type WriteCallback, +} from './utils/normalizeSocketWriteArgs' + +export interface MockSocketOptions { + write: ( + chunk: Buffer | string, + encoding: BufferEncoding | undefined, + callback?: WriteCallback + ) => void + + read: (chunk: Buffer, encoding: BufferEncoding | undefined) => void +} + +export class MockSocket extends net.Socket { + public connecting: boolean + + constructor(protected readonly options: MockSocketOptions) { + super() + this.connecting = false + this.connect() + + this._final = (callback) => { + callback(null) + } + } + + public connect() { + // The connection will remain pending until + // the consumer decides to handle it. + this.connecting = true + return this + } + + public write(...args: Array): boolean { + const [chunk, encoding, callback] = normalizeSocketWriteArgs( + args as WriteArgs + ) + this.options.write(chunk, encoding, callback) + return true + } + + public end(...args: Array) { + const [chunk, encoding, callback] = normalizeSocketWriteArgs( + args as WriteArgs + ) + this.options.write(chunk, encoding, callback) + + return super.end.apply(this, args as any) + } + + public push(chunk: any, encoding?: BufferEncoding): boolean { + this.options.read(chunk, encoding) + return super.push(chunk, encoding) + } +} diff --git a/src/interceptors/Socket/utils/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/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/normalizeSocketWriteArgs.ts b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts new file mode 100644 index 00000000..03a3e9c0 --- /dev/null +++ b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts @@ -0,0 +1,33 @@ +export type WriteCallback = (error?: Error | null) => void + +export type WriteArgs = + | [chunk: unknown, callback?: WriteCallback] + | [chunk: unknown, encoding: BufferEncoding, callback?: WriteCallback] + +export type NormalizedSocketWriteArgs = [ + chunk: any, + encoding?: BufferEncoding, + callback?: WriteCallback, +] + +/** + * Normalizes the arguments provided to the `Writable.prototype.write()` + * and `Writable.prototype.end()`. + */ +export function normalizeSocketWriteArgs( + args: WriteArgs +): NormalizedSocketWriteArgs { + const normalized: NormalizedSocketWriteArgs = [args[0], undefined, undefined] + + if (typeof args[1] === 'string') { + normalized[1] = args[1] + } else if (typeof args[1] === 'function') { + normalized[2] = args[1] + } + + if (typeof args[2] === 'function') { + normalized[2] = args[2] + } + + return normalized +} 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/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) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index f8ddcf47..7c0e0d93 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 nodeFetch 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() @@ -124,7 +125,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 +261,6 @@ it('fetch: emits the "response" event upon the original response', async () => { interceptor.on('response', responseListener) await nodeFetch(httpServer.https.url('/account'), { - agent: httpsAgent, method: 'POST', headers: { 'x-request-custom': 'yes', diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index acc92130..fd2a8a76 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/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index 8e5a57f4..9fde2722 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 { REQUEST_ID_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() @@ -151,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(REQUEST_ID_REGEXP) @@ -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', @@ -302,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(REQUEST_ID_REGEXP) @@ -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', @@ -327,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(REQUEST_ID_REGEXP) 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) diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts new file mode 100644 index 00000000..5b42a435 --- /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 { REQUEST_ID_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(REQUEST_ID_REGEXP) + expect(requestFromListener).toBeInstanceOf(Request) + expect(requestFromListener.method).toBe('GET') + expect(requestFromListener.url).toBe('http://localhost/') + expect( + Object.fromEntries(requestFromListener.headers.entries()) + ).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(requestFromListener.body).toBe(null) + + // Must respond with the mocked response. + expect(res.statusCode).toBe(200) + expect(await text()).toBe('hello world') +}) + +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(REQUEST_ID_REGEXP) + expect(requestFromListener).toBeInstanceOf(Request) + expect(requestFromListener.method).toBe('GET') + expect(requestFromListener.url).toBe(httpServer.http.url('/')) + expect( + Object.fromEntries(requestFromListener.headers.entries()) + ).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(requestFromListener.body).toBe(null) + + // Must respond with the mocked response. + expect(res.statusCode).toBe(200) + expect(await text()).toBe('original-response') +}) diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index 4fa60fb4..d3d0b52f 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -76,7 +76,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) @@ -88,7 +88,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) 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) +}) 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('') +}) diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index d13c414b..b47e8ffb 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -1,11 +1,14 @@ +/** + * @vitest-environment node + */ 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 { 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 { text, 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') }) diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index c7ff3d4d..c7377ee2 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -1,5 +1,8 @@ +/** + * @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' diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts index 4c834709..28b68a80 100644 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ b/test/modules/http/compliance/http-req-callback.test.ts @@ -1,14 +1,16 @@ +/** + * @vitest-environment node + */ import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { IncomingMessage } from 'http' -import https from 'https' -import { HttpServer, httpsAgent } from '@open-draft/test-server/http' -import { getRequestOptionsByUrl } from '../../../../src/utils/getRequestOptionsByUrl' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { IncomingMessage } from 'node:http' +import https from 'node:https' +import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/get', (req, res) => { - res.status(200).send('/').end() + res.status(200).send('/') }) }) @@ -44,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 ) @@ -71,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/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 48f8fda7..8eb64c48 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,10 +1,13 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' 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 { waitForClientRequest } from '../../../helpers' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { sleep, waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { app.post('/resource', express.text({ type: '*/*' }), (req, res) => { @@ -19,10 +22,6 @@ 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 +53,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 +68,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,33 +91,106 @@ 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) }) -it('does not call the write callback when writing an empty string', async () => { - const req = http.request(httpServer.http.url('/resource'), { +it('supports Readable as the request body', async () => { + const request = 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(request) + + await waitForClientRequest(request) + expect(interceptedRequestBody).toHaveBeenCalledWith('hello world') +}) + +it('calls the write 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 write 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(request) + + 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', () => { + request.end() + }) - await waitForClientRequest(req) + const { text } = await waitForClientRequest(request) - expect(writeCallback).not.toHaveBeenCalled() + expect(requestBodyCallback).toHaveBeenCalledWith('one') + expect(await text()).toBe('hello world') }) 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..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,10 +1,19 @@ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +/** + * @vitest-environment node + */ +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() }) @@ -13,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() @@ -25,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') }) @@ -65,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') }) 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..ee5866dc 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -1,16 +1,30 @@ +/** + * @vitest-environment node + */ 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 { 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 () => { @@ -24,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' }) }) 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..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 @@ -1,10 +1,11 @@ /** + * @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' 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..04899ef6 100644 --- a/test/modules/http/compliance/http-res-set-encoding.test.ts +++ b/test/modules/http/compliance/http-res-set-encoding.test.ts @@ -1,5 +1,8 @@ +/** + * @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' 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() +}) 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..7b315a96 --- /dev/null +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -0,0 +1,62 @@ +/** + * @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', socketPromise.resolve) + + 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', socketPromise.resolve) + + 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) +}) 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() +}) 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. diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts new file mode 100644 index 00000000..6d24477b --- /dev/null +++ b/test/modules/http/compliance/http.test.ts @@ -0,0 +1,247 @@ +/** + * @vitest-environment node + */ +import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' +import http from 'node:http' +import express from 'express' +import { HttpServer } from '@open-draft/test-server/http' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' + +const interceptor = new ClientRequestInterceptor() + +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.url).toBe(httpServer.http.url('/user')) + 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.url).toBe(httpServer.http.url('/user')) + 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.url).toBe('http://foo.example.com/') + 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('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 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')) + 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), + }) +}) diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index 89d13f3d..6732e62b 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -1,14 +1,15 @@ /** + * @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 { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { IncomingMessage } from 'node:http' +import https from 'node:https' +import { URL } from 'node:url' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpServer } from '@open-draft/test-server/http' import { getIncomingMessageBody } from '../../../../src/interceptors/ClientRequest/utils/getIncomingMessageBody' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { DeferredPromise } from '@open-draft/deferred-promise' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { @@ -34,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) 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() +}) diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index f0a1956f..87614e3b 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 { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' import { httpGet, PromisifiedResponse, useCors } from '../../helpers' +import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' function arrayWith(length: number, mapFn: (index: number) => V): V[] { return new Array(length).fill(null).map((_, index) => mapFn(index)) @@ -29,6 +32,7 @@ const httpServer = new HttpServer((app) => { }) 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 1edbf38d..a341ea51 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -3,7 +3,7 @@ import http from 'http' import { HttpServer } from '@open-draft/test-server/http' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src' +import { HttpRequestEventMap } from '../../../../src/glossary' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 6b5e4993..8016c884 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 { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' +import { HttpRequestEventMap } from '../../../../src/glossary' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { HttpRequestEventMap } from '../../../../src' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -52,7 +52,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 +80,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,11 +96,13 @@ it('intercepts a POST request', async () => { const req = http.request(url, { method: 'POST', headers: { + 'content-length': '12', 'x-custom-header': 'yes', }, }) req.write('post-payload') req.end() + await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -109,7 +111,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 +127,7 @@ it('intercepts a PUT request', async () => { const req = http.request(url, { method: 'PUT', headers: { + 'content-length': '11', 'x-custom-header': 'yes', }, }) @@ -138,7 +141,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 +157,7 @@ it('intercepts a PATCH request', async () => { const req = http.request(url, { method: 'PATCH', headers: { + 'content-length': '13', 'x-custom-header': 'yes', }, }) @@ -167,7 +171,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,12 +199,12 @@ 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', }) 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(REQUEST_ID_REGEXP) diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 379ce220..9c6fc059 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,9 +1,9 @@ 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 { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' +import { HttpRequestEventMap } from '../../../../src/glossary' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { HttpRequestEventMap } from '../../../../src' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -31,26 +31,29 @@ afterAll(async () => { it('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, { + rejectUnauthorized: false, headers: { 'x-custom-header': 'yes', }, }) - await waitForClientRequest(req) + + 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(REQUEST_ID_REGEXP) }) diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index e606022d..eed473fe 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 { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { HttpRequestEventMap } from '../../../../src' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (req, res) => { @@ -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(REQUEST_ID_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', 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..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 @@ -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' 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) => { 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..09b8b1dd 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -1,8 +1,9 @@ /** + * @vitest-environment node * @see https://github.com/mswjs/interceptors/issues/2 */ import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +import http from 'node:http' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' let requests: Array = [] @@ -41,16 +42,16 @@ it('resolves multiple concurrent requests to the same host independently', async promisifyClientRequest(() => { return http.get('http://httpbin.org/get') }), - promisifyClientRequest(() => { - return http.get('http://httpbin.org/get?header=abc', { - headers: { 'x-custom-header': 'abc' }, - }) - }), - promisifyClientRequest(() => { - return http.get('http://httpbin.org/get?header=123', { - headers: { 'x-custom-header': '123' }, - }) - }), + // promisifyClientRequest(() => { + // return http.get('http://httpbin.org/get?header=abc', { + // headers: { 'x-custom-header': 'abc' }, + // }) + // }), + // promisifyClientRequest(() => { + // return http.get('http://httpbin.org/get?header=123', { + // headers: { 'x-custom-header': '123' }, + // }) + // }), ]) for (const request of requests) { diff --git a/test/modules/http/regressions/http-empty-readable-stream-response.test.ts b/test/modules/http/regressions/http-empty-readable-stream-response.test.ts 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('') +}) diff --git a/test/modules/http/regressions/http-socket-timeout.test.ts b/test/modules/http/regressions/http-socket-timeout.test.ts index 88a86e83..8e138fb1 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' @@ -10,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. @@ -37,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) diff --git a/test/modules/http/regressions/http-socket-timeout.ts b/test/modules/http/regressions/http-socket-timeout.ts index 743b26af..e92b6203 100644 --- a/test/modules/http/regressions/http-socket-timeout.ts +++ b/test/modules/http/regressions/http-socket-timeout.ts @@ -1,11 +1,12 @@ /** + * @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' 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('') +}) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index e5562e02..53f5be65 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 { waitForClientRequest } from '../../../helpers' +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) => { @@ -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-readable-stream.test.ts b/test/modules/http/response/http-readable-stream.test.ts deleted file mode 100644 index 040e51b4..00000000 --- a/test/modules/http/response/http-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 '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/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 06a7ddc1..ab20d2ed 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -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 e11d8ad4..ee12ac93 100644 --- a/test/modules/http/response/http-response-error.test.ts +++ b/test/modules/http/response/http-response-error.test.ts @@ -1,4 +1,4 @@ -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 9ecb5a8b..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,6 @@ 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 { BatchInterceptor } from '../../../../src' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep, waitForClientRequest } from '../../../helpers' @@ -11,10 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new BatchInterceptor({ - name: 'response-patching', - interceptors: [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/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/third-party/axios.test.ts b/test/third-party/axios.test.ts index 48ec8625..e9ef1959 100644 --- a/test/third-party/axios.test.ts +++ b/test/third-party/axios.test.ts @@ -1,10 +1,12 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' +/** + * @vitest-environment jsdom + */ +import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import axios from 'axios' import { HttpServer } from '@open-draft/test-server/http' +import { DeferredPromise } from '@open-draft/deferred-promise' import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' import { useCors } from '../helpers' -import { DeferredPromise } from '@open-draft/deferred-promise' function createMockResponse() { return new Response( diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index db34f6fb..f26bef11 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -1,7 +1,7 @@ // @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' @@ -53,9 +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, }, - agent: httpsAgent, + rejectUnauthorized: false, }, (res) => { catchResponseUrl(res.responseUrl) 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. diff --git a/tsconfig.json b/tsconfig.json index e4500ebd..a6dc9747 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,12 @@ "removeComments": false, "esModuleInterop": true, "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"] + "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], + "types": ["@types/node"], + "baseUrl": ".", + "paths": { + "_http_commons": ["./_http.common.d.ts"] + } }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "**/*.test.*"]