diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 826e8d654..20bd56c65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: build: runs-on: macos-latest strategy: + fail-fast: false matrix: node: [22, 24] steps: @@ -31,16 +32,16 @@ jobs: run: pnpm install - name: Unit tests - run: pnpm test:unit + run: pnpm test -- --project=unit - name: Build run: pnpm build - name: Node.js tests - run: pnpm test:node + run: pnpm test -- --project=node - name: Install Playwright browsers run: npx playwright install chromium --with-deps --only-shell - name: Browser tests - run: pnpm test:browser + run: pnpm test -- --project=browser diff --git a/.gitignore b/.gitignore index c2671178c..21997906b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ package-lock.json .idea /test-results /tmp +__screenshots__ diff --git a/README.md b/README.md index fb5e324f6..455eecfa6 100644 --- a/README.md +++ b/README.md @@ -104,21 +104,6 @@ You can respond to the intercepted HTTP request by constructing a Fetch API Resp - Does **not** provide any request matching logic; - Does **not** handle requests by default. -## Limitations - -- Interceptors will hang indefinitely if you call `req.end()` in the `connect` event listener of the respective `socket`: - -```ts -req.on('socket', (socket) => { - socket.on('connect', () => { - // ❌ While this is allowed in Node.js, this cannot be handled in Interceptors. - req.end() - }) -}) -``` - -> This limitation is intrinsic to the interception algorithm used by the library. In order for it to emit the `connect` event on the socket, the library must know if you've handled the request in any way (e.g. responded with a mocked response or errored it). For that, it emits the `request` event on the interceptor where you can handle the request. Since you can consume the request stream in the `request` event, it waits until the request body stream is complete (i.e. until `req.end()` is called). This creates a catch 22 that causes this limitation. - ## Getting started ```bash @@ -297,10 +282,12 @@ Note that a single request _can only be handled once_. You may want to introduce Requests must be responded to within the same tick as the request listener. This means you cannot respond to a request using `setTimeout`, as this will delegate the callback to the next tick. If you wish to introduce asynchronous side-effects in the listener, consider making it an `async` function, awaiting any side-effects you need. ```js +import { setTimeout } from 'node:timers/promises' + // Respond to all requests with a 500 response // delayed by 500ms. interceptor.on('request', async ({ controller }) => { - await sleep(500) + await setTimeout(500) controller.respondWith(new Response(null, { status: 500 })) }) ``` diff --git a/XMLHttpRequest/package.json b/XMLHttpRequest/package.json index bc0cbf043..b11063132 100644 --- a/XMLHttpRequest/package.json +++ b/XMLHttpRequest/package.json @@ -1,12 +1,20 @@ { - "main": "../lib/node/interceptors/XMLHttpRequest/index.cjs", - "module": "../lib/node/interceptors/XMLHttpRequest/index.mjs", - "browser": "../lib/browser/interceptors/XMLHttpRequest/index.mjs", + "main": "../lib/node/interceptors/XMLHttpRequest/node.cjs", + "module": "../lib/node/interceptors/XMLHttpRequest/node.mjs", + "browser": "../lib/browser/interceptors/XMLHttpRequest/web.mjs", "exports": { ".": { - "browser": "./../lib/browser/interceptors/XMLHttpRequest/index.mjs", - "import": "./../lib/node/interceptors/XMLHttpRequest/index.mjs", - "default": "./../lib/node/interceptors/XMLHttpRequest/index.cjs" + "browser": "./../lib/browser/interceptors/XMLHttpRequest/web.mjs", + "import": "./../lib/node/interceptors/XMLHttpRequest/node.mjs", + "default": "./../lib/node/interceptors/XMLHttpRequest/node.cjs" + }, + "./node": { + "import": "./../lib/node/interceptors/XMLHttpRequest/node.mjs", + "default": "./../lib/node/interceptors/XMLHttpRequest/node.cjs" + }, + "./web": { + "import": "./../lib/browser/interceptors/XMLHttpRequest/web.mjs", + "default": "./../lib/browser/interceptors/XMLHttpRequest/web.cjs" } } } diff --git a/_http_common.d.ts b/_http_common.d.ts deleted file mode 100644 index a968379cc..000000000 --- a/_http_common.d.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Socket } from 'node:net' -import type { IncomingMessage, OutgoingMessage } from 'node:http' - -declare var HTTPParser: { - new (): HTTPParser - REQUEST: 0 - RESPONSE: 1 - - /** - * @see https://github.com/nodejs/node/blob/229cc3be28eab3153c16bc55bc67d1e81c4a7067/src/node_http_parser.cc#L76 - */ - readonly kOnMessageBegin: unique symbol - readonly kOnHeaders: unique symbol - readonly kOnHeadersComplete: unique symbol - readonly kOnBody: unique symbol - readonly kOnMessageComplete: unique symbol - readonly kOnExecute: unique symbol - readonly kOnTimeout: unique symbol -} - -export interface HTTPParser { - new (): HTTPParser - - [HTTPParser.kOnMessageBegin]: (() => void) | null - [HTTPParser.kOnHeaders]: HeadersCallback | null - [HTTPParser.kOnHeadersComplete]: ParserType extends 0 - ? RequestHeadersCompleteCallback | null - : ResponseHeadersCompleteCallback | null - [HTTPParser.kOnBody]: ((chunk: Buffer) => void) | null - [HTTPParser.kOnMessageComplete]: (() => void) | null - [HTTPParser.kOnExecute]: (() => void) | null - [HTTPParser.kOnTimeout]: (() => void) | null - - initialize(type: ParserType, asyncResource: object): void - execute(buffer: Buffer): void - _url: string - _headers?: Array - _consumed?: boolean - maxHeaderPairs: number - socket?: Socket | null - incoming?: IncomingMessage | null - outgoing?: OutgoingMessage | null - onIncoming?: (() => void) | null - joinDuplicateHeaders?: unknown - finish(): void - unconsume(): void - remove(): void - close(): void - free(): void -} - -export type HeadersCallback = (rawHeaders: Array, url: string) => void - -export type RequestHeadersCompleteCallback = ( - versionMajor: number, - versionMinor: number, - rawHeaders: Array, - idk: number, - path: string, - idk2: unknown, - idk3: unknown, - idk4: unknown, - shouldKeepAlive: boolean -) => void - -export type ResponseHeadersCompleteCallback = ( - versionMajor: number, - versionMinor: number, - headers: Array, - method: string | undefined, - url: string | undefined, - status: number, - statusText: string, - upgrade: boolean, - shouldKeepAlive: boolean -) => void diff --git a/discoveries/http.mdx b/discoveries/http.mdx new file mode 100644 index 000000000..4ddcf99b9 --- /dev/null +++ b/discoveries/http.mdx @@ -0,0 +1,57 @@ +# General + +## Reusing sockets + +By default, `Agent` will try to reuse any sockets that were not explicitly closed via `Connection: close` request header. Here's what happens when a socket gets reused: + +1. `free` is emitted on the socket to notify that it's done with the previous request. +2. `connect` is _not_ emitted on this socket anymore since the connection has already been established. No connection attempt will be made whatsoever (`socket._handle.connect` won't be called). +3. The next request's HTTP message is written into the socket. + +> With this in mind, `socket.on('connect', () => request.end())` is a potentially faulty logic. A reused socket will never emit that event and the request will pend indefinitely. + +## `CONNECT` requests + +While forbidden by the Fetch API specification, `CONNECT` requests are possible in Node.js. Here's what's special about connect requests: + +- Are sent to the _running_ server (`options.host` + `options.port)` to notify it about the intent to connect somewhere esle. +- Do _not_ actually establish any connections. Instead, the server handles the same socket instance to establish that connection. +- Never recieve the response (`response` is never emitted.) + +```ts +const client = http.request({ + // Actual server handling the "CONNECT" request. + host: '127.0.0.1', + port: 1337, + // The (proxy) authority in a "host:port" format. + path: 'www.example.com:80' +}) + +client.on('connect', (request, socket) => { + // Use "socket" to write to the authority... +}) +``` + +# HTTPS/TLS + +### `TLSWrap` + +Regular `net.Socket` connections finalize in the `afterConnect` callback [attached](https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/net.js#L1142) to the `TCPWrap` request. A TLS sockets\, while extending `net.Socket` and still relying on `afterConnect` being called to transition the socket into the connected state, _do not_ rely on `_handle.oncomplete`. **It is never called**. + +A TLS socket wraps `net.Socket._handle` in a `TLSWrap`, which becomes responsible for the connection. It calls the `afterConnect` callback _internally_, in the C++ code, and there's no public method to trigger it. + +> TLS wrap has its own methods, like `onhandshakedone` and `verifyError`. + +### Connection event sequence + +> For brevity: "wrap" means the TCP socket TLS extends, "socket" means the TLS socket. + +1. `tls.connect()`. +2. `new TLSSocket(options.socket)`. +3. `TLSSocket.prototype._wrapHandle` (wraps TCPWrap in TLSWrap). +4. Calls `socket._handle.start()` (or schedules it until wrap emits "connect"). +5. Wrap emits "connect". +6. Socket emits "connect". +7. Socket validates the connection (handshake, certificate validation). +7. Socket emits "secureConnect". +7. Socket emits "secure". diff --git a/discoveries/undici.mdx b/discoveries/undici.mdx new file mode 100644 index 000000000..9b7d035be --- /dev/null +++ b/discoveries/undici.mdx @@ -0,0 +1,22 @@ +# Undici + +## `connect` and writing the HTTP message + +Undici's `fetch` waits for the underlying socket to emit the `connect` event _before_ it writes the HTTP message to it. That is different than in `http.request()`, where the HTTP message is written _immediately_ but gets buffered (`socket._pendinData`) and flushed once `connect` is emitted. + +``` +# Node.js v22 +- dispatch() (lib/web/fetch/index.js:2129) + - agent.dispatch() + - Client.dispatch() + - _resume() + - connect() + - client[kConnector](options, cb*) + - Client.connect() + - net.connect(options) // no callback here + - socket.once('connect', cb*) // or "secureConnect" +``` + +> Note that the `cb` called on `connect` is _internal_. It's not provided as the connection callback to `net.connect()`, which makes it invisible to the interceptor. + +Then `connectH1()` calls `writeH1()` to write the HTTP message. diff --git a/fetch/package.json b/fetch/package.json index 5c7a419e9..a55695a08 100644 --- a/fetch/package.json +++ b/fetch/package.json @@ -1,12 +1,12 @@ { - "main": "../lib/node/interceptors/fetch/index.cjs", - "module": "../lib/node/interceptors/fetch/index.mjs", - "browser": "../lib/browser/interceptors/fetch/index.mjs", + "main": "../lib/node/interceptors/fetch/node.cjs", + "module": "../lib/node/interceptors/fetch/node.mjs", + "browser": "../lib/browser/interceptors/fetch/web.mjs", "exports": { ".": { - "browser": "./../lib/browser/interceptors/fetch/index.mjs", - "import": "./../lib/node/interceptors/fetch/index.mjs", - "default": "./../lib/node/interceptors/fetch/index.cjs" + "browser": "./../lib/browser/interceptors/fetch/web.mjs", + "import": "./../lib/node/interceptors/fetch/node.mjs", + "default": "./../lib/node/interceptors/fetch/node.cjs" } } } diff --git a/http/package.json b/http/package.json new file mode 100644 index 000000000..5517cf68f --- /dev/null +++ b/http/package.json @@ -0,0 +1,11 @@ +{ + "main": "../lib/node/interceptors/node/index.cjs", + "module": "../lib/node/interceptors/node/index.mjs", + "browser": null, + "exports": { + ".": { + "import": "./../lib/node/interceptors/node/index.mjs", + "default": "./../lib/node/interceptors/node/index.cjs" + } + } +} diff --git a/net-architecture.md b/net-architecture.md new file mode 100644 index 000000000..d561cef19 --- /dev/null +++ b/net-architecture.md @@ -0,0 +1,28 @@ +## Net-based Interception + +Your task is to implement the network packet interception on the `net.Socket` level in Node.js. The underlying `SocketInterceptor` is already implemented at `src/interceptors/net/index.ts`. Below, are the outline of the necessary pieces. + +## `SocketInterceptor` + +- Responsible for patching `net.createConnection` and `net.connect`. Once patched, any created connection receive a mock socket instance. +- The mock socket instance pretends to successfully establish the connection with whatever connection options provided. This is so connecting to the non-existing hosts works in the mock-first scenario. +- The mock socket is wrapped in a `controller`. Controller is used by a higher-level interceptors to control the underlying socket. Since the data sent over socket is ambiguous, the controller only allows for `.connect()` (to mock the connection), `.passthrough()` (establish the connection as-is), and `.errorWith()` (abort the socket connection). +- Only for the user, a special socket reference is created and exposed as the `socket` argument in the `connection` listener. That special socket is meant to represent the intercepted socket instance _from the server's perspective_. This means that `socket.write()` on the actual socket (e.g. the one created by `net.connect` and then used via `http.ClientRequest`) are translated to the "data" events being emitted on the special server-side `socket`. The server-side socket is used ONLY for this purpose: to observe what the client writes _and_ write data _to_ the client from the `connection` listener as if it has been sent from the "server". + +## `HttpRequestInterceptor` + +- `src/interceptors/http/index.ts` +- This is a higher-level interceptor that relies on `SocketInterceptor` and routes all the intercepted socket packets through the HTTP request parser (we're using Node's parser). +- If the request parser tells us that the sent packet is an HTTP request message header, the interceptor then proceeds with handling that request. It emits the `request` event for the consumer, and uses the established `RequestController` to control the request flow (`.respondWith`, `.errorWith`, etc). These APIs are already created and functional, they need no change. +- The missing parts in the `HttpRequestInterceptor` is establishing the passthrough connection properly. For that, the socket controller accepted the `createConnection` option that constructs the bypassed socket. That works. But the socket hangs forever. + +## Requirements + +- Feel free to improve the existing classes but stay strictily within each class' responsibilities. Those must not leak. +- The overall architecture must compose: MockSocket -> SocketController -> Higher-level interceptors. +- Introduce absolutely no workarounds, zero. +- You will need to reference Node.js internals, particularly `net` and `http` modules to implement this functionality properly. +- Feel free to rely on Node.js internals knowledge and be clever, but not hacky. +- Feel free to `@ts-ignore` property access to Node.js internals, such as `socket.parser`. +- Do not comment your code. +- You can verify your changes via `pnpm test:node test/modules/http/response/http-response-delay.test.ts`. All the test cases must pass there. diff --git a/net-outline.md b/net-outline.md new file mode 100644 index 000000000..8a21dcd73 --- /dev/null +++ b/net-outline.md @@ -0,0 +1,76 @@ +## High-level architecture overview + +> The `net.Socket` constructor doesn't have all the context of `net.connect()` to be a viable layer of interception. All the network requests in Node.js go through `net.connect()` (HTTP, SMTP, etc). + +``` +http.request + Agent.createConnection() + net.Socket +``` + +``` +net.connect() + net.Socket +``` + +## Pieces + +- `SocketInterceptor`. A regular interceptor, patches `net.connect()` and emits the "connection" event to the consumers. +- `MockSocket` returned from the patched `net.connect()`. Always emulates a successful connection, allows spying on the data sent by the client (`socket.write()`) and pushing data to it from the interceptor. +- `SocketController` that, similar to `RequestController`, lets the user decide what to do with the intercepted socket connection. Unlike requests, there's no single `respondWith()` as the nature of data sent via the socket is ambiguous and is for the higher-level interceptors to determine and appropriately handle. There are still, however, methods to error the intercepted connection (`SocketController.errorWith()`) and perform it as-is (`SocketController.passthrough()`). +- A special `net.Socket` representation exposed in the "connection" listener. The `MockSocket` placeholder is _client-side_. To work with the intercepted socket from the server's perspective in the "connection" listener, another, special socket instance has to be provided. It acts as a mirror and allows the user to write data _to_ the client socket via `socket.write()` and listen to the data _sent_ from the client via `socket.on('data')`. This won't be possible with the client placeholder as `socket.on('data')` would represent the data received from the _server_ (and must still be emitted when the interceptor sends mock data to the socket). + +This pretty much covers the mock-first scenarios. But when it comes to passthrough, it gets tricky. The passthrough socket will usually be created after some time the connection has been intercepted. During that time, the intercepted socket might have been acted upon (e.g. written to, changed via methods). The passthrough socket would have to be put _in the exact same state_ as the intercepted socket at the moment of calling `SocketController.passthrough()`. + +This is where I think about employing an _object recorder_. It will use `Proxy` to record any changes done with the intercepted socket instance and then allow us to replay those changes on the passthrough socket. + +## Problems + +### Problem 1: Passthrough socket state + +Node.js sockets are quite intricate. Even with deduped method/property recording via `AsyncLocalStorage`, it still arrives at the state where a change on the intercepted socket is attempted to be recorded when the recorder should've stopped altogether after `passthrough()`. + +### Problem 2: The placeholder `MockSocket` + +Even when the passthrough connection is established, the consumers, like `http.Agent`, have already received and stored the placeholder `MockSocket` as their socket. This means that for passthrough scenarios, the `MockSocket` instance would have to become a _proxy socket_, forwarding whatever data or changes from the passthrough socket. + +What makes it more complex is that the consumers might still _act_ on the placeholder socket even after passthrough. Those acts would also have to be forwarded to the passthrough socket. It seems that the object recorder over the placeholder socket mustn't stop after passthrough, after all. Instead, it should replay the actions on the passthrough socket immediately if passthrough has been established. + +```ts +this.#recorder = new ObjectRecorder(socket, { + filter(entry) { + if (this.#passthroughSocket) { + entry.replay(this.#passthroughSocket) + return false + } + } +}) + +// ...later on. +public passthrough(): net.Socket { + this.#passthroughSocket = this.options.createConnection() + this.#recorder.pause() + this.#recorder.replay(this.#passthroughSocket) + this.#recorder.resume() +} +``` + +> Above, I'm using the `filter()` capabilities of the object recorder to immediately replay the recorded action on the passthrough socket, if it exists. + +Considering that the recorder should continue recording and replaying events, it looks like I need to implement some sort of _entry buffering_. While the previously recorded changes are being replayed on the passthrough socket, the consumers might produce _new changes_ to the placeholder socket. With the approach above, those changes will be lost. So the recorder have to be put in a buffering state until the replay is done, and then replay any buffered events immediately after that. + +> Note: I would love to forego the object recording altogether in favor of something more elegant. I just don't know what that something might be. The actual connection cannot be established as it would error. + +## Alternative to object recording + +Alternatively, instead of having two separate sockets (placeholder and passthrough), only a _single_ passthrough socket can be used. With this approach, all that I have to do is this: + +- Record and silence any errors occurring on the socket. Those are crucial to be replayed if the user decides to passthrough. +- Buffer the data sent from the client instead of sending it to the server. Any writes are still translated to the special `socket` for the "connection" listener. + +Then, if passthrough is established, the socket would have to: + +- Replay the errors, if any. This is for passing through to non-existing hosts. +- If no errors were emitted, replay all the writes that occurred so the original socket will receive them. + +> The danger here are the write callbacks: `socket.write(chunk, encoding, callback)`. Those callbacks would have to be called for the mock-first scenario as consumer logic can and will depend on those callbacks. But calling them _again_ in passthrough will be a mistake. diff --git a/package.json b/package.json index fdc7732a7..7da50fced 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,18 @@ "types": "./lib/node/index.d.cts", "exports": { ".": { - "browser": { - "import": "./lib/browser/index.mjs", - "default": "./lib/browser/index.cjs" + "browser": "./lib/browser/index.mjs", + "default": "./lib/node/index.mjs" + }, + "./http": { + "node": { + "require": "./lib/node/interceptors/http/index.cjs", + "import": "./lib/node/interceptors/http/index.mjs" }, - "import": "./lib/node/index.mjs", - "default": "./lib/node/index.cjs" + "browser": null, + "require": "./lib/node/interceptors/http/index.cjs", + "import": "./lib/node/interceptors/http/index.mjs", + "default": "./lib/node/interceptors/http/index.cjs" }, "./ClientRequest": { "node": { @@ -26,22 +32,23 @@ "default": "./lib/node/interceptors/ClientRequest/index.cjs" }, "./XMLHttpRequest": { - "browser": "./lib/browser/interceptors/XMLHttpRequest/index.mjs", - "require": "./lib/node/interceptors/XMLHttpRequest/index.cjs", - "import": "./lib/node/interceptors/XMLHttpRequest/index.mjs", - "default": "./lib/node/interceptors/XMLHttpRequest/index.cjs" + "browser": "./lib/browser/interceptors/XMLHttpRequest/web.mjs", + "require": "./lib/node/interceptors/XMLHttpRequest/node.cjs", + "import": "./lib/node/interceptors/XMLHttpRequest/node.mjs", + "default": "./lib/node/interceptors/XMLHttpRequest/node.cjs" }, - "./fetch": { - "browser": "./lib/browser/interceptors/fetch/index.mjs", - "require": "./lib/node/interceptors/fetch/index.cjs", - "import": "./lib/node/interceptors/fetch/index.mjs", - "default": "./lib/node/interceptors/fetch/index.cjs" + "./XMLHttpRequest/node": { + "import": "./lib/node/interceptors/XMLHttpRequest/node.mjs", + "default": "./lib/node/interceptors/XMLHttpRequest/node.cjs" }, - "./WebSocket": { - "require": "./lib/browser/interceptors/WebSocket/index.cjs", - "import": "./lib/browser/interceptors/WebSocket/index.mjs", - "default": "./lib/browser/interceptors/WebSocket/index.cjs" + "./XMLHttpRequest/web": "./lib/browser/interceptors/XMLHttpRequest/web.mjs", + "./fetch": { + "browser": "./lib/browser/interceptors/fetch/web.mjs", + "require": "./lib/node/interceptors/fetch/node.cjs", + "import": "./lib/node/interceptors/fetch/node.mjs", + "default": "./lib/node/interceptors/fetch/node.cjs" }, + "./WebSocket": "./lib/browser/interceptors/WebSocket/index.mjs", "./RemoteHttpInterceptor": { "node": { "require": "./lib/node/RemoteHttpInterceptor.cjs", @@ -65,18 +72,12 @@ "./presets/browser": { "browser": "./lib/browser/presets/browser.mjs", "node": null - }, - "./utils/node": { - "node": { - "require": "./lib/node/utils/node/index.cjs", - "import": "./lib/node/utils/node/index.mjs" - }, - "browser": null, - "require": "./lib/node/utils/node/index.cjs", - "import": "./lib/node/utils/node/index.mjs", - "default": "./lib/node/utils/node/index.cjs" } }, + "imports": { + "#/src/*": "./src/*", + "#/test/*": "./test/*" + }, "author": "Artem Zakharchenko", "license": "MIT", "engines": { @@ -84,12 +85,9 @@ }, "scripts": { "start": "tsdown --watch", - "test": "pnpm test:unit && pnpm test:integration", - "test:unit": "vitest", - "test:integration": "pnpm test:node && pnpm test:browser", - "test:node": "vitest -c test/vitest.config.js", - "test:browser": "pnpm playwright test -c test/playwright.config.ts", + "test": "vitest", "test:nock": "./test/third-party/nock.sh", + "lint": "publint", "build": "tsdown", "prepare": "pnpm simple-git-hooks init", "release": "release publish" @@ -123,11 +121,11 @@ "@types/superagent": "^8.1.9", "@types/supertest": "^6.0.2", "@types/ws": "^8.18.0", + "@vitest/browser-playwright": "^4.0.18", "axios": "^1.8.2", "body-parser": "^1.19.0", "commitizen": "^4.2.4", "cors": "^2.8.5", - "cross-env": "^7.0.3", "cz-conventional-changelog": "3.3.0", "engine.io-parser": "^5.2.1", "express": "^4.21.2", @@ -135,9 +133,11 @@ "express-rate-limit": "^7.5.0", "follow-redirects": "^1.15.1", "got": "^14.4.6", - "happy-dom": "^17.3.0", - "jsdom": "^26.1.0", + "happy-dom": "^20.8.3", + "https-proxy-agent": "^7.0.6", "node-fetch": "3.3.2", + "playwright": "^1.58.2", + "publint": "^0.3.18", "simple-git-hooks": "^2.7.0", "socket.io": "^4.7.4", "socket.io-client": "^4.7.4", @@ -146,10 +146,9 @@ "supertest": "^7.0.0", "tsdown": "^0.18.1", "typescript": "^5.8.2", - "undici": "^7.4.0", - "vitest": "^3.0.8", - "vitest-environment-miniflare": "^2.14.1", - "wait-for-expect": "^3.0.2", + "undici": "^7.22.0", + "vitest": "^4.0.18", + "vitest-environment-miniflare": "^2.14.4", "web-encoding": "^1.1.5", "webpack": "^5.105.0", "webpack-http-server": "^0.5.0", @@ -159,9 +158,12 @@ "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", + "@types/debug": "^4.1.12", + "debug": "^4.4.3", + "es-toolkit": "^1.44.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" + "rettime": "^0.11.11" }, "resolutions": { "memfs": "^3.4.13" @@ -184,4 +186,4 @@ "path": "./node_modules/cz-conventional-changelog" } } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36641f6c0..f52cf8f1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,15 +20,24 @@ importers: '@open-draft/until': specifier: ^2.0.0 version: 2.1.0 + '@types/debug': + specifier: ^4.1.12 + version: 4.1.12 + debug: + specifier: ^4.4.3 + version: 4.4.3 + es-toolkit: + specifier: ^1.44.0 + version: 1.44.0 is-node-process: specifier: ^1.2.0 version: 1.2.0 outvariant: specifier: ^1.4.3 version: 1.4.3 - strict-event-emitter: - specifier: ^0.5.1 - version: 0.5.1 + rettime: + specifier: ^0.11.11 + version: 0.11.11 devDependencies: '@commitlint/cli': specifier: ^19.7.1 @@ -72,9 +81,12 @@ importers: '@types/ws': specifier: ^8.18.0 version: 8.18.0 + '@vitest/browser-playwright': + specifier: ^4.0.18 + version: 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18) axios: specifier: ^1.8.2 - version: 1.8.2 + version: 1.8.2(debug@4.4.3) body-parser: specifier: ^1.19.0 version: 1.20.3 @@ -84,9 +96,6 @@ importers: cors: specifier: ^2.8.5 version: 2.8.5 - cross-env: - specifier: ^7.0.3 - version: 7.0.3 cz-conventional-changelog: specifier: 3.3.0 version: 3.3.0(@types/node@22.13.9)(typescript@5.8.2) @@ -104,19 +113,25 @@ importers: version: 7.5.0(express@4.21.2) follow-redirects: specifier: ^1.15.1 - version: 1.15.9 + version: 1.15.9(debug@4.4.3) got: specifier: ^14.4.6 version: 14.4.6 happy-dom: - specifier: ^17.3.0 - version: 17.3.0 - jsdom: - specifier: ^26.1.0 - version: 26.1.0 + specifier: ^20.8.3 + version: 20.8.3 + https-proxy-agent: + specifier: ^7.0.6 + version: 7.0.6 node-fetch: specifier: 3.3.2 version: 3.3.2 + playwright: + specifier: ^1.58.2 + version: 1.58.2 + publint: + specifier: ^0.3.18 + version: 0.3.18 simple-git-hooks: specifier: ^2.7.0 version: 2.11.1 @@ -137,22 +152,19 @@ importers: version: 7.0.0 tsdown: specifier: ^0.18.1 - version: 0.18.1(publint@0.3.16)(typescript@5.8.2) + version: 0.18.1(publint@0.3.18)(typescript@5.8.2) typescript: specifier: ^5.8.2 version: 5.8.2 undici: - specifier: ^7.4.0 - version: 7.4.0 + specifier: ^7.22.0 + version: 7.22.0 vitest: - specifier: ^3.0.8 - version: 3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) vitest-environment-miniflare: - specifier: ^2.14.1 - version: 2.14.4(vitest@3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)) - wait-for-expect: - specifier: ^3.0.2 - version: 3.0.2 + specifier: ^2.14.4 + version: 2.14.4(vitest@4.0.18) web-encoding: specifier: ^1.1.5 version: 1.1.5 @@ -168,8 +180,18 @@ importers: packages: - '@asamuzakjp/css-color@2.8.3': - resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} @@ -200,6 +222,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@cloudflare/workers-types@4.20241127.0': resolution: {integrity: sha512-UqlvtqV8eI0CdPR7nxlbVlE52+lcjHvGdbYXEPwisy23+39RsFV7OOy0da0moJAhqnL2OhDmWTOaKdsVcPHiJQ==} @@ -276,33 +302,36 @@ packages: resolution: {integrity: sha512-DSHae2obMSMkAtTBSOulg5X7/z+rGLxcXQIkg3OmWvY6wifojge5uVMydfhUvs7yQj+V7jNmRZ2Xzl8GJyqRgg==} engines: {node: '>=v18'} - '@csstools/color-helpers@5.0.2': - resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} - engines: {node: '>=18'} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} - '@csstools/css-calc@2.1.2': - resolution: {integrity: sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==} - engines: {node: '>=18'} + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.4 - '@csstools/css-tokenizer': ^3.0.3 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@3.0.8': - resolution: {integrity: sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ==} - engines: {node: '>=18'} + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-parser-algorithms': ^3.0.4 - '@csstools/css-tokenizer': ^3.0.3 + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-parser-algorithms@3.0.4': - resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} - engines: {node: '>=18'} + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} peerDependencies: - '@csstools/css-tokenizer': ^3.0.3 + '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-tokenizer@3.0.3': - resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} - engines: {node: '>=18'} + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -313,144 +342,171 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -471,72 +527,91 @@ packages: '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} '@miniflare/cache@2.14.4': resolution: {integrity: sha512-ayzdjhcj+4mjydbNK7ZGDpIXNliDbQY4GPcY2KrYw0v1OSUdj5kZUkygD09fqoGRfAks0d91VelkyRsAXX8FQA==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/core@2.14.4': resolution: {integrity: sha512-FMmZcC1f54YpF4pDWPtdQPIO8NXfgUxCoR9uyrhxKJdZu7M6n8QKopPVNuaxR40jcsdxb7yKoQoFWnHfzJD9GQ==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/d1@2.14.4': resolution: {integrity: sha512-pMBVq9XWxTDdm+RRCkfXZP+bREjPg1JC8s8C0JTovA9OGmLQXqGTnFxIaS9vf1d8k3uSUGhDzPTzHr0/AUW1gA==} engines: {node: '>=16.7'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/durable-objects@2.14.4': resolution: {integrity: sha512-+JrmHP6gHHrjxV8S3axVw5lGHLgqmAGdcO/1HJUPswAyJEd3Ah2YnKhpo+bNmV4RKJCtEq9A2hbtVjBTD2YzwA==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/html-rewriter@2.14.4': resolution: {integrity: sha512-GB/vZn7oLbnhw+815SGF+HU5EZqSxbhIa3mu2L5MzZ2q5VOD5NHC833qG8c2GzDPhIaZ99ITY+ZJmbR4d+4aNQ==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/kv@2.14.4': resolution: {integrity: sha512-QlERH0Z+klwLg0xw+/gm2yC34Nnr/I0GcQ+ASYqXeIXBwjqOtMBa3YVQnocaD+BPy/6TUtSpOAShHsEj76R2uw==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/queues@2.14.4': resolution: {integrity: sha512-aXQ5Ik8Iq1KGMBzGenmd6Js/jJgqyYvjom95/N9GptCGpiVWE5F0XqC1SL5rCwURbHN+aWY191o8XOFyY2nCUA==} engines: {node: '>=16.7'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/r2@2.14.4': resolution: {integrity: sha512-4ctiZWh7Ty7LB3brUjmbRiGMqwyDZgABYaczDtUidblo2DxX4JZPnJ/ZAyxMPNJif32kOJhcg6arC2hEthR9Sw==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/runner-vm@2.14.4': resolution: {integrity: sha512-Nog0bB9SVhPbZAkTWfO4lpLAUsBXKEjlb4y+y66FJw77mPlmPlVdpjElCvmf8T3VN/pqh83kvELGM+/fucMf4g==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/shared-test-environment@2.14.4': resolution: {integrity: sha512-FdU2/8wEd00vIu+MfofLiHcfZWz+uCbE2VTL85KpyYfBsNGAbgRtzFMpOXdoXLqQfRu6MBiRwWpb2FbMrBzi7g==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/shared@2.14.4': resolution: {integrity: sha512-upl4RSB3hyCnITOFmRZjJj4A72GmkVrtfZTilkdq5Qe5TTlzsjVeDJp7AuNUM9bM8vswRo+N5jOiot6O4PVwwQ==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/sites@2.14.4': resolution: {integrity: sha512-O5npWopi+fw9W9Ki0gy99nuBbgDva/iXy8PDC4dAXDB/pz45nISDqldabk0rL2t4W2+lY6LXKzdOw+qJO1GQTA==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/storage-file@2.14.4': resolution: {integrity: sha512-JxcmX0hXf4cB0cC9+s6ZsgYCq+rpyUKRPCGzaFwymWWplrO3EjPVxKCcMxG44jsdgsII6EZihYUN2J14wwCT7A==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/storage-memory@2.14.4': resolution: {integrity: sha512-9jB5BqNkMZ3SFjbPFeiVkLi1BuSahMhc/W1Y9H0W89qFDrrD+z7EgRgDtHTG1ZRyi9gIlNtt9qhkO1B6W2qb2A==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/watcher@2.14.4': resolution: {integrity: sha512-PYn05ET2USfBAeXF6NZfWl0O32KVyE8ncQ/ngysrh3hoIV7l3qGGH7ubeFx+D8VWQ682qYhwGygUzQv2j1tGGg==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@miniflare/web-sockets@2.14.4': resolution: {integrity: sha512-stTxvLdJ2IcGOs76AnvGYAzGvx8JvQPRxC5DW0P5zdAAnhL33noqb5LKdPt3P37BKp9FzBKZHuihQI9oVqwm0g==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} @@ -578,8 +653,11 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} engines: {node: '>=12'} - '@publint/pack@0.1.2': - resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@publint/pack@0.1.4': + resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} '@quansync/fs@1.0.0': @@ -665,93 +743,128 @@ packages: '@rolldown/pluginutils@1.0.0-beta.55': resolution: {integrity: sha512-vajw/B3qoi7aYnnD4BQ4VoCcXQWnF0roSwE2iynbNxgW4l9mFwtLmLmUhpDdcTBfKyZm1p/T0D13qG94XBLohA==} - '@rollup/rollup-android-arm-eabi@4.28.0': - resolution: {integrity: sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==} + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.28.0': - resolution: {integrity: sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA==} + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.28.0': - resolution: {integrity: sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==} + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.28.0': - resolution: {integrity: sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w==} + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.28.0': - resolution: {integrity: sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ==} + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.28.0': - resolution: {integrity: sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA==} + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.28.0': - resolution: {integrity: sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w==} + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.28.0': - resolution: {integrity: sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg==} + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.28.0': - resolution: {integrity: sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg==} + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.28.0': - resolution: {integrity: sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw==} + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-powerpc64le-gnu@4.28.0': - resolution: {integrity: sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ==} + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.28.0': - resolution: {integrity: sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g==} + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.28.0': - resolution: {integrity: sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw==} + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.28.0': - resolution: {integrity: sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==} + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.28.0': - resolution: {integrity: sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw==} + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - '@rollup/rollup-win32-arm64-msvc@4.28.0': - resolution: {integrity: sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg==} + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.28.0': - resolution: {integrity: sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A==} + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.28.0': - resolution: {integrity: sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ==} + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} cpu: [x64] os: [win32] @@ -765,6 +878,9 @@ packages: '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@szmarczak/http-timer@5.0.1': resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} @@ -781,6 +897,9 @@ packages: '@types/busboy@1.5.4': resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -799,15 +918,18 @@ packages: '@types/cors@2.8.17': resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} '@types/eslint@9.6.1': resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -847,6 +969,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mustache@4.2.5': resolution: {integrity: sha512-PLwiVvTBg59tGFL/8VpcGvqOu3L4OuveNvPi0EYbWchRdEVP++yRUXJPFl+CApKEq13017/4Nf7aQ5lTtHUNsA==} @@ -883,43 +1008,60 @@ packages: '@types/supertest@6.0.2': resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.0': resolution: {integrity: sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@vitest/expect@3.0.8': - resolution: {integrity: sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==} + '@vitest/browser-playwright@4.0.18': + resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + peerDependencies: + playwright: '*' + vitest: 4.0.18 + + '@vitest/browser@4.0.18': + resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + peerDependencies: + vitest: 4.0.18 + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} - '@vitest/mocker@3.0.8': - resolution: {integrity: sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==} + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 + vite: ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.0.8': - resolution: {integrity: sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==} + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} - '@vitest/runner@3.0.8': - resolution: {integrity: sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==} + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} - '@vitest/snapshot@3.0.8': - resolution: {integrity: sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==} + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} - '@vitest/spy@3.0.8': - resolution: {integrity: sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==} + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} - '@vitest/utils@3.0.8': - resolution: {integrity: sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==} + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -1101,6 +1243,9 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} @@ -1171,9 +1316,9 @@ packages: caniuse-lite@1.0.30001767: resolution: {integrity: sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==} - chai@5.2.0: - resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} - engines: {node: '>=12'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} @@ -1190,10 +1335,6 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - check-error@2.1.1: - resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} - engines: {node: '>= 16'} - chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -1337,18 +1478,17 @@ packages: typescript: optional: true - cross-env@7.0.3: - resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} - engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - cssstyle@4.2.1: - resolution: {integrity: sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==} - engines: {node: '>=18'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} cz-conventional-changelog@3.3.0: resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==} @@ -1362,9 +1502,9 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} - data-urls@5.0.0: - resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} - engines: {node: '>=18'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -1386,8 +1526,8 @@ packages: supports-color: optional: true - debug@4.4.0: - resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} peerDependencies: supports-color: '*' @@ -1395,8 +1535,8 @@ packages: supports-color: optional: true - decimal.js@10.5.0: - resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} @@ -1405,10 +1545,6 @@ packages: dedent@0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -1515,8 +1651,12 @@ packages: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} env-paths@2.2.1: @@ -1534,15 +1674,18 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.6.0: - resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} hasBin: true escalade@3.2.0: @@ -1591,8 +1734,8 @@ packages: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} - expect-type@1.2.0: - resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} express-fileupload@1.5.1: @@ -1787,9 +1930,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - happy-dom@17.3.0: - resolution: {integrity: sha512-dTwlpUHrhE0usQOd1Df9k461SOYQUWNl0G31mXCDj+N9//oPcDb+cchrSJzrXN6qxZ5sZSrLf5AfY702Zvddfw==} - engines: {node: '>=18.0.0'} + happy-dom@20.8.3: + resolution: {integrity: sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==} + engines: {node: '>=20.0.0'} has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} @@ -1829,9 +1972,9 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - html-encoding-sniffer@4.0.0: - resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} - engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} html-rewriter-wasm@0.4.1: resolution: {integrity: sha512-lNovG8CMCCmcVB1Q7xggMSf7tqPCijZXaH4gL6iE8BFghdQCbaY5Met9i1x2Ex8m/cZHDUtXK9H6/znKamRP8Q==} @@ -1863,10 +2006,6 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2008,9 +2147,9 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jsdom@26.1.0: - resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} - engines: {node: '>=18'} + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 peerDependenciesMeta: @@ -2113,18 +2252,19 @@ packages: resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==} engines: {node: '>=0.10.0'} - loupe@3.1.3: - resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} - lowercase-keys@3.0.0: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} @@ -2206,6 +2346,10 @@ packages: resolution: {integrity: sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -2219,8 +2363,8 @@ packages: mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} - nanoid@3.3.8: - resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2253,9 +2397,6 @@ packages: npx-import@1.1.4: resolution: {integrity: sha512-3ShymTWOgqGyNlh5lMJAejLuIv3W1K3fbI5Ewc6YErZU3Sp0PqsNs8UIU1O8z5+KVl/Du5ag56Gza9vdorGEoA==} - nwsapi@2.2.16: - resolution: {integrity: sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2326,8 +2467,8 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} - parse5@7.2.1: - resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} @@ -2355,10 +2496,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.0: - resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2384,22 +2521,40 @@ packages: resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} hasBin: true + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + playwright-core@1.51.0: resolution: {integrity: sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==} engines: {node: '>=18'} hasBin: true + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + playwright@1.51.0: resolution: {integrity: sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==} engines: {node: '>=18'} hasBin: true + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.0.0: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} - postcss@8.4.49: - resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} process-nextick-args@2.0.1: @@ -2418,8 +2573,8 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - publint@0.3.16: - resolution: {integrity: sha512-MFqyfRLAExPVZdTQFwkAQELzA8idyXzROVOytg6nEJ/GEypXBUmMGrVaID8cTuzRS1U5L8yTOdOJtMXgFUJAeA==} + publint@0.3.18: + resolution: {integrity: sha512-JRJFeBTrfx4qLwEuGFPk+haJOJN97KnPuK01yj+4k/Wj5BgoOK5uNsivporiqBjk2JDaslg7qJOhGRnpltGeog==} engines: {node: '>=18'} hasBin: true @@ -2512,6 +2667,9 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -2539,14 +2697,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.28.0: - resolution: {integrity: sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==} + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rrweb-cssom@0.8.0: - resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -2635,6 +2790,10 @@ packages: resolution: {integrity: sha512-tgqwPUMDcNDhuf1Xf6KTUsyeqGdgKMhzaH4PAZZuzguOgTl5uuyeYe/8mWgAr6IBxB5V06uqEf6Dy37gIWDtDg==} hasBin: true + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + socket.io-adapter@2.5.5: resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} @@ -2681,8 +2840,8 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - std-env@3.8.0: - resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} @@ -2694,9 +2853,6 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} - strict-event-emitter@0.5.1: - resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2814,23 +2970,15 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.0.2: - resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@3.0.2: - resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tldts-core@6.1.83: - resolution: {integrity: sha512-I2wb9OJc6rXyh9d4aInhSNWChNI+ra6qDnFEGEwe9OoA68lE4Temw29bOkf1Uvwt8VZS079t1BFZdXVBmmB4dw==} + tldts-core@7.0.24: + resolution: {integrity: sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==} - tldts@6.1.83: - resolution: {integrity: sha512-FHxxNJJ0WNsEBPHyC1oesQb3rRoxpuho/z2g3zIIAhw1WHJeQsUzK1jYK8TI1/iClaa4fS3Z2TCA9mtxXsENSg==} + tldts@7.0.24: + resolution: {integrity: sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==} hasBin: true tmp@0.0.33: @@ -2845,13 +2993,17 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tough-cookie@5.1.2: - resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} - tr46@5.0.0: - resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} - engines: {node: '>=18'} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} traverse@0.6.8: resolution: {integrity: sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA==} @@ -2922,8 +3074,8 @@ packages: resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} engines: {node: '>=14.0'} - undici@7.4.0: - resolution: {integrity: sha512-PUQM3/es3noM24oUn10u3kNNap0AbxESOmnssmW+dOi9yGwlUSi5nTNYl3bNbTkWOF8YZDkx2tCmj9OtQ3iGGw==} + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} unicorn-magic@0.1.0: @@ -2978,27 +3130,27 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-node@3.0.8: - resolution: {integrity: sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - - vite@5.4.11: - resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: '@types/node': optional: true + jiti: + optional: true less: optional: true lightningcss: @@ -3013,33 +3165,44 @@ packages: optional: true terser: optional: true + tsx: + optional: true + yaml: + optional: true vitest-environment-miniflare@2.14.4: resolution: {integrity: sha512-DzwQWdY42sVYR6aUndw9FdCtl/i0oh3NkbkQpw+xq5aYQw5eiJn5kwnKaKQEWaoBe8Cso71X2i1EJGvi1jZ2xw==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 peerDependencies: vitest: '>=0.23.0' - vitest@3.0.8: - resolution: {integrity: sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.0.8 - '@vitest/ui': 3.0.8 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': optional: true '@vitest/ui': optional: true @@ -3052,9 +3215,6 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - wait-for-expect@3.0.2: - resolution: {integrity: sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==} - watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -3069,9 +3229,9 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} - webidl-conversions@7.0.0: - resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} - engines: {node: '>=12'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} webpack-http-server@0.5.0: resolution: {integrity: sha512-kyewxAnzmDuZxe09fn/Bb0PeEnaDxHChYKFVsMy4oeBUs9Cyv2j1uEgzQJ7ljPFexLU8ongUS4i4O+e22CeBZQ==} @@ -3090,21 +3250,17 @@ packages: webpack-cli: optional: true - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} - whatwg-url@14.1.1: - resolution: {integrity: sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==} - engines: {node: '>=18'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} which-typed-array@1.1.16: resolution: {integrity: sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==} @@ -3163,6 +3319,18 @@ packages: utf-8-validate: optional: true + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -3204,13 +3372,29 @@ packages: snapshots: - '@asamuzakjp/css-color@2.8.3': + '@acemir/cssom@0.9.31': + optional: true + + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.6 + optional: true + + '@asamuzakjp/dom-selector@6.8.1': dependencies: - '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-color-parser': 3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 - lru-cache: 10.4.3 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + optional: true + + '@asamuzakjp/nwsapi@2.3.9': + optional: true '@babel/code-frame@7.26.2': dependencies: @@ -3241,6 +3425,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + optional: true + '@cloudflare/workers-types@4.20241127.0': {} '@commitlint/cli@19.7.1(@types/node@22.13.9)(typescript@5.8.2)': @@ -3370,25 +3559,33 @@ snapshots: '@types/conventional-commits-parser': 5.0.1 chalk: 5.3.0 - '@csstools/color-helpers@5.0.2': {} + '@csstools/color-helpers@6.0.2': + optional: true - '@csstools/css-calc@2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + optional: true - '@csstools/css-color-parser@3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/color-helpers': 5.0.2 - '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) - '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) - '@csstools/css-tokenizer': 3.0.3 + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + optional: true - '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: - '@csstools/css-tokenizer': 3.0.3 + '@csstools/css-tokenizer': 4.0.0 + optional: true - '@csstools/css-tokenizer@3.0.3': {} + '@csstools/css-syntax-patches-for-csstree@1.1.0': + optional: true + + '@csstools/css-tokenizer@4.0.0': + optional: true '@emnapi/core@1.7.1': dependencies: @@ -3406,73 +3603,85 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.21.5': + '@esbuild/aix-ppc64@0.27.3': optional: true - '@esbuild/android-arm64@0.21.5': + '@esbuild/android-arm64@0.27.3': optional: true - '@esbuild/android-arm@0.21.5': + '@esbuild/android-arm@0.27.3': optional: true - '@esbuild/android-x64@0.21.5': + '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.21.5': + '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/darwin-x64@0.21.5': + '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.21.5': + '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/freebsd-x64@0.21.5': + '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/linux-arm64@0.21.5': + '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/linux-arm@0.21.5': + '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/linux-ia32@0.21.5': + '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-loong64@0.21.5': + '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-mips64el@0.21.5': + '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-ppc64@0.21.5': + '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.21.5': + '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-s390x@0.21.5': + '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-x64@0.21.5': + '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.21.5': + '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.21.5': + '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/sunos-x64@0.21.5': + '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/win32-arm64@0.21.5': + '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/win32-ia32@0.21.5': + '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/win32-x64@0.21.5': + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@exodus/bytes@1.15.0': optional: true '@fastify/busboy@2.1.1': {} @@ -3493,6 +3702,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -3605,7 +3816,7 @@ snapshots: '@miniflare/core': 2.14.4 '@miniflare/shared': 2.14.4 undici: 5.28.4 - ws: 8.18.1 + ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -3656,7 +3867,7 @@ snapshots: outvariant: 1.4.3 pino: 7.11.0 pino-pretty: 7.6.1 - publint: 0.3.16 + publint: 0.3.18 rc: 1.2.8 registry-auth-token: 5.1.0 semver: 7.7.3 @@ -3681,7 +3892,9 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 - '@publint/pack@0.1.2': {} + '@polka/url@1.0.0-next.29': {} + + '@publint/pack@0.1.4': {} '@quansync/fs@1.0.0': dependencies: @@ -3730,58 +3943,79 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.55': {} - '@rollup/rollup-android-arm-eabi@4.28.0': + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': optional: true - '@rollup/rollup-android-arm64@4.28.0': + '@rollup/rollup-linux-arm64-gnu@4.59.0': optional: true - '@rollup/rollup-darwin-arm64@4.28.0': + '@rollup/rollup-linux-arm64-musl@4.59.0': optional: true - '@rollup/rollup-darwin-x64@4.28.0': + '@rollup/rollup-linux-loong64-gnu@4.59.0': optional: true - '@rollup/rollup-freebsd-arm64@4.28.0': + '@rollup/rollup-linux-loong64-musl@4.59.0': optional: true - '@rollup/rollup-freebsd-x64@4.28.0': + '@rollup/rollup-linux-ppc64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.28.0': + '@rollup/rollup-linux-ppc64-musl@4.59.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.28.0': + '@rollup/rollup-linux-riscv64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.28.0': + '@rollup/rollup-linux-riscv64-musl@4.59.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.28.0': + '@rollup/rollup-linux-s390x-gnu@4.59.0': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.28.0': + '@rollup/rollup-linux-x64-gnu@4.59.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.28.0': + '@rollup/rollup-linux-x64-musl@4.59.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.28.0': + '@rollup/rollup-openbsd-x64@4.59.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.28.0': + '@rollup/rollup-openharmony-arm64@4.59.0': optional: true - '@rollup/rollup-linux-x64-musl@4.28.0': + '@rollup/rollup-win32-arm64-msvc@4.59.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.28.0': + '@rollup/rollup-win32-ia32-msvc@4.59.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.28.0': + '@rollup/rollup-win32-x64-gnu@4.59.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.28.0': + '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true '@sec-ant/readable-stream@0.4.1': {} @@ -3790,6 +4024,8 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} + '@szmarczak/http-timer@5.0.1': dependencies: defer-to-connect: 2.0.1 @@ -3801,7 +4037,7 @@ snapshots: '@types/better-sqlite3@7.6.12': dependencies: - '@types/node': 18.19.67 + '@types/node': 22.13.9 '@types/body-parser@1.19.5': dependencies: @@ -3812,6 +4048,11 @@ snapshots: dependencies: '@types/node': 18.19.67 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 18.19.67 @@ -3832,6 +4073,12 @@ snapshots: dependencies: '@types/node': 18.19.67 + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -3842,8 +4089,6 @@ snapshots: '@types/estree': 1.0.8 '@types/json-schema': 7.0.15 - '@types/estree@1.0.6': {} - '@types/estree@1.0.8': {} '@types/express-fileupload@1.5.1': @@ -3895,6 +4140,8 @@ snapshots: '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/mustache@4.2.5': {} '@types/node-fetch@2.6.12': @@ -3943,55 +4190,90 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.0': dependencies: '@types/node': 18.19.67 + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.13.9 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': dependencies: '@types/yargs-parser': 21.0.3 - '@vitest/expect@3.0.8': + '@vitest/browser-playwright@4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18)': + dependencies: + '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) + playwright: 1.58.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18)': + dependencies: + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) + '@vitest/utils': 4.0.18 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.0.18': dependencies: - '@vitest/spy': 3.0.8 - '@vitest/utils': 3.0.8 - chai: 5.2.0 - tinyrainbow: 2.0.0 + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 - '@vitest/mocker@3.0.8(vite@5.4.11(@types/node@22.13.9)(terser@5.36.0))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))': dependencies: - '@vitest/spy': 3.0.8 + '@vitest/spy': 4.0.18 estree-walker: 3.0.3 - magic-string: 0.30.17 + magic-string: 0.30.21 optionalDependencies: - vite: 5.4.11(@types/node@22.13.9)(terser@5.36.0) + vite: 7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0) - '@vitest/pretty-format@3.0.8': + '@vitest/pretty-format@4.0.18': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.0.3 - '@vitest/runner@3.0.8': + '@vitest/runner@4.0.18': dependencies: - '@vitest/utils': 3.0.8 + '@vitest/utils': 4.0.18 pathe: 2.0.3 - '@vitest/snapshot@3.0.8': + '@vitest/snapshot@4.0.18': dependencies: - '@vitest/pretty-format': 3.0.8 - magic-string: 0.30.17 + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.0.8': - dependencies: - tinyspy: 3.0.2 + '@vitest/spy@4.0.18': {} - '@vitest/utils@3.0.8': + '@vitest/utils@4.0.18': dependencies: - '@vitest/pretty-format': 3.0.8 - loupe: 3.1.3 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 '@webassemblyjs/ast@1.14.1': dependencies: @@ -4164,9 +4446,9 @@ snapshots: dependencies: possible-typed-array-names: 1.0.0 - axios@1.8.2: + axios@1.8.2(debug@4.4.3): dependencies: - follow-redirects: 1.15.9 + follow-redirects: 1.15.9(debug@4.4.3) form-data: 4.0.1 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -4180,6 +4462,11 @@ snapshots: baseline-browser-mapping@2.9.19: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + optional: true + birpc@4.0.0: {} bl@4.1.0: @@ -4231,7 +4518,7 @@ snapshots: builtins@5.1.0: dependencies: - semver: 7.6.3 + semver: 7.7.3 busboy@1.6.0: dependencies: @@ -4269,13 +4556,7 @@ snapshots: caniuse-lite@1.0.30001767: {} - chai@5.2.0: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.1 - deep-eql: 5.0.2 - loupe: 3.1.3 - pathval: 2.0.0 + chai@6.2.2: {} chalk@2.4.2: dependencies: @@ -4292,8 +4573,6 @@ snapshots: chardet@0.7.0: {} - check-error@2.1.1: {} - chrome-trace-event@1.0.4: {} cli-cursor@3.1.0: @@ -4438,20 +4717,25 @@ snapshots: optionalDependencies: typescript: 5.8.2 - cross-env@7.0.3: - dependencies: - cross-spawn: 7.0.6 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - cssstyle@4.2.1: + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + optional: true + + cssstyle@6.2.0: dependencies: - '@asamuzakjp/css-color': 2.8.3 - rrweb-cssom: 0.8.0 + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + css-tree: 3.2.1 + lru-cache: 11.2.6 + optional: true cz-conventional-changelog@3.3.0(@types/node@22.13.9)(typescript@5.8.2): dependencies: @@ -4471,10 +4755,13 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-urls@5.0.0: + data-urls@7.0.0: dependencies: - whatwg-mimetype: 4.0.0 - whatwg-url: 14.1.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true dateformat@4.6.3: {} @@ -4486,11 +4773,12 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.0: + debug@4.4.3: dependencies: ms: 2.1.3 - decimal.js@10.5.0: {} + decimal.js@10.6.0: + optional: true decompress-response@6.0.0: dependencies: @@ -4498,8 +4786,6 @@ snapshots: dedent@0.7.0: {} - deep-eql@5.0.2: {} - deep-extend@0.6.0: {} defaults@1.0.4: @@ -4604,7 +4890,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - entities@4.5.0: {} + entities@6.0.1: + optional: true + + entities@7.0.1: {} env-paths@2.2.1: {} @@ -4618,35 +4907,40 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.6.0: {} + es-module-lexer@1.7.0: {} es-module-lexer@2.0.0: {} - esbuild@0.21.5: + es-toolkit@1.44.0: {} + + esbuild@0.27.3: optionalDependencies: - '@esbuild/aix-ppc64': 0.21.5 - '@esbuild/android-arm': 0.21.5 - '@esbuild/android-arm64': 0.21.5 - '@esbuild/android-x64': 0.21.5 - '@esbuild/darwin-arm64': 0.21.5 - '@esbuild/darwin-x64': 0.21.5 - '@esbuild/freebsd-arm64': 0.21.5 - '@esbuild/freebsd-x64': 0.21.5 - '@esbuild/linux-arm': 0.21.5 - '@esbuild/linux-arm64': 0.21.5 - '@esbuild/linux-ia32': 0.21.5 - '@esbuild/linux-loong64': 0.21.5 - '@esbuild/linux-mips64el': 0.21.5 - '@esbuild/linux-ppc64': 0.21.5 - '@esbuild/linux-riscv64': 0.21.5 - '@esbuild/linux-s390x': 0.21.5 - '@esbuild/linux-x64': 0.21.5 - '@esbuild/netbsd-x64': 0.21.5 - '@esbuild/openbsd-x64': 0.21.5 - '@esbuild/sunos-x64': 0.21.5 - '@esbuild/win32-arm64': 0.21.5 - '@esbuild/win32-ia32': 0.21.5 - '@esbuild/win32-x64': 0.21.5 + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 escalade@3.2.0: {} @@ -4669,7 +4963,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 etag@1.8.1: {} @@ -4691,7 +4985,7 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 - expect-type@1.2.0: {} + expect-type@1.3.0: {} express-fileupload@1.5.1: dependencies: @@ -4800,7 +5094,9 @@ snapshots: micromatch: 4.0.8 resolve-dir: 1.0.1 - follow-redirects@1.15.9: {} + follow-redirects@1.15.9(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 for-each@0.3.3: dependencies: @@ -4936,10 +5232,17 @@ snapshots: graceful-fs@4.2.11: {} - happy-dom@17.3.0: + happy-dom@20.8.3: dependencies: - webidl-conversions: 7.0.0 + '@types/node': 22.13.9 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 whatwg-mimetype: 3.0.0 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate has-flag@3.0.0: {} @@ -4971,9 +5274,12 @@ snapshots: hookable@5.5.3: {} - html-encoding-sniffer@4.0.0: + html-encoding-sniffer@6.0.0: dependencies: - whatwg-encoding: 3.1.1 + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + optional: true html-rewriter-wasm@0.4.1: {} @@ -4990,9 +5296,10 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true http2-wrapper@2.2.1: dependencies: @@ -5002,7 +5309,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.3 - debug: 4.4.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5012,10 +5319,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} import-fresh@3.3.0: @@ -5087,7 +5390,8 @@ snapshots: is-obj@2.0.0: {} - is-potential-custom-element-name@1.0.1: {} + is-potential-custom-element-name@1.0.1: + optional: true is-stream@3.0.0: {} @@ -5138,32 +5442,33 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@26.1.0: + jsdom@28.1.0: dependencies: - cssstyle: 4.2.1 - data-urls: 5.0.0 - decimal.js: 10.5.0 - html-encoding-sniffer: 4.0.0 + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.16 - parse5: 7.2.1 - rrweb-cssom: 0.8.0 + parse5: 8.0.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 5.1.2 + tough-cookie: 6.0.0 + undici: 7.22.0 w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.1.1 - ws: 8.18.1 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 xml-name-validator: 5.0.0 transitivePeerDependencies: - - bufferutil + - '@noble/hashes' - supports-color - - utf-8-validate + optional: true jsesc@3.1.0: {} @@ -5234,15 +5539,17 @@ snapshots: longest@2.0.1: {} - loupe@3.1.3: {} - lowercase-keys@3.0.0: {} - lru-cache@10.4.3: {} + lru-cache@11.2.6: + optional: true - magic-string@0.30.17: + magic-string@0.30.21: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: + optional: true media-typer@0.3.0: {} @@ -5295,6 +5602,8 @@ snapshots: mri@1.1.4: {} + mrmime@2.0.1: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -5303,7 +5612,7 @@ snapshots: mute-stream@0.0.8: {} - nanoid@3.3.8: {} + nanoid@3.3.11: {} negotiator@0.6.3: {} @@ -5329,11 +5638,9 @@ snapshots: dependencies: execa: 6.1.0 parse-package-name: 1.0.0 - semver: 7.6.3 + semver: 7.7.3 validate-npm-package-name: 4.0.0 - nwsapi@2.2.16: {} - object-assign@4.1.1: {} object-inspect@1.13.3: {} @@ -5401,9 +5708,10 @@ snapshots: parse-passwd@1.0.0: {} - parse5@7.2.1: + parse5@8.0.0: dependencies: - entities: 4.5.0 + entities: 6.0.1 + optional: true parseurl@1.3.3: {} @@ -5419,8 +5727,6 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.0: {} - picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5464,19 +5770,33 @@ snapshots: sonic-boom: 2.8.0 thread-stream: 0.15.2 + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + playwright-core@1.51.0: {} + playwright-core@1.58.2: {} + playwright@1.51.0: dependencies: playwright-core: 1.51.0 optionalDependencies: fsevents: 2.3.2 + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + pngjs@7.0.0: {} + possible-typed-array-names@1.0.0: {} - postcss@8.4.49: + postcss@8.5.6: dependencies: - nanoid: 3.3.8 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5493,9 +5813,9 @@ snapshots: proxy-from-env@1.1.0: {} - publint@0.3.16: + publint@0.3.18: dependencies: - '@publint/pack': 0.1.2 + '@publint/pack': 0.1.4 package-manager-detector: 1.6.0 picocolors: 1.1.1 sade: 1.8.1 @@ -5505,7 +5825,8 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 - punycode@2.3.1: {} + punycode@2.3.1: + optional: true qs@6.13.0: dependencies: @@ -5589,6 +5910,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + rettime@0.11.11: {} + rfdc@1.4.1: {} rolldown-plugin-dts@0.19.1(rolldown@1.0.0-beta.55)(typescript@5.8.2): @@ -5626,32 +5949,37 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.55 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.55 - rollup@4.28.0: + rollup@4.59.0: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.28.0 - '@rollup/rollup-android-arm64': 4.28.0 - '@rollup/rollup-darwin-arm64': 4.28.0 - '@rollup/rollup-darwin-x64': 4.28.0 - '@rollup/rollup-freebsd-arm64': 4.28.0 - '@rollup/rollup-freebsd-x64': 4.28.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.28.0 - '@rollup/rollup-linux-arm-musleabihf': 4.28.0 - '@rollup/rollup-linux-arm64-gnu': 4.28.0 - '@rollup/rollup-linux-arm64-musl': 4.28.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.28.0 - '@rollup/rollup-linux-riscv64-gnu': 4.28.0 - '@rollup/rollup-linux-s390x-gnu': 4.28.0 - '@rollup/rollup-linux-x64-gnu': 4.28.0 - '@rollup/rollup-linux-x64-musl': 4.28.0 - '@rollup/rollup-win32-arm64-msvc': 4.28.0 - '@rollup/rollup-win32-ia32-msvc': 4.28.0 - '@rollup/rollup-win32-x64-msvc': 4.28.0 + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 - rrweb-cssom@0.8.0: {} - run-async@2.4.1: {} rxjs@7.8.1: @@ -5673,6 +6001,7 @@ snapshots: saxes@6.0.0: dependencies: xmlchars: 2.2.0 + optional: true schema-utils@4.3.3: dependencies: @@ -5750,6 +6079,12 @@ snapshots: simple-git-hooks@2.11.1: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + socket.io-adapter@2.5.5: dependencies: debug: 4.3.7 @@ -5816,7 +6151,7 @@ snapshots: statuses@2.0.1: {} - std-env@3.8.0: {} + std-env@3.10.0: {} stream-combiner2@1.1.1: dependencies: @@ -5827,8 +6162,6 @@ snapshots: streamsearch@1.1.0: {} - strict-event-emitter@0.5.1: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5869,7 +6202,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.3.7 + debug: 4.4.3 fast-safe-stringify: 2.1.1 form-data: 4.0.1 formidable: 3.5.2 @@ -5883,7 +6216,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.0 + debug: 4.4.3 fast-safe-stringify: 2.1.1 form-data: 4.0.1 formidable: 3.5.2 @@ -5912,7 +6245,8 @@ snapshots: dependencies: has-flag: 4.0.0 - symbol-tree@3.2.4: {} + symbol-tree@3.2.4: + optional: true tapable@2.3.0: {} @@ -5956,17 +6290,15 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.0.2: {} + tinyrainbow@3.0.3: {} - tinyrainbow@2.0.0: {} - - tinyspy@3.0.2: {} - - tldts-core@6.1.83: {} + tldts-core@7.0.24: + optional: true - tldts@6.1.83: + tldts@7.0.24: dependencies: - tldts-core: 6.1.83 + tldts-core: 7.0.24 + optional: true tmp@0.0.33: dependencies: @@ -5978,19 +6310,23 @@ snapshots: toidentifier@1.0.1: {} - tough-cookie@5.1.2: + totalist@3.0.1: {} + + tough-cookie@6.0.0: dependencies: - tldts: 6.1.83 + tldts: 7.0.24 + optional: true - tr46@5.0.0: + tr46@6.0.0: dependencies: punycode: 2.3.1 + optional: true traverse@0.6.8: {} tree-kill@1.2.2: {} - tsdown@0.18.1(publint@0.3.16)(typescript@5.8.2): + tsdown@0.18.1(publint@0.3.18)(typescript@5.8.2): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -6009,7 +6345,7 @@ snapshots: unconfig-core: 7.4.2 unrun: 0.2.20 optionalDependencies: - publint: 0.3.16 + publint: 0.3.18 typescript: 5.8.2 transitivePeerDependencies: - '@ts-macro/tsc' @@ -6046,7 +6382,7 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@7.4.0: {} + undici@7.22.0: {} unicorn-magic@0.1.0: {} @@ -6086,73 +6422,61 @@ snapshots: vary@1.1.2: {} - vite-node@3.0.8(@types/node@22.13.9)(terser@5.36.0): - dependencies: - cac: 6.7.14 - debug: 4.4.0 - es-module-lexer: 1.6.0 - pathe: 2.0.3 - vite: 5.4.11(@types/node@22.13.9)(terser@5.36.0) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.11(@types/node@22.13.9)(terser@5.36.0): + vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0): dependencies: - esbuild: 0.21.5 - postcss: 8.4.49 - rollup: 4.28.0 + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.13.9 fsevents: 2.3.3 + jiti: 2.4.2 terser: 5.36.0 - vitest-environment-miniflare@2.14.4(vitest@3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0)): + vitest-environment-miniflare@2.14.4(vitest@4.0.18): dependencies: '@miniflare/queues': 2.14.4 '@miniflare/runner-vm': 2.14.4 '@miniflare/shared': 2.14.4 '@miniflare/shared-test-environment': 2.14.4 undici: 5.28.4 - vitest: 3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0) + vitest: 4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0) transitivePeerDependencies: - bufferutil - utf-8-validate - vitest@3.0.8(@types/node@22.13.9)(happy-dom@17.3.0)(jsdom@26.1.0)(terser@5.36.0): - dependencies: - '@vitest/expect': 3.0.8 - '@vitest/mocker': 3.0.8(vite@5.4.11(@types/node@22.13.9)(terser@5.36.0)) - '@vitest/pretty-format': 3.0.8 - '@vitest/runner': 3.0.8 - '@vitest/snapshot': 3.0.8 - '@vitest/spy': 3.0.8 - '@vitest/utils': 3.0.8 - chai: 5.2.0 - debug: 4.4.0 - expect-type: 1.2.0 - magic-string: 0.30.17 + vitest@4.0.18(@types/node@22.13.9)(@vitest/browser-playwright@4.0.18)(happy-dom@20.8.3)(jiti@2.4.2)(jsdom@28.1.0)(terser@5.36.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 - std-env: 3.8.0 + picomatch: 4.0.3 + std-env: 3.10.0 tinybench: 2.9.0 - tinyexec: 0.3.2 - tinypool: 1.0.2 - tinyrainbow: 2.0.0 - vite: 5.4.11(@types/node@22.13.9)(terser@5.36.0) - vite-node: 3.0.8(@types/node@22.13.9)(terser@5.36.0) + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.13.9 - happy-dom: 17.3.0 - jsdom: 26.1.0 + '@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.13.9)(jiti@2.4.2)(terser@5.36.0))(vitest@4.0.18) + happy-dom: 20.8.3 + jsdom: 28.1.0 transitivePeerDependencies: + - jiti - less - lightningcss - msw @@ -6160,14 +6484,14 @@ snapshots: - sass-embedded - stylus - sugarss - - supports-color - terser + - tsx + - yaml w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 - - wait-for-expect@3.0.2: {} + optional: true watchpack@2.5.1: dependencies: @@ -6186,7 +6510,8 @@ snapshots: web-streams-polyfill@3.3.3: {} - webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: + optional: true webpack-http-server@0.5.0: dependencies: @@ -6238,18 +6563,19 @@ snapshots: - esbuild - uglify-js - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-mimetype@3.0.0: {} - whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: + optional: true - whatwg-url@14.1.1: + whatwg-url@16.0.1: dependencies: - tr46: 5.0.0 - webidl-conversions: 7.0.0 + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + optional: true which-typed-array@1.1.16: dependencies: @@ -6292,9 +6618,13 @@ snapshots: ws@8.18.1: {} - xml-name-validator@5.0.0: {} + ws@8.19.0: {} - xmlchars@2.2.0: {} + xml-name-validator@5.0.0: + optional: true + + xmlchars@2.2.0: + optional: true xmlhttprequest-ssl@2.1.2: {} diff --git a/src/BatchInterceptor.test.ts b/src/BatchInterceptor.test.ts index 5891d6267..d8c777c88 100644 --- a/src/BatchInterceptor.test.ts +++ b/src/BatchInterceptor.test.ts @@ -1,5 +1,5 @@ -import { vi, it, expect, afterEach } from 'vitest' -import { Interceptor } from './Interceptor' +import { TypedEvent } from 'rettime' +import { Interceptor } from './interceptor' import { BatchInterceptor } from './BatchInterceptor' afterEach(() => { @@ -8,20 +8,22 @@ afterEach(() => { it('applies child interceptors', () => { class PrimaryInterceptor extends Interceptor { - constructor() { - super(Symbol('primary')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('secondary')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const instances = { primary: new PrimaryInterceptor(), - secondary: new SecondaryInterceptor(), + secondary: new SecondInterceptor(), } const interceptor = new BatchInterceptor({ @@ -39,23 +41,27 @@ it('applies child interceptors', () => { }) it('proxies event listeners to the interceptors', () => { - class PrimaryInterceptor extends Interceptor<{ hello: [string] }> { - constructor() { - super(Symbol('primary')) + class PrimaryInterceptor extends Interceptor<{ + hello: TypedEvent + }> { + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor<{ - goodbye: [string] + class SecondInterceptor extends Interceptor<{ + goodbye: TypedEvent }> { - constructor() { - super(Symbol('secondary')) + protected predicate(): boolean { + return true } + protected setup(): void {} } const instances = { primary: new PrimaryInterceptor(), - secondary: new SecondaryInterceptor(), + secondary: new SecondInterceptor(), } const interceptor = new BatchInterceptor({ @@ -70,32 +76,35 @@ it('proxies event listeners to the interceptors', () => { interceptor.on('goodbye', goodbyeListener) // Emulate the child interceptor emitting events. - instances.primary['emitter'].emit('hello', 'John') - instances.secondary['emitter'].emit('goodbye', 'Kate') + const helloEvent = new TypedEvent('hello', { data: 'John' }) + instances.primary['emitter'].emit(helloEvent) + + const goodbyeEvent = new TypedEvent('goodbye', { data: 'Kate' }) + instances.secondary['emitter'].emit(goodbyeEvent) // Must call the batch interceptor listener. - expect(helloListener).toHaveBeenCalledTimes(1) - expect(helloListener).toHaveBeenCalledWith('John') - expect(goodbyeListener).toHaveBeenCalledTimes(1) - expect(goodbyeListener).toHaveBeenCalledWith('Kate') + expect(helloListener).toHaveBeenCalledExactlyOnceWith(helloEvent) + expect(goodbyeListener).toHaveBeenCalledExactlyOnceWith(goodbyeEvent) }) it('disposes of child interceptors', async () => { class PrimaryInterceptor extends Interceptor { - constructor() { - super(Symbol('primary')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('secondary')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const instances = { primary: new PrimaryInterceptor(), - secondary: new SecondaryInterceptor(), + secondary: new SecondInterceptor(), } const interceptor = new BatchInterceptor({ @@ -115,18 +124,20 @@ it('disposes of child interceptors', async () => { it('forwards listeners added via "on()"', () => { class FirstInterceptor extends Interceptor { - constructor() { - super(Symbol('first')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('second')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const firstInterceptor = new FirstInterceptor() - const secondInterceptor = new SecondaryInterceptor() + const secondInterceptor = new SecondInterceptor() const interceptor = new BatchInterceptor({ name: 'batch', @@ -138,27 +149,32 @@ it('forwards listeners added via "on()"', () => { expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(1) expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(1) - expect(interceptor['emitter'].listenerCount('foo')).toBe(0) + expect( + interceptor['emitter'].listenerCount('foo'), + 'Does not add the listener onto the batch interceptor' + ).toBe(0) }) -it('forwards listeners removal via "off()"', () => { +it('forwards listeners removal via "removeListener()"', () => { type Events = { foo: [] } class FirstInterceptor extends Interceptor { - constructor() { - super(Symbol('first')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('second')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const firstInterceptor = new FirstInterceptor() - const secondInterceptor = new SecondaryInterceptor() + const secondInterceptor = new SecondInterceptor() const interceptor = new BatchInterceptor({ name: 'batch', @@ -167,7 +183,7 @@ it('forwards listeners removal via "off()"', () => { const listener = vi.fn() interceptor.on('foo', listener) - interceptor.off('foo', listener) + interceptor.removeListener('foo', listener) expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0) expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0) @@ -175,23 +191,25 @@ it('forwards listeners removal via "off()"', () => { it('forwards removal of all listeners by name via ".removeAllListeners()"', () => { type Events = { - foo: [] - bar: [] + foo: TypedEvent + bar: TypedEvent } class FirstInterceptor extends Interceptor { - constructor() { - super(Symbol('first')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('second')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const firstInterceptor = new FirstInterceptor() - const secondInterceptor = new SecondaryInterceptor() + const secondInterceptor = new SecondInterceptor() const interceptor = new BatchInterceptor({ name: 'batch', @@ -204,32 +222,32 @@ it('forwards removal of all listeners by name via ".removeAllListeners()"', () = interceptor.on('bar', listener) expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(2) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2) expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1) + + expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2) expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1) interceptor.removeAllListeners('foo') - expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0) - expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1) - expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1) + expect(interceptor['emitter'].listenerCount('foo')).toBe(0) }) it('forwards removal of all listeners via ".removeAllListeners()"', () => { class FirstInterceptor extends Interceptor { - constructor() { - super(Symbol('first')) + protected predicate(): boolean { + return true } + protected setup(): void {} } - class SecondaryInterceptor extends Interceptor { - constructor() { - super(Symbol('second')) + class SecondInterceptor extends Interceptor { + protected predicate(): boolean { + return true } + protected setup(): void {} } const firstInterceptor = new FirstInterceptor() - const secondInterceptor = new SecondaryInterceptor() + const secondInterceptor = new SecondInterceptor() const interceptor = new BatchInterceptor({ name: 'batch', @@ -242,8 +260,8 @@ it('forwards removal of all listeners via ".removeAllListeners()"', () => { interceptor.on('bar', listener) expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(2) - expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2) expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1) + expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2) expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1) interceptor.removeAllListeners() diff --git a/src/BatchInterceptor.ts b/src/BatchInterceptor.ts index 083ce5fb2..ad7af217d 100644 --- a/src/BatchInterceptor.ts +++ b/src/BatchInterceptor.ts @@ -1,20 +1,29 @@ -import { EventMap, Listener } from 'strict-event-emitter' -import { Interceptor, ExtractEventNames } from './Interceptor' +import { + type Emitter, + type DefaultEventMap, + TypedListenerOptions, + WithReservedEvents, +} from 'rettime' +import { Logger } from '@open-draft/logger' +import { Interceptor } from './interceptor' export interface BatchInterceptorOptions< - InterceptorList extends ReadonlyArray> + InterceptorList extends ReadonlyArray>, > { name: string interceptors: InterceptorList } export type ExtractEventMapType< - InterceptorList extends ReadonlyArray> -> = InterceptorList extends ReadonlyArray - ? InterceptorType extends Interceptor - ? EventMap + InterceptorList extends ReadonlyArray>, +> = + InterceptorList extends ReadonlyArray + ? InterceptorType extends Interceptor + ? EventMap + : never : never - : never + +const logger = new Logger('BatchInterceptor') /** * A batch interceptor that exposes a single interface @@ -22,24 +31,39 @@ export type ExtractEventMapType< */ export class BatchInterceptor< InterceptorList extends ReadonlyArray>, - Events extends EventMap = ExtractEventMapType + Events extends DefaultEventMap = ExtractEventMapType, > extends Interceptor { - static symbol: symbol - - private interceptors: InterceptorList + #logger: Logger + #interceptors: InterceptorList constructor(options: BatchInterceptorOptions) { - BatchInterceptor.symbol = Symbol(options.name) - super(BatchInterceptor.symbol) - this.interceptors = options.interceptors + super() + + this.#logger = logger.extend(options.name) + this.#interceptors = options.interceptors + } + + protected predicate(): boolean { + for (const interceptor of this.#interceptors) { + if (interceptor['predicate']()) { + /** + * @note If at least one of the provided interceptors suits the environment, + * treat this batch interceptor as matching. Since all the interceptors abide + * by the same event map, it will handle the events from any that match. + */ + return true + } + } + + return false } protected setup() { - const logger = this.logger.extend('setup') + const logger = this.#logger.extend('setup') - logger.info('applying all %d interceptors...', this.interceptors.length) + logger.info('applying all %d interceptors...', this.#interceptors.length) - for (const interceptor of this.interceptors) { + for (const interceptor of this.#interceptors) { logger.info('applying "%s" interceptor...', interceptor.constructor.name) interceptor.apply() @@ -48,48 +72,44 @@ export class BatchInterceptor< } } - public on>( - event: EventName, - listener: Listener - ): this { - // Instead of adding a listener to the batch interceptor, - // propagate the listener to each of the individual interceptors. - for (const interceptor of this.interceptors) { - interceptor.on(event, listener) + public on: (typeof this.emitter)['on'] = (type, listener, options) => { + for (const interceptor of this.#interceptors) { + interceptor.on(type, listener, options) } - return this + return this.emitter } - public once>( - event: EventName, - listener: Listener - ): this { - for (const interceptor of this.interceptors) { - interceptor.once(event, listener) + public once: (typeof this.emitter)['once'] = (type, listener, options) => { + for (const interceptor of this.#interceptors) { + interceptor.once(type, listener, options) } - return this + return this.emitter } - public off>( - event: EventName, - listener: Listener - ): this { - for (const interceptor of this.interceptors) { - interceptor.off(event, listener) + public removeListener: (typeof this.emitter)['removeListener'] = ( + type, + listener + ) => { + for (const interceptor of this.#interceptors) { + interceptor.removeListener(type, listener) } - - return this } - public removeAllListeners>( - event?: EventName | undefined - ): this { - for (const interceptors of this.interceptors) { - interceptors.removeAllListeners(event) + public removeAllListeners: (typeof this.emitter)['removeAllListeners'] = ( + type + ) => { + for (const interceptor of this.#interceptors) { + interceptor.removeAllListeners(type) } + } + + public listeners: (typeof this.emitter)['listeners'] = (type) => { + return this.#interceptors[0].listeners(type) + } - return this + public listenerCount: (typeof this.emitter)['listenerCount'] = (type) => { + return this.#interceptors[0].listenerCount(type) } } diff --git a/src/Interceptor.test.ts b/src/Interceptor.test.ts deleted file mode 100644 index e8d78f8a3..000000000 --- a/src/Interceptor.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, vi, it, expect, afterEach } from 'vitest' -import { - Interceptor, - getGlobalSymbol, - deleteGlobalSymbol, - InterceptorReadyState, -} from './Interceptor' -import { nextTickAsync } from './utils/nextTick' - -const symbol = Symbol('test') - -afterEach(() => { - deleteGlobalSymbol(symbol) -}) - -it('does not set a maximum listeners limit', () => { - const interceptor = new Interceptor(symbol) - expect(interceptor['emitter'].getMaxListeners()).toBe(0) -}) - -describe('on()', () => { - it('adds a new listener using "on()"', () => { - const interceptor = new Interceptor(symbol) - expect(interceptor['emitter'].listenerCount('event')).toBe(0) - - const listener = vi.fn() - interceptor.on('event', listener) - expect(interceptor['emitter'].listenerCount('event')).toBe(1) - }) -}) - -describe('once()', () => { - it('calls the listener only once', () => { - const interceptor = new Interceptor(symbol) - const listener = vi.fn() - - interceptor.once('foo', listener) - expect(listener).not.toHaveBeenCalled() - - interceptor['emitter'].emit('foo', 'bar') - - expect(listener).toHaveBeenCalledTimes(1) - expect(listener).toHaveBeenCalledWith('bar') - - listener.mockReset() - - interceptor['emitter'].emit('foo', 'baz') - interceptor['emitter'].emit('foo', 'xyz') - expect(listener).toHaveBeenCalledTimes(0) - }) -}) - -describe('off()', () => { - it('removes a listener using "off()"', () => { - const interceptor = new Interceptor(symbol) - expect(interceptor['emitter'].listenerCount('event')).toBe(0) - - const listener = vi.fn() - interceptor.on('event', listener) - expect(interceptor['emitter'].listenerCount('event')).toBe(1) - - interceptor.off('event', listener) - expect(interceptor['emitter'].listenerCount('event')).toBe(0) - }) -}) - -describe('persistence', () => { - it('stores global reference to the applied interceptor', () => { - const interceptor = new Interceptor(symbol) - interceptor.apply() - - expect(getGlobalSymbol(symbol)).toEqual(interceptor) - }) - - it('deletes global reference when the interceptor is disposed', () => { - const interceptor = new Interceptor(symbol) - - interceptor.apply() - interceptor.dispose() - - expect(getGlobalSymbol(symbol)).toBeUndefined() - }) -}) - -describe('readyState', () => { - it('sets the state to "INACTIVE" when the interceptor is created', () => { - const interceptor = new Interceptor(symbol) - expect(interceptor.readyState).toBe(InterceptorReadyState.INACTIVE) - }) - - it('leaves state as "INACTIVE" if the interceptor failed the environment check', async () => { - class MyInterceptor extends Interceptor { - protected checkEnvironment(): boolean { - return false - } - } - const interceptor = new MyInterceptor(symbol) - interceptor.apply() - - expect(interceptor.readyState).toBe(InterceptorReadyState.INACTIVE) - }) - - it('performs state transition when the interceptor is applying', async () => { - const interceptor = new Interceptor(symbol) - interceptor.apply() - - // The interceptor's state transitions to APPLIED immediately. - // The only exception is if something throws during the setup. - expect(interceptor.readyState).toBe(InterceptorReadyState.APPLIED) - }) - - it('performs state transition when disposing of the interceptor', async () => { - const interceptor = new Interceptor(symbol) - interceptor.apply() - interceptor.dispose() - - // The interceptor's state transitions to DISPOSED immediately. - // The only exception is if something throws during the teardown. - expect(interceptor.readyState).toBe(InterceptorReadyState.DISPOSED) - }) -}) - -describe('apply', () => { - it('does not apply the same interceptor multiple times', () => { - const interceptor = new Interceptor(symbol) - const setupSpy = vi.spyOn( - interceptor, - // @ts-expect-error Protected property spy. - 'setup' - ) - - // Intentionally apply the same interceptor multiple times. - interceptor.apply() - interceptor.apply() - interceptor.apply() - - // The "setup" must not be called repeatedly. - expect(setupSpy).toHaveBeenCalledTimes(1) - - expect(getGlobalSymbol(symbol)).toEqual(interceptor) - }) - - it('does not call "apply" if the interceptor fails environment check', () => { - class MyInterceptor extends Interceptor<{}> { - checkEnvironment() { - return false - } - } - - const interceptor = new MyInterceptor(Symbol('test')) - const setupSpy = vi.spyOn( - interceptor, - // @ts-expect-error Protected property spy. - 'setup' - ) - interceptor.apply() - - expect(setupSpy).not.toHaveBeenCalled() - }) - - it('proxies listeners from new interceptor to already running interceptor', () => { - const firstInterceptor = new Interceptor(symbol) - const secondInterceptor = new Interceptor(symbol) - - firstInterceptor.apply() - const firstListener = vi.fn() - firstInterceptor.on('test', firstListener) - - secondInterceptor.apply() - const secondListener = vi.fn() - secondInterceptor.on('test', secondListener) - - // Emitting event in the first interceptor will bubble to the second one. - firstInterceptor['emitter'].emit('test', 'hello world') - - expect(firstListener).toHaveBeenCalledTimes(1) - expect(firstListener).toHaveBeenCalledWith('hello world') - - expect(secondListener).toHaveBeenCalledTimes(1) - expect(secondListener).toHaveBeenCalledWith('hello world') - - expect(secondInterceptor['emitter'].listenerCount('test')).toBe(0) - }) -}) - -describe('dispose', () => { - it('removes all listeners when the interceptor is disposed', async () => { - const interceptor = new Interceptor(symbol) - - interceptor.apply() - const listener = vi.fn() - interceptor.on('test', listener) - interceptor.dispose() - - // Even after emitting an event, the listener must not get called. - interceptor['emitter'].emit('test') - expect(listener).not.toHaveBeenCalled() - - // The listener must not be called on the next tick either. - await nextTickAsync(() => { - interceptor['emitter'].emit('test') - expect(listener).not.toHaveBeenCalled() - }) - }) -}) diff --git a/src/Interceptor.ts b/src/Interceptor.ts deleted file mode 100644 index b33e74fdf..000000000 --- a/src/Interceptor.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Logger } from '@open-draft/logger' -import { Emitter, Listener } from 'strict-event-emitter' - -export type InterceptorEventMap = Record -export type InterceptorSubscription = () => void - -/** - * Request header name to detect when a single request - * is being handled by nested interceptors (XHR -> ClientRequest). - * Obscure by design to prevent collisions with user-defined headers. - * Ideally, come up with the Interceptor-level mechanism for this. - * @see https://github.com/mswjs/interceptors/issues/378 - */ -export const INTERNAL_REQUEST_ID_HEADER_NAME = - 'x-interceptors-internal-request-id' - -export function getGlobalSymbol(symbol: Symbol): V | undefined { - return ( - // @ts-ignore https://github.com/Microsoft/TypeScript/issues/24587 - globalThis[symbol] || undefined - ) -} - -function setGlobalSymbol(symbol: Symbol, value: any): void { - // @ts-ignore - globalThis[symbol] = value -} - -export function deleteGlobalSymbol(symbol: Symbol): void { - // @ts-ignore - delete globalThis[symbol] -} - -export enum InterceptorReadyState { - INACTIVE = 'INACTIVE', - APPLYING = 'APPLYING', - APPLIED = 'APPLIED', - DISPOSING = 'DISPOSING', - DISPOSED = 'DISPOSED', -} - -export type ExtractEventNames> = - Events extends Record ? EventName : never - -export class Interceptor { - protected emitter: Emitter - protected subscriptions: Array - protected logger: Logger - - public readyState: InterceptorReadyState - - constructor(private readonly symbol: symbol) { - this.readyState = InterceptorReadyState.INACTIVE - - this.emitter = new Emitter() - this.subscriptions = [] - this.logger = new Logger(symbol.description!) - - // Do not limit the maximum number of listeners - // so not to limit the maximum amount of parallel events emitted. - this.emitter.setMaxListeners(0) - - this.logger.info('constructing the interceptor...') - } - - /** - * Determine if this interceptor can be applied - * in the current environment. - */ - protected checkEnvironment(): boolean { - return true - } - - /** - * Apply this interceptor to the current process. - * Returns an already running interceptor instance if it's present. - */ - public apply(): void { - const logger = this.logger.extend('apply') - logger.info('applying the interceptor...') - - if (this.readyState === InterceptorReadyState.APPLIED) { - logger.info('intercepted already applied!') - return - } - - const shouldApply = this.checkEnvironment() - - if (!shouldApply) { - logger.info('the interceptor cannot be applied in this environment!') - return - } - - this.readyState = InterceptorReadyState.APPLYING - - // Whenever applying a new interceptor, check if it hasn't been applied already. - // This enables to apply the same interceptor multiple times, for example from a different - // interceptor, only proxying events but keeping the stubs in a single place. - const runningInstance = this.getInstance() - - if (runningInstance) { - logger.info('found a running instance, reusing...') - - // Proxy any listeners you set on this instance to the running instance. - this.on = (event, listener) => { - logger.info('proxying the "%s" listener', event) - - // Add listeners to the running instance so they appear - // at the top of the event listeners list and are executed first. - runningInstance.emitter.addListener(event, listener) - - // Ensure that once this interceptor instance is disposed, - // it removes all listeners it has appended to the running interceptor instance. - this.subscriptions.push(() => { - runningInstance.emitter.removeListener(event, listener) - logger.info('removed proxied "%s" listener!', event) - }) - - return this - } - - this.readyState = InterceptorReadyState.APPLIED - - return - } - - logger.info('no running instance found, setting up a new instance...') - - // Setup the interceptor. - this.setup() - - // Store the newly applied interceptor instance globally. - this.setInstance() - - this.readyState = InterceptorReadyState.APPLIED - } - - /** - * Setup the module augments and stubs necessary for this interceptor. - * This method is not run if there's a running interceptor instance - * to prevent instantiating an interceptor multiple times. - */ - protected setup(): void {} - - /** - * Listen to the interceptor's public events. - */ - public on>( - event: EventName, - listener: Listener - ): this { - const logger = this.logger.extend('on') - - if ( - this.readyState === InterceptorReadyState.DISPOSING || - this.readyState === InterceptorReadyState.DISPOSED - ) { - logger.info('cannot listen to events, already disposed!') - return this - } - - logger.info('adding "%s" event listener:', event, listener) - - this.emitter.on(event, listener) - return this - } - - public once>( - event: EventName, - listener: Listener - ): this { - this.emitter.once(event, listener) - return this - } - - public off>( - event: EventName, - listener: Listener - ): this { - this.emitter.off(event, listener) - return this - } - - public removeAllListeners>( - event?: EventName - ): this { - this.emitter.removeAllListeners(event) - return this - } - - /** - * Disposes of any side-effects this interceptor has introduced. - */ - public dispose(): void { - const logger = this.logger.extend('dispose') - - if (this.readyState === InterceptorReadyState.DISPOSED) { - logger.info('cannot dispose, already disposed!') - return - } - - logger.info('disposing the interceptor...') - this.readyState = InterceptorReadyState.DISPOSING - - if (!this.getInstance()) { - logger.info('no interceptors running, skipping dispose...') - return - } - - // Delete the global symbol as soon as possible, - // indicating that the interceptor is no longer running. - this.clearInstance() - - logger.info('global symbol deleted:', getGlobalSymbol(this.symbol)) - - if (this.subscriptions.length > 0) { - logger.info('disposing of %d subscriptions...', this.subscriptions.length) - - for (const dispose of this.subscriptions) { - dispose() - } - - this.subscriptions = [] - - logger.info('disposed of all subscriptions!', this.subscriptions.length) - } - - this.emitter.removeAllListeners() - logger.info('destroyed the listener!') - - this.readyState = InterceptorReadyState.DISPOSED - } - - private getInstance(): this | undefined { - const instance = getGlobalSymbol(this.symbol) - this.logger.info('retrieved global instance:', instance?.constructor?.name) - return instance - } - - private setInstance(): void { - setGlobalSymbol(this.symbol, this) - this.logger.info('set global instance!', this.symbol.description) - } - - private clearInstance(): void { - deleteGlobalSymbol(this.symbol) - this.logger.info('cleared global instance!', this.symbol.description) - } -} diff --git a/src/RemoteHttpInterceptor.ts b/src/RemoteHttpInterceptor.ts index 0a1acddd7..41e81bbac 100644 --- a/src/RemoteHttpInterceptor.ts +++ b/src/RemoteHttpInterceptor.ts @@ -1,10 +1,11 @@ -import { ChildProcess } from 'child_process' -import { HttpRequestEventMap } from './glossary' -import { Interceptor } from './Interceptor' +import type { ChildProcess } from 'node:child_process' +import { Logger } from '@open-draft/logger' +import { type HttpRequestEventMap, HttpResponseEvent } from './events/http' +import { Interceptor } from './interceptor' import { BatchInterceptor } from './BatchInterceptor' import { ClientRequestInterceptor } from './interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest' -import { FetchInterceptor } from './interceptors/fetch' +import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest/node' +import { FetchInterceptor } from './interceptors/fetch/node' import { handleRequest } from './utils/handleRequest' import { RequestController } from './RequestController' import { FetchRequest, FetchResponse } from './utils/fetchUtils' @@ -16,7 +17,7 @@ export interface SerializedRequest { method: string headers: Array<[string, string]> credentials: RequestCredentials - body: string + body: string | null } interface RevivedRequest extends Omit { @@ -28,9 +29,11 @@ export interface SerializedResponse { status: number statusText: string headers: Array<[string, string]> - body: string + body: string | null } +const logger = new Logger('remote-http-interceptor') + export class RemoteHttpInterceptor extends BatchInterceptor< [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor] > { @@ -62,12 +65,9 @@ export class RemoteHttpInterceptor extends BatchInterceptor< body: ['GET', 'HEAD'].includes(request.method) ? null : await request.text(), - } as SerializedRequest) + } satisfies SerializedRequest) - this.logger.info( - 'sent serialized request to the child:', - serializedRequest - ) + logger.info('sent serialized request to the child:', serializedRequest) process.send?.(`request:${serializedRequest}`) @@ -108,7 +108,7 @@ export class RemoteHttpInterceptor extends BatchInterceptor< }) // Listen for the mocked response message from the parent. - this.logger.info( + logger.info( 'add "message" listener to the parent process', handleParentMessage ) @@ -141,17 +141,19 @@ export interface RemoveResolverOptions { } export class RemoteHttpResolver extends Interceptor { - static symbol = Symbol('remote-resolver') + static symbol = Symbol('remote-http-resolver') private process: ChildProcess constructor(options: RemoveResolverOptions) { - super(RemoteHttpResolver.symbol) + super() this.process = options.process } - protected setup() { - const logger = this.logger.extend('setup') + protected predicate(): boolean { + return true + } + protected setup() { const handleChildMessage: NodeJS.MessageListener = async (message) => { logger.info('received message from child!', message) @@ -168,7 +170,7 @@ export class RemoteHttpResolver extends Interceptor { const requestJson = JSON.parse( serializedRequest, requestReviver - ) as RevivedRequest + ) satisfies RevivedRequest logger.info('parsed intercepted request', requestJson) @@ -183,38 +185,41 @@ export class RemoteHttpResolver extends Interceptor { passthrough: () => {}, respondWith: async (response) => { if (isResponseError(response)) { - this.logger.info('received a network error!', { response }) + logger.info('received a network error!', { response }) throw new Error('Not implemented') } - this.logger.info('received mocked response!', { response }) + logger.info('received mocked response!', { response }) const responseClone = FetchResponse.clone(response) const responseText = await responseClone.text() - // // Send the mocked response to the child process. + // Send the mocked response to the child process. const serializedResponse = JSON.stringify({ status: response.status, statusText: response.statusText, headers: Array.from(response.headers.entries()), body: responseText, - } as SerializedResponse) + } satisfies SerializedResponse) this.process.send( `response:${requestJson.id}:${serializedResponse}`, - (error) => { + async (error) => { if (error) { return } // Emit an optimistic "response" event at this point, // not to rely on the back-and-forth signaling for the sake of the event. - this.emitter.emit('response', { - request, - requestId: requestJson.id, - response: responseClone, - isMockedResponse: true, - }) + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator: null, + request, + requestId: requestJson.id, + response: responseClone, + responseType: 'mock', + }) + ) } ) @@ -224,12 +229,13 @@ export class RemoteHttpResolver extends Interceptor { ) }, errorWith: (reason) => { - this.logger.info('request has errored!', { error: reason }) + logger.info('request has errored!', { error: reason }) throw new Error('Not implemented') }, }) await handleRequest({ + initiator: null, request, requestId: requestJson.id, controller, diff --git a/src/RequestController.test.ts b/src/RequestController.test.ts index eeed75f0a..aaa7705a5 100644 --- a/src/RequestController.test.ts +++ b/src/RequestController.test.ts @@ -1,4 +1,3 @@ -import { vi, it, expect } from 'vitest' import { RequestController, type RequestControllerSource, diff --git a/src/RequestController.ts b/src/RequestController.ts index 57486d848..3bb82fd52 100644 --- a/src/RequestController.ts +++ b/src/RequestController.ts @@ -3,9 +3,9 @@ import { invariant } from 'outvariant' import { InterceptorError } from './InterceptorError' export interface RequestControllerSource { - passthrough(): void - respondWith(response: Response): void - errorWith(reason?: unknown): void + passthrough(): void | Promise + respondWith(response: Response): void | Promise + errorWith(reason?: unknown): void | Promise } export class RequestController { diff --git a/src/createRequestId.test.ts b/src/createRequestId.test.ts index a59b2c049..8c1ae0747 100644 --- a/src/createRequestId.test.ts +++ b/src/createRequestId.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { createRequestId } from './createRequestId' import { REQUEST_ID_REGEXP } from '../test/helpers' diff --git a/src/disposable.ts b/src/disposable.ts new file mode 100644 index 000000000..5ad368093 --- /dev/null +++ b/src/disposable.ts @@ -0,0 +1,13 @@ +export type DisposableSubscription = () => void + +export class Disposable { + protected subscriptions: Array = [] + + public dispose() { + let subscription: DisposableSubscription | undefined + + while ((subscription = this.subscriptions.pop())) { + subscription() + } + } +} diff --git a/src/events/http.ts b/src/events/http.ts new file mode 100644 index 000000000..09241752b --- /dev/null +++ b/src/events/http.ts @@ -0,0 +1,92 @@ +import { TypedEvent } from 'rettime' +import type { RequestController } from '../RequestController' + +export interface HttpRequestEventData { + request: Request + requestId: string + initiator: unknown + controller: RequestController +} + +export class HttpRequestEvent< + DataType extends HttpRequestEventData = HttpRequestEventData, +> extends TypedEvent { + public request: Request + public requestId: string + public initiator: unknown + public controller: RequestController + + constructor(data: DataType) { + super(...(['request', {}] as any)) + + this.request = data.request + this.requestId = data.requestId + this.initiator = data.initiator + this.controller = data.controller + } +} + +export type HttpResponseType = 'mock' | 'original' + +interface HttpResponseEventData { + response: Response + responseType: HttpResponseType + request: Request + requestId: string + initiator: unknown +} + +export class HttpResponseEvent< + DataType extends HttpResponseEventData = HttpResponseEventData, +> extends TypedEvent { + public response: Response + public responseType: HttpResponseType + public request: Request + public requestId: string + public initiator: unknown + + constructor(data: DataType) { + super(...(['response', {}] as any)) + + this.response = data.response + this.responseType = data.responseType + this.request = data.request + this.requestId = data.requestId + this.initiator = data.initiator + } +} + +interface UnhandledHttpExceptionEventData { + error: unknown + request: Request + requestId: string + initiator: unknown + controller: RequestController +} + +export class UnhandledHttpException< + DataType extends UnhandledHttpExceptionEventData = + UnhandledHttpExceptionEventData, +> extends TypedEvent { + public error: unknown + public request: Request + public requestId: string + public initiator: unknown + public controller: RequestController + + constructor(data: DataType) { + super(...(['unhandledException', {}] as any)) + + this.error = data.error + this.request = data.request + this.requestId = data.requestId + this.initiator = data.initiator + this.controller = data.controller + } +} + +export type HttpRequestEventMap = { + request: HttpRequestEvent + response: HttpResponseEvent + unhandledException: UnhandledHttpException +} diff --git a/src/events/websocket.ts b/src/events/websocket.ts new file mode 100644 index 000000000..74ff4da66 --- /dev/null +++ b/src/events/websocket.ts @@ -0,0 +1,47 @@ +import { TypedEvent } from 'rettime' +import type { + WebSocketClientConnection, + WebSocketServerConnection, +} from '../interceptors/WebSocket' + +/** + * The connection information. + */ +interface WebSocketConnectionInfo { + /** + * The protocols supported by the WebSocket client. + */ + protocols: string | Array | undefined +} + +interface WebSocketConnectionEventData { + /** + * The incoming WebSocket client connection. + */ + client: WebSocketClientConnection + /** + * The original WebSocket server connection. + */ + server: WebSocketServerConnection + info: WebSocketConnectionInfo +} + +export class WebSocketConnectionEvent< + DataType extends WebSocketConnectionEventData = WebSocketConnectionEventData, +> extends TypedEvent { + public client: WebSocketClientConnection + public server: WebSocketServerConnection + public info: WebSocketConnectionInfo + + constructor(data: DataType) { + super(...(['connection', {}] as any)) + + this.client = data.client + this.server = data.server + this.info = data.info + } +} + +export type WebSocketEventMap = { + connection: WebSocketConnectionEvent +} diff --git a/src/getRawRequest.ts b/src/getRawRequest.ts deleted file mode 100644 index 3818711ca..000000000 --- a/src/getRawRequest.ts +++ /dev/null @@ -1,21 +0,0 @@ -const kRawRequest = Symbol('kRawRequest') - -/** - * Returns a raw request instance associated with this request. - * - * @example - * interceptor.on('request', ({ request }) => { - * const rawRequest = getRawRequest(request) - * - * if (rawRequest instanceof http.ClientRequest) { - * console.log(rawRequest.rawHeaders) - * } - * }) - */ -export function getRawRequest(request: Request): unknown | undefined { - return Reflect.get(request, kRawRequest) -} - -export function setRawRequest(request: Request, rawRequest: unknown): void { - Reflect.set(request, kRawRequest, rawRequest) -} diff --git a/src/glossary.ts b/src/glossary.ts index 816ab75ef..10f336b9f 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -7,29 +7,3 @@ import type { RequestController } from './RequestController' export type { RequestController } export type RequestCredentials = 'omit' | 'include' | 'same-origin' - -export type HttpRequestEventMap = { - request: [ - args: { - request: Request - requestId: string - controller: RequestController - }, - ] - response: [ - args: { - response: Response - isMockedResponse: boolean - request: Request - requestId: string - }, - ] - unhandledException: [ - args: { - error: unknown - request: Request - requestId: string - controller: RequestController - }, - ] -} diff --git a/src/index.ts b/src/index.ts index 7b1d7870c..6b852d1ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,15 @@ -export * from './glossary' -export * from './Interceptor' -export * from './BatchInterceptor' +export { Interceptor } from './interceptor' +export { BatchInterceptor } from './BatchInterceptor' export { RequestController, type RequestControllerSource, } from './RequestController' +export type { HttpRequestEventMap } from './events/http' +export type { WebSocketEventMap } from './events/websocket' /* Utils */ export { createRequestId } from './createRequestId' export { getCleanUrl } from './utils/getCleanUrl' export { encodeBuffer, decodeBuffer } from './utils/bufferUtils' export { FetchRequest, FetchResponse } from './utils/fetchUtils' -export { getRawRequest } from './getRawRequest' export { resolveWebSocketUrl } from './utils/resolveWebSocketUrl' diff --git a/src/interceptor.test.ts b/src/interceptor.test.ts new file mode 100644 index 000000000..cfd5c2932 --- /dev/null +++ b/src/interceptor.test.ts @@ -0,0 +1,214 @@ +import { TypedEvent } from 'rettime' +import { Interceptor, InterceptorReadyState } from './interceptor' + +it('nesting interceptors', async () => { + const socketSetup = vi.fn() + const protocolSetup = vi.fn() + + class SocketInterceptor extends Interceptor<{ + data: TypedEvent + }> { + static symbol = Symbol.for('socket-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + socketSetup() + + queueMicrotask(() => { + this.emitter.emit(new TypedEvent('data', { data: 1 })) + this.emitter.emit(new TypedEvent('data', { data: 'hello' })) + this.emitter.emit(new TypedEvent('data', { data: 2 })) + }) + } + } + + class ProtocolInterceptor extends Interceptor<{ + request: TypedEvent + }> { + static symbol = Symbol.for('protocol-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + protocolSetup() + + const socket = Interceptor.singleton(SocketInterceptor) + socket.apply() + this.subscriptions.push(() => socket.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + socket.on( + 'data', + ({ data }) => { + this.emitter.emit(new TypedEvent('request', { data })) + }, + { signal: controller.signal } + ) + } + } + + class NumberInterceptor extends Interceptor<{ + number: TypedEvent + }> { + static symbol = Symbol.for('number-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + const protocol = Interceptor.singleton(ProtocolInterceptor) + protocol.apply() + this.subscriptions.push(() => protocol.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + protocol.on( + 'request', + ({ data }) => { + if (typeof data === 'number') { + this.emitter.emit(new TypedEvent('number', { data })) + } + }, + { signal: controller.signal } + ) + } + } + + class StringInterceptor extends Interceptor<{ + string: TypedEvent + }> { + static symbol = Symbol.for('string-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + const protocol = Interceptor.singleton(ProtocolInterceptor) + protocol.apply() + this.subscriptions.push(() => protocol.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + protocol.on( + 'request', + ({ data }) => { + if (typeof data === 'string') { + this.emitter.emit(new TypedEvent('string', { data })) + } + }, + { signal: controller.signal } + ) + } + } + + const numberListener = vi.fn() + const numberInterceptor = new NumberInterceptor() + numberInterceptor.on('number', ({ data }) => numberListener(data)) + numberInterceptor.apply() + + const stringListener = vi.fn() + const stringInterceptor = new StringInterceptor() + stringInterceptor.on('string', ({ data }) => stringListener(data)) + stringInterceptor.apply() + + expect(socketSetup).toHaveBeenCalledOnce() + expect(protocolSetup).toHaveBeenCalledOnce() + + await expect.poll(() => numberListener).toHaveBeenCalledTimes(2) + + numberInterceptor.dispose() + stringInterceptor.dispose() + + expect(numberListener).toHaveBeenNthCalledWith(1, 1) + expect(numberListener).toHaveBeenNthCalledWith(2, 2) + expect(stringListener).toHaveBeenCalledExactlyOnceWith('hello') +}) + +it('treats an interceptor as a singleton via "Interceptor.singleton()"', () => { + const setup = vi.fn() + const dispose = vi.fn() + + class MyInterceptor extends Interceptor<{ test: TypedEvent }> { + protected predicate(): boolean { + return true + } + + protected setup(): void { + setup() + } + + public dispose(): void { + super.dispose() + dispose() + } + } + + const interceptor = Interceptor.singleton(MyInterceptor) + expect(setup).not.toHaveBeenCalled() + + interceptor.apply() + expect(setup).toHaveBeenCalledOnce() + + { + const interceptor = Interceptor.singleton(MyInterceptor) + expect(setup).toHaveBeenCalledOnce() + + interceptor.dispose() + expect(dispose).toHaveBeenCalledOnce() + } + + interceptor.dispose() + expect(dispose).toHaveBeenCalledTimes(2) +}) + +it('removes all listeners when the interceptor is disposed', () => { + class MyInterceptor extends Interceptor<{ test: TypedEvent }> { + protected predicate(): boolean { + return true + } + + protected setup(): void {} + } + + const interceptor = new MyInterceptor() + interceptor.apply() + + const listener = vi.fn() + interceptor.on('test', listener) + + interceptor.dispose() + + interceptor['emitter'].emit(new TypedEvent('test')) + expect(listener).not.toHaveBeenCalled() +}) + +it('applies the interceptor after disposal', () => { + class MyInterceptor extends Interceptor<{}> { + protected predicate(): boolean { + return true + } + + protected setup(): void {} + } + const interceptor = new MyInterceptor() + + interceptor.apply() + expect(interceptor.readyState).toBe(InterceptorReadyState.ACTIVE) + + interceptor.dispose() + expect(interceptor.readyState).toBe(InterceptorReadyState.DISPOSED) + + interceptor.apply() + expect(interceptor.readyState).toBe(InterceptorReadyState.ACTIVE) +}) diff --git a/src/interceptor.ts b/src/interceptor.ts new file mode 100644 index 000000000..2a88b1ed4 --- /dev/null +++ b/src/interceptor.ts @@ -0,0 +1,122 @@ +import { Emitter, type DefaultEventMap } from 'rettime' +import { Disposable } from './disposable' + +export enum InterceptorReadyState { + INACTIVE = 'INACTIVE', + ACTIVE = 'ACTIVE', + DISPOSED = 'DISPOSED', +} + +declare global { + var __MSW_INTERCEPTORS_REGISTRY: Map> | undefined +} + +const interceptorsRegistry = (globalThis.__MSW_INTERCEPTORS_REGISTRY ??= + new Map>()) + +export abstract class Interceptor< + Events extends DefaultEventMap, +> extends Disposable { + declare ['constructor']: typeof Interceptor + + protected emitter: Emitter + + public readyState: InterceptorReadyState + + static readonly symbol: symbol + + #leaseCount: number + + static singleton>( + InterceptorClass: (new () => T) & { symbol: symbol } + ): T { + const symbol = InterceptorClass.symbol + const existing = interceptorsRegistry.get(symbol) + + if (existing instanceof InterceptorClass) { + return existing + } + + const newInstance = new InterceptorClass() + interceptorsRegistry.set(symbol, newInstance) + return newInstance + } + + constructor() { + super() + + this.#leaseCount = 0 + this.readyState = InterceptorReadyState.INACTIVE + this.emitter = new Emitter() + } + + protected abstract predicate(): boolean + protected abstract setup(): void + + public apply(): void { + if (this.readyState === InterceptorReadyState.ACTIVE) { + return + } + + if (!this.predicate()) { + return + } + + this.#leaseCount++ + + if (this.#leaseCount === 1) { + try { + this.setup() + this.readyState = InterceptorReadyState.ACTIVE + } catch (error) { + this.dispose() + throw error + } + } + } + + public dispose(): void { + if (this.readyState === InterceptorReadyState.DISPOSED) { + return + } + + if (this.#leaseCount === 0) { + return + } + + this.#leaseCount-- + + if (this.#leaseCount === 0) { + super.dispose() + this.emitter.removeAllListeners() + this.readyState = InterceptorReadyState.DISPOSED + } + } + + public on: Emitter['on'] = (type, listener, options) => { + return this.emitter.on(type, listener, options) + } + + public once: Emitter['once'] = (type, listener, options) => { + return this.emitter.once(type, listener, options) + } + + public listeners: Emitter['listeners'] = (type) => { + return this.emitter.listeners(type) + } + + public listenerCount: Emitter['listenerCount'] = (type) => { + return this.emitter.listenerCount(type) + } + + public removeListener: Emitter['removeListener'] = ( + type, + listener + ) => { + return this.emitter.removeListener(type, listener) + } + + public removeAllListeners: Emitter['removeAllListeners'] = (type) => { + return this.emitter.removeAllListeners(type) + } +} diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts deleted file mode 100644 index 4d6a730cf..000000000 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ /dev/null @@ -1,724 +0,0 @@ -import net from 'node:net' -import { - type HeadersCallback, - HTTPParser, - type RequestHeadersCompleteCallback, - type ResponseHeadersCompleteCallback, -} from '_http_common' -import { STATUS_CODES, IncomingMessage, ServerResponse } from 'node:http' -import { Readable } from 'node:stream' -import { invariant } from 'outvariant' -import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' -import { MockSocket } from '../Socket/MockSocket' -import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs' -import { isPropertyAccessible } from '../../utils/isPropertyAccessible' -import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' -import { createRequestId } from '../../createRequestId' -import { getRawFetchHeaders } from './utils/recordRawHeaders' -import { FetchRequest, FetchResponse } from '../../utils/fetchUtils' -import { setRawRequest } from '../../getRawRequest' -import { setRawRequestBodyStream } from '../../utils/node' -import { freeParser } from './utils/parserUtils' - -type HttpConnectionOptions = any - -export type MockHttpSocketRequestCallback = (args: { - requestId: string - request: Request - socket: MockHttpSocket -}) => void - -export type MockHttpSocketResponseCallback = (args: { - requestId: string - request: Request - response: Response - isMockedResponse: boolean - socket: MockHttpSocket -}) => Promise - -interface MockHttpSocketOptions { - connectionOptions: HttpConnectionOptions - createConnection: () => net.Socket - onRequest: MockHttpSocketRequestCallback - onResponse: MockHttpSocketResponseCallback -} - -export const kRequestId = Symbol('kRequestId') - -export class MockHttpSocket extends MockSocket { - private connectionOptions: HttpConnectionOptions - private createConnection: () => net.Socket - private baseUrl: URL - - private onRequest: MockHttpSocketRequestCallback - private onResponse: MockHttpSocketResponseCallback - private responseListenersPromise?: Promise - - private requestRawHeadersBuffer: Array = [] - private responseRawHeadersBuffer: Array = [] - private writeBuffer: Array = [] - private request?: Request - private requestParser: HTTPParser<0> - private requestStream?: Readable - private shouldKeepAlive?: boolean - - private socketState: 'unknown' | 'mock' | 'passthrough' = 'unknown' - private responseParser: HTTPParser<1> - private responseStream?: Readable - private originalSocket?: net.Socket - - constructor(options: MockHttpSocketOptions) { - super({ - write: (chunk, encoding, callback) => { - // Buffer the writes so they can be flushed in case of the original connection - // and when reading the request body in the interceptor. If the connection has - // been established, no need to buffer the chunks anymore, they will be forwarded. - if (this.socketState !== 'passthrough') { - this.writeBuffer.push([chunk, encoding, callback]) - } - - if (chunk) { - /** - * Forward any writes to the mock socket to the underlying original socket. - * This ensures functional duplex connections, like WebSocket. - * @see https://github.com/mswjs/interceptors/issues/682 - */ - if (this.socketState === 'passthrough') { - this.originalSocket?.write(chunk, encoding, callback) - } - - this.requestParser.execute( - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding) - ) - } - }, - read: (chunk) => { - if (chunk !== null) { - /** - * @todo We need to free the parser if the connection has been - * upgraded to a non-HTTP protocol. It won't be able to parse data - * from that point onward anyway. No need to keep it in memory. - */ - this.responseParser.execute( - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - ) - } - }, - }) - - this.connectionOptions = options.connectionOptions - this.createConnection = options.createConnection - this.onRequest = options.onRequest - this.onResponse = options.onResponse - - this.baseUrl = baseUrlFromConnectionOptions(this.connectionOptions) - - // Request parser. - this.requestParser = new HTTPParser() - this.requestParser.initialize(HTTPParser.REQUEST, {}) - this.requestParser[HTTPParser.kOnHeaders] = this.onRequestHeaders.bind(this) - this.requestParser[HTTPParser.kOnHeadersComplete] = - this.onRequestStart.bind(this) - this.requestParser[HTTPParser.kOnBody] = this.onRequestBody.bind(this) - this.requestParser[HTTPParser.kOnMessageComplete] = - this.onRequestEnd.bind(this) - - // Response parser. - this.responseParser = new HTTPParser() - this.responseParser.initialize(HTTPParser.RESPONSE, {}) - this.responseParser[HTTPParser.kOnHeaders] = - this.onResponseHeaders.bind(this) - this.responseParser[HTTPParser.kOnHeadersComplete] = - this.onResponseStart.bind(this) - this.responseParser[HTTPParser.kOnBody] = this.onResponseBody.bind(this) - this.responseParser[HTTPParser.kOnMessageComplete] = - this.onResponseEnd.bind(this) - - // Once the socket is finished, nothing can write to it - // anymore. It has also flushed any buffered chunks. - this.once('finish', () => freeParser(this.requestParser, this)) - - if (this.baseUrl.protocol === 'https:') { - Reflect.set(this, 'encrypted', true) - // The server certificate is not the same as a CA - // passed to the TLS socket connection options. - Reflect.set(this, 'authorized', false) - Reflect.set(this, 'getProtocol', () => 'TLSv1.3') - Reflect.set(this, 'getSession', () => undefined) - Reflect.set(this, 'isSessionReused', () => false) - Reflect.set(this, 'getCipher', () => ({ - name: 'AES256-SHA', - standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', - version: 'TLSv1.3', - })) - } - } - - public emit(event: string | symbol, ...args: any[]): boolean { - const emitEvent = super.emit.bind(this, event as any, ...args) - - if (this.responseListenersPromise) { - this.responseListenersPromise.finally(emitEvent) - return this.listenerCount(event) > 0 - } - - return emitEvent() - } - - public destroy(error?: Error | undefined): this { - // Destroy the response parser when the socket gets destroyed. - // Normally, we should listen to the "close" event but it - // can be suppressed by using the "emitClose: false" option. - freeParser(this.responseParser, this) - - if (error) { - this.emit('error', error) - } - - return super.destroy(error) - } - - /** - * Establish this Socket connection as-is and pipe - * its data/events through this Socket. - */ - public passthrough(): void { - this.socketState = 'passthrough' - - if (this.destroyed) { - return - } - - const socket = this.createConnection() - this.originalSocket = socket - - /** - * @note Inherit the original socket's connection handle. - * Without this, each push to the mock socket results in a - * new "connection" listener being added (i.e. buffering pushes). - * @see https://github.com/nodejs/node/blob/b18153598b25485ce4f54d0c5cb830a9457691ee/lib/net.js#L734 - */ - if ('_handle' in socket) { - Object.defineProperty(this, '_handle', { - value: socket._handle, - enumerable: true, - writable: true, - }) - } - - // The client-facing socket can be destroyed in two ways: - // 1. The developer destroys the socket. - // 2. The passthrough socket "close" is forwarded to the socket. - this.once('close', () => { - socket.removeAllListeners() - - // If the closure didn't originate from the passthrough socket, destroy it. - if (!socket.destroyed) { - socket.destroy() - } - - this.originalSocket = undefined - }) - - this.address = socket.address.bind(socket) - - // Flush the buffered "socket.write()" calls onto - // the original socket instance (i.e. write request body). - // Exhaust the "requestBuffer" in case this Socket - // gets reused for different requests. - let writeArgs: NormalizedSocketWriteArgs | undefined - let headersWritten = false - - while ((writeArgs = this.writeBuffer.shift())) { - if (writeArgs !== undefined) { - if (!headersWritten) { - const [chunk, encoding, callback] = writeArgs - const chunkString = chunk.toString() - const chunkBeforeRequestHeaders = chunkString.slice( - 0, - chunkString.indexOf('\r\n') + 2 - ) - const chunkAfterRequestHeaders = chunkString.slice( - chunk.indexOf('\r\n\r\n') - ) - const rawRequestHeaders = getRawFetchHeaders(this.request!.headers) - const requestHeadersString = rawRequestHeaders - // Skip the internal request ID deduplication header. - .filter(([name]) => { - return name.toLowerCase() !== INTERNAL_REQUEST_ID_HEADER_NAME - }) - .map(([name, value]) => `${name}: ${value}`) - .join('\r\n') - - // Modify the HTTP request message headers - // to reflect any changes to the request headers - // from the "request" event listener. - const headersChunk = `${chunkBeforeRequestHeaders}${requestHeadersString}${chunkAfterRequestHeaders}` - socket.write(headersChunk, encoding, callback) - headersWritten = true - continue - } - - socket.write(...writeArgs) - } - } - - // Forward TLS Socket properties onto this Socket instance - // in the case of a TLS/SSL connection. - if (Reflect.get(socket, 'encrypted')) { - const tlsProperties = [ - 'encrypted', - 'authorized', - 'getProtocol', - 'getSession', - 'isSessionReused', - 'getCipher', - ] - - tlsProperties.forEach((propertyName) => { - Object.defineProperty(this, propertyName, { - enumerable: true, - get: () => { - const value = Reflect.get(socket, propertyName) - return typeof value === 'function' ? value.bind(socket) : value - }, - }) - }) - } - - socket - .on('lookup', (...args) => this.emit('lookup', ...args)) - .on('connect', () => { - this.connecting = socket.connecting - this.emit('connect') - }) - .on('secureConnect', () => this.emit('secureConnect')) - .on('secure', () => this.emit('secure')) - .on('session', (session) => this.emit('session', session)) - .on('ready', () => this.emit('ready')) - .on('drain', () => this.emit('drain')) - .on('data', (chunk) => { - // Push the original response to this socket - // so it triggers the HTTP response parser. This unifies - // the handling pipeline for original and mocked response. - this.push(chunk) - }) - .on('error', (error) => { - Reflect.set(this, '_hadError', Reflect.get(socket, '_hadError')) - this.emit('error', error) - }) - .on('resume', () => this.emit('resume')) - .on('timeout', () => this.emit('timeout')) - .on('prefinish', () => this.emit('prefinish')) - .on('finish', () => this.emit('finish')) - .on('close', (hadError) => this.emit('close', hadError)) - .on('end', () => this.emit('end')) - } - - /** - * Convert the given Fetch API `Response` instance to an - * HTTP message and push it to the socket. - */ - public async respondWith(response: Response): Promise { - // Ignore the mocked response if the socket has been destroyed - // (e.g. aborted or timed out), - if (this.destroyed) { - return - } - - // Prevent recursive calls. - invariant( - this.socketState !== 'mock', - '[MockHttpSocket] Failed to respond to the "%s %s" request with "%s %s": the request has already been handled', - this.request?.method, - this.request?.url, - response.status, - response.statusText - ) - - // Handle "type: error" responses. - if (isPropertyAccessible(response, 'type') && response.type === 'error') { - this.errorWith(new TypeError('Network error')) - return - } - - // First, emit all the connection events - // to emulate a successful connection. - this.mockConnect() - this.socketState = 'mock' - - // Flush the write buffer to trigger write callbacks - // if it hasn't been flushed already (e.g. someone started reading request stream). - this.flushWriteBuffer() - - // Create a `ServerResponse` instance to delegate HTTP message parsing, - // Transfer-Encoding, and other things to Node.js internals. - const serverResponse = new ServerResponse(new IncomingMessage(this)) - - /** - * Assign a mock socket instance to the server response to - * spy on the response chunk writes. Push the transformed response chunks - * to this `MockHttpSocket` instance to trigger the "data" event. - * @note Providing the same `MockSocket` instance when creating `ServerResponse` - * does not have the same effect. - * @see https://github.com/nodejs/node/blob/10099bb3f7fd97bb9dd9667188426866b3098e07/test/parallel/test-http-server-response-standalone.js#L32 - */ - serverResponse.assignSocket( - new MockSocket({ - write: (chunk, encoding, callback) => { - this.push(chunk, encoding) - callback?.() - }, - read() {}, - }) - ) - - /** - * @note Remove the `Connection` and `Date` response headers - * injected by `ServerResponse` by default. Those are required - * from the server but the interceptor is NOT technically a server. - * It's confusing to add response headers that the developer didn't - * specify themselves. They can always add these if they wish. - * @see https://www.rfc-editor.org/rfc/rfc9110#field.date - * @see https://www.rfc-editor.org/rfc/rfc9110#field.connection - */ - serverResponse.removeHeader('connection') - serverResponse.removeHeader('date') - - const rawResponseHeaders = getRawFetchHeaders(response.headers) - - /** - * @note Call `.writeHead` in order to set the raw response headers - * in the same case as they were provided by the developer. Using - * `.setHeader()`/`.appendHeader()` normalizes header names. - */ - serverResponse.writeHead( - response.status, - response.statusText || STATUS_CODES[response.status], - rawResponseHeaders - ) - - // If the developer destroy the socket, gracefully destroy the response. - this.once('error', () => { - serverResponse.destroy() - }) - - if (response.body) { - try { - const reader = response.body.getReader() - - while (true) { - const { done, value } = await reader.read() - - if (done) { - serverResponse.end() - break - } - - serverResponse.write(value) - } - } catch (error) { - if (error instanceof Error) { - serverResponse.destroy() - /** - * @note Destroy the request socket gracefully. - * Response stream errors do NOT produce request errors. - */ - this.destroy() - return - } - - serverResponse.destroy() - throw error - } - } else { - serverResponse.end() - } - - // Close the socket if the connection wasn't marked as keep-alive. - if (!this.shouldKeepAlive) { - this.emit('readable') - - /** - * @todo @fixme This is likely a hack. - * Since we push null to the socket, it never propagates to the - * parser, and the parser never calls "onResponseEnd" to close - * the response stream. We are closing the stream here manually - * but that shouldn't be the case. - */ - this.responseStream?.push(null) - this.push(null) - } - } - - /** - * Close this socket connection with the given error. - */ - public errorWith(error?: Error): void { - this.destroy(error) - } - - private mockConnect(): void { - // Calling this method immediately puts the socket - // into the connected state. - this.connecting = false - - const isIPv6 = - net.isIPv6(this.connectionOptions.hostname) || - this.connectionOptions.family === 6 - const addressInfo = { - address: isIPv6 ? '::1' : '127.0.0.1', - family: isIPv6 ? 'IPv6' : 'IPv4', - port: this.connectionOptions.port, - } - // Return fake address information for the socket. - this.address = () => addressInfo - this.emit( - 'lookup', - null, - addressInfo.address, - addressInfo.family === 'IPv6' ? 6 : 4, - this.connectionOptions.host - ) - this.emit('connect') - this.emit('ready') - - if (this.baseUrl.protocol === 'https:') { - this.emit('secure') - this.emit('secureConnect') - - // A single TLS connection is represented by two "session" events. - this.emit( - 'session', - this.connectionOptions.session || - Buffer.from('mock-session-renegotiate') - ) - this.emit('session', Buffer.from('mock-session-resume')) - } - } - - private flushWriteBuffer(): void { - for (const writeCall of this.writeBuffer) { - if (typeof writeCall[2] === 'function') { - writeCall[2]() - /** - * @note Remove the callback from the write call - * so it doesn't get called twice on passthrough - * if `request.end()` was called within `request.write()`. - * @see https://github.com/mswjs/interceptors/issues/684 - */ - writeCall[2] = undefined - } - } - } - - /** - * This callback might be called when the request is "slow": - * - Request headers were fragmented across multiple TCP packages; - * - Request headers were too large to be processed in a single run - * (e.g. more than 30 request headers). - * @note This is called before request start. - */ - private onRequestHeaders: HeadersCallback = (rawHeaders) => { - this.requestRawHeadersBuffer.push(...rawHeaders) - } - - private onRequestStart: RequestHeadersCompleteCallback = ( - versionMajor, - versionMinor, - rawHeaders, - _, - path, - __, - ___, - ____, - shouldKeepAlive - ) => { - this.shouldKeepAlive = shouldKeepAlive - - const url = new URL(path || '', this.baseUrl) - const method = this.connectionOptions.method?.toUpperCase() || 'GET' - const headers = FetchResponse.parseRawHeaders([ - ...this.requestRawHeadersBuffer, - ...(rawHeaders || []), - ]) - this.requestRawHeadersBuffer.length = 0 - - const canHaveBody = method !== 'GET' && method !== 'HEAD' - - // Translate the basic authorization in the URL to the request header. - // Constructing a Request instance with a URL containing auth is no-op. - if (url.username || url.password) { - if (!headers.has('authorization')) { - headers.set('authorization', `Basic ${url.username}:${url.password}`) - } - url.username = '' - url.password = '' - } - - // Create a new stream for each request. - // If this Socket is reused for multiple requests, - // this ensures that each request gets its own stream. - // One Socket instance can only handle one request at a time. - this.requestStream = new Readable({ - /** - * @note Provide the `read()` method so a `Readable` could be - * used as the actual request body (the stream calls "read()"). - * We control the queue in the onRequestBody/End functions. - */ - read: () => { - // If the user attempts to read the request body, - // flush the write buffer to trigger the callbacks. - // This way, if the request stream ends in the write callback, - // it will indeed end correctly. - this.flushWriteBuffer() - }, - }) - - const requestId = createRequestId() - this.request = new FetchRequest(url, { - method, - headers, - credentials: 'same-origin', - // @ts-expect-error Undocumented Fetch property. - duplex: canHaveBody ? 'half' : undefined, - body: canHaveBody ? (Readable.toWeb(this.requestStream!) as any) : null, - }) - - Reflect.set(this.request, kRequestId, requestId) - - // Set the raw `http.ClientRequest` instance on the request instance. - // This is useful for cases like getting the raw headers of the request. - setRawRequest(this.request, Reflect.get(this, '_httpMessage')) - - // Create a copy of the request body stream and store it on the request. - // This is only needed for the consumers who wish to read the request body stream - // of requests that cannot have a body per Fetch API specification (i.e. GET, HEAD). - setRawRequestBodyStream(this.request, this.requestStream) - - // Skip handling the request that's already being handled - // by another (parent) interceptor. For example, XMLHttpRequest - // is often implemented via ClientRequest in Node.js (e.g. JSDOM). - // In that case, XHR interceptor will bubble down to the ClientRequest - // interceptor. No need to try to handle that request again. - /** - * @fixme Stop relying on the "X-Request-Id" request header - * to figure out if one interceptor has been invoked within another. - * @see https://github.com/mswjs/interceptors/issues/378 - */ - if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) { - this.passthrough() - return - } - - this.onRequest({ - requestId, - request: this.request, - socket: this, - }) - } - - private onRequestBody(chunk: Buffer): void { - invariant( - this.requestStream, - 'Failed to write to a request stream: stream does not exist' - ) - - this.requestStream.push(chunk) - } - - private onRequestEnd(): void { - // Request end can be called for requests without body. - if (this.requestStream) { - this.requestStream.push(null) - } - } - - /** - * This callback might be called when the response is "slow": - * - Response headers were fragmented across multiple TCP packages; - * - Response headers were too large to be processed in a single run - * (e.g. more than 30 response headers). - * @note This is called before response start. - */ - private onResponseHeaders: HeadersCallback = (rawHeaders) => { - this.responseRawHeadersBuffer.push(...rawHeaders) - } - - private onResponseStart: ResponseHeadersCompleteCallback = ( - versionMajor, - versionMinor, - rawHeaders, - method, - url, - status, - statusText - ) => { - const headers = FetchResponse.parseRawHeaders([ - ...this.responseRawHeadersBuffer, - ...(rawHeaders || []), - ]) - this.responseRawHeadersBuffer.length = 0 - - const response = new FetchResponse( - /** - * @note The Fetch API response instance exposed to the consumer - * is created over the response stream of the HTTP parser. It is NOT - * related to the Socket instance. This way, you can read response body - * in response listener while the Socket instance delays the emission - * of "end" and other events until those response listeners are finished. - */ - FetchResponse.isResponseWithBody(status) - ? (Readable.toWeb( - (this.responseStream = new Readable({ read() {} })) - ) as any) - : null, - { - url, - status, - statusText, - headers, - } - ) - - invariant( - this.request, - 'Failed to handle a response: request does not exist' - ) - - FetchResponse.setUrl(this.request.url, response) - - /** - * @fixme Stop relying on the "X-Request-Id" request header - * to figure out if one interceptor has been invoked within another. - * @see https://github.com/mswjs/interceptors/issues/378 - */ - if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) { - return - } - - this.responseListenersPromise = this.onResponse({ - response, - isMockedResponse: this.socketState === 'mock', - requestId: Reflect.get(this.request, kRequestId), - request: this.request, - socket: this, - }) - } - - private onResponseBody(chunk: Buffer) { - invariant( - this.responseStream, - 'Failed to write to a response stream: stream does not exist' - ) - - this.responseStream.push(chunk) - } - - private onResponseEnd(): void { - // Response end can be called for responses without body. - if (this.responseStream) { - this.responseStream.push(null) - } - } -} diff --git a/src/interceptors/ClientRequest/agents.ts b/src/interceptors/ClientRequest/agents.ts deleted file mode 100644 index ec8ddd23c..000000000 --- a/src/interceptors/ClientRequest/agents.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Here's how requests are handled in Node.js: - * - * 1. http.ClientRequest instance calls `agent.addRequest(request, options, cb)`. - * 2. Agent creates a new socket: `agent.createSocket(options, cb)`. - * 3. Agent creates a new connection: `agent.createConnection(options, cb)`. - */ -import net from 'node:net' -import http from 'node:http' -import https from 'node:https' -import { - MockHttpSocket, - type MockHttpSocketRequestCallback, - type MockHttpSocketResponseCallback, -} from './MockHttpSocket' - -declare module 'node:http' { - interface Agent { - options?: http.AgentOptions - createConnection(options: any, callback: any): net.Socket - } -} - -interface MockAgentOptions { - customAgent?: http.RequestOptions['agent'] - onRequest: MockHttpSocketRequestCallback - onResponse: MockHttpSocketResponseCallback -} - -export class MockAgent extends http.Agent { - private customAgent?: http.RequestOptions['agent'] - private onRequest: MockHttpSocketRequestCallback - private onResponse: MockHttpSocketResponseCallback - - constructor(options: MockAgentOptions) { - super() - this.customAgent = options.customAgent - this.onRequest = options.onRequest - this.onResponse = options.onResponse - } - - public createConnection(options: any, callback: any): net.Socket { - const createConnection = - this.customAgent instanceof http.Agent - ? this.customAgent.createConnection - : super.createConnection - - const createConnectionOptions = - this.customAgent instanceof http.Agent - ? { - ...options, - ...this.customAgent.options, - } - : options - - const socket = new MockHttpSocket({ - connectionOptions: options, - createConnection: createConnection.bind( - this.customAgent || this, - createConnectionOptions, - callback - ), - onRequest: this.onRequest.bind(this), - onResponse: this.onResponse.bind(this), - }) - - return socket - } -} - -export class MockHttpsAgent extends https.Agent { - private customAgent?: https.RequestOptions['agent'] - private onRequest: MockHttpSocketRequestCallback - private onResponse: MockHttpSocketResponseCallback - - constructor(options: MockAgentOptions) { - super() - this.customAgent = options.customAgent - this.onRequest = options.onRequest - this.onResponse = options.onResponse - } - - public createConnection(options: any, callback: any): net.Socket { - const createConnection = - this.customAgent instanceof http.Agent - ? this.customAgent.createConnection - : super.createConnection - - const createConnectionOptions = - this.customAgent instanceof http.Agent - ? { - ...options, - ...this.customAgent.options, - } - : options - - const socket = new MockHttpSocket({ - connectionOptions: options, - createConnection: createConnection.bind( - this.customAgent || this, - createConnectionOptions, - callback - ), - onRequest: this.onRequest.bind(this), - onResponse: this.onResponse.bind(this), - }) - - return socket - } -} diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 98539017d..40b19f565 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -1,9 +1,9 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' import { ClientRequestInterceptor } from '.' -import { sleep, waitForClientRequest } from '../../../test/helpers' +import { toWebResponse } from '../../../test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -34,7 +34,7 @@ it('abort the request if the abort signal is emitted', async () => { const requestUrl = httpServer.http.url('/') interceptor.on('request', async function delayedResponse({ controller }) { - await sleep(1_000) + await setTimeout(1000) controller.respondWith(new Response()) }) @@ -49,27 +49,7 @@ it('abort the request if the abort signal is emitted', async () => { }) const abortError = await abortErrorPromise - expect(abortError.name).toEqual('AbortError') + expect(abortError.name).toBe('AbortError') expect(request.destroyed).toBe(true) }) - -it('patch the Headers object correctly after dispose and reapply', async () => { - interceptor.dispose() - interceptor.apply() - - interceptor.on('request', ({ controller }) => { - const headers = new Headers({ - 'X-CustoM-HeadeR': 'Yes', - }) - controller.respondWith(new Response(null, { headers })) - }) - - const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) - - expect(res.rawHeaders).toEqual( - expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) - ) - expect(res.headers['x-custom-header']).toEqual('Yes') -}) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index 471233614..058f8eec1 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,193 +1,153 @@ import http from 'node:http' import https from 'node:https' -import { Interceptor } from '../../Interceptor' -import type { HttpRequestEventMap } from '../../glossary' -import { - kRequestId, - MockHttpSocketRequestCallback, - MockHttpSocketResponseCallback, -} from './MockHttpSocket' -import { MockAgent, MockHttpsAgent } from './agents' -import { RequestController } from '../../RequestController' -import { emitAsync } from '../../utils/emitAsync' -import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' -import { handleRequest } from '../../utils/handleRequest' -import { - recordRawFetchHeaders, - restoreHeadersPrototype, -} from './utils/recordRawHeaders' - +import type { Emitter } from 'rettime' +import { runInRequestContext } from '#/src/request-context' +import { patchesRegistry } from '#/src/utils/patchesRegistry' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { Interceptor } from '../../interceptor' +import { HttpRequestEventMap } from '#/src/events/http' + +/** + * Interceptor for HTTP requests in Node.js made via `http.ClientRequest`. + */ export class ClientRequestInterceptor extends Interceptor { - static symbol = Symbol('client-request-interceptor') + static symbol = Symbol.for('client-request-interceptor') - constructor() { - super(ClientRequestInterceptor.symbol) + protected predicate(): boolean { + return true } protected setup(): void { - const { - ClientRequest: OriginalClientRequest, - get: originalGet, - request: originalRequest, - } = http - const { get: originalHttpsGet, request: originalHttpsRequest } = https - - const onRequest = this.onRequest.bind(this) - const onResponse = this.onResponse.bind(this) - - // Support requests performed via the `ClientRequest` constructor directly. - http.ClientRequest = new Proxy(http.ClientRequest, { - construct: (target, args: Parameters) => { - const [url, options, callback] = normalizeClientRequestArgs( - 'http:', - args - ) - - // Create a mock agent instance appropriate for the request protocol. - const Agent = options.protocol === 'https:' ? MockHttpsAgent : MockAgent - const mockAgent = new Agent({ - customAgent: options.agent, - onRequest, - onResponse, - }) - options.agent = mockAgent - - return Reflect.construct(target, [url, options, callback]) - }, - }) - - http.request = new Proxy(http.request, { - apply: (target, thisArg, args: Parameters) => { - const [url, options, callback] = normalizeClientRequestArgs( - 'http:', - args - ) - const mockAgent = new MockAgent({ - customAgent: options.agent, - onRequest, - onResponse, - }) - options.agent = mockAgent - - return Reflect.apply(target, thisArg, [url, options, callback]) + const httpInterceptor = Interceptor.singleton(HttpRequestInterceptor) + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + httpInterceptor.on( + 'request', + async (event) => { + if (event.initiator instanceof http.ClientRequest) { + await this.emitter.emitAsPromise(event) + } }, - }) - - 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 + { + signal: controller.signal, + } + ) + + const responseListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'response' + > = async (event) => { + if (event.initiator instanceof http.ClientRequest) { + await this.emitter.emitAsPromise(event) + } + } + + const unhandledExceptionListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'unhandledException' + > = async (event) => { + if (event.initiator instanceof http.ClientRequest) { + await this.emitter.emitAsPromise(event) + } + } + + this.emitter.hooks.on( + 'newListener', + (type) => { + if ( + type === 'response' && + !httpInterceptor.listeners('response').includes(responseListener) + ) { + httpInterceptor.on('response', responseListener, { + signal: controller.signal, + }) + } - return Reflect.apply(target, thisArg, [url, options, callback]) + if ( + type === 'unhandledException' && + !httpInterceptor + .listeners('unhandledException') + .includes(unhandledExceptionListener) + ) { + httpInterceptor.on('unhandledException', unhandledExceptionListener, { + signal: controller.signal, + }) + } }, - }) - - // - // HTTPS. - // - - https.request = new Proxy(https.request, { - apply: (target, thisArg, args: Parameters) => { - const [url, options, callback] = normalizeClientRequestArgs( - 'https:', - args - ) - - const mockAgent = new MockHttpsAgent({ - customAgent: options.agent, - onRequest, - onResponse, - }) - options.agent = mockAgent + { + signal: controller.signal, + persist: true, + } + ) + + this.emitter.hooks.on( + 'removeListener', + (type) => { + if ( + type === 'response' && + this.emitter.listenerCount('response') === 0 + ) { + httpInterceptor.removeListener('response', responseListener) + } - return Reflect.apply(target, thisArg, [url, options, callback]) + if ( + type === 'unhandledException' && + this.emitter.listenerCount('unhandledException') === 0 + ) { + httpInterceptor.removeListener( + 'unhandledException', + unhandledExceptionListener + ) + } }, - }) - - 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, + { + signal: controller.signal, + persist: true, + } + ) + + this.subscriptions.push( + patchesRegistry.applyPatch(http, 'ClientRequest', (ClientRequest) => { + return new Proxy(ClientRequest, { + construct(target, args, newTarget) { + return runInRequestContext(() => { + return Reflect.construct(target, args, newTarget) + }) + }, }) - options.agent = mockAgent - - return Reflect.apply(target, thisArg, [url, options, callback]) - }, - }) - - // Spy on `Header.prototype.set` and `Header.prototype.append` calls - // and record the raw header names provided. This is to support - // `IncomingMessage.prototype.rawHeaders`. - recordRawFetchHeaders() - - this.subscriptions.push(() => { - http.ClientRequest = OriginalClientRequest - - http.get = originalGet - http.request = originalRequest - - https.get = originalHttpsGet - https.request = originalHttpsRequest - - restoreHeadersPrototype() - }) - } - - private onRequest: MockHttpSocketRequestCallback = async ({ - request, - socket, - }) => { - const controller = new RequestController(request, { - passthrough() { - socket.passthrough() - }, - async respondWith(response) { - await socket.respondWith(response) - }, - errorWith(reason) { - if (reason instanceof Error) { - socket.errorWith(reason) + }), + patchesRegistry.applyPatch(http, 'get', (httpGet) => { + return function mockHttpGet(...args) { + return runInRequestContext(() => { + return httpGet(...(args as [any, any])) + }) } - }, - }) - - await handleRequest({ - request, - requestId: Reflect.get(request, kRequestId), - controller, - emitter: this.emitter, - }) - } - - public onResponse: MockHttpSocketResponseCallback = async ({ - requestId, - request, - response, - isMockedResponse, - }) => { - // Return the promise to when all the response event listeners - // are finished. - return emitAsync(this.emitter, 'response', { - requestId, - request, - response, - isMockedResponse, - }) + }), + patchesRegistry.applyPatch(http, 'request', (httpRequest) => { + return function mockHttpRequest(...args) { + return runInRequestContext(() => { + return httpRequest(...(args as [any, any])) + }) + } + }), + patchesRegistry.applyPatch(https, 'get', (httpsGet) => { + return function mockHttpsGet(...args) { + return runInRequestContext(() => { + return httpsGet(...(args as [any, any])) + }) + } + }), + patchesRegistry.applyPatch(https, 'request', (httpsRequest) => { + return function mockHttpsRequest(...args) { + return runInRequestContext(() => { + return httpsRequest(...(args as [any, any])) + }) + } + }) + ) } } diff --git a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts deleted file mode 100644 index 76d232971..000000000 --- a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { it, expect } from 'vitest' -import { IncomingMessage } from 'http' -import { Socket } from 'net' -import * as zlib from 'zlib' -import { getIncomingMessageBody } from './getIncomingMessageBody' - -it('returns utf8 string given a utf8 response body', async () => { - const utfBuffer = Buffer.from('one') - const message = new IncomingMessage(new Socket()) - - const pendingResponseBody = getIncomingMessageBody(message) - message.emit('data', utfBuffer) - message.emit('end') - - expect(await pendingResponseBody).toEqual('one') -}) - -it('returns utf8 string given a gzipped response body', async () => { - const utfBuffer = zlib.gzipSync(Buffer.from('two')) - const message = new IncomingMessage(new Socket()) - message.headers = { - 'content-encoding': 'gzip', - } - - const pendingResponseBody = getIncomingMessageBody(message) - message.emit('data', utfBuffer) - message.emit('end') - - expect(await pendingResponseBody).toEqual('two') -}) - -it('returns utf8 string given a gzipped response body with incorrect "content-length"', async () => { - const utfBuffer = zlib.gzipSync(Buffer.from('three')) - const message = new IncomingMessage(new Socket()) - message.headers = { - 'content-encoding': 'gzip', - 'content-length': '500', - } - - const pendingResponseBody = getIncomingMessageBody(message) - message.emit('data', utfBuffer) - message.emit('end') - - expect(await pendingResponseBody).toEqual('three') -}) - -it('returns empty string given an empty body', async () => { - const message = new IncomingMessage(new Socket()) - - const pendingResponseBody = getIncomingMessageBody(message) - message.emit('end') - - expect(await pendingResponseBody).toEqual('') -}) diff --git a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts b/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts deleted file mode 100644 index 563e55259..000000000 --- a/src/interceptors/ClientRequest/utils/getIncomingMessageBody.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { IncomingMessage } from 'http' -import { PassThrough } from 'stream' -import * as zlib from 'zlib' -import { Logger } from '@open-draft/logger' - -const logger = new Logger('http getIncomingMessageBody') - -export function getIncomingMessageBody( - response: IncomingMessage -): Promise { - return new Promise((resolve, reject) => { - logger.info('cloning the original response...') - - // Pipe the original response to support non-clone - // "response" input. No need to clone the response, - // as we always have access to the full "response" input, - // either a clone or an original one (in tests). - const responseClone = response.pipe(new PassThrough()) - const stream = - response.headers['content-encoding'] === 'gzip' - ? responseClone.pipe(zlib.createGunzip()) - : responseClone - - const encoding = response.readableEncoding || 'utf8' - stream.setEncoding(encoding) - logger.info('using encoding:', encoding) - - let body = '' - - stream.on('data', (responseBody) => { - logger.info('response body read:', responseBody) - body += responseBody - }) - - stream.once('end', () => { - logger.info('response body end') - resolve(body) - }) - - stream.once('error', (error) => { - logger.info('error while reading response body:', error) - reject(error) - }) - }) -} diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts deleted file mode 100644 index ab0e217ee..000000000 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { it, expect } from 'vitest' -import { parse } from 'url' -import { globalAgent as httpGlobalAgent, RequestOptions } from 'http' -import { Agent as HttpsAgent, globalAgent as httpsGlobalAgent } from 'https' -import { getUrlByRequestOptions } from '../../../utils/getUrlByRequestOptions' -import { normalizeClientRequestArgs } from './normalizeClientRequestArgs' - -it('handles [string, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - 'https://mswjs.io/resource', - function cb() {}, - ]) - - // URL string must be converted to a URL instance. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Request options must be derived from the URL instance. - expect(options).toHaveProperty('method', 'GET') - expect(options).toHaveProperty('protocol', 'https:') - expect(options).toHaveProperty('hostname', 'mswjs.io') - expect(options).toHaveProperty('path', '/resource') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [string, RequestOptions, callback] input', () => { - const initialOptions = { - headers: { - 'Content-Type': 'text/plain', - }, - } - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - 'https://mswjs.io/resource', - initialOptions, - function cb() {}, - ]) - - // URL must be created from the string. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Request options must be preserved. - expect(options).toHaveProperty('headers', initialOptions.headers) - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - new URL('https://mswjs.io/resource'), - function cb() {}, - ]) - - // URL must be preserved. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Request options must be derived from the URL instance. - expect(options.method).toEqual('GET') - expect(options.protocol).toEqual('https:') - expect(options.hostname).toEqual('mswjs.io') - expect(options.path).toEqual('/resource') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [Absolute Legacy URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - parse('https://cherry:durian@mswjs.io:12345/resource?apple=banana'), - function cb() {}, - ]) - - // URL must be preserved. - expect(url.toJSON()).toEqual( - new URL( - 'https://cherry:durian@mswjs.io:12345/resource?apple=banana' - ).toJSON() - ) - - // Request options must be derived from the URL instance. - expect(options.method).toEqual('GET') - expect(options.protocol).toEqual('https:') - expect(options.hostname).toEqual('mswjs.io') - expect(options.path).toEqual('/resource?apple=banana') - expect(options.port).toEqual(12345) - expect(options.auth).toEqual('cherry:durian') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [Relative Legacy URL, RequestOptions without path set, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('http:', [ - parse('/resource?apple=banana'), - { host: 'mswjs.io' }, - function cb() {}, - ]) - - // Correct WHATWG URL generated. - expect(url.toJSON()).toEqual( - new URL('http://mswjs.io/resource?apple=banana').toJSON() - ) - - // No path in request options, so legacy url path is copied-in. - expect(options.protocol).toEqual('http:') - expect(options.host).toEqual('mswjs.io') - expect(options.path).toEqual('/resource?apple=banana') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [Relative Legacy URL, RequestOptions with path set, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('http:', [ - parse('/resource?apple=banana'), - { host: 'mswjs.io', path: '/other?cherry=durian' }, - function cb() {}, - ]) - - // Correct WHATWG URL generated. - expect(url.toJSON()).toEqual( - new URL('http://mswjs.io/other?cherry=durian').toJSON() - ) - - // Path in request options, so that path is preferred. - expect(options.protocol).toEqual('http:') - expect(options.host).toEqual('mswjs.io') - expect(options.path).toEqual('/other?cherry=durian') - - // Callback must be preserved. - expect(callback?.name).toEqual('cb') -}) - -it('handles [Relative Legacy URL, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('http:', [ - parse('/resource?apple=banana'), - function cb() {}, - ]) - - // Correct WHATWG URL generated. - expect(url.toJSON()).toMatch( - getUrlByRequestOptions({ path: '/resource?apple=banana' }).toJSON() - ) - - // Check path is in options. - expect(options.protocol).toEqual('http:') - expect(options.path).toEqual('/resource?apple=banana') - - // Callback must be preserved. - expect(callback).toBeTypeOf('function') - expect(callback?.name).toEqual('cb') -}) - -it('handles [Relative Legacy URL] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('http:', [ - parse('/resource?apple=banana'), - ]) - - // Correct WHATWG URL generated. - expect(url.toJSON()).toMatch( - getUrlByRequestOptions({ path: '/resource?apple=banana' }).toJSON() - ) - - // Check path is in options. - expect(options.protocol).toEqual('http:') - expect(options.path).toEqual('/resource?apple=banana') - - // Callback must be preserved. - expect(callback).toBeUndefined() -}) - -it('handles [URL, RequestOptions, callback] input', () => { - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - new URL('https://mswjs.io/resource'), - { - agent: false, - headers: { - 'Content-Type': 'text/plain', - }, - }, - function cb() {}, - ]) - - // URL must be preserved. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Options must be preserved. - // `urlToHttpOptions` from `node:url` generates additional - // ClientRequest options, some of which are not legally allowed. - expect(options).toMatchObject({ - agent: false, - _defaultAgent: httpsGlobalAgent, - protocol: url.protocol, - method: 'GET', - headers: { - 'Content-Type': 'text/plain', - }, - hostname: url.hostname, - path: url.pathname, - }) - - // Callback must be preserved. - expect(callback).toBeTypeOf('function') - expect(callback?.name).toEqual('cb') -}) - -it('handles [URL, RequestOptions] where options have custom "hostname"', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url'), - { - hostname: 'host-from-options.com', - }, - ]) - expect(url.href).toBe('http://host-from-options.com/path-from-url') - expect(options).toMatchObject({ - hostname: 'host-from-options.com', - path: '/path-from-url', - }) -}) - -it('handles [URL, RequestOptions] where options contain "host" and "path" and "port"', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url?a=b&c=d'), - { - hostname: 'host-from-options.com', - path: '/path-from-options', - port: 1234, - }, - ]) - // Must remove the query string since it's not specified in "options.path" - expect(url.href).toBe('http://host-from-options.com:1234/path-from-options') - expect(options).toMatchObject({ - hostname: 'host-from-options.com', - path: '/path-from-options', - port: 1234, - }) -}) - -it('handles [URL, RequestOptions] where options contain "path" with query string', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url?a=b&c=d'), - { - path: '/path-from-options?foo=bar&baz=xyz', - }, - ]) - expect(url.href).toBe('http://example.com/path-from-options?foo=bar&baz=xyz') - expect(options).toMatchObject({ - hostname: 'example.com', - path: '/path-from-options?foo=bar&baz=xyz', - }) -}) - -it('handles [RequestOptions, callback] input', () => { - const initialOptions = { - method: 'POST', - protocol: 'https:', - host: 'mswjs.io', - /** - * @see https://github.com/mswjs/msw/issues/705 - */ - origin: 'https://mswjs.io', - path: '/resource', - headers: { - 'Content-Type': 'text/plain', - }, - } - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - initialOptions, - function cb() {}, - ]) - - // URL must be derived from request options. - expect(url.href).toEqual('https://mswjs.io/resource') - - // Request options must be preserved. - expect(options).toMatchObject(initialOptions) - - // Callback must be preserved. - expect(callback).toBeTypeOf('function') - expect(callback?.name).toEqual('cb') -}) - -it('handles [Empty RequestOptions, callback] input', () => { - const [_, options, callback] = normalizeClientRequestArgs('https:', [ - {}, - function cb() {}, - ]) - - expect(options.protocol).toEqual('https:') - - // Callback must be preserved - expect(callback?.name).toEqual('cb') -}) - -/** - * @see https://github.com/mswjs/interceptors/issues/19 - */ -it('handles [PartialRequestOptions, callback] input', () => { - const initialOptions = { - method: 'GET', - port: '50176', - path: '/resource', - host: '127.0.0.1', - ca: undefined, - key: undefined, - pfx: undefined, - cert: undefined, - passphrase: undefined, - agent: false, - } - const [url, options, callback] = normalizeClientRequestArgs('https:', [ - initialOptions, - function cb() {}, - ]) - - // URL must be derived from request options. - expect(url.toJSON()).toEqual( - new URL('https://127.0.0.1:50176/resource').toJSON() - ) - - // Request options must be preserved. - expect(options).toMatchObject(initialOptions) - - // Options protocol must be inferred from the request issuing module. - expect(options.protocol).toEqual('https:') - - // Callback must be preserved. - expect(callback).toBeTypeOf('function') - expect(callback?.name).toEqual('cb') -}) - -it('sets the default Agent for HTTP request', () => { - const [, options] = normalizeClientRequestArgs('http:', [ - 'http://github.com', - {}, - ]) - - expect(options._defaultAgent).toEqual(httpGlobalAgent) -}) - -it('sets the default Agent for HTTPS request', () => { - const [, options] = normalizeClientRequestArgs('https:', [ - 'https://github.com', - {}, - ]) - - expect(options._defaultAgent).toEqual(httpsGlobalAgent) -}) - -it('preserves a custom default Agent when set', () => { - const [, options] = normalizeClientRequestArgs('https:', [ - 'https://github.com', - { - /** - * @note Intentionally incorrect Agent for HTTPS request. - */ - _defaultAgent: httpGlobalAgent, - }, - ]) - - expect(options._defaultAgent).toEqual(httpGlobalAgent) -}) - -it('merges URL-based RequestOptions with the custom RequestOptions', () => { - const [url, options] = normalizeClientRequestArgs('https:', [ - 'https://github.com/graphql', - { - method: 'GET', - pfx: 'PFX_KEY', - }, - ]) - - expect(url.href).toEqual('https://github.com/graphql') - - // Original request options must be preserved. - expect(options.method).toEqual('GET') - expect(options.pfx).toEqual('PFX_KEY') - - // Other options must be inferred from the URL. - expect(options.protocol).toEqual(url.protocol) - expect(options.hostname).toEqual(url.hostname) - expect(options.path).toEqual(url.pathname) -}) - -it('respects custom "options.path" over URL path', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url'), - { - path: '/path-from-options', - }, - ]) - - expect(url.href).toBe('http://example.com/path-from-options') - expect(options.protocol).toBe('http:') - expect(options.hostname).toBe('example.com') - expect(options.path).toBe('/path-from-options') -}) - -it('respects custom "options.path" over URL path with query string', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com/path-from-url?a=b&c=d'), - { - path: '/path-from-options', - }, - ]) - - // Must replace both the path and the query string. - expect(url.href).toBe('http://example.com/path-from-options') - expect(options.protocol).toBe('http:') - expect(options.hostname).toBe('example.com') - expect(options.path).toBe('/path-from-options') -}) - -it('preserves URL query string', () => { - const [url, options] = normalizeClientRequestArgs('http:', [ - new URL('http://example.com:8080/resource?a=b&c=d'), - ]) - - expect(url.href).toBe('http://example.com:8080/resource?a=b&c=d') - expect(options.protocol).toBe('http:') - // expect(options.host).toBe('example.com:8080') - expect(options.hostname).toBe('example.com') - // Query string is a part of the options path. - expect(options.path).toBe('/resource?a=b&c=d') - expect(options.port).toBe(8080) -}) diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts deleted file mode 100644 index 324f865fb..000000000 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestArgs.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { urlToHttpOptions } from 'node:url' -import { - Agent as HttpAgent, - globalAgent as httpGlobalAgent, - IncomingMessage, -} from 'node:http' -import { - RequestOptions, - Agent as HttpsAgent, - globalAgent as httpsGlobalAgent, -} from 'node:https' -import { - /** - * @note Use the Node.js URL instead of the global URL - * because environments like JSDOM may override the global, - * breaking the compatibility with Node.js. - * @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555 - */ - URL, - Url as LegacyURL, - parse as parseUrl, -} from 'node:url' -import { Logger } from '@open-draft/logger' -import { - ResolvedRequestOptions, - getUrlByRequestOptions, -} from '../../../utils/getUrlByRequestOptions' -import { cloneObject } from '../../../utils/cloneObject' -import { isObject } from '../../../utils/isObject' - -const logger = new Logger('http normalizeClientRequestArgs') - -export type HttpRequestCallback = (response: IncomingMessage) => void - -export type ClientRequestArgs = - // Request without any arguments is also possible. - | [] - | [string | URL | LegacyURL, HttpRequestCallback?] - | [string | URL | LegacyURL, RequestOptions, HttpRequestCallback?] - | [RequestOptions, HttpRequestCallback?] - -function resolveRequestOptions( - args: ClientRequestArgs, - url: URL -): RequestOptions { - // Calling `fetch` provides only URL to `ClientRequest` - // without any `RequestOptions` or callback. - if (typeof args[1] === 'undefined' || typeof args[1] === 'function') { - logger.info('request options not provided, deriving from the url', url) - return urlToHttpOptions(url) - } - - if (args[1]) { - logger.info('has custom RequestOptions!', args[1]) - const requestOptionsFromUrl = urlToHttpOptions(url) - - logger.info('derived RequestOptions from the URL:', requestOptionsFromUrl) - - /** - * Clone the request options to lock their state - * at the moment they are provided to `ClientRequest`. - * @see https://github.com/mswjs/interceptors/issues/86 - */ - logger.info('cloning RequestOptions...') - const clonedRequestOptions = cloneObject(args[1]) - logger.info('successfully cloned RequestOptions!', clonedRequestOptions) - - return { - ...requestOptionsFromUrl, - ...clonedRequestOptions, - } - } - - logger.info('using an empty object as request options') - return {} as RequestOptions -} - -/** - * Overrides the given `URL` instance with the explicit properties provided - * on the `RequestOptions` object. The options object takes precedence, - * and will replace URL properties like "host", "path", and "port", if specified. - */ -function overrideUrlByRequestOptions(url: URL, options: RequestOptions): URL { - url.host = options.host || url.host - url.hostname = options.hostname || url.hostname - url.port = options.port ? options.port.toString() : url.port - - if (options.path) { - const parsedOptionsPath = parseUrl(options.path, false) - url.pathname = parsedOptionsPath.pathname || '' - url.search = parsedOptionsPath.search || '' - } - - return url -} - -function resolveCallback( - args: ClientRequestArgs -): HttpRequestCallback | undefined { - return typeof args[1] === 'function' ? args[1] : args[2] -} - -export type NormalizedClientRequestArgs = [ - url: URL, - options: ResolvedRequestOptions, - callback?: HttpRequestCallback -] - -/** - * Normalizes parameters given to a `http.request` call - * so it always has a `URL` and `RequestOptions`. - */ -export function normalizeClientRequestArgs( - defaultProtocol: string, - args: ClientRequestArgs -): NormalizedClientRequestArgs { - let url: URL - let options: ResolvedRequestOptions - let callback: HttpRequestCallback | undefined - - logger.info('arguments', args) - logger.info('using default protocol:', defaultProtocol) - - // Support "http.request()" calls without any arguments. - // That call results in a "GET http://localhost" request. - if (args.length === 0) { - const url = new URL('http://localhost') - const options = resolveRequestOptions(args, url) - return [url, options] - } - - // Convert a url string into a URL instance - // and derive request options from it. - if (typeof args[0] === 'string') { - logger.info('first argument is a location string:', args[0]) - - url = new URL(args[0]) - logger.info('created a url:', url) - - const requestOptionsFromUrl = urlToHttpOptions(url) - logger.info('request options from url:', requestOptionsFromUrl) - - options = resolveRequestOptions(args, url) - logger.info('resolved request options:', options) - - callback = resolveCallback(args) - } - // Handle a given URL instance as-is - // and derive request options from it. - else if (args[0] instanceof URL) { - url = args[0] - logger.info('first argument is a URL:', url) - - // Check if the second provided argument is RequestOptions. - // If it is, check if "options.path" was set and rewrite it - // on the input URL. - // Do this before resolving options from the URL below - // to prevent query string from being duplicated in the path. - if (typeof args[1] !== 'undefined' && isObject(args[1])) { - url = overrideUrlByRequestOptions(url, args[1]) - } - - options = resolveRequestOptions(args, url) - logger.info('derived request options:', options) - - callback = resolveCallback(args) - } - // Handle a legacy URL instance and re-normalize from either a RequestOptions object - // or a WHATWG URL. - else if ('hash' in args[0] && !('method' in args[0])) { - const [legacyUrl] = args - logger.info('first argument is a legacy URL:', legacyUrl) - - if (legacyUrl.hostname === null) { - /** - * We are dealing with a relative url, so use the path as an "option" and - * merge in any existing options, giving priority to existing options -- i.e. a path in any - * existing options will take precedence over the one contained in the url. This is consistent - * with the behaviour in ClientRequest. - * @see https://github.com/nodejs/node/blob/d84f1312915fe45fe0febe888db692c74894c382/lib/_http_client.js#L122 - */ - logger.info('given legacy URL is relative (no hostname)') - - return isObject(args[1]) - ? normalizeClientRequestArgs(defaultProtocol, [ - { path: legacyUrl.path, ...args[1] }, - args[2], - ]) - : normalizeClientRequestArgs(defaultProtocol, [ - { path: legacyUrl.path }, - args[1] as HttpRequestCallback, - ]) - } - - logger.info('given legacy url is absolute') - - // We are dealing with an absolute URL, so convert to WHATWG and try again. - const resolvedUrl = new URL(legacyUrl.href) - - return args[1] === undefined - ? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl]) - : typeof args[1] === 'function' - ? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl, args[1]]) - : normalizeClientRequestArgs(defaultProtocol, [ - resolvedUrl, - args[1], - args[2], - ]) - } - // Handle a given "RequestOptions" object as-is - // and derive the URL instance from it. - else if (isObject(args[0])) { - options = { ...(args[0] as any) } - logger.info('first argument is RequestOptions:', options) - - // When handling a "RequestOptions" object without an explicit "protocol", - // infer the protocol from the request issuing module (http/https). - options.protocol = options.protocol || defaultProtocol - logger.info('normalized request options:', options) - - url = getUrlByRequestOptions(options) - logger.info('created a URL from RequestOptions:', url.href) - - callback = resolveCallback(args) - } else { - throw new Error( - `Failed to construct ClientRequest with these parameters: ${args}` - ) - } - - options.protocol = options.protocol || url.protocol - options.method = options.method || 'GET' - - /** - * Ensure that the default Agent is always set. - * This prevents the protocol mismatch for requests with { agent: false }, - * where the global Agent is inferred. - * @see https://github.com/mswjs/msw/issues/1150 - * @see https://github.com/nodejs/node/blob/418ff70b810f0e7112d48baaa72932a56cfa213b/lib/_http_client.js#L130 - * @see https://github.com/nodejs/node/blob/418ff70b810f0e7112d48baaa72932a56cfa213b/lib/_http_client.js#L157-L159 - */ - if (!options._defaultAgent) { - logger.info( - 'has no default agent, setting the default agent for "%s"', - options.protocol - ) - - options._defaultAgent = - options.protocol === 'https:' ? httpsGlobalAgent : httpGlobalAgent - } - - logger.info('successfully resolved url:', url.href) - logger.info('successfully resolved options:', options) - logger.info('successfully resolved callback:', callback) - - /** - * @note If the user-provided URL is not a valid URL in Node.js, - * (e.g. the one provided by the JSDOM polyfills), case it to - * string. Otherwise, this throws on Node.js incompatibility - * (`ERR_INVALID_ARG_TYPE` on the connection listener) - * @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555 - */ - if (!(url instanceof URL)) { - url = (url as any).toString() - } - - return [url, options, callback] -} diff --git a/src/interceptors/ClientRequest/utils/parserUtils.ts b/src/interceptors/ClientRequest/utils/parserUtils.ts deleted file mode 100644 index 6c9c9585a..000000000 --- a/src/interceptors/ClientRequest/utils/parserUtils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Socket } from 'node:net' -import { HTTPParser } from '_http_common' - -/** - * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_common.js#L180 - */ -export function freeParser(parser: HTTPParser, socket?: Socket): void { - if (parser._consumed) { - parser.unconsume() - } - - parser._headers = [] - parser._url = '' - parser.socket = null - parser.incoming = null - parser.outgoing = null - parser.maxHeaderPairs = 2000 - parser._consumed = false - parser.onIncoming = null - - parser[HTTPParser.kOnHeaders] = null - parser[HTTPParser.kOnHeadersComplete] = null - parser[HTTPParser.kOnMessageBegin] = null - parser[HTTPParser.kOnMessageComplete] = null - parser[HTTPParser.kOnBody] = null - parser[HTTPParser.kOnExecute] = null - parser[HTTPParser.kOnTimeout] = null - - parser.remove() - parser.free() - - if (socket) { - /** - * @note Unassigning the socket's parser will fail this assertion - * if there's still some data being processed on the socket: - * @see https://github.com/nodejs/node/blob/4e1f39b678b37017ac9baa0971e3aeecd3b67b51/lib/_http_client.js#L613 - */ - if (socket.destroyed) { - // @ts-expect-error Node.js internals. - socket.parser = null - } else { - socket.once('end', () => { - // @ts-expect-error Node.js internals. - socket.parser = null - }) - } - } -} diff --git a/src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts b/src/interceptors/ClientRequest/utils/record-raw-headers.test.ts similarity index 99% rename from src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts rename to src/interceptors/ClientRequest/utils/record-raw-headers.test.ts index 929bb3421..2f8a00e38 100644 --- a/src/interceptors/ClientRequest/utils/recordRawHeaders.test.ts +++ b/src/interceptors/ClientRequest/utils/record-raw-headers.test.ts @@ -1,10 +1,9 @@ // @vitest-environment node -import { it, expect, afterEach } from 'vitest' import { recordRawFetchHeaders, restoreHeadersPrototype, getRawFetchHeaders, -} from './recordRawHeaders' +} from './record-raw-headers' const url = 'http://localhost' diff --git a/src/interceptors/ClientRequest/utils/recordRawHeaders.ts b/src/interceptors/ClientRequest/utils/record-raw-headers.ts similarity index 92% rename from src/interceptors/ClientRequest/utils/recordRawHeaders.ts rename to src/interceptors/ClientRequest/utils/record-raw-headers.ts index a596ced10..ed9f799b8 100644 --- a/src/interceptors/ClientRequest/utils/recordRawHeaders.ts +++ b/src/interceptors/ClientRequest/utils/record-raw-headers.ts @@ -71,7 +71,7 @@ function defineRawHeadersSymbol(headers: Headers, rawHeaders: RawHeaders) { * h.append('x-custom', 'two') * h[Symbol('headers map')] // Map { 'X-Custom' => 'one, two' } */ -export function recordRawFetchHeaders() { +export function recordRawFetchHeaders(): () => void { // Prevent patching the Headers prototype multiple times. if (Reflect.get(Headers, kRestorePatches)) { return Reflect.get(Headers, kRestorePatches) @@ -255,6 +255,8 @@ export function recordRawFetchHeaders() { Object.setPrototypeOf(FetchRequest.prototype, globalThis.Request.prototype) Object.setPrototypeOf(FetchResponse, globalThis.Response) Object.setPrototypeOf(FetchResponse.prototype, globalThis.Response.prototype) + + return restoreHeadersPrototype } export function restoreHeadersPrototype() { @@ -293,3 +295,28 @@ function inferRawHeaders(headers: HeadersInit): RawHeaders { return Reflect.get(new Headers(headers), kRawHeaders) } + +export function copyRawHeaders(source: Headers, destination: Headers): void { + const rawHeaders = [...getRawFetchHeaders(source)] + + if (rawHeaders.length === 0) { + return + } + + /** + * @note Add headers from trhe destination that raw headers from the source + * don't have. Undici automatically appends a "Content-Type" header for responses + * and, for some reason, that change is not recorded. This preserves it. + */ + for (const [name, value] of destination) { + if ( + rawHeaders.every( + (header) => header[0].toLowerCase() !== name.toLowerCase() + ) + ) { + rawHeaders.push([name, value]) + } + } + + defineRawHeadersSymbol(destination, rawHeaders) +} diff --git a/src/interceptors/Socket/MockSocket.test.ts b/src/interceptors/Socket/MockSocket.test.ts deleted file mode 100644 index 61235cf36..000000000 --- a/src/interceptors/Socket/MockSocket.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * @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 deleted file mode 100644 index 5bcc42eea..000000000 --- a/src/interceptors/Socket/MockSocket.ts +++ /dev/null @@ -1,58 +0,0 @@ -import net from 'node:net' -import { - normalizeSocketWriteArgs, - type WriteArgs, - type WriteCallback, -} from './utils/normalizeSocketWriteArgs' - -export interface MockSocketOptions { - write: ( - chunk: Buffer | string, - encoding: BufferEncoding | undefined, - callback?: WriteCallback - ) => void - - read: (chunk: Buffer, encoding: BufferEncoding | undefined) => void -} - -export class MockSocket extends net.Socket { - public connecting: boolean - - constructor(protected readonly options: MockSocketOptions) { - super() - this.connecting = false - this.connect() - - this._final = (callback) => { - callback(null) - } - } - - public connect() { - // The connection will remain pending until - // the consumer decides to handle it. - this.connecting = true - return this - } - - public write(...args: Array): boolean { - const [chunk, encoding, callback] = normalizeSocketWriteArgs( - args as WriteArgs - ) - this.options.write(chunk, encoding, callback) - return true - } - - public end(...args: Array) { - const [chunk, encoding, callback] = normalizeSocketWriteArgs( - args as WriteArgs - ) - this.options.write(chunk, encoding, callback) - return super.end.apply(this, args as any) - } - - public push(chunk: any, encoding?: BufferEncoding): boolean { - this.options.read(chunk, encoding) - return super.push(chunk, encoding) - } -} diff --git a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts deleted file mode 100644 index 6a4f33adb..000000000 --- a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts +++ /dev/null @@ -1,26 +0,0 @@ -export function baseUrlFromConnectionOptions(options: any): URL { - if ('href' in options) { - return new URL(options.href) - } - - const protocol = options.port === 443 ? 'https:' : 'http:' - const host = options.host - - const url = new URL(`${protocol}//${host}`) - - if (options.port) { - url.port = options.port.toString() - } - - if (options.path) { - url.pathname = options.path - } - - if (options.auth) { - const [username, password] = options.auth.split(':') - url.username = username - url.password = password - } - - return url -} diff --git a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts b/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts deleted file mode 100644 index 32f2e1d5a..000000000 --- a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @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 deleted file mode 100644 index 03a3e9c05..000000000 --- a/src/interceptors/Socket/utils/normalizeSocketWriteArgs.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type WriteCallback = (error?: Error | null) => void - -export type WriteArgs = - | [chunk: unknown, callback?: WriteCallback] - | [chunk: unknown, encoding: BufferEncoding, callback?: WriteCallback] - -export type NormalizedSocketWriteArgs = [ - chunk: any, - encoding?: BufferEncoding, - callback?: WriteCallback, -] - -/** - * Normalizes the arguments provided to the `Writable.prototype.write()` - * and `Writable.prototype.end()`. - */ -export function normalizeSocketWriteArgs( - args: WriteArgs -): NormalizedSocketWriteArgs { - const normalized: NormalizedSocketWriteArgs = [args[0], undefined, undefined] - - if (typeof args[1] === 'string') { - normalized[1] = args[1] - } else if (typeof args[1] === 'function') { - normalized[2] = args[1] - } - - if (typeof args[2] === 'function') { - normalized[2] = args[2] - } - - return normalized -} diff --git a/src/interceptors/WebSocket/index.ts b/src/interceptors/WebSocket/index.ts index 937282add..1c8413b73 100644 --- a/src/interceptors/WebSocket/index.ts +++ b/src/interceptors/WebSocket/index.ts @@ -1,4 +1,8 @@ -import { Interceptor } from '../../Interceptor' +import { Interceptor } from '../../interceptor' +import { + WebSocketConnectionEvent, + type WebSocketEventMap, +} from '../../events/websocket' import { WebSocketClientConnectionProtocol, WebSocketClientConnection, @@ -17,8 +21,8 @@ import { } from './WebSocketOverride' import { bindEvent } from './utils/bindEvent' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { emitAsync } from '../../utils/emitAsync' import { patchesRegistry } from '../../utils/patchesRegistry' +import { Logger } from '@open-draft/logger' export { type WebSocketData, @@ -39,49 +43,21 @@ export { CancelableMessageEvent, } from './utils/events' -export type WebSocketEventMap = { - connection: [args: WebSocketConnectionData] -} - -export type WebSocketConnectionData = { - /** - * The incoming WebSocket client connection. - */ - client: WebSocketClientConnection - - /** - * The original WebSocket server connection. - */ - server: WebSocketServerConnection - - /** - * The connection information. - */ - info: { - /** - * The protocols supported by the WebSocket client. - */ - protocols: string | Array | undefined - } -} +const logger = new Logger('websocket') /** * Intercept the outgoing WebSocket connections created using * the global `WebSocket` class. */ export class WebSocketInterceptor extends Interceptor { - static symbol = Symbol('websocket') + static symbol = Symbol.for('websocket-interceptor') - constructor() { - super(WebSocketInterceptor.symbol) - } - - protected checkEnvironment(): boolean { + protected predicate(): boolean { return hasConfigurableGlobal('WebSocket') } protected setup(): void { - const logger = this.logger.extend('setup') + logger.info('setup') const WebSocketProxy = new Proxy(globalThis.WebSocket, { construct: ( @@ -118,13 +94,15 @@ export class WebSocketInterceptor extends Interceptor { // The "globalThis.WebSocket" class stands for // the client-side connection. Assume it's established // as soon as the WebSocket instance is constructed. - await emitAsync(this.emitter, 'connection', { - client: new WebSocketClientConnection(socket, transport), - server, - info: { - protocols, - }, - }) + await this.emitter.emitAsPromise( + new WebSocketConnectionEvent({ + client: new WebSocketClientConnection(socket, transport), + server, + info: { + protocols, + }, + }) + ) if (hasConnectionListeners) { socket[kPassthroughPromise].resolve(false) diff --git a/src/interceptors/WebSocket/utils/bindEvent.test.ts b/src/interceptors/WebSocket/utils/bindEvent.test.ts index 7871961f0..9998dcaa8 100644 --- a/src/interceptors/WebSocket/utils/bindEvent.test.ts +++ b/src/interceptors/WebSocket/utils/bindEvent.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { it, expect } from 'vitest' import { bindEvent } from './bindEvent' it('sets the "target" on the given event', () => { diff --git a/src/interceptors/WebSocket/utils/events.test.ts b/src/interceptors/WebSocket/utils/events.test.ts index 84a211633..9be4547d8 100644 --- a/src/interceptors/WebSocket/utils/events.test.ts +++ b/src/interceptors/WebSocket/utils/events.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { describe, it, expect } from 'vitest' import { CancelableMessageEvent, CloseEvent } from './events' describe(CancelableMessageEvent, () => { diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 6a47ac896..18037b45d 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -1,6 +1,7 @@ +import { until } from '@open-draft/until' import { invariant } from 'outvariant' -import { isNodeProcess } from 'is-node-process' import type { Logger } from '@open-draft/logger' +import type { HttpResponseType } from '../../events/http' import { concatArrayBuffer } from './utils/concatArrayBuffer' import { createEvent } from './utils/createEvent' import { @@ -12,15 +13,14 @@ import { createProxy } from '../../utils/createProxy' import { isDomParserSupportedType } from './utils/isDomParserSupportedType' import { parseJson } from '../../utils/parseJson' import { createResponse } from './utils/createResponse' -import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { createRequestId } from '../../createRequestId' import { getBodyByteLength } from './utils/getBodyByteLength' -import { setRawRequest } from '../../getRawRequest' -import { FetchRequest } from '../../utils/fetchUtils' +import { FetchRequest, FetchResponse } from '../../utils/fetchUtils' +import { isResponseError } from '../../utils/responseUtils' const kIsRequestHandled = Symbol('kIsRequestHandled') -const IS_NODE = isNodeProcess() const kFetchRequest = Symbol('kFetchRequest') +const MAX_REDIRECTS = 20 /** * An `XMLHttpRequest` instance controller that allows us @@ -40,7 +40,7 @@ export class XMLHttpRequestController { this: XMLHttpRequestController, args: { response: Response - isMockedResponse: boolean + responseType: HttpResponseType request: Request requestId: string } @@ -48,10 +48,13 @@ export class XMLHttpRequestController { [kIsRequestHandled]: boolean; [kFetchRequest]?: Request + + private sync: boolean = false private method: string = 'GET' private url: URL = null as any private requestHeaders: Headers private responseBuffer: Uint8Array + private redirectCount: number private events: Map> private uploadEvents: Map< keyof XMLHttpRequestEventTargetEventMap, @@ -64,6 +67,7 @@ export class XMLHttpRequestController { ) { this[kIsRequestHandled] = false + this.redirectCount = 0 this.events = new Map() this.uploadEvents = new Map() this.requestId = createRequestId() @@ -71,32 +75,16 @@ export class XMLHttpRequestController { this.responseBuffer = new Uint8Array() this.request = createProxy(initialRequest, { - setProperty: ([propertyName, nextValue], invoke) => { - switch (propertyName) { - case 'ontimeout': { - const eventName = propertyName.slice( - 2 - ) as keyof XMLHttpRequestEventTargetEventMap - - /** - * @note Proxy callbacks to event listeners because JSDOM has trouble - * translating these properties to callbacks. It seemed to be operating - * on events exclusively. - */ - this.request.addEventListener(eventName, nextValue as any) - - return invoke() - } - - default: { - return invoke() - } - } - }, methodCall: ([methodName, args], invoke) => { switch (methodName) { case 'open': { - const [method, url] = args as [string, string | undefined] + const [method, url, async] = args as [ + string, + string | undefined, + boolean | undefined, + ] + + this.sync = !(async ?? true) if (typeof url === 'undefined') { this.method = 'GET' @@ -138,6 +126,13 @@ export class XMLHttpRequestController { body?: XMLHttpRequestBodyInit | Document | null, ] + if (this.sync) { + console.warn( + `Failed to intercept an XMLHttpRequest (${this.method} ${this.url}): synchronous requests are not supported. This request will be performed as-is.` + ) + return invoke() + } + this.request.addEventListener('load', () => { if (typeof this.onResponse !== 'undefined') { // Create a Fetch API Response representation of whichever @@ -156,7 +151,7 @@ export class XMLHttpRequestController { // Notify the consumer about the response. this.onResponse.call(this, { response: fetchResponse, - isMockedResponse: this[kIsRequestHandled], + responseType: this[kIsRequestHandled] ? 'mock' : 'original', request: fetchRequest, requestId: this.requestId!, }) @@ -189,22 +184,6 @@ export class XMLHttpRequestController { this.request.readyState ) - /** - * @note Set the intercepted request ID on the original request in Node.js - * so that if it triggers any other interceptors, they don't attempt - * to process it once again. - * - * For instance, XMLHttpRequest is often implemented via "http.ClientRequest" - * and we don't want for both XHR and ClientRequest interceptors to - * handle the same request at the same time (e.g. emit the "response" event twice). - */ - if (IS_NODE) { - this.request.setRequestHeader( - INTERNAL_REQUEST_ID_HEADER_NAME, - this.requestId! - ) - } - return invoke() } }) @@ -301,43 +280,260 @@ export class XMLHttpRequestController { */ this[kIsRequestHandled] = true - /** - * Dispatch request upload events for requests with a body. - * @see https://github.com/mswjs/interceptors/issues/573 - */ + this.logger.info( + 'responding with a mocked response: %d %s', + response.status, + response.statusText + ) + + FetchResponse.setUrl(this.url.href, response) + + // Update the response getters to resolve against the mocked response. + Object.defineProperties(this.request, { + response: { + enumerable: true, + configurable: false, + get: () => this.response, + }, + responseText: { + enumerable: true, + configurable: false, + get: () => this.responseText, + }, + responseXML: { + enumerable: true, + configurable: false, + get: () => this.responseXML, + }, + }) + + // 1. Fire a progress event named loadstart at this with 0 and 0. + this.trigger('loadstart', this.request, { loaded: 0, total: 0 }) + + // 2. Let requestBodyTransmitted be 0. + let requestBodyTransmitted = 0 + let uploadComplete = false + if (this[kFetchRequest]) { - const totalRequestBodyLength = await getBodyByteLength( - this[kFetchRequest] + const requestBodyLength = await getBodyByteLength( + this[kFetchRequest].clone() ) - this.trigger('loadstart', this.request.upload, { - loaded: 0, - total: totalRequestBodyLength, - }) - this.trigger('progress', this.request.upload, { - loaded: totalRequestBodyLength, - total: totalRequestBodyLength, - }) - this.trigger('load', this.request.upload, { - loaded: totalRequestBodyLength, - total: totalRequestBodyLength, - }) + // 5. If this’s upload complete flag is unset and this’s upload listener flag is set, then fire a progress event named loadstart at this’s upload object with requestBodyTransmitted and requestBodyLength. + if (!uploadComplete) { + this.trigger('loadstart', this.request.upload, { + loaded: 0, + total: requestBodyLength, + }) + } - this.trigger('loadend', this.request.upload, { - loaded: totalRequestBodyLength, - total: totalRequestBodyLength, - }) + const processRequestBodyChunkLength = (bytesLength: number) => { + requestBodyTransmitted += bytesLength + + if (requestBodyTransmitted < requestBodyLength) { + this.trigger('progress', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + } + } + + const processRequestEndOfBody = () => { + uploadComplete = true + + this.trigger('progress', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + this.trigger('load', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + this.trigger('loadend', this.request.upload, { + loaded: requestBodyTransmitted, + total: requestBodyLength, + }) + } + + if (this[kFetchRequest]?.body != null) { + const reader = this[kFetchRequest].body.getReader() + + while (true) { + const { value, done } = await reader.read() + + if (done) { + processRequestEndOfBody() + break + } + + processRequestBodyChunkLength(value.byteLength) + } + } else { + processRequestEndOfBody() + } } - this.logger.info( - 'responding with a mocked response: %d %s', - response.status, - response.statusText - ) + let timedOut = false + const responseReadController = new AbortController() + + const requestErrorSteps = ( + event: keyof XMLHttpRequestEventTargetEventMap + ) => { + this.setReadyState(this.request.DONE) + + if (!uploadComplete) { + this.trigger(event, this.request.upload, { + loaded: 0, + total: 0, + }) + this.trigger('loadend', this.request.upload, { + loaded: 0, + total: 0, + }) + } + + this.trigger(event, this.request, { loaded: 0, total: 0 }) + this.trigger('loadend', this.request, { loaded: 0, total: 0 }) + } + + const processResponse = async (response: Response) => { + const handleErrors = () => { + if (timedOut) { + requestErrorSteps('timeout') + } else if (responseReadController.signal.aborted) { + requestErrorSteps('abort') + } else if (isResponseError(response)) { + requestErrorSteps('error') + } + } + + handleErrors() + + define(this.request, 'status', response.status) + define(this.request, 'statusText', response.statusText) + + if (!this.request.responseURL) { + define(this.request, 'responseURL', response.url) + } + + if (isResponseError(response)) { + return + } + + /** + * @note The response body length is derived ONLY from the "content-length" header. + * If that response header is not set, the "total" in all progress events must be 0. + */ + const responseBodyLength = Number( + response.headers.get('content-length') ?? '0' + ) + + this.setReadyState(this.request.HEADERS_RECEIVED) + + let receivedBytes = 0 + let lastReceivedResponseBytesAt = performance.now() + + const processResponseBodyChunk = (bytesLength: number) => { + receivedBytes += bytesLength + + const now = performance.now() + const shouldBuffer = + now - lastReceivedResponseBytesAt <= 60 && + receivedBytes < responseBodyLength + lastReceivedResponseBytesAt = now + + if (shouldBuffer) { + return + } + + if (this.request.readyState === this.request.HEADERS_RECEIVED) { + this.setReadyState(this.request.LOADING, false) + } + + this.trigger('readystatechange', this.request) + this.trigger('progress', this.request, { + loaded: receivedBytes, + total: responseBodyLength, + }) + } + + const processResponseBodyError = () => { + requestErrorSteps('error') + } + + const processResponseEndOfBody = async () => { + handleErrors() + + if (isResponseError(response)) { + return + } + + // 3. Let transmitted be xhr’s received bytes’s length. + let transmitted = receivedBytes + + // 9. Fire an event named readystatechange at xhr. + this.setReadyState(this.request.DONE) + // 10. Fire a progress event named load at xhr with transmitted and length. + this.trigger('load', this.request, { + loaded: transmitted, + total: responseBodyLength, + }) + // 11. Fire a progress event named loadend at xhr with transmitted and length. + this.trigger('loadend', this.request, { + loaded: transmitted, + total: responseBodyLength, + }) + } + + // 7. If this’s response’s body is null, then run handle response end-of-body for this and return. + if (response.body == null) { + processResponseEndOfBody() + } else { + const reader = response.body.getReader() + + while (true) { + if (responseReadController.signal.aborted) { + break + } + + try { + const { value, done } = await reader.read() + + if (done) { + processResponseEndOfBody() + return + } + + processResponseBodyChunk(value.byteLength) + this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) + } catch { + processResponseBodyError() + } + } + } + } + + // Redirects are followed as a part of the fetch controller. Since we don't have one, + // retrieve the final response and then continue with processing it instead of the mocked one. + const { error: redirectError, data: finalResponse } = await until(() => { + return this.followRedirects(response) + }) + + if (redirectError) { + return + } - define(this.request, 'status', response.status) - define(this.request, 'statusText', response.statusText) - define(this.request, 'responseURL', this.url.href) + processResponse(finalResponse) + + // 12.1, 12.2. + if (this.request.timeout) { + setTimeout(() => { + if (this.request.readyState !== this.request.DONE) { + timedOut = true + responseReadController.abort() + } + }, this.request.timeout) + } this.request.getResponseHeader = new Proxy(this.request.getResponseHeader, { apply: (_, __, args: [name: string]) => { @@ -345,12 +541,10 @@ export class XMLHttpRequestController { if (this.request.readyState < this.request.HEADERS_RECEIVED) { this.logger.info('headers not received yet, returning null') - - // Headers not received yet, nothing to return. return null } - const headerValue = response.headers.get(args[0]) + const headerValue = finalResponse.headers.get(args[0]) this.logger.info( 'resolved response header "%s" to', args[0], @@ -369,12 +563,10 @@ export class XMLHttpRequestController { if (this.request.readyState < this.request.HEADERS_RECEIVED) { this.logger.info('headers not received yet, returning empty string') - - // Headers not received yet, nothing to return. return '' } - const headersList = Array.from(response.headers.entries()) + const headersList = Array.from(finalResponse.headers) const allHeaders = headersList .map(([headerName, headerValue]) => { return `${headerName}: ${headerValue}` @@ -387,85 +579,6 @@ export class XMLHttpRequestController { }, } ) - - // Update the response getters to resolve against the mocked response. - Object.defineProperties(this.request, { - response: { - enumerable: true, - configurable: false, - get: () => this.response, - }, - responseText: { - enumerable: true, - configurable: false, - get: () => this.responseText, - }, - responseXML: { - enumerable: true, - configurable: false, - get: () => this.responseXML, - }, - }) - - const totalResponseBodyLength = await getBodyByteLength(response.clone()) - - this.logger.info('calculated response body length', totalResponseBodyLength) - - this.trigger('loadstart', this.request, { - loaded: 0, - total: totalResponseBodyLength, - }) - - this.setReadyState(this.request.HEADERS_RECEIVED) - this.setReadyState(this.request.LOADING) - - const finalizeResponse = () => { - this.logger.info('finalizing the mocked response...') - - this.setReadyState(this.request.DONE) - - this.trigger('load', this.request, { - loaded: this.responseBuffer.byteLength, - total: totalResponseBodyLength, - }) - - this.trigger('loadend', this.request, { - loaded: this.responseBuffer.byteLength, - total: totalResponseBodyLength, - }) - } - - if (response.body) { - this.logger.info('mocked response has body, streaming...') - - const reader = response.body.getReader() - - const readNextResponseBodyChunk = async () => { - const { value, done } = await reader.read() - - if (done) { - this.logger.info('response body stream done!') - finalizeResponse() - return - } - - if (value) { - this.logger.info('read response body chunk:', value) - this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) - - this.trigger('progress', this.request, { - loaded: this.responseBuffer.byteLength, - total: totalResponseBodyLength, - }) - } - - readNextResponseBodyChunk() - } - - readNextResponseBodyChunk() - } else { - finalizeResponse() - } } private responseBufferToText(): string { @@ -580,6 +693,48 @@ export class XMLHttpRequestController { return null } + private async followRedirects(response: Response): Promise { + const redirectLocation = response.headers.get('location') + + if ( + !redirectLocation || + !FetchResponse.isRedirectResponse(response.status) + ) { + return response + } + + this.redirectCount++ + + if (this.redirectCount > MAX_REDIRECTS) { + throw new Error('Too many redirects') + } + + const redirectUrl = new URL(redirectLocation, location.href) + const redirectMethod = FetchResponse.isResponseWithBody(response.status) + ? this.method + : 'GET' + + const redirectResponse = await new Promise((resolve, reject) => { + const request = new XMLHttpRequest() + request.responseType = this.request.responseType + + request.addEventListener('load', () => { + this.url = new URL(request.responseURL) + resolve(createResponse(request, request.response)) + }) + + request.addEventListener('error', () => { + this.errorWith() + reject(new Error('Redirect request failed')) + }) + + request.open(redirectMethod, redirectUrl.href) + request.send() + }) + + return this.followRedirects(redirectResponse) + } + public errorWith(error?: Error): void { /** * @note Mark this request as handled even if it received a mock error. @@ -596,7 +751,10 @@ export class XMLHttpRequestController { /** * Transitions this request's `readyState` to the given one. */ - private setReadyState(nextReadyState: number): void { + private setReadyState( + nextReadyState: number, + triggerReadyStateChangeEvent = true + ): void { this.logger.info( 'setReadyState: %d -> %d', this.request.readyState, @@ -612,6 +770,10 @@ export class XMLHttpRequestController { this.logger.info('set readyState to: %d', nextReadyState) + if (!triggerReadyStateChangeEvent) { + return + } + if (nextReadyState !== this.request.UNSENT) { this.logger.info('triggering "readystatechange" event...') @@ -679,9 +841,7 @@ export class XMLHttpRequestController { * @see https://xhr.spec.whatwg.org/#cross-origin-credentials */ credentials: this.request.withCredentials ? 'include' : 'same-origin', - body: ['GET', 'HEAD'].includes(this.method.toUpperCase()) - ? null - : resolvedBody, + body: resolvedBody, }) const proxyHeaders = createProxy(fetchRequest.headers, { @@ -710,7 +870,7 @@ export class XMLHttpRequestController { }, }) define(fetchRequest, 'headers', proxyHeaders) - setRawRequest(fetchRequest, this.request) + // setRawRequest(fetchRequest, this.request) this.logger.info('converted request to a Fetch API Request!', fetchRequest) diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts index 4cfe8b89b..85b74b90d 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts @@ -1,12 +1,12 @@ import type { Logger } from '@open-draft/logger' -import { XMLHttpRequestEmitter } from '.' +import type { Emitter } from 'rettime' +import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' import { RequestController } from '../../RequestController' import { XMLHttpRequestController } from './XMLHttpRequestController' import { handleRequest } from '../../utils/handleRequest' -import { isResponseError } from '../../utils/responseUtils' export interface XMLHttpRequestProxyOptions { - emitter: XMLHttpRequestEmitter + emitter: Emitter logger: Logger } @@ -60,11 +60,6 @@ export function createXMLHttpRequestProxy({ ) }, respondWith: async (response) => { - if (isResponseError(response)) { - this.errorWith(new TypeError('Network error')) - return - } - await this.respondWith(response) }, errorWith: (reason) => { @@ -84,6 +79,7 @@ export function createXMLHttpRequestProxy({ ) await handleRequest({ + initiator: this.request, request, requestId, controller, @@ -93,7 +89,7 @@ export function createXMLHttpRequestProxy({ xhrRequestController.onResponse = async function ({ response, - isMockedResponse, + responseType, request, requestId, }) { @@ -102,12 +98,15 @@ export function createXMLHttpRequestProxy({ emitter.listenerCount('response') ) - emitter.emit('response', { - response, - isMockedResponse, - request, - requestId, - }) + await emitter.emitAsPromise( + new HttpResponseEvent({ + initiator: this.request, + response, + responseType, + request, + requestId, + }) + ) } // Return the proxied request from the controller diff --git a/src/interceptors/XMLHttpRequest/node.ts b/src/interceptors/XMLHttpRequest/node.ts new file mode 100644 index 000000000..241f5c0b7 --- /dev/null +++ b/src/interceptors/XMLHttpRequest/node.ts @@ -0,0 +1,164 @@ +import type { Emitter } from 'rettime' +import { requestContext } from '#/src/request-context' +import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' +import { Interceptor } from '#/src/interceptor' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { patchesRegistry } from '#/src/utils/patchesRegistry' +import { FetchRequest } from '#/src/utils/fetchUtils' +import { HttpRequestEventMap } from '#/src/events/http' +import { createLogger } from '#/src/utils/logger' + +const log = createLogger('xhr') + +export class XMLHttpRequestInterceptor extends Interceptor { + static symbol = Symbol.for('xhr-interceptor') + + protected predicate() { + return hasConfigurableGlobal('XMLHttpRequest') + } + + protected setup(): void { + const httpInterceptor = Interceptor.singleton(HttpRequestInterceptor) + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + httpInterceptor.on( + 'request', + async (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + await this.emitter.emitAsPromise(event) + } + }, + { + signal: controller.signal, + } + ) + + const responseListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'response' + > = async (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + await this.emitter.emitAsPromise(event) + } + } + + const unhandledExceptionListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'unhandledException' + > = async (event) => { + if (event.initiator instanceof XMLHttpRequest) { + event.request = this.#transformRequest(event.request, event.initiator) + await this.emitter.emitAsPromise(event) + } + } + + this.emitter.hooks.on( + 'newListener', + (type) => { + if ( + type === 'response' && + !httpInterceptor.listeners('response').includes(responseListener) + ) { + httpInterceptor.on('response', responseListener, { + signal: controller.signal, + }) + } + + if ( + type === 'unhandledException' && + !httpInterceptor + .listeners('unhandledException') + .includes(unhandledExceptionListener) + ) { + httpInterceptor.on('unhandledException', unhandledExceptionListener, { + signal: controller.signal, + }) + } + }, + { + signal: controller.signal, + persist: true, + } + ) + + this.emitter.hooks.on( + 'removeListener', + (type) => { + if ( + type === 'response' && + this.emitter.listenerCount('response') === 0 + ) { + httpInterceptor.removeListener('response', responseListener) + } + + if ( + type === 'unhandledException' && + this.emitter.listenerCount('unhandledException') === 0 + ) { + httpInterceptor.removeListener( + 'unhandledException', + unhandledExceptionListener + ) + } + }, + { + signal: controller.signal, + persist: true, + } + ) + + log('patching global "XMLHttpRequest"...') + + this.subscriptions.push( + patchesRegistry.applyPatch( + globalThis, + 'XMLHttpRequest', + (realXMLHttpRequest) => { + return new Proxy(realXMLHttpRequest, { + construct(target, args, newTarget) { + const xmlHttpRequest = Reflect.construct(target, args, newTarget) + + /** + * @note Use `.enterWith()` here because XHR in JSDOM is implemented + * via `http`/`https`. This makes the initiator cascading work properly. + */ + requestContext.enterWith({ initiator: xmlHttpRequest }) + + /** + * @todo Do we need to exit the async context at some point? + */ + + return xmlHttpRequest + }, + }) + } + ) + ) + + log('global "XMLHttpRequest" patched!') + } + + #transformRequest(request: Request, initiator: XMLHttpRequest): Request { + const expectedCredentials = initiator.withCredentials + ? 'include' + : 'same-origin' + + if (request.credentials === expectedCredentials) { + return request + } + + return new FetchRequest(request.url, { + ...request, + method: request.method, + headers: request.headers, + credentials: expectedCredentials, + body: request.body, + }) + } +} diff --git a/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts b/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts index fab59abd6..eb5a422db 100644 --- a/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts +++ b/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { concatArrayBuffer } from './concatArrayBuffer' const encoder = new TextEncoder() diff --git a/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts b/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts index 0b2321691..a2f899d80 100644 --- a/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts +++ b/src/interceptors/XMLHttpRequest/utils/createEvent.test.ts @@ -1,5 +1,4 @@ -// @vitest-environment jsdom -import { it, expect } from 'vitest' +// @vitest-environment happy-dom import { createEvent } from './createEvent' import { EventPolyfill } from '../polyfills/EventPolyfill' diff --git a/src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts b/src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts index eacebb2a5..503b96729 100644 --- a/src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts +++ b/src/interceptors/XMLHttpRequest/utils/getBodyByteLength.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect } from 'vitest' import { getBodyByteLength } from './getBodyByteLength' const url = 'http://localhost' diff --git a/src/interceptors/XMLHttpRequest/index.ts b/src/interceptors/XMLHttpRequest/web.ts similarity index 54% rename from src/interceptors/XMLHttpRequest/index.ts rename to src/interceptors/XMLHttpRequest/web.ts index 965c67a65..2f847b0e5 100644 --- a/src/interceptors/XMLHttpRequest/index.ts +++ b/src/interceptors/XMLHttpRequest/web.ts @@ -1,39 +1,33 @@ -import { Emitter } from 'strict-event-emitter' -import { HttpRequestEventMap } from '../../glossary' -import { Interceptor } from '../../Interceptor' +import { Logger } from '@open-draft/logger' +import { HttpRequestEventMap } from '../../events/http' +import { Interceptor } from '../../interceptor' import { createXMLHttpRequestProxy } from './XMLHttpRequestProxy' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { patchesRegistry } from '../../utils/patchesRegistry' -export type XMLHttpRequestEmitter = Emitter +const logger = new Logger('xhr') export class XMLHttpRequestInterceptor extends Interceptor { - static interceptorSymbol = Symbol('xhr') + static interceptorSymbol = Symbol.for('xhr-interceptor') - constructor() { - super(XMLHttpRequestInterceptor.interceptorSymbol) - } - - protected checkEnvironment() { + protected predicate() { return hasConfigurableGlobal('XMLHttpRequest') } protected setup() { - const logger = this.logger.extend('setup') - - logger.info('patching global XMLHttpRequest...') + logger.info('patching "XMLHttpRequest"...') this.subscriptions.push( patchesRegistry.applyPatch(globalThis, 'XMLHttpRequest', () => { return createXMLHttpRequestProxy({ emitter: this.emitter, - logger: this.logger, + logger, }) }) ) logger.info( - 'global XMLHttpRequest patched!', + 'global "XMLHttpRequest" patched!', globalThis.XMLHttpRequest.name ) } diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts deleted file mode 100644 index 888183568..000000000 --- a/src/interceptors/fetch/index.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { until } from '@open-draft/until' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '../../glossary' -import { Interceptor } from '../../Interceptor' -import { RequestController } from '../../RequestController' -import { emitAsync } from '../../utils/emitAsync' -import { handleRequest } from '../../utils/handleRequest' -import { canParseUrl } from '../../utils/canParseUrl' -import { createRequestId } from '../../createRequestId' -import { createNetworkError } from './utils/createNetworkError' -import { followFetchRedirect } from './utils/followRedirect' -import { decompressResponse } from './utils/decompression' -import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' -import { FetchRequest, FetchResponse } from '../../utils/fetchUtils' -import { setRawRequest } from '../../getRawRequest' -import { isResponseError } from '../../utils/responseUtils' -import { patchesRegistry } from '../../utils/patchesRegistry' - -export class FetchInterceptor extends Interceptor { - static symbol = Symbol('fetch') - - constructor() { - super(FetchInterceptor.symbol) - } - - protected checkEnvironment() { - return hasConfigurableGlobal('fetch') - } - - protected async setup() { - const logger = this.logger.extend('setup') - - const pureFetch = globalThis.fetch - - const fetchProxy: typeof fetch = async (input, init) => { - const requestId = createRequestId() - - /** - * @note Resolve potentially relative request URL - * against the present `location`. This is mainly - * for native `fetch` in JSDOM. - * @see https://github.com/mswjs/msw/issues/1625 - */ - const resolvedInput = - typeof input === 'string' && - typeof location !== 'undefined' && - !canParseUrl(input) - ? new URL(input, location.href) - : input - - const request = new FetchRequest(resolvedInput, init) - - /** - * @note Set the raw request only if a Request instance was provided to fetch. - */ - if (input instanceof Request) { - setRawRequest(request, input) - } - - const responsePromise = new DeferredPromise() - - const controller = new RequestController(request, { - passthrough: async () => { - this.logger.info('request has not been handled, passthrough...') - - /** - * @note Clone the request instance right before performing it. - * This preserves any modifications made to the intercepted request - * in the "request" listener. This also allows the user to read the - * request body in the "response" listener (otherwise "unusable"). - */ - const requestCloneForResponseEvent = request.clone() - - // Perform the intercepted request as-is. - const { error: responseError, data: originalResponse } = await until( - () => pureFetch(request) - ) - - if (responseError) { - return responsePromise.reject(responseError) - } - - this.logger.info('original fetch performed', originalResponse) - - if (this.emitter.listenerCount('response') > 0) { - this.logger.info('emitting the "response" event...') - - const responseClone = FetchResponse.clone(originalResponse) - await emitAsync(this.emitter, 'response', { - response: responseClone, - isMockedResponse: false, - request: requestCloneForResponseEvent, - requestId, - }) - } - - // Resolve the response promise with the original response - // since the `fetch()` return this internal promise. - responsePromise.resolve(originalResponse) - }, - respondWith: async (rawResponse) => { - // Handle mocked `Response.error()` (i.e. request errors). - if (isResponseError(rawResponse)) { - this.logger.info('request has errored!', { response: rawResponse }) - responsePromise.reject(createNetworkError(rawResponse)) - return - } - - this.logger.info('received mocked response!', { - rawResponse, - }) - - // Decompress the mocked response body, if applicable. - const decompressedStream = decompressResponse(rawResponse) - const response = new FetchResponse( - decompressedStream || rawResponse.body, - { - url: request.url, - status: rawResponse.status, - statusText: rawResponse.statusText, - headers: rawResponse.headers, - } - ) - - /** - * Undici's handling of following redirect responses. - * Treat the "manual" redirect mode as a regular mocked response. - * This way, the client can manually follow the redirect it receives. - * @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1173 - */ - if (FetchResponse.isRedirectResponse(response.status)) { - // Reject the request promise if its `redirect` is set to `error` - // and it receives a mocked redirect response. - if (request.redirect === 'error') { - responsePromise.reject(createNetworkError('unexpected redirect')) - return - } - - if (request.redirect === 'follow') { - followFetchRedirect(request, response).then( - (response) => { - responsePromise.resolve(response) - }, - (reason) => { - responsePromise.reject(reason) - } - ) - return - } - } - - if (this.emitter.listenerCount('response') > 0) { - this.logger.info('emitting the "response" event...') - - // Await the response listeners to finish before resolving - // the response promise. This ensures all your logic finishes - // before the interceptor resolves the pending response. - await emitAsync(this.emitter, 'response', { - // Clone the mocked response for the "response" event listener. - // This way, the listener can read the response and not lock its body - // for the actual fetch consumer. - response: FetchResponse.clone(response), - isMockedResponse: true, - request, - requestId, - }) - } - - responsePromise.resolve(response) - }, - errorWith: (reason) => { - this.logger.info('request has been aborted!', { reason }) - responsePromise.reject(reason) - }, - }) - - this.logger.info('[%s] %s', request.method, request.url) - this.logger.info('awaiting for the mocked response...') - - this.logger.info( - 'emitting the "request" event for %s listener(s)...', - this.emitter.listenerCount('request') - ) - - await handleRequest({ - request, - requestId, - emitter: this.emitter, - controller, - }) - - return responsePromise - } - - logger.info('patching global fetch...') - - this.subscriptions.push( - patchesRegistry.applyPatch(globalThis, 'fetch', () => fetchProxy) - ) - - logger.info('global fetch patched!', globalThis.fetch.name) - } -} diff --git a/src/interceptors/fetch/node.ts b/src/interceptors/fetch/node.ts new file mode 100644 index 000000000..91dc7477a --- /dev/null +++ b/src/interceptors/fetch/node.ts @@ -0,0 +1,143 @@ +import type { Emitter } from 'rettime' +import { hasConfigurableGlobal } from '#/src/utils/hasConfigurableGlobal' +import { canParseUrl } from '#/src/utils/canParseUrl' +import { requestContext } from '#/src/request-context' +import { patchesRegistry } from '#/src/utils/patchesRegistry' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { HttpRequestEventMap } from '#/src/events/http' +import { Interceptor } from '../../interceptor' + +/** + * Interceptor for `fetch` requests in Node.js. + * @note This interceptor only affects requests performed via + * the global `fetch` function. To intercept fetch requests performed + * by other means (e.g. direct `request()` from Undici) use the + * `HttpRequestInterceptor` instead. + */ +export class FetchInterceptor extends Interceptor { + static symbol = Symbol.for('fetch-interceptor') + + protected predicate() { + return hasConfigurableGlobal('fetch') + } + + protected setup(): void { + const httpInterceptor = Interceptor.singleton(HttpRequestInterceptor) + httpInterceptor.apply() + this.subscriptions.push(() => httpInterceptor.dispose()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + httpInterceptor.on( + 'request', + async (event) => { + if (event.initiator instanceof Request) { + await this.emitter.emitAsPromise(event) + } + }, + { + signal: controller.signal, + } + ) + + const responseListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'response' + > = async (event) => { + if (event.initiator instanceof Request) { + await this.emitter.emitAsPromise(event) + } + } + + const unhandledExceptionListener: Emitter.Listener< + (typeof httpInterceptor)['emitter'], + 'unhandledException' + > = async (event) => { + if (event.initiator instanceof Request) { + await this.emitter.emitAsPromise(event) + } + } + + this.emitter.hooks.on( + 'newListener', + (type) => { + if ( + type === 'response' && + !httpInterceptor.listeners('response').includes(responseListener) + ) { + httpInterceptor.on('response', responseListener, { + signal: controller.signal, + }) + } + + if ( + type === 'unhandledException' && + !httpInterceptor + .listeners('unhandledException') + .includes(unhandledExceptionListener) + ) { + httpInterceptor.on('unhandledException', unhandledExceptionListener, { + signal: controller.signal, + }) + } + }, + { + signal: controller.signal, + persist: true, + } + ) + + this.emitter.hooks.on( + 'removeListener', + (type) => { + if ( + type === 'response' && + this.emitter.listenerCount('response') === 0 + ) { + httpInterceptor.removeListener('response', responseListener) + } + + if ( + type === 'unhandledException' && + this.emitter.listenerCount('unhandledException') === 0 + ) { + httpInterceptor.removeListener( + 'unhandledException', + unhandledExceptionListener + ) + } + }, + { + signal: controller.signal, + persist: true, + } + ) + + this.subscriptions.push( + patchesRegistry.applyPatch(globalThis, 'fetch', (realFetch) => { + return (input, init) => { + /** + * Resolve potentially relative request URL against the present `location`. + * This is mainly for native `fetch` in browser-like environments. + * @see https://github.com/mswjs/msw/issues/1625 + */ + const resolvedInput = + typeof input === 'string' && + typeof location !== 'undefined' && + !canParseUrl(input) + ? new URL(input, location.href) + : input + + const request = new Request(resolvedInput, init) + + requestContext.enterWith({ + initiator: request, + }) + + return realFetch(request) + } + }) + ) + } +} diff --git a/src/interceptors/fetch/web.ts b/src/interceptors/fetch/web.ts new file mode 100644 index 000000000..44d30ccf3 --- /dev/null +++ b/src/interceptors/fetch/web.ts @@ -0,0 +1,205 @@ +import { until } from '@open-draft/until' +import { Logger } from '@open-draft/logger' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' +import { RequestController } from '../../RequestController' +import { handleRequest } from '../../utils/handleRequest' +import { canParseUrl } from '../../utils/canParseUrl' +import { createRequestId } from '../../createRequestId' +import { createNetworkError } from './utils/createNetworkError' +import { followFetchRedirect } from './utils/followRedirect' +import { decompressResponse } from './utils/decompression' +import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' +import { FetchResponse } from '../../utils/fetchUtils' +import { isResponseError } from '../../utils/responseUtils' +import { patchesRegistry } from '../../utils/patchesRegistry' +import { copyRawHeaders } from '../ClientRequest/utils/record-raw-headers' +import { Interceptor } from '../../interceptor' + +const logger = new Logger('fetch') + +/** + * Interceptor for `fetch` requests in the browser. + */ +export class FetchInterceptor extends Interceptor { + static symbol = Symbol.for('fetch-interceptor') + + protected predicate() { + return hasConfigurableGlobal('fetch') + } + + protected async setup() { + logger.info('patching global fetch...') + + this.subscriptions.push( + patchesRegistry.applyPatch(globalThis, 'fetch', (realFetch) => { + return async (input, init) => { + const requestId = createRequestId() + + /** + * @note Resolve potentially relative request URL + * against the present `location`. This is mainly + * for native `fetch` in JSDOM. + * @see https://github.com/mswjs/msw/issues/1625 + */ + const resolvedInput = + typeof input === 'string' && + typeof location !== 'undefined' && + !canParseUrl(input) + ? new URL(input, location.href) + : input + + const request = new Request(resolvedInput, init) + + const responsePromise = new DeferredPromise() + + const controller = new RequestController(request, { + passthrough: async () => { + logger.info('request has not been handled, passthrough...') + + /** + * @note Clone the request instance right before performing it. + * This preserves any modifications made to the intercepted request + * in the "request" listener. This also allows the user to read the + * request body in the "response" listener (otherwise "unusable"). + */ + const requestCloneForResponseEvent = request.clone() + + // Perform the intercepted request as-is. + const { error: responseError, data: originalResponse } = + await until(() => realFetch(request)) + + if (responseError) { + return responsePromise.reject(responseError) + } + + logger.info('original fetch performed', originalResponse) + + if (this.emitter.listenerCount('response') > 0) { + logger.info('emitting the "response" event...') + + const responseClone = FetchResponse.clone(originalResponse) + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator: requestCloneForResponseEvent, + request: requestCloneForResponseEvent, + requestId, + response: responseClone, + responseType: 'original', + }) + ) + } + + // Resolve the response promise with the original response + // since the `fetch()` return this internal promise. + responsePromise.resolve(originalResponse) + }, + respondWith: async (rawResponse) => { + // Handle mocked `Response.error()` (i.e. request errors). + if (isResponseError(rawResponse)) { + logger.info('request has errored!', { + response: rawResponse, + }) + responsePromise.reject(createNetworkError(rawResponse)) + return + } + + logger.info('received mocked response!', { + rawResponse, + }) + + // Decompress the mocked response body, if applicable. + const decompressedStream = decompressResponse(rawResponse) + const response = new FetchResponse( + decompressedStream || rawResponse.body, + { + url: request.url, + status: rawResponse.status, + statusText: rawResponse.statusText, + headers: rawResponse.headers, + } + ) + + copyRawHeaders(rawResponse.headers, response.headers) + + /** + * Undici's handling of following redirect responses. + * Treat the "manual" redirect mode as a regular mocked response. + * This way, the client can manually follow the redirect it receives. + * @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1173 + */ + if (FetchResponse.isRedirectResponse(response.status)) { + // Reject the request promise if its `redirect` is set to `error` + // and it receives a mocked redirect response. + if (request.redirect === 'error') { + responsePromise.reject( + createNetworkError('unexpected redirect') + ) + return + } + + if (request.redirect === 'follow') { + followFetchRedirect(request, response).then( + (response) => { + responsePromise.resolve(response) + }, + (reason) => { + responsePromise.reject(reason) + } + ) + return + } + } + + if (this.emitter.listenerCount('response') > 0) { + logger.info('emitting the "response" event...') + + // Await the response listeners to finish before resolving + // the response promise. This ensures all your logic finishes + // before the interceptor resolves the pending response. + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator: request, + // Clone the mocked response for the "response" event listener. + // This way, the listener can read the response and not lock its body + // for the actual fetch consumer. + response: FetchResponse.clone(response), + responseType: 'mock', + request, + requestId, + }) + ) + } + + responsePromise.resolve(response) + }, + errorWith: (reason) => { + logger.info('request has been aborted!', { reason }) + responsePromise.reject(reason) + }, + }) + + logger.info('[%s] %s', request.method, request.url) + logger.info('awaiting for the mocked response...') + + logger.info( + 'emitting the "request" event for %s listener(s)...', + this.emitter.listenerCount('request') + ) + + await handleRequest({ + initiator: request, + request, + requestId, + emitter: this.emitter, + controller, + }) + + return responsePromise + } + }) + ) + + logger.info('global fetch patched!', globalThis.fetch.name) + } +} diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts new file mode 100644 index 000000000..48283b3a6 --- /dev/null +++ b/src/interceptors/http/http-parser.ts @@ -0,0 +1,130 @@ +import { Readable } from 'node:stream' +import { invariant } from 'outvariant' +import { FetchRequest, FetchResponse } from '../../utils/fetchUtils' +import { HttpParser } from './http-parser/index' + +interface HttpRequestParserOptions { + connectionOptions: { + method?: string + url: URL + } + onRequest: (request: Request) => void +} + +export class HttpRequestParser extends HttpParser<1> { + #requestBodyStream?: Readable + + constructor(options: HttpRequestParserOptions) { + super(1, { + onHeadersComplete: ({ rawHeaders, method, url: path }) => { + /** + * @note When the socket is reused, "connectionOptions" will point + * to the "net.connect()" call options that established the connection, + * which may differ from the description of the current request (e.g. method). + * Rely on the HTTPParser supplying us with the correct "rawMethod" number. + */ + const finalMethod = ( + method || + options.connectionOptions.method || + 'GET' + ).toUpperCase() + + const url = new URL(path || '', options.connectionOptions.url) + const headers = FetchResponse.parseRawHeaders([...rawHeaders]) + + // Translate the basic authorization to request headers. + // Constructing a Request instance with a URL containing auth is no-op. + if (url.username || url.password) { + if (!headers.has('authorization')) { + const credentials = Buffer.from( + `${url.username}:${url.password}` + ).toString('base64') + headers.set('authorization', `Basic ${credentials}`) + } + url.username = '' + url.password = '' + } + + this.#requestBodyStream = new Readable({ + /** + * @note Provide the `read()` method so a `Readable` could be + * used as the actual request body (the stream calls "read()"). + */ + read: () => {}, + }) + + const request = new FetchRequest(url, { + method: finalMethod, + headers, + credentials: 'same-origin', + body: Readable.toWeb(this.#requestBodyStream) as any, + }) + options.onRequest(request) + }, + onBody: (chunk) => { + invariant( + this.#requestBodyStream, + 'Failed to write to a request stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub.' + ) + + this.#requestBodyStream.push(chunk) + }, + onMessageComplete: () => { + this.#requestBodyStream?.push(null) + }, + }) + } + + public free(): void { + this.destroy() + this.#requestBodyStream?.destroy() + this.#requestBodyStream = undefined + } +} + +export class HttpResponseParser extends HttpParser<2> { + #responseBodyStream?: Readable | null + + constructor(options: { onResponse: (response: Response) => void }) { + super(2, { + onHeadersComplete: ({ + rawHeaders, + statusCode: status, + statusMessage: statusText, + }) => { + const headers = FetchResponse.parseRawHeaders([...rawHeaders]) + + const response = new FetchResponse( + FetchResponse.isResponseWithBody(status) + ? (Readable.toWeb( + (this.#responseBodyStream = new Readable({ read() {} })) + ) as any) + : null, + { + status, + statusText, + headers, + } + ) + + options.onResponse(response) + }, + onBody: (chunk) => { + invariant( + this.#responseBodyStream, + 'Failed to read from a response stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub.' + ) + + this.#responseBodyStream.push(chunk) + }, + onMessageComplete: () => { + this.#responseBodyStream?.push(null) + }, + }) + } + + public free(): void { + this.destroy() + this.#responseBodyStream = null + } +} diff --git a/src/interceptors/http/http-parser/index.ts b/src/interceptors/http/http-parser/index.ts new file mode 100644 index 000000000..07bdafa8d --- /dev/null +++ b/src/interceptors/http/http-parser/index.ts @@ -0,0 +1,302 @@ +import fs from 'node:fs' +import * as constants from './llhttp/constants.js' + +export { constants } + +export interface RequestHeadersComplete { + versionMajor: number + versionMinor: number + rawHeaders: Array + method: string + url: string + upgrade: boolean + shouldKeepAlive: boolean +} + +export interface ResponseHeadersComplete { + versionMajor: number + versionMinor: number + rawHeaders: Array + statusCode: number + statusMessage: string + upgrade: boolean + shouldKeepAlive: boolean +} + +const KIND_REQUEST = constants.TYPE.REQUEST +const KIND_RESPONSE = constants.TYPE.RESPONSE + +type ParserKind = typeof KIND_REQUEST | typeof KIND_RESPONSE + +export interface ParserCallbacks { + onMessageBegin?: () => number | void + onHeadersComplete?: ( + info: K extends typeof KIND_REQUEST + ? RequestHeadersComplete + : ResponseHeadersComplete + ) => number | void + onBody?: (body: Buffer) => number | void + onMessageComplete?: () => number | void +} + +export type RequestParserCallbacks = ParserCallbacks +export type ResponseParserCallbacks = ParserCallbacks + +const kPointer = Symbol('kPtr') +const kUrl = Symbol('kUrl') +const kStatusMessage = Symbol('kStatusMessage') +const kHeadersFields = Symbol('kHeadersFields') +const kHeadersValues = Symbol('kHeadersValues') +const kLastHeaderCallback = Symbol('kLastHeaderCallback') +const kCallbacks = Symbol('kCallbacks') +const kType = Symbol('kType') + +const HEADER_CB_NONE = 0 +const HEADER_CB_FIELD = 1 +const HEADER_CB_VALUE = 2 + +const parsersMap = new Map>() + +const methodNames = Object.fromEntries( + Object.entries(constants.METHODS).map(([name, num]) => [num, name]) +) as Record + +function readStringFrom(pointer: number, length: number): string { + return Buffer.from(llhttp_memory.buffer, pointer, length).toString('latin1') +} + +const llhttpModule = new WebAssembly.Module( + fs.readFileSync(__dirname + '/llhttp/llhttp.wasm') +) + +const llhttpInstance = new WebAssembly.Instance(llhttpModule, { + env: { + wasm_on_message_begin(parserPointer: number) { + const parser = parsersMap.get(parserPointer)! + parser[kUrl] = '' + parser[kStatusMessage] = '' + parser[kHeadersFields] = [] + parser[kHeadersValues] = [] + parser[kLastHeaderCallback] = HEADER_CB_NONE + return parser[kCallbacks].onMessageBegin?.() ?? 0 + }, + // Request only + wasm_on_url(parserPointer: number, at: number, length: number) { + parsersMap.get(parserPointer)![kUrl] = readStringFrom(at, length) + return 0 + }, + // Response only + wasm_on_status(parserPointer: number, at: number, length: number) { + parsersMap.get(parserPointer)![kStatusMessage] = readStringFrom( + at, + length + ) + return 0 + }, + wasm_on_header_field(parserPointer: number, at: number, length: number) { + const parser = parsersMap.get(parserPointer)! + const chunk = readStringFrom(at, length) + const fields = parser[kHeadersFields] + + // llhttp emits header field/value across multiple callbacks when the + // bytes span buffer boundaries. Concatenate consecutive same-type + // callbacks into a single entry; switch entries on field<->value transitions. + if (parser[kLastHeaderCallback] === HEADER_CB_FIELD) { + fields[fields.length - 1] += chunk + } else { + fields.push(chunk) + parser[kLastHeaderCallback] = HEADER_CB_FIELD + } + return 0 + }, + wasm_on_header_value(parserPointer: number, at: number, length: number) { + const parser = parsersMap.get(parserPointer)! + const chunk = readStringFrom(at, length) + const values = parser[kHeadersValues] + + if (parser[kLastHeaderCallback] === HEADER_CB_VALUE) { + values[values.length - 1] += chunk + } else { + values.push(chunk) + parser[kLastHeaderCallback] = HEADER_CB_VALUE + } + return 0 + }, + wasm_on_headers_complete( + parserPointer: number, + statusCode: number, + rawUpgrade: number, + rawShouldKeepAlive: number + ) { + const parser = parsersMap.get(parserPointer)! + const versionMajor = llhttp_get_version_major(parserPointer) + const versionMinor = llhttp_get_version_minor(parserPointer) + const rawHeaders: Array = [] + const upgrade = rawUpgrade === 1 + const shouldKeepAlive = rawShouldKeepAlive === 1 + + for (let c = 0; c < parser[kHeadersFields].length; c++) { + rawHeaders.push(parser[kHeadersFields][c]!, parser[kHeadersValues][c]!) + } + + if (parser[kType] === KIND_REQUEST) { + const method = methodNames[llhttp_get_method(parserPointer)] + const url = parser[kUrl] + const callback = parser[kCallbacks] as ParserCallbacks< + typeof KIND_REQUEST + > + + return ( + callback.onHeadersComplete?.({ + versionMajor, + versionMinor, + rawHeaders, + method, + url, + upgrade, + shouldKeepAlive, + }) ?? 0 + ) + } else { + const statusCode = llhttp_get_status_code(parserPointer) as number + const statusMessage = parser[kStatusMessage] + const callback = parser[kCallbacks] as ParserCallbacks< + typeof KIND_RESPONSE + > + + return ( + callback.onHeadersComplete?.({ + versionMajor, + versionMinor, + rawHeaders, + statusCode, + statusMessage, + upgrade, + shouldKeepAlive, + }) ?? 0 + ) + } + }, + wasm_on_body(parserPointer: number, at: number, length: number) { + const parser = parsersMap.get(parserPointer)! + // Create a copy of the body chunk, as the underlying memory buffer is reused by llhttp and can be overwritten on the next callback call. + // Maybe not the most efficient way, but it is simple and safe. + const body = Buffer.from(new Uint8Array(llhttp_memory.buffer, at, length)) + return parser[kCallbacks].onBody?.(body) ?? 0 + }, + wasm_on_message_complete(parserPointer: number) { + return ( + parsersMap.get(parserPointer)![kCallbacks].onMessageComplete?.() ?? 0 + ) + }, + }, +}) + +const llhttp_memory = llhttpInstance.exports.memory as WebAssembly.Memory +const llhttp_alloc = llhttpInstance.exports.llhttp_alloc as CallableFunction +const llhttp_malloc = llhttpInstance.exports.malloc as CallableFunction +const llhttp_execute = llhttpInstance.exports.llhttp_execute as CallableFunction +const llhttp_get_type = llhttpInstance.exports + .llhttp_get_type as CallableFunction +const llhttp_get_upgrade = llhttpInstance.exports + .llhttp_get_upgrade as CallableFunction +const llhttp_should_keep_alive = llhttpInstance.exports + .llhttp_should_keep_alive as CallableFunction +const llhttp_get_method = llhttpInstance.exports + .llhttp_get_method as CallableFunction +const llhttp_get_status_code = llhttpInstance.exports + .llhttp_get_status_code as CallableFunction +const llhttp_get_version_minor = llhttpInstance.exports + .llhttp_get_http_minor as CallableFunction +const llhttp_get_version_major = llhttpInstance.exports + .llhttp_get_http_major as CallableFunction +const llhttp_get_error_reason = llhttpInstance.exports + .llhttp_get_error_reason as CallableFunction +const llhttp_get_error_pos = llhttpInstance.exports + .llhttp_get_error_pos as CallableFunction +const llhttp_free = llhttpInstance.exports.free as CallableFunction + +const initialize = llhttpInstance.exports._initialize as CallableFunction +initialize() // wasi reactor + +export class HttpParser { + [kPointer]: number; + [kUrl]: string = ''; + [kStatusMessage]: string = ''; + [kHeadersFields]: Array = []; + [kHeadersValues]: Array = []; + [kLastHeaderCallback]: number = HEADER_CB_NONE; + [kCallbacks]: ParserCallbacks; + [kType]: ParserKind + + constructor(type: K, callbacks: ParserCallbacks) { + this[kType] = type + this[kCallbacks] = callbacks + + const parserPointer = llhttp_alloc(type) + + if (parserPointer === 0) { + throw new Error('Failed to allocate llhttp parser') + } + + this[kPointer] = parserPointer + parsersMap.set(this[kPointer], this) + } + + destroy() { + // Guard against multiple calls to free/destroy. + if (this[kPointer] === 0) { + return + } + + parsersMap.delete(this[kPointer]) + llhttp_free(this[kPointer]) + this[kPointer] = 0 + } + + execute(data: Buffer) { + const pointer = llhttp_malloc(data.byteLength) + + if (pointer === 0) { + throw new Error('Failed to allocate llhttp input buffer') + } + + let ret: number + try { + const buffer = new Uint8Array(llhttp_memory.buffer) + buffer.set(data, pointer) + ret = llhttp_execute(this[kPointer], pointer, data.byteLength) + } catch (error) { + // Free the input buffer if a user callback threw synchronously, + // otherwise the wasm heap leaks the chunk on every execute() call. + llhttp_free(pointer) + throw error + } + + if (ret === constants.ERROR.PAUSED_UPGRADE) { + // Find how many bytes llhttp consumed + const errorPos = llhttp_get_error_pos(this[kPointer]) + const consumed = errorPos - pointer + llhttp_free(pointer) + // Return the unconsumed trailing bytes (tunnel/protocol data) + return data.subarray(consumed) + } + + llhttp_free(pointer) + this.#checkError(ret) + + return null // fully consumed + } + + #checkError(errorCode: number) { + if (errorCode === constants.ERROR.OK) { + return + } + + const errorPointer = llhttp_get_error_reason(this[kPointer]) + const buffer = new Uint8Array(llhttp_memory.buffer) + const length = buffer.indexOf(0, errorPointer) - errorPointer + + throw new Error(readStringFrom(errorPointer, length)) + } +} diff --git a/src/interceptors/http/http-parser/llhttp/constants.d.ts b/src/interceptors/http/http-parser/llhttp/constants.d.ts new file mode 100644 index 000000000..b5e907f7f --- /dev/null +++ b/src/interceptors/http/http-parser/llhttp/constants.d.ts @@ -0,0 +1,957 @@ +export type IntDict = Readonly>; +type Simplify = T extends any[] | Date ? T : { + [K in keyof T]: T[K]; +} & {}; +export declare const ERROR: { + readonly OK: 0; + readonly INTERNAL: 1; + readonly STRICT: 2; + readonly CR_EXPECTED: 25; + readonly LF_EXPECTED: 3; + readonly UNEXPECTED_CONTENT_LENGTH: 4; + readonly UNEXPECTED_SPACE: 30; + readonly CLOSED_CONNECTION: 5; + readonly INVALID_METHOD: 6; + readonly INVALID_URL: 7; + readonly INVALID_CONSTANT: 8; + readonly INVALID_VERSION: 9; + readonly INVALID_HEADER_TOKEN: 10; + readonly INVALID_CONTENT_LENGTH: 11; + readonly INVALID_CHUNK_SIZE: 12; + readonly INVALID_STATUS: 13; + readonly INVALID_EOF_STATE: 14; + readonly INVALID_TRANSFER_ENCODING: 15; + readonly CB_MESSAGE_BEGIN: 16; + readonly CB_HEADERS_COMPLETE: 17; + readonly CB_MESSAGE_COMPLETE: 18; + readonly CB_CHUNK_HEADER: 19; + readonly CB_CHUNK_COMPLETE: 20; + readonly PAUSED: 21; + readonly PAUSED_UPGRADE: 22; + readonly PAUSED_H2_UPGRADE: 23; + readonly USER: 24; + readonly CB_URL_COMPLETE: 26; + readonly CB_STATUS_COMPLETE: 27; + readonly CB_METHOD_COMPLETE: 32; + readonly CB_VERSION_COMPLETE: 33; + readonly CB_HEADER_FIELD_COMPLETE: 28; + readonly CB_HEADER_VALUE_COMPLETE: 29; + readonly CB_CHUNK_EXTENSION_NAME_COMPLETE: 34; + readonly CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35; + readonly CB_RESET: 31; + readonly CB_PROTOCOL_COMPLETE: 38; +}; +export declare const TYPE: { + readonly BOTH: 0; + readonly REQUEST: 1; + readonly RESPONSE: 2; +}; +export declare const FLAGS: { + readonly CONNECTION_KEEP_ALIVE: number; + readonly CONNECTION_CLOSE: number; + readonly CONNECTION_UPGRADE: number; + readonly CHUNKED: number; + readonly UPGRADE: number; + readonly CONTENT_LENGTH: number; + readonly SKIPBODY: number; + readonly TRAILING: number; + readonly TRANSFER_ENCODING: number; +}; +export declare const LENIENT_FLAGS: { + readonly HEADERS: number; + readonly CHUNKED_LENGTH: number; + readonly KEEP_ALIVE: number; + readonly TRANSFER_ENCODING: number; + readonly VERSION: number; + readonly DATA_AFTER_CLOSE: number; + readonly OPTIONAL_LF_AFTER_CR: number; + readonly OPTIONAL_CRLF_AFTER_CHUNK: number; + readonly OPTIONAL_CR_BEFORE_LF: number; + readonly SPACES_AFTER_CHUNK_SIZE: number; + readonly HEADER_VALUE_RELAXED: number; +}; +export declare const STATUSES: { + readonly CONTINUE: 100; + readonly SWITCHING_PROTOCOLS: 101; + readonly PROCESSING: 102; + readonly EARLY_HINTS: 103; + readonly RESPONSE_IS_STALE: 110; + readonly REVALIDATION_FAILED: 111; + readonly DISCONNECTED_OPERATION: 112; + readonly HEURISTIC_EXPIRATION: 113; + readonly MISCELLANEOUS_WARNING: 199; + readonly OK: 200; + readonly CREATED: 201; + readonly ACCEPTED: 202; + readonly NON_AUTHORITATIVE_INFORMATION: 203; + readonly NO_CONTENT: 204; + readonly RESET_CONTENT: 205; + readonly PARTIAL_CONTENT: 206; + readonly MULTI_STATUS: 207; + readonly ALREADY_REPORTED: 208; + readonly TRANSFORMATION_APPLIED: 214; + readonly IM_USED: 226; + readonly MISCELLANEOUS_PERSISTENT_WARNING: 299; + readonly MULTIPLE_CHOICES: 300; + readonly MOVED_PERMANENTLY: 301; + readonly FOUND: 302; + readonly SEE_OTHER: 303; + readonly NOT_MODIFIED: 304; + readonly USE_PROXY: 305; + readonly SWITCH_PROXY: 306; + readonly TEMPORARY_REDIRECT: 307; + readonly PERMANENT_REDIRECT: 308; + readonly BAD_REQUEST: 400; + readonly UNAUTHORIZED: 401; + readonly PAYMENT_REQUIRED: 402; + readonly FORBIDDEN: 403; + readonly NOT_FOUND: 404; + readonly METHOD_NOT_ALLOWED: 405; + readonly NOT_ACCEPTABLE: 406; + readonly PROXY_AUTHENTICATION_REQUIRED: 407; + readonly REQUEST_TIMEOUT: 408; + readonly CONFLICT: 409; + readonly GONE: 410; + readonly LENGTH_REQUIRED: 411; + readonly PRECONDITION_FAILED: 412; + readonly PAYLOAD_TOO_LARGE: 413; + readonly URI_TOO_LONG: 414; + readonly UNSUPPORTED_MEDIA_TYPE: 415; + readonly RANGE_NOT_SATISFIABLE: 416; + readonly EXPECTATION_FAILED: 417; + readonly IM_A_TEAPOT: 418; + readonly PAGE_EXPIRED: 419; + readonly ENHANCE_YOUR_CALM: 420; + readonly MISDIRECTED_REQUEST: 421; + readonly UNPROCESSABLE_ENTITY: 422; + readonly LOCKED: 423; + readonly FAILED_DEPENDENCY: 424; + readonly TOO_EARLY: 425; + readonly UPGRADE_REQUIRED: 426; + readonly PRECONDITION_REQUIRED: 428; + readonly TOO_MANY_REQUESTS: 429; + readonly REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430; + readonly REQUEST_HEADER_FIELDS_TOO_LARGE: 431; + readonly LOGIN_TIMEOUT: 440; + readonly NO_RESPONSE: 444; + readonly RETRY_WITH: 449; + readonly BLOCKED_BY_PARENTAL_CONTROL: 450; + readonly UNAVAILABLE_FOR_LEGAL_REASONS: 451; + readonly CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460; + readonly INVALID_X_FORWARDED_FOR: 463; + readonly REQUEST_HEADER_TOO_LARGE: 494; + readonly SSL_CERTIFICATE_ERROR: 495; + readonly SSL_CERTIFICATE_REQUIRED: 496; + readonly HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497; + readonly INVALID_TOKEN: 498; + readonly CLIENT_CLOSED_REQUEST: 499; + readonly INTERNAL_SERVER_ERROR: 500; + readonly NOT_IMPLEMENTED: 501; + readonly BAD_GATEWAY: 502; + readonly SERVICE_UNAVAILABLE: 503; + readonly GATEWAY_TIMEOUT: 504; + readonly HTTP_VERSION_NOT_SUPPORTED: 505; + readonly VARIANT_ALSO_NEGOTIATES: 506; + readonly INSUFFICIENT_STORAGE: 507; + readonly LOOP_DETECTED: 508; + readonly BANDWIDTH_LIMIT_EXCEEDED: 509; + readonly NOT_EXTENDED: 510; + readonly NETWORK_AUTHENTICATION_REQUIRED: 511; + readonly WEB_SERVER_UNKNOWN_ERROR: 520; + readonly WEB_SERVER_IS_DOWN: 521; + readonly CONNECTION_TIMEOUT: 522; + readonly ORIGIN_IS_UNREACHABLE: 523; + readonly TIMEOUT_OCCURED: 524; + readonly SSL_HANDSHAKE_FAILED: 525; + readonly INVALID_SSL_CERTIFICATE: 526; + readonly RAILGUN_ERROR: 527; + readonly SITE_IS_OVERLOADED: 529; + readonly SITE_IS_FROZEN: 530; + readonly IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561; + readonly NETWORK_READ_TIMEOUT: 598; + readonly NETWORK_CONNECT_TIMEOUT: 599; +}; +export declare const FINISH: { + readonly SAFE: 0; + readonly SAFE_WITH_CB: 1; + readonly UNSAFE: 2; +}; +export declare const HEADER_STATE: { + readonly GENERAL: 0; + readonly CONNECTION: 1; + readonly CONTENT_LENGTH: 2; + readonly TRANSFER_ENCODING: 3; + readonly UPGRADE: 4; + readonly CONNECTION_KEEP_ALIVE: 5; + readonly CONNECTION_CLOSE: 6; + readonly CONNECTION_UPGRADE: 7; + readonly TRANSFER_ENCODING_CHUNKED: 8; +}; +export declare const METHODS_HTTP1_HEAD: { + readonly HEAD: 2; +}; +/** + * HTTP methods as defined by RFC-9110 and other specifications. + * @see https://httpwg.org/specs/rfc9110.html#method.definitions + */ +export declare const METHODS_BASIC_HTTP: { + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; +}; +export declare const METHODS_WEBDAV: { + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; +}; +export declare const METHODS_SUBVERSION: { + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; +}; +export declare const METHODS_UPNP: { + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; +}; +export declare const METHODS_CALDAV: { + readonly MKCALENDAR: 30; +}; +export declare const METHODS_NON_STANDARD: { + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; +}; +export declare const METHODS_ICECAST: { + readonly SOURCE: 33; +}; +export declare const METHODS_AIRPLAY: Simplify>; +export declare const METHODS_RAOP: { + readonly FLUSH: 45; +}; +export declare const METHODS_RTSP: { + readonly FLUSH: 45; + readonly GET: 1; + readonly POST: 3; + readonly OPTIONS: 6; + readonly DESCRIBE: 35; + readonly ANNOUNCE: 36; + readonly SETUP: 37; + readonly PLAY: 38; + readonly PAUSE: 39; + readonly TEARDOWN: 40; + readonly GET_PARAMETER: 41; + readonly SET_PARAMETER: 42; + readonly REDIRECT: 43; + readonly RECORD: 44; +}; +export declare const METHODS_HTTP1: { + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; +}; +export declare const METHODS_HTTP2: { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; +}; +export declare const METHODS_HTTP: { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; +}; +export declare const METHODS: { + readonly FLUSH: 45; + readonly GET: 1; + readonly POST: 3; + readonly OPTIONS: 6; + readonly DESCRIBE: 35; + readonly ANNOUNCE: 36; + readonly SETUP: 37; + readonly PLAY: 38; + readonly PAUSE: 39; + readonly TEARDOWN: 40; + readonly GET_PARAMETER: 41; + readonly SET_PARAMETER: 42; + readonly REDIRECT: 43; + readonly RECORD: 44; + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly PUT: 4; + readonly CONNECT: 5; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; +}; +export declare const ALPHA: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z"]; +export declare const NUM_MAP: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; +}; +export declare const HEX_MAP: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + readonly A: 10; + readonly B: 11; + readonly C: 12; + readonly D: 13; + readonly E: 14; + readonly F: 15; + readonly a: 10; + readonly b: 11; + readonly c: 12; + readonly d: 13; + readonly e: 14; + readonly f: 15; +}; +export declare const DIGIT: readonly ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export declare const ALPHANUM: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export declare const MARK: readonly ["-", "_", ".", "!", "~", "*", "'", "(", ")"]; +export declare const USERINFO_CHARS: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "_", ".", "!", "~", "*", "'", "(", ")", "%", ";", ":", "&", "=", "+", "$", ","]; +export declare const URL_CHAR: readonly ["!", "\"", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", ":", ";", "<", "=", ">", "@", "[", "\\", "]", "^", "_", "`", "{", "|", "}", "~", "A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export declare const HEX: readonly ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F"]; +export declare const TOKEN: readonly ["!", "#", "$", "%", "&", "'", "*", "+", "-", ".", "^", "_", "`", "|", "~", "A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; +export declare const HTAB: readonly ["\t"]; +export declare const SP: readonly [" "]; +export declare const HTAB_SP_VCHAR_OBS_TEXT: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const HEADER_CHARS: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const RELAXED_HEADER_CHARS: readonly [1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 127, "\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const CONNECTION_TOKEN_CHARS: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const QDTEXT: readonly ["\t", " ", 33, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; +export declare const MAJOR: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; +}; +export declare const MINOR: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; +}; +export declare const SPECIAL_HEADERS: { + readonly connection: 1; + readonly 'content-length': 2; + readonly 'proxy-connection': 1; + readonly 'transfer-encoding': 3; + readonly upgrade: 4; +}; +declare const _default: { + ERROR: { + readonly OK: 0; + readonly INTERNAL: 1; + readonly STRICT: 2; + readonly CR_EXPECTED: 25; + readonly LF_EXPECTED: 3; + readonly UNEXPECTED_CONTENT_LENGTH: 4; + readonly UNEXPECTED_SPACE: 30; + readonly CLOSED_CONNECTION: 5; + readonly INVALID_METHOD: 6; + readonly INVALID_URL: 7; + readonly INVALID_CONSTANT: 8; + readonly INVALID_VERSION: 9; + readonly INVALID_HEADER_TOKEN: 10; + readonly INVALID_CONTENT_LENGTH: 11; + readonly INVALID_CHUNK_SIZE: 12; + readonly INVALID_STATUS: 13; + readonly INVALID_EOF_STATE: 14; + readonly INVALID_TRANSFER_ENCODING: 15; + readonly CB_MESSAGE_BEGIN: 16; + readonly CB_HEADERS_COMPLETE: 17; + readonly CB_MESSAGE_COMPLETE: 18; + readonly CB_CHUNK_HEADER: 19; + readonly CB_CHUNK_COMPLETE: 20; + readonly PAUSED: 21; + readonly PAUSED_UPGRADE: 22; + readonly PAUSED_H2_UPGRADE: 23; + readonly USER: 24; + readonly CB_URL_COMPLETE: 26; + readonly CB_STATUS_COMPLETE: 27; + readonly CB_METHOD_COMPLETE: 32; + readonly CB_VERSION_COMPLETE: 33; + readonly CB_HEADER_FIELD_COMPLETE: 28; + readonly CB_HEADER_VALUE_COMPLETE: 29; + readonly CB_CHUNK_EXTENSION_NAME_COMPLETE: 34; + readonly CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35; + readonly CB_RESET: 31; + readonly CB_PROTOCOL_COMPLETE: 38; + }; + TYPE: { + readonly BOTH: 0; + readonly REQUEST: 1; + readonly RESPONSE: 2; + }; + FLAGS: { + readonly CONNECTION_KEEP_ALIVE: number; + readonly CONNECTION_CLOSE: number; + readonly CONNECTION_UPGRADE: number; + readonly CHUNKED: number; + readonly UPGRADE: number; + readonly CONTENT_LENGTH: number; + readonly SKIPBODY: number; + readonly TRAILING: number; + readonly TRANSFER_ENCODING: number; + }; + LENIENT_FLAGS: { + readonly HEADERS: number; + readonly CHUNKED_LENGTH: number; + readonly KEEP_ALIVE: number; + readonly TRANSFER_ENCODING: number; + readonly VERSION: number; + readonly DATA_AFTER_CLOSE: number; + readonly OPTIONAL_LF_AFTER_CR: number; + readonly OPTIONAL_CRLF_AFTER_CHUNK: number; + readonly OPTIONAL_CR_BEFORE_LF: number; + readonly SPACES_AFTER_CHUNK_SIZE: number; + readonly HEADER_VALUE_RELAXED: number; + }; + STATUSES: { + readonly CONTINUE: 100; + readonly SWITCHING_PROTOCOLS: 101; + readonly PROCESSING: 102; + readonly EARLY_HINTS: 103; + readonly RESPONSE_IS_STALE: 110; + readonly REVALIDATION_FAILED: 111; + readonly DISCONNECTED_OPERATION: 112; + readonly HEURISTIC_EXPIRATION: 113; + readonly MISCELLANEOUS_WARNING: 199; + readonly OK: 200; + readonly CREATED: 201; + readonly ACCEPTED: 202; + readonly NON_AUTHORITATIVE_INFORMATION: 203; + readonly NO_CONTENT: 204; + readonly RESET_CONTENT: 205; + readonly PARTIAL_CONTENT: 206; + readonly MULTI_STATUS: 207; + readonly ALREADY_REPORTED: 208; + readonly TRANSFORMATION_APPLIED: 214; + readonly IM_USED: 226; + readonly MISCELLANEOUS_PERSISTENT_WARNING: 299; + readonly MULTIPLE_CHOICES: 300; + readonly MOVED_PERMANENTLY: 301; + readonly FOUND: 302; + readonly SEE_OTHER: 303; + readonly NOT_MODIFIED: 304; + readonly USE_PROXY: 305; + readonly SWITCH_PROXY: 306; + readonly TEMPORARY_REDIRECT: 307; + readonly PERMANENT_REDIRECT: 308; + readonly BAD_REQUEST: 400; + readonly UNAUTHORIZED: 401; + readonly PAYMENT_REQUIRED: 402; + readonly FORBIDDEN: 403; + readonly NOT_FOUND: 404; + readonly METHOD_NOT_ALLOWED: 405; + readonly NOT_ACCEPTABLE: 406; + readonly PROXY_AUTHENTICATION_REQUIRED: 407; + readonly REQUEST_TIMEOUT: 408; + readonly CONFLICT: 409; + readonly GONE: 410; + readonly LENGTH_REQUIRED: 411; + readonly PRECONDITION_FAILED: 412; + readonly PAYLOAD_TOO_LARGE: 413; + readonly URI_TOO_LONG: 414; + readonly UNSUPPORTED_MEDIA_TYPE: 415; + readonly RANGE_NOT_SATISFIABLE: 416; + readonly EXPECTATION_FAILED: 417; + readonly IM_A_TEAPOT: 418; + readonly PAGE_EXPIRED: 419; + readonly ENHANCE_YOUR_CALM: 420; + readonly MISDIRECTED_REQUEST: 421; + readonly UNPROCESSABLE_ENTITY: 422; + readonly LOCKED: 423; + readonly FAILED_DEPENDENCY: 424; + readonly TOO_EARLY: 425; + readonly UPGRADE_REQUIRED: 426; + readonly PRECONDITION_REQUIRED: 428; + readonly TOO_MANY_REQUESTS: 429; + readonly REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430; + readonly REQUEST_HEADER_FIELDS_TOO_LARGE: 431; + readonly LOGIN_TIMEOUT: 440; + readonly NO_RESPONSE: 444; + readonly RETRY_WITH: 449; + readonly BLOCKED_BY_PARENTAL_CONTROL: 450; + readonly UNAVAILABLE_FOR_LEGAL_REASONS: 451; + readonly CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460; + readonly INVALID_X_FORWARDED_FOR: 463; + readonly REQUEST_HEADER_TOO_LARGE: 494; + readonly SSL_CERTIFICATE_ERROR: 495; + readonly SSL_CERTIFICATE_REQUIRED: 496; + readonly HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497; + readonly INVALID_TOKEN: 498; + readonly CLIENT_CLOSED_REQUEST: 499; + readonly INTERNAL_SERVER_ERROR: 500; + readonly NOT_IMPLEMENTED: 501; + readonly BAD_GATEWAY: 502; + readonly SERVICE_UNAVAILABLE: 503; + readonly GATEWAY_TIMEOUT: 504; + readonly HTTP_VERSION_NOT_SUPPORTED: 505; + readonly VARIANT_ALSO_NEGOTIATES: 506; + readonly INSUFFICIENT_STORAGE: 507; + readonly LOOP_DETECTED: 508; + readonly BANDWIDTH_LIMIT_EXCEEDED: 509; + readonly NOT_EXTENDED: 510; + readonly NETWORK_AUTHENTICATION_REQUIRED: 511; + readonly WEB_SERVER_UNKNOWN_ERROR: 520; + readonly WEB_SERVER_IS_DOWN: 521; + readonly CONNECTION_TIMEOUT: 522; + readonly ORIGIN_IS_UNREACHABLE: 523; + readonly TIMEOUT_OCCURED: 524; + readonly SSL_HANDSHAKE_FAILED: 525; + readonly INVALID_SSL_CERTIFICATE: 526; + readonly RAILGUN_ERROR: 527; + readonly SITE_IS_OVERLOADED: 529; + readonly SITE_IS_FROZEN: 530; + readonly IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561; + readonly NETWORK_READ_TIMEOUT: 598; + readonly NETWORK_CONNECT_TIMEOUT: 599; + }; + FINISH: { + readonly SAFE: 0; + readonly SAFE_WITH_CB: 1; + readonly UNSAFE: 2; + }; + HEADER_STATE: { + readonly GENERAL: 0; + readonly CONNECTION: 1; + readonly CONTENT_LENGTH: 2; + readonly TRANSFER_ENCODING: 3; + readonly UPGRADE: 4; + readonly CONNECTION_KEEP_ALIVE: 5; + readonly CONNECTION_CLOSE: 6; + readonly CONNECTION_UPGRADE: 7; + readonly TRANSFER_ENCODING_CHUNKED: 8; + }; + ALPHA: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z"]; + NUM_MAP: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + }; + HEX_MAP: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + readonly A: 10; + readonly B: 11; + readonly C: 12; + readonly D: 13; + readonly E: 14; + readonly F: 15; + readonly a: 10; + readonly b: 11; + readonly c: 12; + readonly d: 13; + readonly e: 14; + readonly f: 15; + }; + DIGIT: readonly ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + ALPHANUM: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + MARK: readonly ["-", "_", ".", "!", "~", "*", "'", "(", ")"]; + USERINFO_CHARS: readonly ["A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "_", ".", "!", "~", "*", "'", "(", ")", "%", ";", ":", "&", "=", "+", "$", ","]; + URL_CHAR: readonly ["!", "\"", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", ":", ";", "<", "=", ">", "@", "[", "\\", "]", "^", "_", "`", "{", "|", "}", "~", "A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + HEX: readonly ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F"]; + TOKEN: readonly ["!", "#", "$", "%", "&", "'", "*", "+", "-", ".", "^", "_", "`", "|", "~", "A", "a", "B", "b", "C", "c", "D", "d", "E", "e", "F", "f", "G", "g", "H", "h", "I", "i", "J", "j", "K", "k", "L", "l", "M", "m", "N", "n", "O", "o", "P", "p", "Q", "q", "R", "r", "S", "s", "T", "t", "U", "u", "V", "v", "W", "w", "X", "x", "Y", "y", "Z", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + HEADER_CHARS: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + RELAXED_HEADER_CHARS: readonly [1, 2, 3, 4, 5, 6, 7, 8, 11, 12, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 127, "\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + CONNECTION_TOKEN_CHARS: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + QDTEXT: readonly ["\t", " ", 33, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + HTAB_SP_VCHAR_OBS_TEXT: readonly ["\t", " ", 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]; + MAJOR: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + }; + MINOR: { + readonly 0: 0; + readonly 1: 1; + readonly 2: 2; + readonly 3: 3; + readonly 4: 4; + readonly 5: 5; + readonly 6: 6; + readonly 7: 7; + readonly 8: 8; + readonly 9: 9; + }; + SPECIAL_HEADERS: { + readonly connection: 1; + readonly 'content-length': 2; + readonly 'proxy-connection': 1; + readonly 'transfer-encoding': 3; + readonly upgrade: 4; + }; + METHODS: { + readonly FLUSH: 45; + readonly GET: 1; + readonly POST: 3; + readonly OPTIONS: 6; + readonly DESCRIBE: 35; + readonly ANNOUNCE: 36; + readonly SETUP: 37; + readonly PLAY: 38; + readonly PAUSE: 39; + readonly TEARDOWN: 40; + readonly GET_PARAMETER: 41; + readonly SET_PARAMETER: 42; + readonly REDIRECT: 43; + readonly RECORD: 44; + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly PUT: 4; + readonly CONNECT: 5; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + }; + METHODS_HTTP: { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; + }; + METHODS_HTTP1_HEAD: { + readonly HEAD: 2; + }; + METHODS_HTTP1: { + readonly SOURCE: 33; + /** + * Not defined in any RFC but commonly used + */ + readonly PURGE: 29; + readonly QUERY: 46; + readonly MKCALENDAR: 30; + readonly 'M-SEARCH': 24; + readonly NOTIFY: 25; + readonly SUBSCRIBE: 26; + readonly UNSUBSCRIBE: 27; + readonly REPORT: 20; + readonly MKACTIVITY: 21; + readonly CHECKOUT: 22; + readonly MERGE: 23; + readonly COPY: 8; + readonly LOCK: 9; + readonly MKCOL: 10; + readonly MOVE: 11; + readonly PROPFIND: 12; + readonly PROPPATCH: 13; + readonly SEARCH: 14; + readonly UNLOCK: 15; + readonly BIND: 16; + readonly REBIND: 17; + readonly UNBIND: 18; + readonly ACL: 19; + readonly POST: 3; + readonly PUT: 4; + readonly CONNECT: 5; + readonly OPTIONS: 6; + readonly TRACE: 7; + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + readonly PATCH: 28; + readonly LINK: 31; + readonly UNLINK: 32; + readonly HEAD: 2; + readonly DELETE: 0; + readonly GET: 1; + }; + METHODS_HTTP2: { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + readonly PRI: 34; + }; + METHODS_ICECAST: { + readonly SOURCE: 33; + }; + METHODS_RTSP: { + readonly FLUSH: 45; + readonly GET: 1; + readonly POST: 3; + readonly OPTIONS: 6; + readonly DESCRIBE: 35; + readonly ANNOUNCE: 36; + readonly SETUP: 37; + readonly PLAY: 38; + readonly PAUSE: 39; + readonly TEARDOWN: 40; + readonly GET_PARAMETER: 41; + readonly SET_PARAMETER: 42; + readonly REDIRECT: 43; + readonly RECORD: 44; + }; +}; +export default _default; \ No newline at end of file diff --git a/src/interceptors/http/http-parser/llhttp/constants.js b/src/interceptors/http/http-parser/llhttp/constants.js new file mode 100644 index 000000000..87a91a9dd --- /dev/null +++ b/src/interceptors/http/http-parser/llhttp/constants.js @@ -0,0 +1,485 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SPECIAL_HEADERS = exports.MINOR = exports.MAJOR = exports.QDTEXT = exports.CONNECTION_TOKEN_CHARS = exports.RELAXED_HEADER_CHARS = exports.HEADER_CHARS = exports.HTAB_SP_VCHAR_OBS_TEXT = exports.SP = exports.HTAB = exports.TOKEN = exports.HEX = exports.URL_CHAR = exports.USERINFO_CHARS = exports.MARK = exports.ALPHANUM = exports.DIGIT = exports.HEX_MAP = exports.NUM_MAP = exports.ALPHA = exports.METHODS = exports.METHODS_HTTP = exports.METHODS_HTTP2 = exports.METHODS_HTTP1 = exports.METHODS_RTSP = exports.METHODS_RAOP = exports.METHODS_AIRPLAY = exports.METHODS_ICECAST = exports.METHODS_NON_STANDARD = exports.METHODS_CALDAV = exports.METHODS_UPNP = exports.METHODS_SUBVERSION = exports.METHODS_WEBDAV = exports.METHODS_BASIC_HTTP = exports.METHODS_HTTP1_HEAD = exports.HEADER_STATE = exports.FINISH = exports.STATUSES = exports.LENIENT_FLAGS = exports.FLAGS = exports.TYPE = exports.ERROR = void 0; +exports.ERROR = { + OK: 0, + INTERNAL: 1, + STRICT: 2, + CR_EXPECTED: 25, + LF_EXPECTED: 3, + UNEXPECTED_CONTENT_LENGTH: 4, + UNEXPECTED_SPACE: 30, + CLOSED_CONNECTION: 5, + INVALID_METHOD: 6, + INVALID_URL: 7, + INVALID_CONSTANT: 8, + INVALID_VERSION: 9, + INVALID_HEADER_TOKEN: 10, + INVALID_CONTENT_LENGTH: 11, + INVALID_CHUNK_SIZE: 12, + INVALID_STATUS: 13, + INVALID_EOF_STATE: 14, + INVALID_TRANSFER_ENCODING: 15, + CB_MESSAGE_BEGIN: 16, + CB_HEADERS_COMPLETE: 17, + CB_MESSAGE_COMPLETE: 18, + CB_CHUNK_HEADER: 19, + CB_CHUNK_COMPLETE: 20, + PAUSED: 21, + PAUSED_UPGRADE: 22, + PAUSED_H2_UPGRADE: 23, + USER: 24, + CB_URL_COMPLETE: 26, + CB_STATUS_COMPLETE: 27, + CB_METHOD_COMPLETE: 32, + CB_VERSION_COMPLETE: 33, + CB_HEADER_FIELD_COMPLETE: 28, + CB_HEADER_VALUE_COMPLETE: 29, + CB_CHUNK_EXTENSION_NAME_COMPLETE: 34, + CB_CHUNK_EXTENSION_VALUE_COMPLETE: 35, + CB_RESET: 31, + CB_PROTOCOL_COMPLETE: 38, +}; +exports.TYPE = { + BOTH: 0, // default + REQUEST: 1, + RESPONSE: 2, +}; +exports.FLAGS = { + CONNECTION_KEEP_ALIVE: 1 << 0, + CONNECTION_CLOSE: 1 << 1, + CONNECTION_UPGRADE: 1 << 2, + CHUNKED: 1 << 3, + UPGRADE: 1 << 4, + CONTENT_LENGTH: 1 << 5, + SKIPBODY: 1 << 6, + TRAILING: 1 << 7, + // 1 << 8 is unused + TRANSFER_ENCODING: 1 << 9, +}; +exports.LENIENT_FLAGS = { + HEADERS: 1 << 0, + CHUNKED_LENGTH: 1 << 1, + KEEP_ALIVE: 1 << 2, + TRANSFER_ENCODING: 1 << 3, + VERSION: 1 << 4, + DATA_AFTER_CLOSE: 1 << 5, + OPTIONAL_LF_AFTER_CR: 1 << 6, + OPTIONAL_CRLF_AFTER_CHUNK: 1 << 7, + OPTIONAL_CR_BEFORE_LF: 1 << 8, + SPACES_AFTER_CHUNK_SIZE: 1 << 9, + HEADER_VALUE_RELAXED: 1 << 10, +}; +exports.STATUSES = { + CONTINUE: 100, + SWITCHING_PROTOCOLS: 101, + PROCESSING: 102, + EARLY_HINTS: 103, + RESPONSE_IS_STALE: 110, // Unofficial + REVALIDATION_FAILED: 111, // Unofficial + DISCONNECTED_OPERATION: 112, // Unofficial + HEURISTIC_EXPIRATION: 113, // Unofficial + MISCELLANEOUS_WARNING: 199, // Unofficial + OK: 200, + CREATED: 201, + ACCEPTED: 202, + NON_AUTHORITATIVE_INFORMATION: 203, + NO_CONTENT: 204, + RESET_CONTENT: 205, + PARTIAL_CONTENT: 206, + MULTI_STATUS: 207, + ALREADY_REPORTED: 208, + TRANSFORMATION_APPLIED: 214, // Unofficial + IM_USED: 226, + MISCELLANEOUS_PERSISTENT_WARNING: 299, // Unofficial + MULTIPLE_CHOICES: 300, + MOVED_PERMANENTLY: 301, + FOUND: 302, + SEE_OTHER: 303, + NOT_MODIFIED: 304, + USE_PROXY: 305, + SWITCH_PROXY: 306, // No longer used + TEMPORARY_REDIRECT: 307, + PERMANENT_REDIRECT: 308, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + PAYMENT_REQUIRED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + METHOD_NOT_ALLOWED: 405, + NOT_ACCEPTABLE: 406, + PROXY_AUTHENTICATION_REQUIRED: 407, + REQUEST_TIMEOUT: 408, + CONFLICT: 409, + GONE: 410, + LENGTH_REQUIRED: 411, + PRECONDITION_FAILED: 412, + PAYLOAD_TOO_LARGE: 413, + URI_TOO_LONG: 414, + UNSUPPORTED_MEDIA_TYPE: 415, + RANGE_NOT_SATISFIABLE: 416, + EXPECTATION_FAILED: 417, + IM_A_TEAPOT: 418, + PAGE_EXPIRED: 419, // Unofficial + ENHANCE_YOUR_CALM: 420, // Unofficial + MISDIRECTED_REQUEST: 421, + UNPROCESSABLE_ENTITY: 422, + LOCKED: 423, + FAILED_DEPENDENCY: 424, + TOO_EARLY: 425, + UPGRADE_REQUIRED: 426, + PRECONDITION_REQUIRED: 428, + TOO_MANY_REQUESTS: 429, + REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL: 430, // Unofficial + REQUEST_HEADER_FIELDS_TOO_LARGE: 431, + LOGIN_TIMEOUT: 440, // Unofficial + NO_RESPONSE: 444, // Unofficial + RETRY_WITH: 449, // Unofficial + BLOCKED_BY_PARENTAL_CONTROL: 450, // Unofficial + UNAVAILABLE_FOR_LEGAL_REASONS: 451, + CLIENT_CLOSED_LOAD_BALANCED_REQUEST: 460, // Unofficial + INVALID_X_FORWARDED_FOR: 463, // Unofficial + REQUEST_HEADER_TOO_LARGE: 494, // Unofficial + SSL_CERTIFICATE_ERROR: 495, // Unofficial + SSL_CERTIFICATE_REQUIRED: 496, // Unofficial + HTTP_REQUEST_SENT_TO_HTTPS_PORT: 497, // Unofficial + INVALID_TOKEN: 498, // Unofficial + CLIENT_CLOSED_REQUEST: 499, // Unofficial + INTERNAL_SERVER_ERROR: 500, + NOT_IMPLEMENTED: 501, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504, + HTTP_VERSION_NOT_SUPPORTED: 505, + VARIANT_ALSO_NEGOTIATES: 506, + INSUFFICIENT_STORAGE: 507, + LOOP_DETECTED: 508, + BANDWIDTH_LIMIT_EXCEEDED: 509, + NOT_EXTENDED: 510, + NETWORK_AUTHENTICATION_REQUIRED: 511, + WEB_SERVER_UNKNOWN_ERROR: 520, // Unofficial + WEB_SERVER_IS_DOWN: 521, // Unofficial + CONNECTION_TIMEOUT: 522, // Unofficial + ORIGIN_IS_UNREACHABLE: 523, // Unofficial + TIMEOUT_OCCURED: 524, // Unofficial + SSL_HANDSHAKE_FAILED: 525, // Unofficial + INVALID_SSL_CERTIFICATE: 526, // Unofficial + RAILGUN_ERROR: 527, // Unofficial + SITE_IS_OVERLOADED: 529, // Unofficial + SITE_IS_FROZEN: 530, // Unofficial + IDENTITY_PROVIDER_AUTHENTICATION_ERROR: 561, // Unofficial + NETWORK_READ_TIMEOUT: 598, // Unofficial + NETWORK_CONNECT_TIMEOUT: 599, // Unofficial +}; +exports.FINISH = { + SAFE: 0, + SAFE_WITH_CB: 1, + UNSAFE: 2, +}; +exports.HEADER_STATE = { + GENERAL: 0, + CONNECTION: 1, + CONTENT_LENGTH: 2, + TRANSFER_ENCODING: 3, + UPGRADE: 4, + CONNECTION_KEEP_ALIVE: 5, + CONNECTION_CLOSE: 6, + CONNECTION_UPGRADE: 7, + TRANSFER_ENCODING_CHUNKED: 8, +}; +exports.METHODS_HTTP1_HEAD = { + HEAD: 2, +}; +/** + * HTTP methods as defined by RFC-9110 and other specifications. + * @see https://httpwg.org/specs/rfc9110.html#method.definitions + */ +exports.METHODS_BASIC_HTTP = { + DELETE: 0, + GET: 1, + ...exports.METHODS_HTTP1_HEAD, + POST: 3, + PUT: 4, + CONNECT: 5, + OPTIONS: 6, + TRACE: 7, + /** + * @see https://www.rfc-editor.org/rfc/rfc5789.html + */ + PATCH: 28, + /* RFC-2068, section 19.6.1.2 */ + LINK: 31, + UNLINK: 32, +}; +exports.METHODS_WEBDAV = { + COPY: 8, + LOCK: 9, + MKCOL: 10, + MOVE: 11, + PROPFIND: 12, + PROPPATCH: 13, + SEARCH: 14, + UNLOCK: 15, + BIND: 16, + REBIND: 17, + UNBIND: 18, + ACL: 19, +}; +exports.METHODS_SUBVERSION = { + REPORT: 20, + MKACTIVITY: 21, + CHECKOUT: 22, + MERGE: 23, +}; +exports.METHODS_UPNP = { + 'M-SEARCH': 24, + NOTIFY: 25, + SUBSCRIBE: 26, + UNSUBSCRIBE: 27, +}; +exports.METHODS_CALDAV = { + MKCALENDAR: 30, +}; +exports.METHODS_NON_STANDARD = { + /** + * Not defined in any RFC but commonly used + */ + PURGE: 29, + /* DRAFT https://www.ietf.org/archive/id/draft-ietf-httpbis-safe-method-w-body-02.html */ + QUERY: 46, +}; +exports.METHODS_ICECAST = { + SOURCE: 33, +}; +exports.METHODS_AIRPLAY = { + GET: 1, + POST: 3, +}; +exports.METHODS_RAOP = { + FLUSH: 45, +}; +/* RFC-2326 RTSP */ +exports.METHODS_RTSP = { + OPTIONS: exports.METHODS_BASIC_HTTP.OPTIONS, + DESCRIBE: 35, + ANNOUNCE: 36, + SETUP: 37, + PLAY: 38, + PAUSE: 39, + TEARDOWN: 40, + GET_PARAMETER: 41, + SET_PARAMETER: 42, + REDIRECT: 43, + RECORD: 44, + ...exports.METHODS_AIRPLAY, + ...exports.METHODS_RAOP, +}; +exports.METHODS_HTTP1 = { + ...exports.METHODS_BASIC_HTTP, + ...exports.METHODS_WEBDAV, + ...exports.METHODS_SUBVERSION, + ...exports.METHODS_UPNP, + ...exports.METHODS_CALDAV, + ...exports.METHODS_NON_STANDARD, + // TODO(indutny): should we allow it with HTTP? + ...exports.METHODS_ICECAST, +}; +exports.METHODS_HTTP2 = { + /** + * RFC-9113, section 11.6 + * @see https://www.rfc-editor.org/rfc/rfc9113.html#preface + */ + PRI: 34, +}; +exports.METHODS_HTTP = { + ...exports.METHODS_HTTP1, + ...exports.METHODS_HTTP2, +}; +exports.METHODS = { + ...exports.METHODS_HTTP1, + ...exports.METHODS_HTTP2, + ...exports.METHODS_RTSP, +}; +// ALPHA: https://tools.ietf.org/html/rfc5234#appendix-B.1 +exports.ALPHA = [ + "A", "a", "B", "b", "C", "c", "D", "d", + "E", "e", "F", "f", "G", "g", "H", "h", + "I", "i", "J", "j", "K", "k", "L", "l", + "M", "m", "N", "n", "O", "o", "P", "p", + "Q", "q", "R", "r", "S", "s", "T", "t", + "U", "u", "V", "v", "W", "w", "X", "x", + "Y", "y", "Z", "z", +]; +exports.NUM_MAP = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, + 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, +}; +exports.HEX_MAP = { + 0: 0, 1: 1, 2: 2, 3: 3, 4: 4, + 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, + A: 0XA, B: 0XB, C: 0XC, D: 0XD, E: 0XE, F: 0XF, + a: 0xa, b: 0xb, c: 0xc, d: 0xd, e: 0xe, f: 0xf, +}; +// DIGIT: https://tools.ietf.org/html/rfc5234#appendix-B.1 +exports.DIGIT = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', +]; +exports.ALPHANUM = [...exports.ALPHA, ...exports.DIGIT]; +exports.MARK = ['-', '_', '.', '!', '~', '*', '\'', '(', ')']; +exports.USERINFO_CHARS = [...exports.ALPHANUM, ...exports.MARK, '%', ';', ':', '&', '=', '+', '$', ',']; +// TODO(indutny): use RFC +exports.URL_CHAR = [ + '!', '"', '$', '%', '&', '\'', + '(', ')', '*', '+', ',', '-', '.', '/', + ':', ';', '<', '=', '>', + '@', '[', '\\', ']', '^', '_', + '`', + '{', '|', '}', '~', + ...exports.ALPHANUM +]; +exports.HEX = [...exports.DIGIT, 'a', 'b', 'c', 'd', 'e', 'f', 'A', 'B', 'C', 'D', 'E', 'F']; +/* Tokens as defined by rfc 2616. Also lowercases them. + * token = 1* + * separators = "(" | ")" | "<" | ">" | "@" + * | "," | ";" | ":" | "\" | <"> + * | "/" | "[" | "]" | "?" | "=" + * | "{" | "}" | SP | HT + */ +exports.TOKEN = [ + '!', '#', '$', '%', '&', '\'', + '*', '+', '-', '.', + '^', '_', '`', + '|', '~', + ...exports.ALPHANUM +]; +// HTAB: https://tools.ietf.org/html/rfc5234#appendix-B.1 +exports.HTAB = ['\t']; +// SP: https://tools.ietf.org/html/rfc5234#appendix-B.1 +exports.SP = [' ']; +// VCHAR: https://tools.ietf.org/html/rfc5234#appendix-B.1 +const VCHAR = [ + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, + 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, + 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, + 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, +]; +// OBS_TEXT: https://datatracker.ietf.org/doc/html/rfc9110#name-collected-abnf +// 0x80 - 0xff +const OBS_TEXT = [ + 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, + 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, + 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, + 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, + 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, + 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, + 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, + 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, + 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, + 0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf, + 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf, + 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, + 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef, + 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, + 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, +]; +exports.HTAB_SP_VCHAR_OBS_TEXT = [...exports.HTAB, ...exports.SP, ...VCHAR, ...OBS_TEXT]; +exports.HEADER_CHARS = exports.HTAB_SP_VCHAR_OBS_TEXT; +const RELAXED_CTRL_CHARS = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, // Before TAB + 0x0b, 0x0c, // VT, FF (between TAB and CR) + 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, // After CR/LF, before space + 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, + 0x1e, 0x1f, + 0x7f, // DEL +]; +// Relaxed header chars includes control characters (above) that are not allowed +// by default. This excludes only NULL (0x00), CR (0x0d), LF (0x0a). +exports.RELAXED_HEADER_CHARS = [...RELAXED_CTRL_CHARS, ...exports.HEADER_CHARS]; +// ',' = \x2c +exports.CONNECTION_TOKEN_CHARS = [ + ...exports.HTAB, ...exports.SP, + 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x2b, /* */ 0x2d, 0x2e, 0x2f, 0x30, + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, + 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, + 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, + 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f, 0x60, + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, + 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, + 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, + 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, + ...OBS_TEXT +]; +// QDTEXT: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.4 +exports.QDTEXT = [ + ...exports.HTAB, ...exports.SP, + 0x21, + 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, + 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, + 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, + 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, + 0x4b, 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, + 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, + 0x5b, + 0x5d, 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64, + 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, + 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0x73, 0x74, + 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, + 0x7d, 0x7e, + ...OBS_TEXT +]; +exports.MAJOR = exports.NUM_MAP; +exports.MINOR = exports.MAJOR; +exports.SPECIAL_HEADERS = { + 'connection': exports.HEADER_STATE.CONNECTION, + 'content-length': exports.HEADER_STATE.CONTENT_LENGTH, + 'proxy-connection': exports.HEADER_STATE.CONNECTION, + 'transfer-encoding': exports.HEADER_STATE.TRANSFER_ENCODING, + 'upgrade': exports.HEADER_STATE.UPGRADE, +}; +exports.default = { + ERROR: exports.ERROR, + TYPE: exports.TYPE, + FLAGS: exports.FLAGS, + LENIENT_FLAGS: exports.LENIENT_FLAGS, + STATUSES: exports.STATUSES, + FINISH: exports.FINISH, + HEADER_STATE: exports.HEADER_STATE, + ALPHA: exports.ALPHA, + NUM_MAP: exports.NUM_MAP, + HEX_MAP: exports.HEX_MAP, + DIGIT: exports.DIGIT, + ALPHANUM: exports.ALPHANUM, + MARK: exports.MARK, + USERINFO_CHARS: exports.USERINFO_CHARS, + URL_CHAR: exports.URL_CHAR, + HEX: exports.HEX, + TOKEN: exports.TOKEN, + HEADER_CHARS: exports.HEADER_CHARS, + RELAXED_HEADER_CHARS: exports.RELAXED_HEADER_CHARS, + CONNECTION_TOKEN_CHARS: exports.CONNECTION_TOKEN_CHARS, + QDTEXT: exports.QDTEXT, + HTAB_SP_VCHAR_OBS_TEXT: exports.HTAB_SP_VCHAR_OBS_TEXT, + MAJOR: exports.MAJOR, + MINOR: exports.MINOR, + SPECIAL_HEADERS: exports.SPECIAL_HEADERS, + METHODS: exports.METHODS, + METHODS_HTTP: exports.METHODS_HTTP, + METHODS_HTTP1_HEAD: exports.METHODS_HTTP1_HEAD, + METHODS_HTTP1: exports.METHODS_HTTP1, + METHODS_HTTP2: exports.METHODS_HTTP2, + METHODS_ICECAST: exports.METHODS_ICECAST, + METHODS_RTSP: exports.METHODS_RTSP, +}; \ No newline at end of file diff --git a/src/interceptors/http/http-parser/llhttp/llhttp.wasm b/src/interceptors/http/http-parser/llhttp/llhttp.wasm new file mode 100755 index 000000000..fbc714152 Binary files /dev/null and b/src/interceptors/http/http-parser/llhttp/llhttp.wasm differ diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts new file mode 100644 index 000000000..f079f1605 --- /dev/null +++ b/src/interceptors/http/index.ts @@ -0,0 +1,516 @@ +import net from 'node:net' +import { + METHODS, + STATUS_CODES, + ServerResponse, + IncomingMessage, +} from 'node:http' +import { invariant } from 'outvariant' +import { HttpResponseEvent, type HttpRequestEventMap } from '../../events/http' +import { RequestController } from '../../RequestController' +import { + getRawFetchHeaders, + recordRawFetchHeaders, +} from '../ClientRequest/utils/record-raw-headers' +import { SocketInterceptor } from '../net' +import { connectionOptionsToUrl } from '../net/utils/connection-options-to-url' +import { toBuffer } from '../../utils/bufferUtils' +import { createRequestId } from '../../createRequestId' +import { HttpRequestParser, HttpResponseParser } from './http-parser' +import { handleRequest, HandleRequestOptions } from '../../utils/handleRequest' +import { isResponseError } from '../../utils/responseUtils' +import { createLogger } from '../../utils/logger' +import { + kRawSocket, + SocketController, + type FlushPendingDataFunction, +} from '../net/socket-controller' +import { unwrapPendingData } from '../net/utils/flush-writes' +import { FetchResponse } from '../../utils/fetchUtils' +import { requestContext } from '../../request-context' +import { Interceptor } from '#/src/interceptor' + +const log = createLogger('HttpRequestInterceptor') + +/** + * Interceptor for HTTP requests in Node.js. + * Routes socket connections through an HTTP parser. + */ +export class HttpRequestInterceptor extends Interceptor { + static symbol = Symbol.for('http-request-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + const socketInterceptor = Interceptor.singleton(SocketInterceptor) + socketInterceptor.apply() + this.subscriptions.push(() => socketInterceptor.dispose()) + + /** + * @note Record the raw values provided to Headers set/append + * in order to support "IncomingMessage.prototype.rawHeaders". + * This is meant for the headers in mocked responses. + */ + this.subscriptions.push(recordRawFetchHeaders()) + + const controller = new AbortController() + this.subscriptions.push(() => controller.abort()) + + socketInterceptor.on( + 'connection', + ({ connectionOptions, socket, controller: socketController }) => { + /** + * @note Only listen to the first sent packet. + * A single socket cannot be used for different protocols. + */ + socket.once('data', (chunk) => { + const httpMessage = chunk.toString() + const httpMethod = httpMessage.split(' ')[0] || '' + + // Ignore non-HTTP packets sent via this socket. + if (!METHODS.includes(httpMethod.toUpperCase())) { + return + } + + const baseUrl = connectionOptionsToUrl(connectionOptions, socket) + + log('handling http message...', { + httpMessage, + httpMethod, + baseUrl, + }) + + // Get the request initiator from the async context, if any. + // Use the underlying socket as a fallback. + const parentInitiator = requestContext.getStore()?.initiator + const initiator = parentInitiator || socket + + const requestParser = new HttpRequestParser({ + connectionOptions: { + method: httpMethod, + url: baseUrl, + }, + onRequest: async (request) => { + const requestId = createRequestId() + + log('received a parsed HTTP request!', { + method: request.method, + url: request.url, + }) + + const requestController = new RequestController(request, { + respondWith: async (rawResponse) => { + log('respondWith() %o', { + status: rawResponse.status, + statusText: rawResponse.statusText, + hasBody: rawResponse.body != null, + }) + + socketController.claim() + + const response = FetchResponse.from(rawResponse, { + url: request.url, + }) + + /** + * @note Clone the response before "respondWith" because it will + * consume its body. This way, we can have a readable response copy + * for the "response" event below. + */ + const responseClone = isResponseError(response) + ? null + : response.clone() + + const respond = () => { + return this.respondWith({ + socket: socketController[kRawSocket], + request: context.request, + response, + }) + } + + if (responseClone) { + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator, + requestId, + request: context.request, + response: responseClone, + responseType: 'mock', + }) + ) + } + + if (socket.connecting) { + // Send a mocked response once the socket connects, just like the real server would. + // This preserves the correct order of events (e.g. connect, then data). + socket.once('connect', respond) + } else { + /** + * @note Reused sockets stay connected between requests and will not + * emit "connect" anymore. If that's the case, respond immediately. + */ + await respond() + } + }, + errorWith: (reason) => { + if (reason instanceof Error) { + socket.destroy(reason) + } + }, + passthrough: () => { + const realSocket = socketController.passthrough( + this.#modifyHttpHeaders(context.request) + ) + + if (this.emitter.listenerCount('response') > 0) { + log('found "response" listener, pausing socket...') + + const mockSocket = socketController[kRawSocket] + + // Pause the mock socket to prevent the passthrough 'data' listener + // from pushing data to it. The passthrough checks isPaused() and skips + // pushing when paused, allowing realSocket to continue emitting data + // for our response parser without backpressure issues. + mockSocket.pause() + + const responseParser = new HttpResponseParser({ + onResponse: async (response) => { + log( + 'http response parser parsed a response!', + response.status, + response.statusText + ) + + if (isResponseError(response)) { + log( + 'response is an error response, resuming socket...' + ) + + mockSocket.resume() + return + } + + FetchResponse.setUrl(request.url, response) + + log('emitting "response" event...') + await this.emitter.emitAsPromise( + new HttpResponseEvent({ + initiator, + requestId, + request: context.request, + response, + responseType: 'original', + }) + ) + + log('resuming socket...') + mockSocket.resume() + }, + }) + + realSocket + .on('data', (chunk) => responseParser.execute(chunk)) + .on('close', () => responseParser.free()) + } + }, + }) + + invariant( + socketController['readyState'] === SocketController.PENDING, + 'CANNOT HANDLE ALREADY HANDLED REQUEST', + request.method, + request.url, + socketController['readyState'] + ) + + /** + * @note Create a request resolution context. + * This is so modifications to the "request" in upstream interceptors + * are correctly picked up by the underlying HTTP interceptor. + */ + const context: HandleRequestOptions = { + initiator, + requestId, + request, + controller: requestController, + emitter: this.emitter, + } + + await handleRequest(context) + }, + }) + + // Forward the first frame to the parser. + requestParser.execute(toBuffer(chunk)) + + // Forward subsequent socket writes to the parser. + socket + .on('data', (chunk) => { + if (chunk) { + requestParser.execute(toBuffer(chunk)) + } + }) + .on('close', () => requestParser.free()) + }) + }, + { + signal: controller.signal, + } + ) + } + + private async respondWith(args: { + socket: net.Socket + request: Request + response: Response + }): Promise { + const { socket, request, response } = args + + if (socket.destroyed) { + return + } + + if (isResponseError(response)) { + socket.destroy(new TypeError('Network error')) + return + } + + invariant( + !socket.connecting, + 'Failed to mock a response for "%s %s": socket has not connected', + request.method, + request.url + ) + + /** + * Use native server response handling in Node.js. + * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/_http_server.js#L202 + */ + const serverResponse = new ServerResponse(new IncomingMessage(socket)) + + const responseSocket = new net.Socket() + + responseSocket._writeGeneric = (writev, data, encoding, callback) => { + unwrapPendingData(data, (chunk, encoding) => { + socket.push(toBuffer(chunk), encoding) + }) + callback?.() + } + + responseSocket._destroy = ( + error: Error | null, + callback: (error: Error | null) => void + ) => { + /** + * Only destroy the socket on stream errors. + * On a clean end, the socket is already signaled via `socket.push(null)` + * in the main response flow. Destroying it here prematurely would prevent + * the client from processing the response (e.g. calling `response.destroy()`). + * @see https://github.com/mswjs/interceptors/issues/738 + */ + if (error) { + socket.destroy() + } + + callback(null) + } + + responseSocket.on('drain', () => serverResponse.emit('drain')) + serverResponse.assignSocket(responseSocket) + + serverResponse.removeHeader('connection') + serverResponse.removeHeader('date') + + const rawResponseHeaders = getRawFetchHeaders(response.headers) + serverResponse.writeHead( + response.status, + response.statusText || STATUS_CODES[response.status], + rawResponseHeaders + ) + + /** + * @note Override the socket's `_destroy` before writing the response body. + * The underlying TCP handle (from `socket.connect()`) makes `_destroy` async + * (`_handle.close()` callback), which delays the 'error' event. Since the real + * TCP connection is irrelevant for mocked responses, take the synchronous path + * so that user-initiated `response.destroy(error)` emits the error promptly. + * This must happen before `serverResponse.end()` because the HTTP parser may + * fire the 'response' event synchronously during `socket.push()`. + */ + socket._destroy = function ( + error: Error | null, + callback: (error: Error | null) => void + ) { + if (error) { + /** + * Emit the error event as a microtask instead of relying on the default + * `process.nextTick(emitErrorNT)` from `callback(error)`. This is necessary + * because `respondWith` runs inside a microtask (from `await reader.read()`). + * A resolved DeferredPromise continuation (from toWebResponse) is queued as + * another microtask during the same phase. Since microtasks are drained before + * nextTick, the test's `await` would resolve before the error event fires. + * Using `queueMicrotask` ensures the error event is emitted within the current + * microtask phase, before other queued microtasks. + */ + queueMicrotask(() => this.emit('error', error)) + } + + callback(null) + + /** + * @note `net.Socket` is constructed with `emitClose: false`, so Node's + * stream destroy machinery does not emit `'close'` automatically; the + * stock `net.Socket._destroy` only emits it via `_handle.close()`. + * Since this override replaces `_destroy`, emit `'close'` here so the + * mocked socket completes its lifecycle (otherwise consumers waiting + * on `'close'`, like `http.ClientRequest`, hang). + */ + process.nextTick(() => this.emit('close', error != null)) + } + + if (response.body) { + const reader = response.body.getReader() + + try { + while (true) { + const { done, value } = await reader.read() + + if (done) { + serverResponse.end() + break + } + + if (!serverResponse.write(value)) { + await new Promise((resolve) => { + serverResponse.once('drain', resolve) + }) + } + } + } catch { + /** + * @note Delay the socket destruction to allow the event loop + * to flush already-pushed response data (headers + body chunks) + * through the HTTP parser. Without this, the socket is destroyed + * on the same tick as `socket.push(data)` and the client never + * reads the response. + */ + await new Promise((resolve) => process.nextTick(resolve)) + socket.destroy() + return + } + } else { + serverResponse.end() + } + + if (request.method !== 'CONNECT') { + /** + * @note Defer the end-of-stream signal so the HTTP parser has a chance + * to process already-pushed response data and fire the 'response' event + * before the socket is ended. Without this, the parser marks the response + * as "complete" before the client can interact with it (e.g. `response.destroy()`). + */ + await new Promise((resolve) => process.nextTick(resolve)) + socket.push(null) + } + } + + #modifyHttpHeaders(request: Request): FlushPendingDataFunction { + const transformRequestMessage = ( + httpMessage: string | Buffer, + encoding?: BufferEncoding | 'buffer' + ): string | Buffer => { + /** + * @note Socket can write a buffer (e.g. uploaded file) even before + * it writes the HTTP message. Bypass those cases. + */ + if (encoding === 'buffer') { + return httpMessage + } + + const parts = httpMessage.toString(encoding).split('\r\n') + const headersEndIndex = parts.findIndex((field) => field === '') + const httpMessageHeaderPairs = parts.slice(1, headersEndIndex) + + // Extract raw [name, value] tuples from the wire format so they + // can be compared against the request's raw fetch headers. + const httpMessageRawHeaders = httpMessageHeaderPairs.map( + (line): [string, string] => { + const separatorIndex = line.indexOf(': ') + return [line.slice(0, separatorIndex), line.slice(separatorIndex + 2)] + } + ) + + const requestRawHeaders = getRawFetchHeaders(request.headers) + + // If the raw headers from the outgoing HTTP message and the request + // headers are identical, send the message as-is to avoid the cost + // (and side effects) of reserializing the headers block. + const headersUnchanged = + httpMessageRawHeaders.length === requestRawHeaders.length && + httpMessageRawHeaders.every((tuple, index) => { + const requestTuple = requestRawHeaders[index] + return tuple[0] === requestTuple[0] && tuple[1] === requestTuple[1] + }) + + if (headersUnchanged) { + return httpMessage + } + + const httpMessageHeaders = FetchResponse.parseRawHeaders( + httpMessageHeaderPairs.flatMap((header) => header.split(': ')) + ) + + const visitedHeaders = new Set() + + for (const [headerName] of requestRawHeaders) { + const normalizedHeaderName = headerName.toLowerCase() + + if (visitedHeaders.has(normalizedHeaderName)) { + continue + } + + visitedHeaders.add(normalizedHeaderName) + + /** + * @note Forbidden Fetch headers (e.g. Host, Origin, Connection) + * are stripped from `request.headers` but remain in the raw + * headers list. Skip them so the original values from the + * outgoing HTTP message are preserved. + */ + const headerValue = request.headers.get(headerName) + if (headerValue === null) { + continue + } + + // Use the merged value from Headers to correctly handle + // appended headers (e.g. "1, 2" instead of just "2"). + httpMessageHeaders.set(headerName, headerValue) + } + + visitedHeaders.clear() + + const httpMessageHeadersString = Array.from(httpMessageHeaders) + .map(([name, value]) => `${name}: ${value}`) + .join('\r\n') + parts.splice(1, headersEndIndex - 1, httpMessageHeadersString) + + return parts.join('\r\n') + } + + return (pendingData, encoding, callback) => { + if (Array.isArray(pendingData)) { + pendingData[0].chunk = transformRequestMessage( + pendingData[0].chunk, + pendingData[0].encoding + ) + } else { + pendingData = transformRequestMessage(pendingData, encoding) + } + + callback(pendingData) + } + } +} diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts new file mode 100644 index 000000000..bd2c8fc03 --- /dev/null +++ b/src/interceptors/net/index.ts @@ -0,0 +1,175 @@ +import net from 'node:net' +import tls from 'node:tls' +import { TypedEvent } from 'rettime' +import { + type NetworkConnectionOptions, + normalizeNetConnectArgs, +} from './utils/normalize-net-connect-args' +import { TcpSocketController, TlsSocketController } from './socket-controller' +import { normalizeTlsConnectArgs } from './utils/normalize-tls-connect-args' +import { createLogger } from '../../utils/logger' +import { patchesRegistry } from '../../utils/patchesRegistry' +import { Interceptor } from '#/src/interceptor' + +interface SocketConnectionEventData { + socket: net.Socket | tls.TLSSocket + connectionOptions: NetworkConnectionOptions + controller: TcpSocketController | TlsSocketController +} + +class SocketConnectionEvent< + DataType extends SocketConnectionEventData = SocketConnectionEventData, +> extends TypedEvent { + public socket: net.Socket | tls.TLSSocket + public connectionOptions: NetworkConnectionOptions + public controller: TcpSocketController | TlsSocketController + + constructor(data: DataType) { + super(...(['connection', {}] as any)) + + this.socket = data.socket + this.connectionOptions = data.connectionOptions + this.controller = data.controller + } +} + +type SocketEventMap = { + connection: SocketConnectionEvent +} + +const log = createLogger('SocketInterceptor') + +/** + * Interceptor for `net.Socket` connections. + */ +export class SocketInterceptor extends Interceptor { + static symbol = Symbol.for('socket-interceptor') + + protected predicate(): boolean { + return true + } + + protected setup(): void { + this.subscriptions.push( + patchesRegistry.applyPatch(net, 'connect', (realNetConnect) => { + return (...args: [any, any]) => { + log('net.connect()', args) + + const [connectionOptions, connectionCallback] = + normalizeNetConnectArgs(args) + + log({ connectionOptions, connectionCallback }) + + const socket = new net.Socket() + const controller = new TcpSocketController(socket, () => { + return realNetConnect(...args) + }) + + process.nextTick(() => { + if ( + !this.emitter.emit( + new SocketConnectionEvent({ + socket: controller.serverSocket, + controller, + connectionOptions, + }) + ) + ) { + log( + 'no "connection" listeners found on the interceptor, passthrough...' + ) + + controller.passthrough() + return + } + + log('emitted "connection" event!') + }) + + log('connecting the socket...') + + // Patch the lookup option so DNS lookup always succeeds. + // Passthrough connections are created with the original options and won't be affected. + connectionOptions.lookup = function mockLookup( + hostname, + dnsOptions, + callback + ) { + callback(null, [{ address: '127.0.0.1', family: 4 }]) + } + + return socket.connect(connectionOptions, connectionCallback) + } + }), + patchesRegistry.applyPatch(tls, 'connect', (realTlsConnect) => { + return (...args: [any, any]) => { + log('tls.connect()', args) + + const [tlsConnectionOptions, secureConnectionCallback] = + normalizeTlsConnectArgs(args) + + const tlsSocket = realTlsConnect( + { + ...tlsConnectionOptions, + /** + * Use a fake IP address to bypass DNS lookup. + * This ensures that "connectionAttempt" event fires even for non-existent hosts. + * Node.js skips DNS resolution when the host is an IP address, going directly to + * "internalConnect()" which emits "connectionAttempt". + * @see https://github.com/nodejs/node/blob/5babc8d5c91914ce0fb708e647c144570c671c50/lib/net.js + * + * @todo This will produce invalid "lookup" event on the socket, failing compliance. + */ + host: '127.0.0.1', + /** + * Suppress unauthorized connection errors to allow mocking connections to non-existing hosts. + * This prevents the "Error: Hostname/IP does not match certificate's altnames: Cert does not contain a DNS name" error. + * @note Passthrough scenarios will respect the original "rejectUnauthorized" option. + */ + rejectUnauthorized: false, + }, + secureConnectionCallback + ) + + const controller = new TlsSocketController(tlsSocket, () => { + return realTlsConnect(...args) + }) + + process.nextTick(() => { + if ( + !this.emitter.emit( + new SocketConnectionEvent({ + socket: controller.serverSocket, + controller, + connectionOptions: tlsConnectionOptions, + }) + ) + ) { + log( + 'no "connection" listeners found on the interceptor, passthrough...' + ) + + controller.passthrough() + return + } + + log('emitted the "connection" event!') + }) + + return tlsSocket + } + }) + ) + + /** + * @note `net.createConnection()` is an alias for `net.connect()`. + * But we still have to reassign it to point to the patched `net.connect()`. + */ + const { createConnection: realNetCreateConnection } = net + net.createConnection = net.connect + + this.subscriptions.push(() => { + net.createConnection = realNetCreateConnection + }) + } +} diff --git a/src/interceptors/net/socket-controller.ts b/src/interceptors/net/socket-controller.ts new file mode 100644 index 000000000..fddf0b063 --- /dev/null +++ b/src/interceptors/net/socket-controller.ts @@ -0,0 +1,724 @@ +import net from 'node:net' +import tls from 'node:tls' +import { invariant } from 'outvariant' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { toBuffer } from '../../utils/bufferUtils' +import { createLogger } from '../../utils/logger' +import { unwrapPendingData } from './utils/flush-writes' +import { NetworkConnectionOptions } from './utils/normalize-net-connect-args' +import { getAddressInfoByConnectionOptions } from './utils/address-info' + +const kListenerWrap = Symbol('kListenerWrap') +export const kRawSocket = Symbol('kRawSocket') + +const log = createLogger('SocketController') + +// Internally, Node.js represents the result of various operations +// by the number they return: 0 (error), 1 (success). +type OperationStatus = 0 | 1 + +declare module 'node:net' { + interface Socket { + _pendingData: + | string + | Buffer + | Array<{ + chunk: string | Buffer + encoding?: BufferEncoding + callback?: (error?: Error | null) => void + }> + | null + _pendingEncoding: BufferEncoding | '' + _writeGeneric( + writev: boolean, + data: NonNullable, + encoding: BufferEncoding, + callback?: (error?: Error | null) => void + ): void + _handle: TcpHandle + _start: () => void + } +} + +declare module 'node:tls' { + interface TLSSocket { + _handle: TcpHandle & { + start: () => void + onhandshakedone: () => void + onnewsession: (sessionId: unknown, session: Buffer) => void + getSession: () => Buffer + getServername: () => string + getCipher: () => { name: string; standardName: string; version: string } + verifyError: () => void + } + } +} + +export interface TcpHandle { + open: (fd: unknown) => OperationStatus + connect: (request: TcpWrap, address: string, port: number) => void + connect6: (request: TcpWrap, address: string, port: number) => void + listen: (backlog: number) => OperationStatus + onconnection?: () => void + getpeername?: () => OperationStatus + getsockname?: () => OperationStatus + reading: boolean + onread: () => void + readStart: () => void + readStop: () => void + bytesRead: number + bytesWritten: number + ref?: () => void + unref?: () => void + fchmod: (mode: number) => void + setBlocking: (blocking: boolean) => OperationStatus + setNoDelay?: (noDelay: boolean) => void + setKeepAlive?: (keepAlive: boolean, initialDelay: number) => void + setTypeOfService?: (tos: number) => OperationStatus + shutdown: (reqest: unknown /* ShutdownWrap */) => OperationStatus + close: () => void + + _parent?: TcpHandle +} + +export interface TcpWrap { + oncomplete: ( + status: OperationStatus, + owner: TcpHandle, + request: TcpWrap, + readable?: boolean, + writable?: boolean + ) => void +} + +/** + * Create a proxy `net.Socket` instance that represents the intercepted socket server-side. + * This is the reference exposed as `socket` in the connection listener. This proxy allows + * the user to interact with `socket` from the server's perspective (e.g. `socket.write()` + * on the server translates to the `socket.push()` on the client). + */ +function toServerSocket(socket: T): T { + return new Proxy(socket, { + get: (target, property, receiver) => { + const getRealValue = () => { + return Reflect.get(target, property, receiver) + } + + if (property === 'on' || property === 'addListener') { + const realAddListener = getRealValue() as net.Socket['addListener'] + + return (event: any, listener: (...args: Array) => void) => { + if (event === 'data') { + const listenerWrap = (chunk: any, encoding?: BufferEncoding) => { + listener(toBuffer(chunk, encoding)) + } + + Object.defineProperty(listener, kListenerWrap, { + enumerable: false, + writable: false, + value: listenerWrap, + }) + + socket.on('internal:write', listenerWrap) + + return target + } + + return realAddListener.call(target, event, listener) + } + } + + if (property === 'off' || property === 'removeListener') { + const realRemoveListener = + getRealValue() as net.Socket['removeListener'] + + return (event: string, listener: any) => { + if (event === 'data') { + const listenerWrap = listener[kListenerWrap] + + if (listenerWrap) { + return realRemoveListener.call(target, event, listenerWrap) + } + } + + return realRemoveListener.call(target, event, listener) + } + } + + // Push data to the client socket when server "socket.write()" is called. + if (property === 'write') { + return ((chunk, encoding, callback) => { + const translateWrite = () => { + socket.push(toBuffer(chunk, encoding), encoding) + callback?.() + } + + if (socket.connecting) { + socket.once('ready', () => translateWrite()) + } else { + translateWrite() + } + }) as net.Socket['write'] + } + + // Translate server-side "socket.end()" to client-sode "socket.push(null)". + if (property === 'end') { + const realEnd = getRealValue() as net.Socket['end'] + + return ((...args: Parameters) => { + const callback = args[args.length - 1] + + const translateEnd = () => { + socket.push(null) + + if (typeof callback === 'function') { + callback() + } + } + + if (socket.connecting) { + socket.once('ready', () => translateEnd()) + } else { + translateEnd() + } + + return realEnd.apply(target, args) + }) as net.Socket['end'] + } + + return getRealValue() + }, + }) +} + +export abstract class SocketController { + static PENDING = 0 as const + static CLAIMED = 1 as const + static PASSTHROUGH = 2 as const + + protected readyState: + | typeof SocketController.PENDING + | typeof SocketController.CLAIMED + | typeof SocketController.PASSTHROUGH + + private [kRawSocket]: net.Socket + + constructor(socket: net.Socket) { + this[kRawSocket] = socket + this.readyState = SocketController.PENDING + } + + /** + * Claim this socket. Once claimed, the connection attempt succeeds + * regardless of the requested host and the interceptor becomes the + * mocked server for this connection. + */ + public claim(): void { + invariant( + this.readyState === SocketController.PENDING, + 'Failed to claim a socket connection: already handled (%s)', + this.readyState + ) + + this.readyState = SocketController.CLAIMED + } + + public passthrough(): void { + invariant( + this.readyState === SocketController.PENDING, + 'Failed to passthrough a socket connection: already handled (%s)', + this.readyState + ) + + this.readyState = SocketController.PASSTHROUGH + } +} + +export type FlushPendingDataFunction = ( + data: NonNullable, + encoding: BufferEncoding | undefined, + callback: (data: NonNullable) => void +) => void + +export class TcpSocketController extends SocketController { + public serverSocket: net.Socket + + protected pendingConnection: DeferredPromise<[TcpWrap, TcpHandle]> + + #connectionOptions?: NetworkConnectionOptions + #realWriteGeneric: net.Socket['_writeGeneric'] + #passthroughSocket: net.Socket | null = null + #bufferedWrites: Array> = [] + + constructor( + protected readonly socket: net.Socket, + protected readonly createConnection: () => net.Socket + ) { + super(socket) + + // Implement the read method to prevent the "Error: read ENOTCONN" errors on non-existing hosts. + this.socket._read = () => {} + + // Store the unpatched write method once so we have access to it between socket state resets. + this.#realWriteGeneric = this.socket._writeGeneric + this.#bufferedWrites = [] + + this.socket.connect = new Proxy(this.socket.connect, { + apply: (target, thisArg, args) => { + log('socket.connect()', args) + + this.#connectionOptions = args[0] + return Reflect.apply(target, thisArg, args) + }, + }) + + /** + * @note A single socket can be reused for connections to the same host. + * When one connection ends, the Agent frees the socket, then uses it + * to write the next request's HTTP message immediately. Use the "free" + * event to transition the controller into the pending state so "_writeGeneric" + * would behave correctly. + */ + socket + .on('free', () => { + log('client socket freed!') + this.#reset() + }) + .on('close', () => { + log('client socket closed!') + this.#passthroughSocket = null + this.#bufferedWrites = [] + }) + + this.serverSocket = toServerSocket(this.socket) + + this.pendingConnection = new DeferredPromise() + this.#reset() + } + + #reset(): void { + log('resetting the socket...') + + this.readyState = SocketController.PENDING + this.pendingConnection = new DeferredPromise() + this.#bufferedWrites = [] + + const wrapHandle = (handle: TcpHandle) => { + this.pendingConnection.then(() => { + log('connection request resolved!', this.readyState) + + process.nextTick(() => { + /** + * @note If by this point the socket hasn't been handled, + * is still connecting, doesn't have any writes buffered, + * and has a "connect" listener, assume it's the "write after connect" + * scenario (e.g. undici). In that case, auto-claim the socket to + * transition to the connected state appropriately to its handle. + */ + if ( + this.readyState === SocketController.PENDING && + this.socket.connecting && + this.#bufferedWrites.length === 0 && + this.socket.listenerCount('connect') > 0 + ) { + log('assume connect->write socket, calling "connect" listeners...') + this.emulateConnect() + } + }) + }) + + /** + * Remove the "setTypeOfService" from the handle, if present (Node.js v24+). + * Removing it has no effect on the socket but prevents the "setTypeOfService EBADF" error. + * @see https://github.com/nodejs/node/blob/69a970f76814d40f55cf162d0cc3632fe8a7e599/lib/net.js#L661 + * @see https://github.com/nodejs/undici/blob/bf684f7de01616708a33a5d1c092177622394442/lib/dispatcher/client-h1.js#L1136 + */ + if (handle.setTypeOfService) { + handle.setTypeOfService = undefined + } + + handle.connect = handle.connect6 = (request) => { + log('handle.connect()') + this.pendingConnection.resolve([request, handle]) + } + + log('socket handle wrapped! waiting for connection request...') + } + + if (this.socket._handle) { + wrapHandle(this.socket._handle) + } else { + this.socket.prependOnceListener('connectionAttempt', () => { + wrapHandle(this.socket._handle) + }) + } + + this.socket._writeGeneric = (...args) => { + const data = args[1] + const callback = args[3] + + log(this.readyState, 'write:', args) + + /** + * @note Buffer the write BEFORE pushing data to the server socket. + * `#push` triggers the 'data' event on the server socket synchronously, + * which may lead to `passthrough()` being called within the same call stack. + * If we buffer after `#push`, passthrough will read an empty `#bufferedWrites` + * and the request data will never be flushed to the real socket. + */ + this.#bufferedWrites.push(args) + + // The server socket will NEVER have any "data" listeners attached + // becuase the "connection" interceptor event emits on the next tick. + if (this.socket.listenerCount('internal:write') === 0) { + log('no server data listeners, scheduling to the next tick...') + + process.nextTick(() => { + log( + this.readyState, + '(scheduled) forwarding write to server socket...', + data + ) + this.#push(data) + }) + } else { + log(this.readyState, 'found server data listeners, pushing...') + this.#push(data) + } + + /** + * @note Only skip the callback if the socket transitioned to PASSTHROUGH. + * In the passthrough case, `#push` triggered `passthrough()` synchronously + * and the buffered write was already flushed to the real socket with the + * original callback. Calling it again would result in "Callback called + * multiple times" error. + * + * For CLAIMED state, `#push` may have triggered `claim()` synchronously + * (e.g. the handler responded with a mocked response). In that case, + * the callback was NOT flushed anywhere and must still be called here + * so the socket's writable state completes properly (enabling "finish"). + */ + if ( + typeof callback === 'function' && + this.readyState !== SocketController.PASSTHROUGH + ) { + callback() + args[3] = function mockNoop() {} + } + } + } + + protected emulateConnect() { + for (const listener of this.socket.listeners('connect')) { + listener.apply(this.socket) + } + } + + /** + * Push the given data to the server socket. + * This has no effect on the public-facing socket and is used + * only for the interceptors to subscribe to "socket.on('data')" + * before the data is actually written anywhere. + */ + #push = (data: net.Socket['_pendingData']) => { + if (data == null) { + return + } + + log('(server) push:', data) + + unwrapPendingData(data, (chunk, encoding) => { + log('(server) emitting "data"...', { chunk, encoding }) + + this.socket.emit('internal:write', chunk, encoding) + }) + } + + #onRealSocketConnect = () => { + if (!this.#passthroughSocket) { + return + } + + this.socket._handle = this.#passthroughSocket._handle + + Reflect.set(this.socket, 'connecting', false) + this.socket.emit('connect') + this.socket.emit('ready') + } + + #onRealSocketData = (data: Buffer) => { + log('real socket "data" event:\n', data?.toString()) + + if (!this.socket.push(data)) { + log( + 'client socket forbade more pushes, pausing the passthrough socket...' + ) + this.#passthroughSocket?.pause() + } + } + + #onRealSocketError = (error: Error) => { + log('real socket "error" event:\n', error) + + if (this.socket.destroyed) { + log( + 'real socket errored but client socket already destroyed, skipping...' + ) + return + } + + log('real socket errored, forwarding...', error) + + this.socket.destroy(error) + + // The handle swap in passthrough (this.socket._handle = realSocket._handle) + // breaks Node's internal close machinery—destroy() emits "error" but never + // emits "close". Consumers like Undici wait for "close" to finalize the + // request, so we must emit it manually. + process.nextTick(() => this.socket.emit('close', true)) + } + + #onRealSocketEnd = () => { + this.socket.push(null) + } + + #onRealSocketClose = (hadError: boolean) => { + this.socket.emit('close', hadError) + } + + #onMockSocketDrain = () => { + log('client socket drained!') + this.#passthroughSocket?.resume() + } + + public claim(): void { + super.claim() + + if (!this.socket.connecting) { + log('socket already connected, skipping claim...') + return + } + + log('-> claim!') + + /** + * Patch the "getsockname" on the handle in case Node.js decides to handle its errors. + * Run this if the socket is connecting because "_handle" can be null if socket timed out. + * @see https://github.com/nodejs/node/blob/13eb80f3b718452213e0fc449702aefbbfe4110f/lib/net.js#L971 + */ + this.socket._handle.getsockname = () => 0 + this.socket.address = () => { + return getAddressInfoByConnectionOptions(this.#connectionOptions) + } + + this.#bufferedWrites = [] + + /** + * @note Once claimed, there's nowhere to write chunks to. + * Just forward the writes to the server socket and invoke callbacks. + * Attempting to write past this point will result in the "Error: write EBADF". + */ + this.socket._writeGeneric = (...args) => { + log(this.readyState, 'write:', args) + + const data = args[1] + const callback = args[3] + + this.#push(data) + + if (typeof callback === 'function') { + log(this.readyState, 'invoking callback for write:', data, callback) + callback() + } + } + + this.pendingConnection.then(([request, handle]) => { + log('connection request resolved, mocking the connection...') + + /** + * @see https://github.com/nodejs/node/blob/9cd6630870b776e96c5cf0ac68c31e2f46df3835/lib/net.js#L1142 + */ + request.oncomplete(0, handle, request, true, true) + }) + } + + public passthrough(flushPendingData?: FlushPendingDataFunction): net.Socket { + super.passthrough() + + log('-> passthrough!') + + const createRealSocket = () => { + const realSocket = this.createConnection() + + if (this.socket.timeout != null) { + realSocket.setTimeout(this.socket.timeout) + } + + return realSocket + } + + // If keepalive, reuse the existing real socket. + const realSocket = + this.#passthroughSocket && !this.#passthroughSocket.destroyed + ? this.#passthroughSocket + : createRealSocket() + + if (realSocket !== this.#passthroughSocket) { + this.#passthroughSocket = realSocket + } + + if (this.#bufferedWrites.length === 0) { + log( + this.readyState, + 'WARNING: passthrough with empty writes buffer! This likely indicates an issue.' + ) + } + + /** + * Flush any writes during the pending phase to the passthrough socket. + * @note These are written directly on the passthrough socket to prevent + * them from being forwarded as "data" events on the server (already emitted). + */ + for (let i = 0; i < this.#bufferedWrites.length; i++) { + const pendingWrite = this.#bufferedWrites[i] + + if (i === 0 && typeof flushPendingData === 'function') { + const data = pendingWrite[1] + const encoding = pendingWrite[2] + flushPendingData(data, encoding, (nextData) => { + pendingWrite[1] = nextData + }) + } + + realSocket._writeGeneric.apply(realSocket, pendingWrite) + } + + this.#bufferedWrites = [] + this.socket._pendingData = null + this.socket._pendingEncoding = '' + + this.socket._writeGeneric = (...args) => { + log(this.readyState, 'write:', args) + + this.#push(args[1]) + return this.#realWriteGeneric.apply(this.socket, args) + } + + this.socket.address = realSocket.address.bind(realSocket) + + this.socket.removeListener('drain', this.#onMockSocketDrain) + this.socket.on('drain', this.#onMockSocketDrain) + + realSocket + .removeListener('connect', this.#onRealSocketConnect) + .removeListener('data', this.#onRealSocketData) + .removeListener('error', this.#onRealSocketError) + .removeListener('end', this.#onRealSocketEnd) + .removeListener('close', this.#onRealSocketClose) + + realSocket + .once('connect', this.#onRealSocketConnect) + .on('data', this.#onRealSocketData) + .on('error', this.#onRealSocketError) + .on('end', this.#onRealSocketEnd) + .on('close', this.#onRealSocketClose) + + return realSocket + } +} + +export class TlsSocketController extends TcpSocketController { + constructor( + protected readonly socket: tls.TLSSocket, + protected readonly createConnection: () => tls.TLSSocket + ) { + super(socket, createConnection) + } + + protected emulateConnect(): void { + super.emulateConnect() + + // For TLS sockets, also invoke the "secureConnect" callbacks since some consumers, + // like Undici, listen to those to start writing to the socket. + for (const listener of this.socket.listeners('secureConnect')) { + listener.apply(this.socket) + } + } + + public claim(): void { + // Run this logic before the parent's class method so it executes first. + // TLSWrap methods have to be patched before TCPWrap fires "oncomplete". + const handle = this.socket._handle + + handle.start = () => void 0 + + /** + * Mock this to prevent the "Error: Worker exited unexpectedly" error. + * This will trigger when "secure" is emitted. + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1648 + */ + handle.verifyError = () => void 0 + + handle.getSession = () => { + return Buffer.from('mocked session') + } + + handle.getCipher = () => { + return { + name: 'TLS_AES_256_GCM_SHA384', + standardName: 'TLS_AES_256_GCM_SHA384', + version: 'TLSv1.3', + } + } + + this.socket.once('connect', () => { + handle.onhandshakedone() + handle.onnewsession(1, Buffer.alloc(0)) + }) + + super.claim() + } + + public passthrough( + flushPendingData?: FlushPendingDataFunction + ): tls.TLSSocket { + const realSocket = super.passthrough(flushPendingData) as tls.TLSSocket + + /** + * @note Remove the internal "connect" listener added by the TLS socket. + * Normally, that listener manages the SSL handshake. But since we're in passthrough, + * we delegate that to the real socket. Leaving the listener on the mock socket while + * inheriting the real socket's handle will result in the handshake performed twice, which is a no-op. + * @see https://github.com/nodejs/node/blob/abddfc921bf2af02a04a6a5d2bca8e2d91d80958/lib/internal/tls/wrap.js#L1105 + * + * This prevents the following error: + * # node (vitest 4)[8686]: static void node::crypto::TLSWrap::Start(const FunctionCallbackInfo &) at ../src/crypto/crypto_tls.cc:589 + # Assertion failed: !wrap->started_ + */ + for (const connectListener of this.socket.listeners('connect')) { + if ( + connectListener === this.socket._start || + ('listener' in connectListener && + connectListener.listener === this.socket._start) + ) { + this.socket.removeListener('connect', connectListener as () => void) + } + } + + realSocket + .on('secure', () => { + this.socket.emit('secure') + }) + .on('session', (...args) => { + this.socket.emit('session', ...args) + }) + .on('keylog', (...args) => { + this.socket.emit('keylog', ...args) + }) + .on('OCSPResponse', (...args) => { + this.socket.emit('OCSPResponse', ...args) + }) + + return realSocket + } +} diff --git a/src/interceptors/net/utils/address-info.ts b/src/interceptors/net/utils/address-info.ts new file mode 100644 index 000000000..d3bfabfca --- /dev/null +++ b/src/interceptors/net/utils/address-info.ts @@ -0,0 +1,23 @@ +import net from 'node:net' +import { NetworkConnectionOptions } from './normalize-net-connect-args' + +export function getAddressInfoByConnectionOptions( + options?: NetworkConnectionOptions +): ReturnType { + if (options == null) { + return {} + } + + const isIPv6 = options.family === 6 || net.isIPv6(options.host || '') + const ipAddress = options.host + ? net.isIP(options.host) !== 0 + ? options.host + : null + : null + + return { + address: ipAddress || isIPv6 ? '::1' : '127.0.0.1', + port: options.port || (options.protocol === 'https:' ? 443 : 80), + family: isIPv6 ? 'IPv6' : 'IPv4', + } +} diff --git a/src/interceptors/net/utils/connection-options-to-url.ts b/src/interceptors/net/utils/connection-options-to-url.ts new file mode 100644 index 000000000..ffc4c395d --- /dev/null +++ b/src/interceptors/net/utils/connection-options-to-url.ts @@ -0,0 +1,56 @@ +import net from 'node:net' +import tls from 'node:tls' +import { NetworkConnectionOptions } from './normalize-net-connect-args' + +/** + * Creates a `URL` instance out of the `net.connect()` options. + * @note This implies that the passed connection is an HTTP connection. + */ +export function connectionOptionsToUrl( + options: NetworkConnectionOptions, + socket: net.Socket +): URL { + const isIPv6 = options.family === 6 || net.isIPv6(options.host || '') + const protocol = + socket instanceof tls.TLSSocket + ? 'https:' + : getProtocolByConnectionOptions(options) + const host = options.host || 'localhost' + + const url = new URL(`${protocol}//${isIPv6 ? `[${host}]` : host}`) + + if (options.path) { + url.pathname = options.path + } + + if (options.port) { + url.port = options.port.toString() + } + + if (options.auth) { + const [username, password] = options.auth.split(':') + /** + * Authentication options are provided as plain values. + * Encode them to form a valid URL. + * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/url.js#L1452 + */ + url.username = encodeURIComponent(username) + url.password = encodeURIComponent(password) + } + + return url +} + +function getProtocolByConnectionOptions( + options: NetworkConnectionOptions +): 'https:' | 'http:' | (string & {}) { + if (options.protocol) { + return options.protocol + } + + if (options.port === 443) { + return 'https:' + } + + return 'http:' +} diff --git a/src/interceptors/net/utils/flush-writes.ts b/src/interceptors/net/utils/flush-writes.ts new file mode 100644 index 000000000..b9ae20dfc --- /dev/null +++ b/src/interceptors/net/utils/flush-writes.ts @@ -0,0 +1,18 @@ +import net from 'node:net' + +export function unwrapPendingData( + data: NonNullable, + callback: ( + chunk: string | Buffer, + encoding?: BufferEncoding, + callback?: (error?: Error | null) => void + ) => void +) { + if (Array.isArray(data)) { + for (const entry of data) { + callback(entry.chunk, entry.encoding, entry.callback) + } + } else { + callback(data) + } +} diff --git a/src/interceptors/net/utils/normalize-net-connect-args.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts new file mode 100644 index 000000000..542756a0f --- /dev/null +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -0,0 +1,94 @@ +import net from 'node:net' +import url from 'node:url' + +export interface NetworkConnectionOptions { + port?: number | null + path: string | null + host?: string | null + protocol?: string | null + auth?: string | null + family?: number | null + session?: Buffer + localAddress?: string | null + localPort?: number | null + timeout?: number + lookup?: net.LookupFunction +} + +export type NetConnectArgs = + | [] + | [options: net.NetConnectOpts, callback?: () => void] + | [url: URL, callback?: () => void] + | [port: number, host?: string, callback?: () => void] + | [path: string, callback?: () => void] + +export type NormalizedNetConnectArgs = [ + options: NetworkConnectionOptions, + callback: (() => void) | null, +] + +/** + * Normalizes the arguments passed to `net.connect()`. + */ +export function normalizeNetConnectArgs( + args: NetConnectArgs +): NormalizedNetConnectArgs { + if (args.length === 0) { + return [{ path: '' }, null] + } + + const callback = typeof args[1] === 'function' ? args[1] : args[2] || null + + if (typeof args[0] === 'string') { + return [{ path: args[0] }, callback] + } + + if (typeof args[0] === 'number' && typeof args[1] === 'string') { + return [{ port: args[0], path: '', host: args[1] }, callback] + } + + if (typeof args[0] === 'object') { + if ('href' in args[0]) { + const options = url.urlToHttpOptions(args[0]) + + return [ + { + protocol: args[0].protocol, + path: options.path || '', + port: +args[0].port, + host: options.hostname, + auth: options.auth, + }, + callback, + ] + } + + if ('port' in args[0]) { + return [ + { + path: '', + port: args[0].port, + host: args[0].host, + auth: Reflect.get(args[0], 'auth'), + family: args[0].family, + session: Reflect.get(args[0], 'session'), + localAddress: args[0].localAddress, + localPort: args[0].localPort, + }, + callback, + ] + } + + return [ + { + path: args[0].path || '', + family: Reflect.get(args[0], 'family'), + session: Reflect.get(args[0], 'session'), + auth: Reflect.get(args[0], 'auth'), + }, + callback, + ] + } + + throw new Error(`Invalid arguments passed to net.connect: ${args}`) +} diff --git a/src/interceptors/net/utils/normalize-tls-connect-args.ts b/src/interceptors/net/utils/normalize-tls-connect-args.ts new file mode 100644 index 000000000..5f6ff7f39 --- /dev/null +++ b/src/interceptors/net/utils/normalize-tls-connect-args.ts @@ -0,0 +1,46 @@ +import tls from 'node:tls' +import { + type NetConnectArgs, + normalizeNetConnectArgs, +} from './normalize-net-connect-args' + +type TlsConnectArgs = + | [] + | [options: tls.ConnectionOptions, callback?: () => void] + | [url: URL, callback?: () => void] + | [port: number, options?: tls.ConnectionOptions, callback?: () => void] + | [ + port: number, + host?: string, + options?: tls.ConnectionOptions, + callback?: () => void, + ] + +type NormalizedTlsConnectionArgs = [ + options: tls.ConnectionOptions, + callback?: () => void, +] + +export function normalizeTlsConnectArgs( + args: TlsConnectArgs +): NormalizedTlsConnectionArgs { + /** + * @note Despite incorrect type definitions, "tls.connect()" has all the + * options of "net.connect()" and then those specific to TLS connections, + * like "session" or "socket". + * @see https://github.com/nodejs/node/blob/bdc8131fa78089b81b74dbff467365afb6536e6a/lib/internal/tls/wrap.js#L1615 + */ + const netConnectArgs = normalizeNetConnectArgs(args as NetConnectArgs) + const options = netConnectArgs[0] as tls.ConnectionOptions + const callback = netConnectArgs[1] + + if (args[0] !== null && typeof args[0] === 'object') { + Object.assign(options, args[0]) + } else if (args[1] !== null && typeof args[1] === 'object') { + Object.assign(options, args[1]) + } else if (args[2] !== null && typeof args[2] === 'object') { + Object.assign(options, args[2]) + } + + return callback ? [options, callback] : [options] +} diff --git a/src/presets/browser.ts b/src/presets/browser.ts index c609adc5e..26827a086 100644 --- a/src/presets/browser.ts +++ b/src/presets/browser.ts @@ -1,9 +1,9 @@ -import { FetchInterceptor } from '../interceptors/fetch' -import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest' +import { FetchInterceptor } from '../interceptors/fetch/web' +import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest/web' /** - * The default preset provisions the interception of requests - * regardless of their type (fetch/XMLHttpRequest). + * A browser preset for the request interception regardless + * of their initiator (fetch, XMLHttpRequest). */ export default [ new FetchInterceptor(), diff --git a/src/presets/node.ts b/src/presets/node.ts index 63fbe0f45..6eda8ec61 100644 --- a/src/presets/node.ts +++ b/src/presets/node.ts @@ -1,10 +1,10 @@ import { ClientRequestInterceptor } from '../interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest' -import { FetchInterceptor } from '../interceptors/fetch' +import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest/node' +import { FetchInterceptor } from '../interceptors/fetch/node' /** - * The default preset provisions the interception of requests - * regardless of their type (http/https/XMLHttpRequest). + * A Node.js preset for the request interception regardless + * of their initiator (http, fetch, XMLHttpRequest). */ export default [ new ClientRequestInterceptor(), diff --git a/src/request-context.ts b/src/request-context.ts new file mode 100644 index 000000000..0af639074 --- /dev/null +++ b/src/request-context.ts @@ -0,0 +1,23 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +interface RequestContext { + initiator: unknown +} + +export const requestContext = new AsyncLocalStorage() + +export function runInRequestContext(callback: () => T): T { + const parentInitiator = requestContext.getStore()?.initiator + + if (parentInitiator) { + return callback() + } + + const context: RequestContext = { initiator: 'TEMP' } + + return requestContext.run(context, () => { + const initiator = callback() + context.initiator = initiator + return initiator + }) +} diff --git a/src/utils/bufferUtils.test.ts b/src/utils/bufferUtils.test.ts index 7c5545561..519ccd380 100644 --- a/src/utils/bufferUtils.test.ts +++ b/src/utils/bufferUtils.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { decodeBuffer, encodeBuffer } from './bufferUtils' it('encodes utf-8 string', () => { diff --git a/src/utils/bufferUtils.ts b/src/utils/bufferUtils.ts index b4b1c3784..20b19afb1 100644 --- a/src/utils/bufferUtils.ts +++ b/src/utils/bufferUtils.ts @@ -20,3 +20,18 @@ export function toArrayBuffer(array: Uint8Array): ArrayBuffer { array.byteOffset + array.byteLength ) } + +export function toBuffer( + data: string | Buffer | Uint8Array, + encoding?: BufferEncoding +): Buffer { + if (Buffer.isBuffer(data)) { + return data + } + + if (data instanceof Uint8Array) { + return Buffer.from(data.buffer) + } + + return Buffer.from(data, encoding) +} diff --git a/src/utils/cloneObject.test.ts b/src/utils/cloneObject.test.ts index 85a1dbbb3..c75a71470 100644 --- a/src/utils/cloneObject.test.ts +++ b/src/utils/cloneObject.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { cloneObject } from './cloneObject' it('clones a shallow object', () => { diff --git a/src/utils/createProxy.test.ts b/src/utils/createProxy.test.ts index acbd165f5..70cefc6b3 100644 --- a/src/utils/createProxy.test.ts +++ b/src/utils/createProxy.test.ts @@ -1,4 +1,3 @@ -import { vi, it, expect } from 'vitest' import { createProxy } from './createProxy' it('does not interfere with default constructors', () => { @@ -86,7 +85,10 @@ it('infer prototype descriptors', () => { it('spies on the constructor', () => { const OriginalClass = class { - constructor(public name: string, public age: number) {} + constructor( + public name: string, + public age: number + ) {} } const constructorCall = vi.fn< diff --git a/src/utils/emitAsync.ts b/src/utils/emitAsync.ts deleted file mode 100644 index 2edf1ceeb..000000000 --- a/src/utils/emitAsync.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Emitter, EventMap } from 'strict-event-emitter' - -/** - * Emits an event on the given emitter but executes - * the listeners sequentially. This accounts for asynchronous - * listeners (e.g. those having "sleep" and handling the request). - */ -export async function emitAsync< - Events extends EventMap, - EventName extends keyof Events ->( - emitter: Emitter, - eventName: EventName, - ...data: Events[EventName] -): Promise { - const listeners = emitter.listeners(eventName) - - if (listeners.length === 0) { - return - } - - for (const listener of listeners) { - await listener.apply(emitter, data) - } -} diff --git a/src/utils/fetchUtils.ts b/src/utils/fetchUtils.ts index 27e127f51..7c432df6f 100644 --- a/src/utils/fetchUtils.ts +++ b/src/utils/fetchUtils.ts @@ -1,5 +1,7 @@ +import { copyRawHeaders } from '../interceptors/ClientRequest/utils/record-raw-headers' import { canParseUrl } from './canParseUrl' import { getValueBySymbol } from './getValueBySymbol' +import { isResponseError } from './responseUtils' interface UndiciRequestState extends RequestInit {} @@ -164,6 +166,27 @@ const kStatus = Symbol('kStatus') const kUrl = Symbol('kUrl') export class FetchResponse extends Response { + static from(response: Response, init?: FetchResponseInit): FetchResponse { + if (response instanceof FetchResponse) { + return response + } + + if (isResponseError(response)) { + return response + } + + const fetchResponse = new FetchResponse(response.body, { + url: init?.url ?? response.url, + status: init?.status || response.status, + statusText: init?.statusText ?? response.statusText, + headers: init?.headers ?? response.headers, + }) + + copyRawHeaders(response.headers, fetchResponse.headers) + + return fetchResponse + } + /** * Response status codes for responses that cannot have body. * @see https://fetch.spec.whatwg.org/#statuses @@ -283,6 +306,9 @@ export class FetchResponse extends Response { } } + #status?: number + #url?: string + constructor(body?: BodyInit | null, init: FetchResponseInit = {}) { const status = init.status ?? 200 const safeStatus = FetchResponse.isConfigurableStatusCode(status) diff --git a/src/utils/findPropertySource.test.ts b/src/utils/findPropertySource.test.ts index cce02ca9e..faac766ea 100644 --- a/src/utils/findPropertySource.test.ts +++ b/src/utils/findPropertySource.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { findPropertySource } from './findPropertySource' it('returns the source for objects without prototypes', () => { diff --git a/src/utils/getCleanUrl.test.ts b/src/utils/getCleanUrl.test.ts index 9f70ca81d..defdcb066 100644 --- a/src/utils/getCleanUrl.test.ts +++ b/src/utils/getCleanUrl.test.ts @@ -1,4 +1,3 @@ -import { describe, it, expect } from 'vitest' import { getCleanUrl } from './getCleanUrl' describe('getCleanUrl', () => { diff --git a/src/utils/getUrlByRequestOptions.test.ts b/src/utils/getUrlByRequestOptions.test.ts index cc9423609..cfbde182d 100644 --- a/src/utils/getUrlByRequestOptions.test.ts +++ b/src/utils/getUrlByRequestOptions.test.ts @@ -1,6 +1,5 @@ -import { it, expect } from 'vitest' -import { Agent as HttpAgent } from 'http' -import { RequestOptions, Agent as HttpsAgent } from 'https' +import { Agent as HttpAgent } from 'node:http' +import { RequestOptions, Agent as HttpsAgent } from 'node:https' import { getUrlByRequestOptions } from './getUrlByRequestOptions' it('returns a URL based on the basic RequestOptions', () => { @@ -130,9 +129,7 @@ it('use "hostname" if both "hostname" and "host" are specified', () => { path: '/resource', } - expect(getUrlByRequestOptions(options).href).toBe( - 'https://hostname/resource' - ) + expect(getUrlByRequestOptions(options).href).toBe('https://hostname/resource') }) it('parses "host" in IPv6', () => { @@ -149,7 +146,6 @@ it('parses "host" in IPv6', () => { path: '/resource', }).href ).toBe('http://[::1]/resource') - }) it('parses "host" and "port" in IPv6', () => { diff --git a/src/utils/getUrlByRequestOptions.ts b/src/utils/getUrlByRequestOptions.ts index 0b8a5ef76..79309dfcb 100644 --- a/src/utils/getUrlByRequestOptions.ts +++ b/src/utils/getUrlByRequestOptions.ts @@ -1,5 +1,5 @@ -import { Agent } from 'http' -import { RequestOptions, Agent as HttpsAgent } from 'https' +import { Agent } from 'node:http' +import { RequestOptions, Agent as HttpsAgent } from 'node:https' import { Logger } from '@open-draft/logger' const logger = new Logger('utils getUrlByRequestOptions') @@ -13,7 +13,7 @@ export interface RequestSelf { export type ResolvedRequestOptions = RequestOptions & RequestSelf -export const DEFAULT_PATH = '/' +const DEFAULT_PATH = '/' const DEFAULT_PROTOCOL = 'http:' const DEFAULT_HOSTNAME = 'localhost' const SSL_PORT = 443 @@ -94,7 +94,7 @@ function getHostname(options: ResolvedRequestOptions): string | undefined { if (host) { if (isRawIPv6Address(host)) { - host = `[${host}]` + host = `[${host}]` } // Check the presence of the port, and if it's present, diff --git a/src/utils/getValueBySymbol.test.ts b/src/utils/getValueBySymbol.test.ts index 9e2b21c8c..edf890363 100644 --- a/src/utils/getValueBySymbol.test.ts +++ b/src/utils/getValueBySymbol.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { getValueBySymbol } from './getValueBySymbol' it('returns undefined given a non-existing symbol', () => { diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 4ef6fcd5a..ea6eae150 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -1,8 +1,12 @@ -import type { Emitter } from 'strict-event-emitter' +import type { Emitter } from 'rettime' import { DeferredPromise } from '@open-draft/deferred-promise' import { until } from '@open-draft/until' -import type { HttpRequestEventMap } from '../glossary' -import { emitAsync } from './emitAsync' +import { + HttpRequestEvent, + HttpRequestEventData, + UnhandledHttpException, + type HttpRequestEventMap, +} from '../events/http' import { RequestController } from '../RequestController' import { createServerErrorResponse, @@ -13,7 +17,8 @@ import { InterceptorError } from '../InterceptorError' import { isNodeLikeError } from './isNodeLikeError' import { isObject } from './isObject' -interface HandleRequestOptions { +export interface HandleRequestOptions { + initiator: unknown requestId: string request: Request emitter: Emitter @@ -77,18 +82,6 @@ export async function handleRequest( return false } - // Add the last "request" listener to check if the request - // has been handled in any way. If it hasn't, resolve the - // response promise with undefined. - // options.emitter.once('request', async ({ requestId: pendingRequestId }) => { - // if ( - // pendingRequestId === options.requestId && - // options.controller.readyState === RequestController.PENDING - // ) { - // await options.controller.passthrough() - // } - // }) - const requestAbortPromise = new DeferredPromise() /** @@ -114,11 +107,14 @@ export async function handleRequest( // for that event are finished (e.g. async listeners awaited). // By the end of this promise, the developer cannot affect the // request anymore. - const requestListenersPromise = emitAsync(options.emitter, 'request', { + const requestEventData: HttpRequestEventData = { + initiator: options.initiator, requestId: options.requestId, request: options.request, controller: options.controller, - }) + } + const requestEvent = new HttpRequestEvent(requestEventData) + const requestListenersPromise = options.emitter.emitAsPromise(requestEvent) await Promise.race([ // Short-circuit the request handling promise if the request gets aborted. @@ -126,6 +122,16 @@ export async function handleRequest( requestListenersPromise, options.controller.handled, ]) + + /** + * @note If the "request" listener has replaced the request instance, + * propagate that mutation back to the underlying insterceptor. + * This happens with XMLHttpRequest that replaces request instances + * to correctly reflect the "withCredentials" option on the Fetch API request. + */ + if (requestEvent.request !== options.request) { + options.request = requestEvent.request + } }) // Handle the request being aborted while waiting for the request listeners. @@ -173,12 +179,15 @@ export async function handleRequest( } ) - await emitAsync(options.emitter, 'unhandledException', { - error: result.error, - request: options.request, - requestId: options.requestId, - controller: unhandledExceptionController, - }) + await options.emitter.emitAsPromise( + new UnhandledHttpException({ + initiator: options.initiator, + error: result.error, + request: options.request, + requestId: options.requestId, + controller: unhandledExceptionController, + }) + ) // If all the "unhandledException" listeners have finished // but have not handled the request in any way, passthrough. diff --git a/src/utils/hasConfigurableGlobal.test.ts b/src/utils/hasConfigurableGlobal.test.ts index 4d643d437..1be4970f1 100644 --- a/src/utils/hasConfigurableGlobal.test.ts +++ b/src/utils/hasConfigurableGlobal.test.ts @@ -1,4 +1,3 @@ -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' import { hasConfigurableGlobal } from './hasConfigurableGlobal' let originalGlobalPrototype = Object.getPrototypeOf(globalThis) diff --git a/src/utils/isObject.test.ts b/src/utils/isObject.test.ts index ea4e99d60..6e826320c 100644 --- a/src/utils/isObject.test.ts +++ b/src/utils/isObject.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { isObject } from './isObject' it('returns true given an object', () => { diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 000000000..e0ec28187 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,5 @@ +import debug from 'debug' + +export function createLogger(namespace: string) { + return debug(`interceptors:${namespace}`) +} diff --git a/src/utils/nextTick.ts b/src/utils/nextTick.ts index a080ca1e1..31e31cee5 100644 --- a/src/utils/nextTick.ts +++ b/src/utils/nextTick.ts @@ -1,7 +1,3 @@ -export function nextTick(callback: () => void) { - setTimeout(callback, 0) -} - export function nextTickAsync(callback: () => void) { return new Promise((resolve) => { setTimeout(() => { diff --git a/src/utils/node/index.ts b/src/utils/node/index.ts deleted file mode 100644 index e4f0e72c1..000000000 --- a/src/utils/node/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { ClientRequest } from 'node:http' -import { Readable } from 'node:stream' -import { invariant } from 'outvariant' -import { getRawRequest } from '../../getRawRequest' - -const kRawRequestBodyStream = Symbol('kRawRequestBodyStream') - -/** - * Returns the request body stream of the given request. - * @note This is only relevant in the context of `http.ClientRequest`. - * This function will throw if the given `request` wasn't created based on - * the `http.ClientRequest` instance. - * You must rely on the web stream consumers for other request clients. - */ -export function getClientRequestBodyStream(request: Request): Readable { - const rawRequest = getRawRequest(request) - - invariant( - rawRequest instanceof ClientRequest, - `Failed to retrieve raw request body stream: request is not an instance of "http.ClientRequest". Note that you can only use the "getClientRequestBodyStream" function with the requests issued by "http.clientRequest".` - ) - - const requestBodyStream = Reflect.get(request, kRawRequestBodyStream) - - invariant( - requestBodyStream instanceof Readable, - 'Failed to retrieve raw request body stream: corrupted stream (%s)', - typeof requestBodyStream - ) - - return requestBodyStream -} - -export function setRawRequestBodyStream( - request: Request, - stream: Readable -): void { - Reflect.set(request, kRawRequestBodyStream, stream) -} diff --git a/src/utils/parseJson.test.ts b/src/utils/parseJson.test.ts index b02103481..bd7fdcc18 100644 --- a/src/utils/parseJson.test.ts +++ b/src/utils/parseJson.test.ts @@ -1,4 +1,3 @@ -import { it, expect } from 'vitest' import { parseJson } from './parseJson' it('parses a given string into JSON', () => { diff --git a/test/envs/node-with-websocket.ts b/test/envs/node-with-websocket.ts index 2189fc392..a1f187210 100644 --- a/test/envs/node-with-websocket.ts +++ b/test/envs/node-with-websocket.ts @@ -1,13 +1,12 @@ /** * Node.js environment superset that has a global WebSocket API. */ -import type { Environment } from 'vitest' -import { builtinEnvironments } from 'vitest/environments' +import { builtinEnvironments, type Environment } from 'vitest/environments' import { WebSocket } from 'undici' export default { name: 'node-with-websocket', - transformMode: 'ssr', + viteEnvironment: 'ssr', async setup(global, options) { const { teardown } = await builtinEnvironments.node.setup(global, options) diff --git a/test/envs/react-native-like.ts b/test/envs/react-native-like.ts deleted file mode 100644 index 66df92679..000000000 --- a/test/envs/react-native-like.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * React Native-like environment for Vitest. - */ -import type { Environment } from 'vitest' -import { builtinEnvironments } from 'vitest/environments' - -export default { - name: 'react-native-like', - transformMode: 'ssr', - async setup(global, options) { - const { teardown } = await builtinEnvironments.jsdom.setup(global, options) - - // React Native does not have the global "location" property. - Reflect.deleteProperty(globalThis, 'window') - Reflect.deleteProperty(globalThis, 'location') - - return { - teardown, - } - }, -} diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 6ccf717b9..de61372ed 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -1,108 +1,138 @@ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' -import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestEventMap } from '../../../src' -import { - createXMLHttpRequest, - useCors, - REQUEST_ID_REGEXP, - waitForClientRequest, -} from '../../helpers' -import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' -import { BatchInterceptor } from '../../../src/BatchInterceptor' -import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest' -import { RequestController } from '../../../src/RequestController' - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.post('/user', (req, res) => { - res.status(201).end() - }) -}) - -const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() - +// @vitest-environment happy-dom +import http from 'node:http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { BatchInterceptor, RequestController } from '@mswjs/interceptors' +import nodeInterceptors from '@mswjs/interceptors/presets/node' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() const interceptor = new BatchInterceptor({ name: 'batch-interceptor', - interceptors: [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - ], + interceptors: nodeInterceptors, }) -interceptor.on('request', requestListener) -beforeAll(async () => { +beforeAll(() => { interceptor.apply() - await httpServer.listen() }) afterEach(() => { - vi.resetAllMocks() + interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await httpServer.close() }) it('ClientRequest: emits the "request" event upon the request', async () => { - const url = httpServer.http.url('/user') + const requestListener = vi.fn() + interceptor.on('request', requestListener) + + const url = server.http.url('/user') const req = http.request(url, { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'content-type': 'application/json', }, }) req.write(JSON.stringify({ userId: 'abc-123' })) req.end() - await waitForClientRequest(req) + await toWebResponse(req) - expect(requestListener).toHaveBeenCalledTimes(1) + expect.soft(requestListener).toHaveBeenCalledTimes(1) const [{ request, requestId, controller }] = requestListener.mock.calls[0] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.headers.get('content-type')).toBe('application/json') - expect(request.credentials).toBe('same-origin') - expect(await request.json()).toEqual({ userId: 'abc-123' }) - expect(controller).toBeInstanceOf(RequestController) + expect.soft(request.method).toBe('POST') + expect.soft(request.url).toBe(url.href) + expect.soft(request.headers.get('content-type')).toBe('application/json') + expect.soft(request.credentials).toBe('same-origin') + await expect.soft(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect.soft(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) }) -it('XMLHttpRequest: emits the "request" event upon the request', async () => { - const url = httpServer.http.url('/user') - await createXMLHttpRequest((req) => { - req.open('POST', url) - req.setRequestHeader('Content-Type', 'application/json') - req.send(JSON.stringify({ userId: 'abc-123' })) - }) +it('XMLHttpRequest: emits the "request" event upon the request (no CORS)', async () => { + const requestListener = vi.fn() + interceptor.on('request', requestListener) + + const url = server.http.url('/user') + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('content-type', 'application/json') + request.send(JSON.stringify({ userId: 'abc-123' })) + + await waitForXMLHttpRequest(request) /** - * @note There are 3 requests that happen: - * 1. POST by XMLHttpRequestInterceptor. - * 2. OPTIONS request by ClientRequestInterceptor. - * 3. POST by ClientRequestInterceptor (XHR in JSDOM relies on ClientRequest). - * - * But there will only be 2 "request" events emitted: - * 1. POST by XMLHttpRequestInterceptor. - * 2. OPTIONS request by ClientRequestInterceptor. - * The second POST that bubbles down from XHR to ClientRequest is deduped - * via the "INTERNAL_REQUEST_ID_HEADER_NAME" request header. + * @note XHR in HappyDOM issues a preflight OPTIONS request. */ - expect(requestListener).toHaveBeenCalledTimes(2) + expect.soft(requestListener).toHaveBeenCalledTimes(2) - const [{ request, requestId, controller }] = requestListener.mock.calls[0] + // Preflight request. + { + const [{ request }] = requestListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + await expect.soft(request.text()).resolves.toBe('') + } + + { + const [{ request, requestId, controller }] = requestListener.mock.calls[1] + + expect.soft(request.method).toBe('POST') + expect.soft(request.url).toBe(url.href) + expect.soft(request.headers.get('content-type')).toBe('application/json') + expect.soft(request.credentials).toBe('same-origin') + await expect.soft(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect.soft(controller).toBeInstanceOf(RequestController) + + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } +}) + +it('XMLHttpRequest: emits the preflight "request" event upon the request (CORS)', async () => { + const requestListener = vi.fn() + interceptor.on('request', requestListener) + + const url = server.http.url('/user') + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('content-type', 'application/json') + /** + * @note The addition of this custom header triggers the OPTIONS request in XHR. + */ + request.setRequestHeader('X-Custom-Header', 'yes') + request.send(JSON.stringify({ userId: 'abc-123' })) + + await waitForXMLHttpRequest(request) + + expect.soft(requestListener).toHaveBeenCalledTimes(2) + + { + const [{ request, requestId, controller }] = requestListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + expect.soft(request.credentials).toBe('same-origin') + await expect.soft(request.text()).resolves.toBe('') + expect.soft(controller).toBeInstanceOf(RequestController) + + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + { + const [{ request, requestId, controller }] = requestListener.mock.calls[1] - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.headers.get('content-type')).toBe('application/json') - expect(request.credentials).toBe('same-origin') - expect(await request.json()).toEqual({ userId: 'abc-123' }) - expect(controller).toBeInstanceOf(RequestController) + expect.soft(request.method).toBe('POST') + expect.soft(request.url).toBe(url.href) + expect.soft(request.headers.get('content-type')).toBe('application/json') + expect.soft(request.credentials).toBe('same-origin') + await expect.soft(request.json()).resolves.toEqual({ userId: 'abc-123' }) + expect.soft(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } }) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index e40e6eead..87702d9ad 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -1,329 +1,410 @@ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment happy-dom import https from 'node:https' -import { HttpServer } from '@open-draft/test-server/http' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '../../../src' -import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest' -import { BatchInterceptor } from '../../../src/BatchInterceptor' -import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' -import { FetchInterceptor } from '../../../src/interceptors/fetch' -import { - useCors, - createXMLHttpRequest, - waitForClientRequest, -} from '../../helpers' - -declare namespace window { - export const _resourceLoader: { - _strictSSL: boolean - } -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) - - app.get('/user', (_req, res) => { - res.status(509).send('must-use-mocks') - }) - - app.post('/account', (_req, res) => { - return res - .status(200) - .set('access-control-expose-headers', 'x-response-type') - .set('x-response-type', 'original') - .send('original-response-text') - }) -}) +import { BatchInterceptor, HttpRequestEventMap } from '@mswjs/interceptors' +import nodeInterceptors from '@mswjs/interceptors/presets/node' +import { toWebResponse } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +const server = getTestServer() const interceptor = new BatchInterceptor({ name: 'batch-interceptor', - interceptors: [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], + interceptors: nodeInterceptors, }) -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - if (url.pathname === '/user') { - controller.respondWith( - new Response('mocked-response-text', { - status: 200, - statusText: 'OK', - headers: { - 'x-response-type': 'mocked', - }, - }) - ) - } -}) - -beforeAll(async () => { - // Allow XHR requests to the local HTTPS server with a self-signed certificate. - window._resourceLoader._strictSSL = false - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - - await httpServer.listen() +beforeAll(() => { interceptor.apply() }) afterEach(() => { - interceptor.removeAllListeners('response') - vi.resetAllMocks() + vi.clearAllMocks() + interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await httpServer.close() + vi.restoreAllMocks() }) it('ClientRequest: emits the "response" event for a mocked response', async () => { + interceptor.on('request', ({ request, controller }) => { + controller.respondWith( + new Response('mocked-response-text', { + statusText: 'OK', + headers: { 'x-response-type': 'mocked' }, + }) + ) + }) + const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.once('response', responseListener) - const req = https.request(httpServer.https.url('/user'), { + const req = https.request(server.https.url('/user'), { method: 'GET', headers: { 'x-request-custom': 'yes', }, + rejectUnauthorized: false, }) req.end() - const { res } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) // Must receive a mocked response. - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') - expect(responseListener).toHaveBeenCalledOnce() + await expect.poll(() => responseListener).toHaveBeenCalledOnce() - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[0] + { + const [{ response, request, responseType }] = responseListener.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(request.method).toBe('GET') + expect(request.url).toBe(server.https.url('/user').href) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('mocked') - await expect(response.text()).resolves.toBe('mocked-response-text') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-response-type')).toBe('mocked') + await expect(response.text()).resolves.toBe('mocked-response-text') - expect(isMockedResponse).toBe(true) + expect(responseType).toBe('mock') + } }) it('ClientRequest: emits the "response" event upon the original response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) - const req = https.request(httpServer.https.url('/account'), { + const req = https.request(server.https.url('/account'), { method: 'POST', headers: { 'x-request-custom': 'yes', }, + rejectUnauthorized: false, }) - req.write('request-body') + req.write('original-body') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(responseListener).toHaveBeenCalledOnce() - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls[0] + const [{ response, request, responseType }] = responseListener.mock.calls[0] expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.https.url('/account')) + expect(request.url).toBe(server.https.url('/account').href) expect(request.headers.get('x-request-custom')).toBe('yes') expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('request-body') + await expect(request.text()).resolves.toBe('original-body') expect(response.status).toBe(200) expect(response.statusText).toBe('OK') expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('original') - await expect(response.text()).resolves.toBe('original-response-text') + expect(response.headers.get('x-request-custom')).toBe('yes') + await expect(response.text()).resolves.toBe('original-body') - expect(isMockedResponse).toBe(false) + expect(responseType).toBe('original') }) it('XMLHttpRequest: emits the "response" event upon a mocked response', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'x-request-custom', + }, + }) + ) + } + + controller.respondWith( + new Response('mocked-response-text', { + statusText: 'OK', + headers: { + 'access-control-allow-origin': '*', + 'x-response-type': 'mocked', + }, + }) + ) + }) + const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) - const originalRequest = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/user')) - req.setRequestHeader('x-request-custom', 'yes') - req.send() - }) + const url = 'http://any.host.here/resource' + const request = new XMLHttpRequest() + request.open('GET', url) + request.setRequestHeader('x-request-custom', 'yes') + request.send() - expect(responseListener).toHaveBeenCalledOnce() + await waitForXMLHttpRequest(request) - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls.find(([{ request }]) => { - // The first response event will be from the "OPTIONS" preflight request. - return request.method === 'GET' - })! + expect(responseListener).toHaveBeenCalledTimes(2) + expect(request.responseText).toBe('mocked-response-text') - expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + { + const [{ response, request, responseType }] = responseListener.mock.calls[0] - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('mocked') - await expect(response.text()).resolves.toBe('mocked-response-text') - expect(isMockedResponse).toBe(true) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + expect.soft(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('') + + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('') + expect.soft(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('') + expect(responseType).toBe('mock') + } + + { + const [{ response, request, responseType }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + expect.soft(request.headers.get('x-request-custom')).toBe('yes') + expect.soft(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) - // Original response. - expect(originalRequest.responseText).toEqual('mocked-response-text') + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect.soft(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('mocked-response-text') + expect(responseType).toBe('mock') + } }) it('XMLHttpRequest: emits the "response" event upon the original response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('response', responseListener) - const originalRequest = await createXMLHttpRequest((req) => { - req.open('POST', httpServer.https.url('/account')) - req.setRequestHeader('x-request-custom', 'yes') - req.send('request-body') - }) + const url = server.http.url('/account') + const request = new XMLHttpRequest() + request.open('POST', url) + request.setRequestHeader('x-request-custom', 'yes') + request.send('original-body') + + await waitForXMLHttpRequest(request) - /** - * @note The "response" event will be emitted twice because XMLHttpRequest - * is polyfilled by "http.ClientRequest" in Node.js. When this request will be - * passthrough to the ClientRequest, it will perform an "OPTIONS" request first, - * thus two request/response events emitted. - */ expect(responseListener).toHaveBeenCalledTimes(2) + expect(request.responseText).toBe('original-body') - // Lookup the correct response listener call. - const [{ response, request, isMockedResponse }] = - responseListener.mock.calls.find(([{ request }]) => { - return request.method === 'POST' - })! + { + const [{ response, request, responseType }] = responseListener.mock.calls[0] - expect(request).toBeDefined() - expect(response).toBeDefined() + expect(request).toBeDefined() + expect(response).toBeDefined() - expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.https.url('/account')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('request-body') + expect(request.method).toBe('OPTIONS') + expect(request.url).toBe(url.href) + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('') - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('original') - await expect(response.text()).resolves.toBe('original-response-text') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('') + + expect(responseType).toBe('original') + } + + { + const [{ response, request, responseType }] = responseListener.mock.calls[1] + + expect(request).toBeDefined() + expect(response).toBeDefined() - expect(isMockedResponse).toBe(false) + expect(request.method).toBe('POST') + expect(request.url).toBe(url.href) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('original-body') - // Original response. - expect(originalRequest.responseText).toEqual('original-response-text') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-request-custom')).toBe('yes') + await expect(response.text()).resolves.toBe('original-body') + + expect(responseType).toBe('original') + } }) it('fetch: emits the "response" event upon a mocked response', async () => { - const responseListenerArgs = new DeferredPromise< - HttpRequestEventMap['response'][0] - >() - interceptor.on('response', (args) => { - responseListenerArgs.resolve({ - ...args, - request: args.request.clone(), - }) + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'x-request-custom', + }, + }) + ) + } + + controller.respondWith( + new Response('mocked-response-text', { + statusText: 'OK', + headers: { + 'x-response-type': 'mocked', + }, + }) + ) }) - await fetch(httpServer.https.url('/user'), { + const responseListener = vi.fn() + interceptor.on('response', responseListener) + + await fetch(server.https.url('/user'), { headers: { 'x-request-custom': 'yes', }, }) - const { response, request, isMockedResponse } = await responseListenerArgs + await expect.poll(() => responseListener).toHaveBeenCalledTimes(2) - expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + { + const [{ response, request, responseType }] = responseListener.mock.calls[0] - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('mocked') - await expect(response.text()).resolves.toBe('mocked-response-text') + expect(request.method).toBe('OPTIONS') + expect(request.url).toBe(server.https.url('/user').href) + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('') + + expect(response.status).toBe(200) + expect(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('') - expect(isMockedResponse).toBe(true) + expect(responseType).toBe('mock') + } + + { + const [{ response, request, responseType }] = responseListener.mock.calls[1] + + expect(request.method).toBe('GET') + expect(request.url).toBe(server.https.url('/user').href) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-response-type')).toBe('mocked') + await expect(response.text()).resolves.toBe('mocked-response-text') + + expect(responseType).toBe('mock') + } }) -it('fetch: emits the "response" event upon the original response', async () => { - const responseListenerArgs = new DeferredPromise< - HttpRequestEventMap['response'][0] - >() - interceptor.on('response', (args) => { - responseListenerArgs.resolve({ - ...args, - request: args.request.clone(), +it( + 'fetch: emits the "response" event upon the original response', + { timeout: 1500 }, + async () => { + const responseListener = vi.fn() + interceptor.on('response', responseListener) + + await fetch(server.http.url('/account'), { + method: 'POST', + headers: { + 'x-request-custom': 'yes', + }, + body: 'original-body', }) - }) - await fetch(httpServer.https.url('/account'), { - method: 'POST', - headers: { - 'x-request-custom': 'yes', - }, - body: 'request-body', - }) + await expect.poll(() => responseListener).toHaveBeenCalledTimes(2) - const { response, request, isMockedResponse } = await responseListenerArgs + { + const [{ response, request, responseType }] = + responseListener.mock.calls[0] - expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.https.url('/account')) - expect(request.headers.get('x-request-custom')).toBe('yes') - expect(request.credentials).toBe('same-origin') - await expect(request.text()).resolves.toBe('request-body') + expect(request.method).toBe('OPTIONS') + expect(request.url).toBe(server.http.url('/account').href) + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('') - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.url).toBe(request.url) - expect(response.headers.get('x-response-type')).toBe('original') - await expect(response.text()).resolves.toBe('original-response-text') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + await expect(response.text()).resolves.toBe('') - expect(isMockedResponse).toBe(false) -}) + expect(responseType).toBe('original') + } + + { + const [{ response, request, responseType }] = + responseListener.mock.calls[1] + + expect(request.method).toBe('POST') + expect(request.url).toBe(server.http.url('/account').href) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + await expect(request.text()).resolves.toBe('original-body') + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.url).toBe(request.url) + expect(response.headers.get('x-request-custom')).toBe('yes') + await expect(response.text()).resolves.toBe('original-body') + + expect(responseType).toBe('original') + } + } +) it('supports reading the request and response bodies in the "response" listener', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'x-request-custom', + }, + }) + ) + } + + controller.respondWith( + new Response('mocked-response-text', { + statusText: 'OK', + headers: { + 'x-response-type': 'mocked', + }, + }) + ) + }) + const requestCallback = vi.fn() const responseCallback = vi.fn() - const responseListener = vi.fn< - (...args: HttpRequestEventMap['response']) => void - >(async ({ request, response }) => { + + interceptor.on('response', async ({ request, response }) => { requestCallback(await request.clone().text()) responseCallback(await response.clone().text()) }) - interceptor.on('response', responseListener) - await fetch(httpServer.https.url('/user'), { + await fetch(server.https.url('/user'), { method: 'POST', body: 'request-body', }) - await expect.poll(() => responseListener).toHaveBeenCalledOnce() + await expect.poll(() => requestCallback).toHaveReturnedTimes(2) + + expect(requestCallback).toHaveBeenNthCalledWith(1, '') + expect(requestCallback).toHaveBeenNthCalledWith(2, 'request-body') + + await expect.poll(() => responseCallback).toHaveReturnedTimes(2) - expect(requestCallback).toHaveBeenCalledWith('request-body') - expect(responseCallback).toHaveBeenCalledWith('mocked-response-text') + expect(responseCallback).toHaveBeenNthCalledWith(1, '') + expect(responseCallback).toHaveBeenNthCalledWith(2, 'mocked-response-text') }) diff --git a/test/features/get-client-request-body-stream.test.ts b/test/features/get-client-request-body-stream.test.ts deleted file mode 100644 index 2e0e7db17..000000000 --- a/test/features/get-client-request-body-stream.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// @vitest-environment node -import http from 'node:http' -import { Readable } from 'node:stream' -import { text } from 'node:stream/consumers' -import { afterAll, afterEach, beforeAll, it, expect } from 'vitest' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { BatchInterceptor } from '../../src' -import interceptors from '../../src/presets/node' -import { getClientRequestBodyStream } from '../../src/utils/node' -import { waitForClientRequest } from '../helpers' - -const interceptor = new BatchInterceptor({ - name: 'interceptor', - interceptors, -}) - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('returns the underlying request body stream for http.ClientRequest', async () => { - const requestBodyStreamPromise = new DeferredPromise() - - interceptor.on('request', ({ request, controller }) => { - try { - const requestBodyStream = getClientRequestBodyStream(request) - requestBodyStreamPromise.resolve(requestBodyStream) - } catch (error) { - requestBodyStreamPromise.reject(error) - } - - controller.respondWith(new Response()) - }) - - const request = http.request('http://localhost:3000/resource', { - method: 'GET', - headers: { - /** - * @note The `content-length` header is required to send payload in a GET request. - */ - 'content-length': '11', - }, - }) - request.write('hello world') - request.end() - - await waitForClientRequest(request) - const requestBodyStream = await requestBodyStreamPromise - expect(requestBodyStream).toBeInstanceOf(Readable) - - await expect(text(requestBodyStream)).resolves.toBe('hello world') -}) - -it('throws if the request is not an instance of http.ClientRequest', async () => { - const requestBodyStreamPromise = new DeferredPromise() - - interceptor.on('request', ({ request, controller }) => { - try { - const requestBodyStream = getClientRequestBodyStream(request) - requestBodyStreamPromise.resolve(requestBodyStream) - } catch (error) { - requestBodyStreamPromise.reject(error) - } - - controller.respondWith(new Response()) - }) - - fetch('http://localhost:3000/resource', { - method: 'POST', - body: 'hello world', - }) - - await expect(requestBodyStreamPromise).rejects.toThrow( - `Failed to retrieve raw request body stream: request is not an instance of "http.ClientRequest". Note that you can only use the "getClientRequestBodyStream" function with the requests issued by "http.clientRequest".` - ) -}) diff --git a/test/features/get-raw-request.test.ts b/test/features/get-raw-request.test.ts deleted file mode 100644 index 45d5e6bb9..000000000 --- a/test/features/get-raw-request.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// @vitest-environment jsdom -import { it, expect, afterAll, afterEach, beforeAll } from 'vitest' -import http from 'node:http' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { BatchInterceptor, getRawRequest } from '../../src' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '../../src/interceptors/fetch' -import { createXMLHttpRequest } from '../helpers' - -const interceptor = new BatchInterceptor({ - name: 'batch-interceptor', - interceptors: [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], -}) - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('returns a reference to a raw http.ClientRequest instance', async () => { - const rawRequestPromise = new DeferredPromise() - - interceptor.on('request', ({ request, controller }) => { - const rawRequest = getRawRequest(request) - if (rawRequest instanceof http.ClientRequest) { - rawRequestPromise.resolve(rawRequest) - } else { - console.error(rawRequest) - rawRequestPromise.reject( - new Error('Expected rawRequest to be an instance of http.ClientRequest') - ) - } - - controller.respondWith(new Response()) - }) - - http - .request('http://localhost', { - headers: { - 'X-CustoM-HeadeR-NamE': 'value', - }, - }) - .end() - - const rawRequest = await rawRequestPromise - expect(rawRequest).toBeInstanceOf(http.ClientRequest) - expect(rawRequest.getRawHeaderNames()).toContain('X-CustoM-HeadeR-NamE') -}) - -it('returns a reference to a raw XMLHttpRequest instance', async () => { - const rawRequestPromise = new DeferredPromise() - interceptor.on('request', ({ request, controller }) => { - const rawRequest = getRawRequest(request) - - if (rawRequest instanceof XMLHttpRequest) { - rawRequestPromise.resolve(rawRequest) - } else { - console.error(rawRequest) - rawRequestPromise.reject( - new Error('Expected rawRequest to be an instance of XMLHttpRequest') - ) - } - - controller.respondWith(new Response('hello world')) - }) - - await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost:3000') - request.withCredentials = true - request.send() - }) - - const rawRequest = await rawRequestPromise - expect(rawRequest).toBeInstanceOf(XMLHttpRequest) - expect(rawRequest.withCredentials).toBe(true) - expect(rawRequest.responseText).toBe('hello world') -}) - -it('returns a reference to a raw Request instance (fetch)', async () => { - const rawRequestPromise = new DeferredPromise() - interceptor.on('request', ({ request, controller }) => { - const rawRequest = getRawRequest(request) - - if (rawRequest instanceof Request) { - rawRequestPromise.resolve(rawRequest) - } else { - console.error(rawRequest) - rawRequestPromise.reject( - new Error('Expected rawRequest to be an instance of XMLHttpRequest') - ) - } - - controller.respondWith(new Response('hello world')) - }) - - const request = new Request('http://localhost:3000') - await fetch(request) - - const rawRequest = await rawRequestPromise - expect(rawRequest).toEqual(request) -}) - -it('returns undefined for a non-Request input (fetch)', async () => { - const rawRequestPromise = new DeferredPromise() - interceptor.on('request', ({ request, controller }) => { - const rawRequest = getRawRequest(request) - - if (typeof rawRequest === 'undefined') { - rawRequestPromise.resolve(rawRequest) - } else { - console.error(rawRequest) - rawRequestPromise.reject(new Error('Expected rawRequest to be undefined')) - } - - controller.respondWith(new Response('hello world')) - }) - - await fetch('http://localhost:3000') - await expect(rawRequestPromise).resolves.toBeUndefined() -}) diff --git a/test/features/presets/browser-preset.browser.test.ts b/test/features/presets/browser-preset.browser.test.ts deleted file mode 100644 index 05ab577db..000000000 --- a/test/features/presets/browser-preset.browser.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { test, expect } from '../../playwright.extend' - -test('intercepts and mocks a fetch request', async ({ loadExample, page }) => { - await loadExample(require.resolve('./browser-preset.runtime.js')) - - // Perform a fetch request. - const response = await page.evaluate(() => { - return fetch('http://localhost:3001/resource').then(async (response) => { - return { - status: response.status, - statusText: response.statusText, - text: await response.text(), - } - }) - }) - - expect(response.status).toBe(200) - expect(response.text).toBe('mocked') -}) - -test('intercepts and mocks an XMLHttpRequest', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./browser-preset.runtime.js')) - - const [request, response] = await callXMLHttpRequest({ - method: 'GET', - url: 'http://localhost:3001/resource', - }) - - expect(request.method).toBe('GET') - expect(request.url).toBe('http://localhost:3001/resource') - - expect(response.status).toBe(200) - expect(response.body).toBe('mocked') -}) diff --git a/test/features/presets/browser-preset.runtime.js b/test/features/presets/browser-preset.runtime.js deleted file mode 100644 index ef0bb5fb2..000000000 --- a/test/features/presets/browser-preset.runtime.js +++ /dev/null @@ -1,26 +0,0 @@ -import { BatchInterceptor } from '@mswjs/interceptors' -import browserInterceptors from '@mswjs/interceptors/presets/browser' - -const interceptor = new BatchInterceptor({ - name: 'browser-preset-interceptor', - interceptors: browserInterceptors, -}) - -interceptor.apply() - -interceptor.on('request', async ({ request, requestId, controller }) => { - window.dispatchEvent( - new CustomEvent('resolver', { - detail: { - id: requestId, - method: request.method, - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - credentials: request.credentials, - body: await request.clone().text(), - }, - }) - ) - - controller.respondWith(new Response('mocked')) -}) diff --git a/test/features/presets/browser-preset.v-browser.test.ts b/test/features/presets/browser-preset.v-browser.test.ts new file mode 100644 index 000000000..4852bd52d --- /dev/null +++ b/test/features/presets/browser-preset.v-browser.test.ts @@ -0,0 +1,64 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { BatchInterceptor } from '@mswjs/interceptors' +import browserInterceptors from '@mswjs/interceptors/presets/browser' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' + +const interceptor = new BatchInterceptor({ + name: 'browser', + interceptors: browserInterceptors, +}) + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts and mocks a fetch request', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.respondWith(new Response('mocked')) + }) + + const response = await fetch('http://localhost:3001/resource') + + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked') + + const request = await requestPromise + + expect(request.method).toBe('GET') + expect(request.url).toBe('http://localhost:3001/resource') + expect(request.body).toBeNull() +}) + +it('intercepts and mocks an XMLHttpRequest', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.respondWith(new Response('mocked')) + }) + + const xhr = new XMLHttpRequest() + xhr.open('GET', 'http://localhost:3001/resource') + xhr.send() + + await waitForXMLHttpRequest(xhr) + + expect(xhr.status).toBe(200) + expect(xhr.response).toBe('mocked') + + const request = await requestPromise + + expect(request.method).toBe('GET') + expect(request.url).toBe('http://localhost:3001/resource') +}) diff --git a/test/features/presets/node-preset.test.ts b/test/features/presets/node-preset.test.ts index 3b12d6898..81f2760ab 100644 --- a/test/features/presets/node-preset.test.ts +++ b/test/features/presets/node-preset.test.ts @@ -1,27 +1,21 @@ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment happy-dom import http from 'node:http' -import { BatchInterceptor } from '../../../lib/node/index.mjs' -import nodeInterceptors from '../../../lib/node/presets/node.mjs' -import { createXMLHttpRequest, waitForClientRequest } from '../../helpers' +import { BatchInterceptor } from '@mswjs/interceptors' +import nodeInterceptors from '@mswjs/interceptors/presets/node' +import { toWebResponse } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new BatchInterceptor({ name: 'node-preset-interceptor', interceptors: nodeInterceptors, }) -const requestListener = vi.fn() - beforeAll(() => { interceptor.apply() - interceptor.on('request', ({ request, controller }) => { - requestListener(request) - controller.respondWith(new Response('mocked')) - }) }) afterEach(() => { - vi.resetAllMocks() + interceptor.removeAllListeners() }) afterAll(() => { @@ -29,8 +23,21 @@ afterAll(() => { }) it('intercepts and mocks a ClientRequest', async () => { + const requestListener = vi.fn() + interceptor.on('request', ({ request, controller }) => { + requestListener(request) + + controller.respondWith( + new Response('mocked', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + const request = http.get('http://localhost:3001/resource') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must call the "request" event listener. expect(requestListener).toHaveBeenCalledWith( @@ -41,16 +48,30 @@ it('intercepts and mocks a ClientRequest', async () => { ) // The listener must send back a mocked response. - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('mocked') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('mocked') }) it('intercepts and mocks an XMLHttpRequest (jsdom)', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost:3001/resource') - request.send() + const requestListener = vi.fn() + interceptor.on('request', ({ request, controller }) => { + requestListener(request) + + controller.respondWith( + new Response('mocked', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) }) + const request = new XMLHttpRequest() + request.open('GET', 'http://localhost:3001/resource') + request.send() + + await waitForXMLHttpRequest(request) + expect(requestListener).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', @@ -63,6 +84,20 @@ it('intercepts and mocks an XMLHttpRequest (jsdom)', async () => { }) it('intercepts and mocks a fetch request', async () => { + const requestListener = vi.fn() + interceptor.on('request', ({ request, controller }) => { + + requestListener(request) + + controller.respondWith( + new Response('mocked', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + const response = await fetch('http://localhost:3001/resource') expect(requestListener).toHaveBeenCalledWith( diff --git a/test/features/remote/remote.test.ts b/test/features/remote/remote.test.ts index 0c57abe78..e6b4578ce 100644 --- a/test/features/remote/remote.test.ts +++ b/test/features/remote/remote.test.ts @@ -1,7 +1,6 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' import * as path from 'path' import { spawn } from 'child_process' -import { RemoteHttpResolver } from '../../../src/RemoteHttpInterceptor' +import { RemoteHttpResolver } from '#/src/RemoteHttpInterceptor' const CHILD_PATH = path.resolve(__dirname, 'child.js') @@ -13,26 +12,14 @@ const resolver = new RemoteHttpResolver({ process: child, }) -resolver.on('request', ({ controller }) => { - controller.respondWith( - new Response( - JSON.stringify({ - mockedFromParent: true, - }), - { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - } - ) - ) -}) - beforeAll(() => { resolver.apply() }) +afterEach(() => { + resolver.removeAllListeners() +}) + afterAll(() => { if (!child.killed) { child.kill() @@ -42,6 +29,22 @@ afterAll(() => { }) it('intercepts an HTTP request made in a child process', async () => { + resolver.on('request', ({ controller }) => { + controller.respondWith( + new Response( + JSON.stringify({ + mockedFromParent: true, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + ) + }) + child.send('make:request') const response = await new Promise((resolve, reject) => { diff --git a/test/features/request-initiator.test.ts b/test/features/request-initiator.test.ts new file mode 100644 index 000000000..3cc93f8a9 --- /dev/null +++ b/test/features/request-initiator.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom +import http from 'node:http' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { BatchInterceptor } from '#/src/BatchInterceptor' +import { ClientRequestInterceptor } from '#/src/interceptors/ClientRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' +import { FetchInterceptor } from '#/src/interceptors/fetch/node' +import { toWebResponse } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' + +const interceptor = new BatchInterceptor({ + name: 'interceptor', + interceptors: [ + new ClientRequestInterceptor(), + new XMLHttpRequestInterceptor(), + new FetchInterceptor(), + ], +}) + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('exposes the initiator of a mocked ClientRequest request', async () => { + const initiatorPromise = new DeferredPromise() + interceptor.on('request', ({ initiator, controller }) => { + initiatorPromise.resolve(initiator as XMLHttpRequest) + controller.respondWith(new Response('mocked')) + }) + + const request = http.get('http://localhost:3001/api') + const [response] = await toWebResponse(request) + + await expect(initiatorPromise).resolves.toEqual(request) + await expect(response.text()).resolves.toBe('mocked') +}) + +it('exposes the initiator of a mocked XMLHttpRequest request', async () => { + const initiatorPromise = new DeferredPromise() + interceptor.on('request', ({ initiator, controller }) => { + initiatorPromise.resolve(initiator as XMLHttpRequest) + + controller.respondWith( + new Response('mocked', { + headers: { + 'Access-Control-Allow-Origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://localhost/api') + request.send() + + await waitForXMLHttpRequest(request) + + await expect(initiatorPromise).resolves.toEqual(request) + expect(request.responseText).toBe('mocked') +}) + +it('exposes the initiator of a mocked fetch request', async () => { + const initiatorPromise = new DeferredPromise() + interceptor.on('request', ({ initiator, controller }) => { + initiatorPromise.resolve(initiator as XMLHttpRequest) + + controller.respondWith( + new Response('mocked', { + headers: { + 'Access-Control-Allow-Origin': '*', + }, + }) + ) + }) + + const request = new Request('http://localhost/api') + const response = await fetch(request) + + /** + * @note Use "toMatchObject" instead of "toEqual" to ignore the difference + * in internal symbols on the request, which Undici modifies after "fetch". + */ + await expect(initiatorPromise).resolves.toMatchObject(request) + await expect(response.text()).resolves.toBe('mocked') +}) diff --git a/test/helpers.ts b/test/helpers.ts index cd4ba87e5..4fbdfb273 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,177 +1,31 @@ -import { urlToHttpOptions } from 'node:url' -import https from 'node:https' +import { invariant } from 'outvariant' +import net from 'node:net' import zlib from 'node:zlib' -import http, { ClientRequest, IncomingMessage, RequestOptions } from 'node:http' -import { Page } from '@playwright/test' -import { getIncomingMessageBody } from '../src/interceptors/ClientRequest/utils/getIncomingMessageBody' -import { SerializedRequest } from '../src/RemoteHttpInterceptor' +import { Readable } from 'node:stream' +import http from 'node:http' import { RequestHandler } from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' +import { Page } from '@playwright/test' +import { MockedFunction } from 'node_modules/vitest/dist' +import { SerializedRequest } from '#/src/RemoteHttpInterceptor' +import { FetchResponse } from '#/src/utils/fetchUtils' export const REQUEST_ID_REGEXP = /^\w{9,}$/ -export interface PromisifiedResponse { - req: ClientRequest - res: IncomingMessage - resBody: string - url: string - options: RequestOptions -} - -export function httpGet( - url: string, - options: RequestOptions = {} -): Promise { - const parsedUrl = new URL(url) - return new Promise((resolve, reject) => { - const req = http.get(parsedUrl, options, async (res) => { - res.setEncoding('utf8') - const resBody = await getIncomingMessageBody(res) - resolve({ req, res, resBody, url, options }) - }) - - req.on('error', reject) - }) -} - -export function httpsGet( - url: string, - options?: RequestOptions -): Promise { - const parsedUrl = new URL(url) - const resolvedOptions = Object.assign( - {}, - urlToHttpOptions(parsedUrl), - options - ) - - return new Promise((resolve, reject) => { - const req = https.get(resolvedOptions, async (res) => { - res.setEncoding('utf8') - const resBody = await getIncomingMessageBody(res) - resolve({ req, res, resBody, url, options: resolvedOptions }) - }) - - req.on('error', reject) - }) -} - -export function httpRequest( - url: string, - options?: RequestOptions, - body?: string -): Promise { - const parsedUrl = new URL(url) - const resolvedOptions = Object.assign( - {}, - urlToHttpOptions(parsedUrl), - options - ) - - return new Promise((resolve) => { - const req = http.request(resolvedOptions, async (res) => { - res.setEncoding('utf8') - const resBody = await getIncomingMessageBody(res) - resolve({ req, res, resBody, url, options: resolvedOptions }) - }) - - if (body) { - req.write(body) - } - - req.end() - }) -} - -export function httpsRequest( - url: string, - options?: RequestOptions, - body?: string -): Promise { - const parsedUrl = new URL(url) - const resolvedOptions = Object.assign( - {}, - urlToHttpOptions(parsedUrl), - options - ) - - return new Promise((resolve) => { - const req = https.request(resolvedOptions, async (res) => { - res.setEncoding('utf8') - const resBody = await getIncomingMessageBody(res) - resolve({ req, res, resBody, url, options: resolvedOptions }) - }) - - if (body) { - req.write(body) - } - - req.end() - }) -} - -interface PromisifiedFetchPayload { - res: Response - url: string - init?: RequestInit -} - -export async function fetch( - info: RequestInfo | URL, - init?: RequestInit -): Promise { - let url: string = '' - const res = await globalThis.fetch(info, init) - - if (typeof info === 'string') { - url = info - } else if ('href' in info) { - url = info.href - } else if ('url' in info) { - url = info.url - } - - return { - res, - url, - init, - } -} - export async function readBlob( blob: Blob ): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.addEventListener('loadend', () => { - resolve(reader.result) - }) - reader.addEventListener('abort', reject) - reader.addEventListener('error', reject) - reader.readAsText(blob) - }) -} - -export function createXMLHttpRequest( - middleware: (req: XMLHttpRequest) => void -): Promise { - const request = new XMLHttpRequest() - middleware(request) + const pendingResult = new DeferredPromise() - if (request.readyState < 1) { - throw new Error( - 'Failed to create an XMLHttpRequest. Did you forget to call `.open()` in the middleware function?' - ) - } - - return new Promise((resolve, reject) => { - request.addEventListener('loadend', () => { - resolve(request) - }) - request.addEventListener('abort', (error) => { - reject(error) - }) + const reader = new FileReader() + reader.addEventListener('loadend', () => { + pendingResult.resolve(reader.result) }) + reader.addEventListener('abort', () => pendingResult.reject()) + reader.addEventListener('error', () => pendingResult.reject()) + reader.readAsText(blob) + + return pendingResult } export interface XMLHttpResponse { @@ -285,36 +139,38 @@ export function createBrowserXMLHttpRequest(page: Page) { } } -export async function waitForClientRequest( +export async function toWebResponse( request: http.ClientRequest -): Promise<{ - res: http.IncomingMessage - text(): Promise -}> { - return new Promise((resolve, reject) => { - request.on('response', async (response) => { - response.setEncoding('utf8') - resolve({ - res: response, - text: getIncomingMessageBody.bind(null, response), +): Promise<[Response, http.IncomingMessage]> { + const pendingResponse = new DeferredPromise< + [Response, http.IncomingMessage] + >() + + request + .on('response', (response) => { + const responseBody = response.destroyed + ? null + : (Readable.toWeb(response) as ReadableStream) + + const fetchResponse = new FetchResponse(responseBody, { + status: response.statusCode, + statusText: response.statusMessage, + headers: FetchResponse.parseRawHeaders(response.rawHeaders), }) - }) - request.on('error', reject) - request.on('abort', reject) - request.on('timeout', reject) - }) -} + pendingResponse.resolve([fetchResponse, response]) + }) + .on('error', (error) => pendingResponse.reject(error)) + .on('abort', () => pendingResponse.reject(new Error('Request aborted'))) -export function sleep(duration: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, duration) - }) + return pendingResponse } export const useCors: RequestHandler = (_req, res, next) => { res.set({ 'access-control-allow-origin': '*', + 'access-control-allow-headers': '*', + 'access-control-allow-methods': '*', }) return next() } @@ -341,3 +197,104 @@ export function compressResponse( return output } + +export async function createTestServer( + createServer: () => T +): Promise< + AsyncDisposable & { + instance: T + port: number + hostname: string + http: { + url: (path: string) => URL + } + https: { + url: (path: string) => URL + } + } +> { + const server = createServer() + + const pendingListen = new DeferredPromise() + + server + .listen(0, '127.0.0.1', () => pendingListen.resolve()) + .once('error', (error) => pendingListen.reject(error)) + + await pendingListen + + const rawAddress = server.address() + + invariant( + rawAddress != null, + 'Failed to open a test server: server address is null' + ) + invariant( + typeof rawAddress === 'object' && 'port' in rawAddress, + 'Failed to open a test server: server address is not AddressInfo' + ) + + const createUrlHelper = (protocol: 'https' | 'http') => { + return (path: string): URL => { + return new URL( + path, + new URL(`${protocol}://${rawAddress.address}:${rawAddress.port}`) + ) + } + } + + return { + async [Symbol.asyncDispose]() { + const pendingClose = new DeferredPromise() + server.close((error) => { + if (error) { + return pendingClose.reject(error) + } + + pendingClose.resolve() + }) + }, + instance: server, + port: rawAddress.port, + hostname: rawAddress.address, + http: { + url: createUrlHelper('http'), + }, + https: { + url: createUrlHelper('https'), + }, + } +} + +export function spyOnSocket(socket: net.Socket) { + const eventNames = [ + 'lookup', + 'connectionAttempt', + 'connectionAttemptFailed', + 'connectionAttemptTimeout', + 'connect', + 'ready', + 'data', + 'drain', + 'end', + 'error', + 'timeout', + 'close', + ] as const + + const events: Array = [] + const listeners = {} as Record< + (typeof eventNames)[number], + MockedFunction + > + + for (const eventName of eventNames) { + listeners[eventName] = vi.fn((...args) => events.push([eventName, ...args])) + socket.on(eventName, listeners[eventName]) + } + + return { + events, + listeners, + } +} diff --git a/test/modules/WebSocket/compliance/websocket.client.events.test.ts b/test/modules/WebSocket/compliance/websocket.client.events.test.ts index 7ad6a9463..75fc6ad8d 100644 --- a/test/modules/WebSocket/compliance/websocket.client.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.client.events.test.ts @@ -3,11 +3,10 @@ * This test suite asserts that the "client" connection object * dispatches the right events in different scenarios. */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketData, WebSocketInterceptor, -} from '../../../../src/interceptors/WebSocket' +} from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.client.send.test.ts b/test/modules/WebSocket/compliance/websocket.client.send.test.ts index d26606f18..311c098a9 100644 --- a/test/modules/WebSocket/compliance/websocket.client.send.test.ts +++ b/test/modules/WebSocket/compliance/websocket.client.send.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node-with-websocket -import { beforeAll, afterEach, afterAll, vi, it, expect } from 'vitest' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.close.test.ts b/test/modules/WebSocket/compliance/websocket.close.test.ts index 9f4054928..69854fb3e 100644 --- a/test/modules/WebSocket/compliance/websocket.close.test.ts +++ b/test/modules/WebSocket/compliance/websocket.close.test.ts @@ -2,9 +2,8 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#dom-websocket-close */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForNextTick } from '../utils/waitForNextTick' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.connection.test.ts b/test/modules/WebSocket/compliance/websocket.connection.test.ts index bed8b2ef9..089cf4199 100644 --- a/test/modules/WebSocket/compliance/websocket.connection.test.ts +++ b/test/modules/WebSocket/compliance/websocket.connection.test.ts @@ -1,11 +1,10 @@ /** * @vitest-environment node-with-websocket */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' -import { REQUEST_ID_REGEXP } from '../../../helpers' +import { REQUEST_ID_REGEXP } from '#/test/helpers' import { waitForNextTick } from '../utils/waitForNextTick' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.constructor.test.ts b/test/modules/WebSocket/compliance/websocket.constructor.test.ts index 352497182..89c7d6920 100644 --- a/test/modules/WebSocket/compliance/websocket.constructor.test.ts +++ b/test/modules/WebSocket/compliance/websocket.constructor.test.ts @@ -2,8 +2,7 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org//#dom-websocket-websocket */ -import { it, expect, beforeAll, afterAll } from 'vitest' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.default.test.ts b/test/modules/WebSocket/compliance/websocket.default.test.ts index 643db532f..0e2924ed2 100644 --- a/test/modules/WebSocket/compliance/websocket.default.test.ts +++ b/test/modules/WebSocket/compliance/websocket.default.test.ts @@ -2,8 +2,7 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#the-websocket-interface */ -import { it, expect, beforeAll, afterAll } from 'vitest' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.events.test.ts b/test/modules/WebSocket/compliance/websocket.events.test.ts index 69b7209f9..8ca0f54d4 100644 --- a/test/modules/WebSocket/compliance/websocket.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.events.test.ts @@ -3,12 +3,11 @@ * This test suite asserts that the intercepted WebSocket client * still dispatches the correct events in mocked/bypassed scenarios. */ -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' -import { sleep } from '../../../helpers' const wsServer = new WebSocketServer({ host: '127.0.0.1', @@ -288,7 +287,7 @@ it('allows erroring the connection from an asynchronous listener', async ({ vi.spyOn(console, 'error').mockImplementation(() => {}) interceptor.once('connection', async () => { - await sleep(200) + await setTimeout(200) throw new Error('mock error') }) diff --git a/test/modules/WebSocket/compliance/websocket.protocol.test.ts b/test/modules/WebSocket/compliance/websocket.protocol.test.ts index 986362547..b187a39bd 100644 --- a/test/modules/WebSocket/compliance/websocket.protocol.test.ts +++ b/test/modules/WebSocket/compliance/websocket.protocol.test.ts @@ -2,9 +2,8 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#dom-websocket-close */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts index 76a79ac6a..45e24b4a1 100644 --- a/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts +++ b/test/modules/WebSocket/compliance/websocket.reuse-listeners.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/compliance/websocket.send.test.ts b/test/modules/WebSocket/compliance/websocket.send.test.ts index 772f85c31..54c30806e 100644 --- a/test/modules/WebSocket/compliance/websocket.send.test.ts +++ b/test/modules/WebSocket/compliance/websocket.send.test.ts @@ -2,10 +2,9 @@ * @vitest-environment node-with-websocket * @see https://websockets.spec.whatwg.org/#dom-websocket-send */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { Data, WebSocketServer } from 'ws' import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/compliance/websocket.server.close.test.ts b/test/modules/WebSocket/compliance/websocket.server.close.test.ts index 9eb03dc54..cf565265a 100644 --- a/test/modules/WebSocket/compliance/websocket.server.close.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.close.test.ts @@ -1,11 +1,10 @@ // @vitest-environment node-with-websocket import { DeferredPromise } from '@open-draft/deferred-promise' -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer, Data } from 'ws' import { WebSocketInterceptor, WebSocketServerConnection, -} from '../../../../src/interceptors/WebSocket/index' +} from '#/src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/compliance/websocket.server.connect.test.ts b/test/modules/WebSocket/compliance/websocket.server.connect.test.ts index bb0ecad4d..434dd471a 100644 --- a/test/modules/WebSocket/compliance/websocket.server.connect.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.connect.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { Data, WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' diff --git a/test/modules/WebSocket/compliance/websocket.server.events.test.ts b/test/modules/WebSocket/compliance/websocket.server.events.test.ts index 500d24d5a..6bbad779c 100644 --- a/test/modules/WebSocket/compliance/websocket.server.events.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.events.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/compliance/websocket.server.socket.test.ts b/test/modules/WebSocket/compliance/websocket.server.socket.test.ts index 6425adf64..8b5d2a40e 100644 --- a/test/modules/WebSocket/compliance/websocket.server.socket.test.ts +++ b/test/modules/WebSocket/compliance/websocket.server.socket.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node-with-websocket -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/modules/WebSocket/compliance/websocket.setters.test.ts b/test/modules/WebSocket/compliance/websocket.setters.test.ts index 887110460..15f57e42e 100644 --- a/test/modules/WebSocket/compliance/websocket.setters.test.ts +++ b/test/modules/WebSocket/compliance/websocket.setters.test.ts @@ -1,9 +1,8 @@ /** * @vitest-environment node-with-websocket */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForNextTick } from '../utils/waitForNextTick' import { getWsUrl } from '../utils/getWsUrl' diff --git a/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts b/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts index 3e2ca5c12..4f2e87f8c 100644 --- a/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.addEventListener.test.ts @@ -1,9 +1,5 @@ -/** - * @vitest-environment node-with-websocket - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import waitForExpect from 'wait-for-expect' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +// @vitest-environment node-with-websocket +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() @@ -24,14 +20,13 @@ it('adds event listener for the "message" event', async () => { const ws = new WebSocket('wss://example.com') ws.onopen = () => ws.send('hello') - await waitForExpect(() => { - expect(messageListener).toHaveBeenCalledTimes(1) - expect(messageListener).toHaveBeenCalledWith( + await expect + .poll(() => messageListener) + .toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ data: 'hello', }) ) - }) }) it('adds event listener for the "close" event', async () => { @@ -43,14 +38,13 @@ it('adds event listener for the "close" event', async () => { const ws = new WebSocket('wss://example.com') ws.onopen = () => ws.close() - await waitForExpect(() => { - expect(closeListener).toHaveBeenCalledTimes(1) - expect(closeListener).toHaveBeenCalledWith( + await expect + .poll(() => closeListener) + .toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ code: 1000, reason: '', wasClean: true, }) ) - }) }) diff --git a/test/modules/WebSocket/exchange/websocket.client.close.browser.test.ts b/test/modules/WebSocket/exchange/websocket.client.close.browser.test.ts index c70efa4c9..fcc76ea05 100644 --- a/test/modules/WebSocket/exchange/websocket.client.close.browser.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.close.browser.test.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../../playwright.extend' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' declare global { interface Window { diff --git a/test/modules/WebSocket/exchange/websocket.client.close.test.ts b/test/modules/WebSocket/exchange/websocket.client.close.test.ts index c4b1c9a9e..a463d9dca 100644 --- a/test/modules/WebSocket/exchange/websocket.client.close.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.close.test.ts @@ -1,9 +1,8 @@ /** * @vitest-environment node-with-websocket */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts b/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts index cbc09ef8c..da35db0f5 100644 --- a/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.removeEventListener.test.ts @@ -1,12 +1,8 @@ -/** - * @vitest-environment node-with-websocket - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import waitForExpect from 'wait-for-expect' +// @vitest-environment node-with-websocket import { WebSocketClientConnection, WebSocketInterceptor, -} from '../../../../src/interceptors/WebSocket' +} from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() @@ -32,16 +28,12 @@ it('removes the listener for the given event', async () => { const ws = new WebSocket('wss://example.com') ws.onopen = () => ws.send('hello') - await waitForExpect(() => { - expect(firstListener).toHaveBeenCalledTimes(1) - expect(secondListener).toHaveBeenCalledTimes(1) - }) + await expect.poll(() => firstListener).toHaveBeenCalledOnce() + await expect.poll(() => secondListener).toHaveBeenCalledOnce() capturedClient?.removeEventListener('message', secondListener) ws.send('hello') - await waitForExpect(() => { - expect(firstListener).toHaveBeenCalledTimes(2) - expect(secondListener).toHaveBeenCalledTimes(1) - }) + await expect.poll(() => firstListener).toHaveBeenCalledTimes(2) + await expect.poll(() => secondListener).toHaveBeenCalledOnce() }) diff --git a/test/modules/WebSocket/exchange/websocket.client.send.browser.test.ts b/test/modules/WebSocket/exchange/websocket.client.send.browser.test.ts index 498d3986c..b27b74a80 100644 --- a/test/modules/WebSocket/exchange/websocket.client.send.browser.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.send.browser.test.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../../playwright.extend' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' declare global { interface Window { diff --git a/test/modules/WebSocket/exchange/websocket.client.send.test.ts b/test/modules/WebSocket/exchange/websocket.client.send.test.ts index 1db3c713a..a8f846213 100644 --- a/test/modules/WebSocket/exchange/websocket.client.send.test.ts +++ b/test/modules/WebSocket/exchange/websocket.client.send.test.ts @@ -1,9 +1,8 @@ /** * @vitest-environment node-with-websocket */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts b/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts index a02e72a45..120c2f831 100644 --- a/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts +++ b/test/modules/WebSocket/exchange/websocket.interceptor.exception.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node-with-websocket -import { it, expect, beforeAll, afterEach, afterAll, vi } from 'vitest' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/exchange/websocket.readystate.test.ts b/test/modules/WebSocket/exchange/websocket.readystate.test.ts index c45a6fa08..6e3e91ed5 100644 --- a/test/modules/WebSocket/exchange/websocket.readystate.test.ts +++ b/test/modules/WebSocket/exchange/websocket.readystate.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { waitForWebSocketEvent } from '../utils/waitForWebSocketEvent' import { waitForNextTick } from '../utils/waitForNextTick' diff --git a/test/modules/WebSocket/exchange/websocket.server.connect.browser.test.ts b/test/modules/WebSocket/exchange/websocket.server.connect.browser.test.ts index 8dce4d19d..9773c3f57 100644 --- a/test/modules/WebSocket/exchange/websocket.server.connect.browser.test.ts +++ b/test/modules/WebSocket/exchange/websocket.server.connect.browser.test.ts @@ -1,6 +1,6 @@ import { WebSocketServer } from 'ws' import { test, expect } from '../../../playwright.extend' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' declare global { diff --git a/test/modules/WebSocket/exchange/websocket.server.connect.test.ts b/test/modules/WebSocket/exchange/websocket.server.connect.test.ts index bc7002e4b..4df3bcc4b 100644 --- a/test/modules/WebSocket/exchange/websocket.server.connect.test.ts +++ b/test/modules/WebSocket/exchange/websocket.server.connect.test.ts @@ -1,8 +1,7 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { WebSocketServer } from 'ws' import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/intercept/websocket.dispose.test.ts b/test/modules/WebSocket/intercept/websocket.dispose.test.ts index 7f0dc6730..93d24beaf 100644 --- a/test/modules/WebSocket/intercept/websocket.dispose.test.ts +++ b/test/modules/WebSocket/intercept/websocket.dispose.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket/index' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket/index' import { getWsUrl } from '../utils/getWsUrl' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/intercept/websocket.send.browser.test.ts b/test/modules/WebSocket/intercept/websocket.send.browser.test.ts index 6b542376b..36c22b732 100644 --- a/test/modules/WebSocket/intercept/websocket.send.browser.test.ts +++ b/test/modules/WebSocket/intercept/websocket.send.browser.test.ts @@ -1,6 +1,6 @@ import { test, expect } from '../../../playwright.extend' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' -import { WebSocketData } from '../../../../src/interceptors/WebSocket/WebSocketTransport' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' +import { WebSocketData } from '#/src/interceptors/WebSocket/WebSocketTransport' declare global { interface Window { diff --git a/test/modules/WebSocket/intercept/websocket.send.test.ts b/test/modules/WebSocket/intercept/websocket.send.test.ts index e0c7cedb2..e92492392 100644 --- a/test/modules/WebSocket/intercept/websocket.send.test.ts +++ b/test/modules/WebSocket/intercept/websocket.send.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node-with-websocket -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/intercept/websocket.server.events.test.ts b/test/modules/WebSocket/intercept/websocket.server.events.test.ts index bb8dd5ff4..1b73c7197 100644 --- a/test/modules/WebSocket/intercept/websocket.server.events.test.ts +++ b/test/modules/WebSocket/intercept/websocket.server.events.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { WebSocketServer } from 'ws' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' import { getWsUrl } from '../utils/getWsUrl' const interceptor = new WebSocketInterceptor() diff --git a/test/modules/WebSocket/third-party/socket.io.browser.test.ts b/test/modules/WebSocket/third-party/socket.io.browser.test.ts index 41fec23f7..48e3ac3a0 100644 --- a/test/modules/WebSocket/third-party/socket.io.browser.test.ts +++ b/test/modules/WebSocket/third-party/socket.io.browser.test.ts @@ -5,7 +5,7 @@ import type { Encoder, Decoder } from 'socket.io-parser' import type { encodePacket, decodePacket } from 'engine.io-parser' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import type { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' +import type { WebSocketInterceptor } from '#/src/interceptors/WebSocket' declare global { interface Window { diff --git a/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts b/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts index cac34040e..1367ed4e1 100644 --- a/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts +++ b/test/modules/WebSocket/third-party/socket.io.send.bypass.test.ts @@ -1,15 +1,14 @@ // @vitest-environment node import http from 'node:http' -import { vi, beforeAll, beforeEach, afterAll, it, expect } from 'vitest' import { io } from 'socket.io-client' import { Server } from 'socket.io' -import { BatchInterceptor } from '../../../../src' -import { WebSocketInterceptor } from '../../../../src/interceptors/WebSocket' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { BatchInterceptor } from '#/src/BatchInterceptor' +import { WebSocketInterceptor } from '#/src/interceptors/WebSocket' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const interceptor = new BatchInterceptor({ name: 'test-interceptor', - interceptors: [new ClientRequestInterceptor(), new WebSocketInterceptor()], + interceptors: [new HttpRequestInterceptor(), new WebSocketInterceptor()], }) const wss = new Server() diff --git a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts deleted file mode 100644 index 9c144b5f5..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-add-event-listener.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// @vitest-environment jsdom -/** - * @see https://github.com/mswjs/msw/issues/273 - */ -import { it, expect, beforeAll, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - if (request.url === 'https://test.mswjs.io/user') { - controller.respondWith( - new Response(JSON.stringify({ mocked: true }), { - status: 200, - statusText: 'OK', - headers: { - 'content-type': 'application/json', - 'x-header': 'yes', - }, - }) - ) - } -}) - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('calls the "load" event attached via "addEventListener" with a mocked response', async () => { - await createXMLHttpRequest((req) => { - req.open('GET', 'https://test.mswjs.io/user') - req.responseType = 'json' - - req.addEventListener('load', function () { - const { status, response } = this - const headers = this.getAllResponseHeaders() - - expect(status).toBe(200) - expect(headers).toContain('x-header: yes') - expect(response).toEqual({ mocked: true }) - }) - req.send() - }) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.neutral.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.neutral.test.ts new file mode 100644 index 000000000..28b3e8b71 --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.neutral.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment happy-dom +/** + * @see https://xhr.spec.whatwg.org/#event-handlers + */ +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it.each<[name: string, getUrl: () => string]>([ + ['passthrough', () => server.https.url('/').href], + ['mocked', () => 'http://localhost/resource'], +])( + `does not fail when unsetting event handlers for a successful %s response`, + async (_, getUrl) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith(new Response('hello')) + }) + const url = getUrl() + + const request = new XMLHttpRequest() + request.open('POST', url) + request.onreadystatechange = null + request.onloadstart = null + request.onprogress = null + request.onload = null + request.onloadend = null + request.ontimeout = null + request.send('hello') + + await waitForXMLHttpRequest(request) + + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(200) + expect.soft(request.responseText).toBe('hello') + } +) + +it.each<[name: string, getUrl: () => string]>([ + ['passthrough', () => server.https.url('/server-error').href], + ['mocked', () => 'http://localhost/error-response'], +])( + `does not fail when unsetting event handlers for a %s error response`, + async (_, getUrl) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith( + new Response('Internal Server Error', { status: 500 }) + ) + }) + const url = getUrl() + + const request = new XMLHttpRequest() + request.open('GET', url) + request.onreadystatechange = null + request.onloadstart = null + request.onprogress = null + request.onload = null + request.onloadend = null + request.ontimeout = null + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(500) + expect.soft(request.responseText).toBe('Internal Server Error') + } +) + +it.each<[name: string, getUrl: () => string]>([ + ['passthrough', () => server.https.url('/network-error').href], + ['mocked', () => 'http://localhost/network-error'], +])( + `does not fail when unsetting event handlers for a %s request error`, + async (_, getUrl) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith(Response.error()) + }) + const url = getUrl() + + const request = new XMLHttpRequest() + request.open('GET', url) + request.onreadystatechange = null + request.onloadstart = null + request.onprogress = null + request.onload = null + request.onloadend = null + request.ontimeout = null + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(0) + expect.soft(request.responseText).toBe('') + } +) + +it('does not fail when unsetting event handlers during unhandled exception in the interceptor', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + throw new Error('Custom error') + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', server.https.url('/network-error')) + request.onreadystatechange = null + request.onloadstart = null + request.onprogress = null + request.onload = null + request.onloadend = null + request.ontimeout = null + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(500) + expect.soft(request.statusText).toBe('Unhandled Exception') + expect.soft(request.response).toEqual({ + name: 'Error', + message: 'Custom error', + stack: expect.any(String), + }) +}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts deleted file mode 100644 index 00dd8fd3d..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-callback-null.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * @note https://xhr.spec.whatwg.org/#event-handlers - */ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() - -const httpServer = new HttpServer((app) => { - app.get('/resource', (req, res) => { - res.send('hello') - }) - app.get('/error-response', (req, res) => { - res.status(500).send('Internal Server Error') - }) - app.get('/exception', (req, res) => { - throw new Error('Server error') - }) -}) - -beforeAll(async () => { - interceptor.apply() - interceptor.on('request', ({ request, controller }) => { - switch (true) { - case request.url.endsWith('/exception'): { - throw new Error('Custom error') - } - - case request.url.endsWith('/network-error'): { - return controller.respondWith(Response.error()) - } - - case request.url.endsWith('/error-response'): { - return controller.respondWith( - new Response('Internal Server Error', { status: 500 }) - ) - } - - default: - return controller.respondWith(new Response('hello')) - } - }) - - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/resource')], - ['mocked', () => 'http://localhost/resource'], -])( - `does not fail when unsetting event handlers for a successful %s response`, - async (_, getUrl) => { - const url = getUrl() - - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = null - request.onloadstart = null - request.onprogress = null - request.onload = null - request.onloadend = null - request.ontimeout = null - - request.send() - }) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(200) - expect(request.responseText).toBe('hello') - } -) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/error-response')], - ['mocked', () => 'http://localhost/error-response'], -])( - `does not fail when unsetting event handlers for a %s error response`, - async (_, getUrl) => { - const url = getUrl() - - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = null - request.onloadstart = null - request.onprogress = null - request.onload = null - request.onloadend = null - request.ontimeout = null - - request.send() - }) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(500) - expect(request.responseText).toBe('Internal Server Error') - } -) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/network-error')], - ['mocked', () => 'http://localhost/network-error'], -])( - `does not fail when unsetting event handlers for a %s request error`, - async (_, getUrl) => { - const url = getUrl() - - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = null - request.onloadstart = null - request.onprogress = null - request.onload = null - request.onloadend = null - request.ontimeout = null - - request.send() - }) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(0) - expect(request.responseText).toBe('') - } -) - -it('does not fail when unsetting event handlers during unhandled exception in the interceptor', async () => { - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', httpServer.https.url('/exception')) - - request.onreadystatechange = null - request.onloadstart = null - request.onprogress = null - request.onload = null - request.onloadend = null - request.ontimeout = null - - request.send() - }) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(500) - expect(request.statusText).toBe('Unhandled Exception') - expect(request.response).toEqual({ - name: 'Error', - message: 'Custom error', - stack: expect.any(String), - }) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.browser.test.ts index 718b3bcb0..87e509cba 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.browser.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.browser.test.ts @@ -1,6 +1,6 @@ import { HttpServer } from '@open-draft/test-server/http' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.use(useCors) @@ -17,7 +17,10 @@ test.afterAll(async () => { await httpServer.close() }) -test('onloadend handler is called when not returning a mocked response', async ({ page, loadExample }) => { +test('onloadend handler is called when not returning a mocked response', async ({ + page, + loadExample, +}) => { await loadExample(require.resolve('./xhr-event-handlers.browser.runtime.js')) const { request, calls } = await page.evaluate(async (url) => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts deleted file mode 100644 index 8ade2023b..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-event-handlers.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * @note https://xhr.spec.whatwg.org/#event-handlers - */ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() - -const httpServer = new HttpServer((app) => { - app.get('/resource', (req, res) => { - res.send('hello') - }) - app.get('/error-response', (req, res) => { - res.status(500).send('Internal Server Error') - }) - app.get('/exception', (req, res) => { - throw new Error('Server error') - }) -}) - -beforeAll(async () => { - interceptor.apply() - interceptor.on('request', ({ request, controller }) => { - switch (true) { - case request.url.endsWith('/exception'): { - throw new Error('Custom error') - } - - case request.url.endsWith('/network-error'): { - return controller.respondWith(Response.error()) - } - - case request.url.endsWith('/error-response'): { - return controller.respondWith( - new Response('Internal Server Error', { status: 500 }) - ) - } - - default: - return controller.respondWith(new Response('hello')) - } - }) - - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/resource')], - ['mocked', () => 'http://localhost/resource'], -])( - `dispatches relevant events upon a successful %s response`, - async (_, getUrl) => { - const url = getUrl() - - const onReadyStateChangeHandler = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const onLoadStartHandler = vi.fn() - const onProgressHandler = vi.fn() - const onLoadHandler = vi.fn() - const onLoadEndHandler = vi.fn() - - const onReadyStateChangeListener = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const loadStartListener = vi.fn() - const progressListener = vi.fn() - const loadListener = vi.fn() - const loadEndListener = vi.fn() - - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - - request.send() - }) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(200) - expect(request.responseText).toBe('hello') - - expect(onReadyStateChangeHandler).toHaveBeenCalledTimes(3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(1, 2) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(2, 3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(3, 4) - expect(onLoadStartHandler).toHaveBeenCalledTimes(1) - expect(onProgressHandler).toHaveBeenCalledTimes(1) - expect(onLoadHandler).toHaveBeenCalledTimes(1) - expect(onLoadEndHandler).toHaveBeenCalledTimes(1) - - expect(onReadyStateChangeListener).toHaveBeenCalledTimes(3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(1, 2) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(2, 3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(3, 4) - expect(loadStartListener).toHaveBeenCalledTimes(1) - expect(progressListener).toHaveBeenCalledTimes(1) - expect(loadListener).toHaveBeenCalledTimes(1) - expect(loadEndListener).toHaveBeenCalledTimes(1) - } -) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/error-response')], - ['mocked', () => 'http://localhost/error-response'], -])(`dispatches relevant events upon a %s error response`, async (_, getUrl) => { - const url = getUrl() - - const onReadyStateChangeHandler = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const onLoadStartHandler = vi.fn() - const onProgressHandler = vi.fn() - const onLoadHandler = vi.fn() - const onLoadEndHandler = vi.fn() - - const onReadyStateChangeListener = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const loadStartListener = vi.fn() - const progressListener = vi.fn() - const loadListener = vi.fn() - const loadEndListener = vi.fn() - - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - - request.send() - }) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(500) - expect(request.responseText).toBe('Internal Server Error') - - expect(onReadyStateChangeHandler).toHaveBeenCalledTimes(3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(1, 2) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(2, 3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(3, 4) - expect(onLoadStartHandler).toHaveBeenCalledTimes(1) - expect(onProgressHandler).toHaveBeenCalledTimes(1) - expect(onLoadHandler).toHaveBeenCalledTimes(1) - expect(onLoadEndHandler).toHaveBeenCalledTimes(1) - - expect(onReadyStateChangeListener).toHaveBeenCalledTimes(3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(1, 2) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(2, 3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(3, 4) - expect(loadStartListener).toHaveBeenCalledTimes(1) - expect(progressListener).toHaveBeenCalledTimes(1) - expect(loadListener).toHaveBeenCalledTimes(1) - expect(loadEndListener).toHaveBeenCalledTimes(1) -}) - -it.each<[name: string, getUrl: () => string]>([ - ['passthrough', () => httpServer.https.url('/network-error')], - ['mocked', () => 'http://localhost/network-error'], -])(`dispatches relevant events upon a %s request error`, async (_, getUrl) => { - const url = getUrl() - - const onReadyStateChangeHandler = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const onLoadStartHandler = vi.fn() - const onProgressHandler = vi.fn() - const onLoadHandler = vi.fn() - const onLoadEndHandler = vi.fn() - - const onReadyStateChangeListener = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const loadStartListener = vi.fn() - const progressListener = vi.fn() - const loadListener = vi.fn() - const loadEndListener = vi.fn() - - const request = await createXMLHttpRequest((request) => { - request.open('GET', url) - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - - request.send() - }) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(0) - expect(request.responseText).toBe('') - - expect(onReadyStateChangeHandler).toHaveBeenCalledTimes(1) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(1, 4) - expect(onLoadStartHandler).not.toHaveBeenCalled() - expect(onProgressHandler).not.toHaveBeenCalled() - expect(onLoadHandler).not.toHaveBeenCalled() - expect(onLoadEndHandler).toHaveBeenCalledTimes(1) - - expect(onReadyStateChangeListener).toHaveBeenCalledTimes(1) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(1, 4) - expect(loadStartListener).not.toHaveBeenCalled() - expect(progressListener).not.toHaveBeenCalled() - expect(loadListener).not.toHaveBeenCalled() - expect(loadEndListener).toHaveBeenCalledTimes(1) -}) - -it('dispatched relevant events upon an unhandled exception in the interceptor', async () => { - const onReadyStateChangeHandler = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const onLoadStartHandler = vi.fn() - const onProgressHandler = vi.fn() - const onLoadHandler = vi.fn() - const onLoadEndHandler = vi.fn() - const onErrorHandler = vi.fn() - - const onReadyStateChangeListener = vi.fn(function (this: XMLHttpRequest) { - return this.readyState - }) - const loadStartListener = vi.fn() - const progressListener = vi.fn() - const loadListener = vi.fn() - const loadEndListener = vi.fn() - const errorListener = vi.fn() - - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - - request.onreadystatechange = onReadyStateChangeHandler - request.onloadstart = onLoadStartHandler - request.onprogress = onProgressHandler - request.onload = onLoadHandler - request.onloadend = onLoadEndHandler - request.onerror = onErrorHandler - - request.addEventListener('readystatechange', onReadyStateChangeListener) - request.addEventListener('loadstart', loadStartListener) - request.addEventListener('progress', progressListener) - request.addEventListener('load', loadListener) - request.addEventListener('loadend', loadEndListener) - request.addEventListener('error', errorListener) - - // Open the connection after the callbacks/listeners have been added. - // Some XHR interactions, like file uploads, require you to add the - // progress listeners BEFORE the request is open. - request.open('GET', httpServer.https.url('/exception')) - request.send() - }) - - expect(request.readyState).toBe(4) - expect(request.status).toBe(500) - expect(request.statusText).toBe('Unhandled Exception') - expect(request.response).toEqual({ - name: 'Error', - message: 'Custom error', - stack: expect.any(String), - }) - - expect(onReadyStateChangeHandler).toHaveBeenCalledTimes(4) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(1, 1) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(2, 2) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(3, 3) - expect(onReadyStateChangeHandler).toHaveNthReturnedWith(4, 4) - expect(onLoadStartHandler).toHaveBeenCalledTimes(1) - expect(onProgressHandler).toHaveBeenCalledTimes(1) - expect(onLoadHandler).toHaveBeenCalledTimes(1) - expect(onLoadEndHandler).toHaveBeenCalledTimes(1) - expect(onErrorHandler).not.toHaveBeenCalled() - - expect(onReadyStateChangeListener).toHaveBeenCalledTimes(4) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(1, 1) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(2, 2) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(3, 3) - expect(onReadyStateChangeListener).toHaveNthReturnedWith(4, 4) - expect(loadStartListener).toHaveBeenCalledTimes(1) - expect(progressListener).toHaveBeenCalledTimes(1) - expect(loadListener).toHaveBeenCalledTimes(1) - expect(loadEndListener).toHaveBeenCalledTimes(1) - expect(errorListener).not.toHaveBeenCalled() -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts deleted file mode 100644 index 9d346a30e..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -// @vitest-environment jsdom -/** - * @see https://xhr.spec.whatwg.org/#events - */ -import { Mock, vi, it, expect, beforeAll, afterAll } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/', (_req, res) => { - res.status(200).end() - }) - app.get('/numbers', (_req, res) => { - res.status(200).json([1, 2, 3]) - }) -}) - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - switch (url.pathname) { - case '/user': { - controller.respondWith(new Response()) - break - } - - case '/numbers-mock': { - controller.respondWith(new Response(JSON.stringify([1, 2, 3]))) - break - } - } -}) - -function spyOnEvents(req: XMLHttpRequest, listener: Mock) { - function wrapListener(this: XMLHttpRequest, event: Event) { - listener(event.type, this.readyState) - } - - req.addEventListener('readystatechange', wrapListener) - req.addEventListener('loadstart', wrapListener) - req.addEventListener('progress', wrapListener) - req.addEventListener('timeout', wrapListener) - req.addEventListener('load', wrapListener) - req.addEventListener('loadend', wrapListener) - req.addEventListener('abort', wrapListener) - req.addEventListener('error', wrapListener) -} - -beforeAll(async () => { - interceptor.apply() - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('emits correct events sequence for an unhandled request with no response body', async () => { - const listener = vi.fn() - const req = await createXMLHttpRequest((req) => { - spyOnEvents(req, listener) - req.open('GET', httpServer.http.url()) - req.send() - }) - - expect(listener.mock.calls).toEqual([ - ['readystatechange', 1], // OPEN - ['loadstart', 1], - ['readystatechange', 2], // HEADERS_RECEIVED - ['readystatechange', 4], // DONE - - ['load', 4], - ['loadend', 4], - ]) - expect(req.readyState).toEqual(4) -}) - -it('emits correct events sequence for a handled request with no response body', async () => { - const listener = vi.fn() - const req = await createXMLHttpRequest((req) => { - spyOnEvents(req, listener) - req.open('GET', httpServer.http.url('/user')) - req.send() - }) - - expect(listener.mock.calls).toEqual([ - ['readystatechange', 1], // OPEN - ['loadstart', 1], - ['readystatechange', 2], // HEADERS_RECEIVED - ['readystatechange', 3], // LOADING - ['readystatechange', 4], // DONE - ['load', 4], - ['loadend', 4], - ]) - expect(req.readyState).toBe(4) -}) - -it('emits correct events sequence for an unhandled request with a response body', async () => { - const listener = vi.fn() - const req = await createXMLHttpRequest((req) => { - spyOnEvents(req, listener) - req.open('GET', httpServer.http.url('/numbers')) - req.send() - }) - - expect(listener.mock.calls).toEqual([ - ['readystatechange', 1], // OPEN - ['loadstart', 1], - ['readystatechange', 2], // HEADERS_RECEIVED - ['readystatechange', 3], // LOADING - ['progress', 3], - ['readystatechange', 4], - ['load', 4], - ['loadend', 4], - ]) - expect(req.readyState).toBe(4) -}) - -it('emits correct events sequence for a handled request with a response body', async () => { - const listener = vi.fn() - const req = await createXMLHttpRequest((req) => { - spyOnEvents(req, listener) - req.open('GET', httpServer.http.url('/numbers-mock')) - req.send() - }) - - expect(listener.mock.calls).toEqual([ - ['readystatechange', 1], // OPEN - ['loadstart', 1], - ['readystatechange', 2], // HEADERS_RECEIVED - ['readystatechange', 3], // LOADING - ['progress', 3], - ['readystatechange', 4], // DONE - ['load', 4], - ['loadend', 4], - ]) - expect(req.readyState).toBe(4) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index c7f6fd772..33fbfbf3c 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -1,11 +1,10 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/msw/issues/355 */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import axios from 'axios' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() @@ -26,11 +25,12 @@ it('XMLHttpRequest: treats unhandled interceptor exceptions as 500 responses', a throw new Error('Custom error') }) - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', 'http://localhost/api') - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://localhost/api') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(500) expect(request.statusText).toBe('Unhandled Exception') @@ -42,7 +42,16 @@ it('XMLHttpRequest: treats unhandled interceptor exceptions as 500 responses', a }) it('axios: unhandled interceptor exceptions are treated as 500 responses', async () => { - interceptor.on('request', () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + throw new Error('Custom error') }) @@ -67,11 +76,12 @@ it('treats a thrown Response instance as a mocked response', async () => { throw new Response('hello world') }) - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(200) expect(request.response).toBe('hello world') @@ -84,12 +94,13 @@ it('treats a Response.error() as a network error', async () => { }) const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.addEventListener('error', requestErrorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(0) expect(requestErrorListener).toHaveBeenCalledTimes(1) @@ -101,12 +112,13 @@ it('treats a thrown Response.error() as a network error', async () => { }) const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.addEventListener('error', requestErrorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(0) expect(requestErrorListener).toHaveBeenCalledTimes(1) @@ -120,11 +132,12 @@ it('handles exceptions by default if "unhandledException" listener is provided b }) interceptor.on('unhandledException', unhandledExceptionListener) - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', 'http://localhost/api') - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://localhost/api') + request.send() + + await waitForXMLHttpRequest(request) // Must emit the "unhandledException" interceptor event. expect(unhandledExceptionListener).toHaveBeenCalledWith( @@ -150,57 +163,66 @@ it('handles exceptions as instructed in "unhandledException" listener (mock resp throw new Error('Custom error') }) interceptor.on('unhandledException', (args) => { - const { controller } = args unhandledExceptionListener(args) // Handle exceptions as a fallback 200 OK response. - controller.respondWith(new Response('fallback response')) + args.controller.respondWith(new Response('fallback response')) }) const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.addEventListener('error', requestErrorListener) + request.send() - expect(request.status).toBe(200) - expect(request.response).toBe('fallback response') + await waitForXMLHttpRequest(request) - expect(unhandledExceptionListener).toHaveBeenCalledWith( + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('fallback response') + + expect.soft(unhandledExceptionListener).toHaveBeenCalledWith( expect.objectContaining({ error: new Error('Custom error'), }) ) - expect(unhandledExceptionListener).toHaveBeenCalledOnce() - expect(requestErrorListener).not.toHaveBeenCalled() + expect.soft(unhandledExceptionListener).toHaveBeenCalledOnce() + expect.soft(requestErrorListener).not.toHaveBeenCalled() }) it('handles exceptions as instructed in "unhandledException" listener (request error)', async () => { const unhandledExceptionListener = vi.fn() - interceptor.on('request', () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + throw new Error('Custom error') }) interceptor.on('unhandledException', (args) => { - const { request, controller } = args unhandledExceptionListener(args) // Handle exceptions as request errors. - controller.errorWith(new Error('Fallback error')) + args.controller.errorWith(new Error('Fallback error')) }) const requestErrorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.responseType = 'text' - request.open('GET', 'http://localhost/api') - request.addEventListener('error', requestErrorListener) - request.send() - }) + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.addEventListener('error', requestErrorListener) + request.send() + + await waitForXMLHttpRequest(request) expect(requestErrorListener).toHaveBeenCalledOnce() - expect(request.readyState).toBe(request.DONE) + expect(request.readyState).toBe(4) expect(unhandledExceptionListener).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts index 6f4b53dbf..6aff31365 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -1,8 +1,8 @@ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const server = new HttpServer((app) => { app.use(useCors) @@ -27,40 +27,48 @@ const server = new HttpServer((app) => { const interceptor = new XMLHttpRequestInterceptor() beforeAll(async () => { - vi.spyOn(console, 'warn').mockImplementation(() => void 0) await server.listen() interceptor.apply() }) afterAll(async () => { - vi.restoreAllMocks() await server.close() interceptor.dispose() }) it('allows modifying outgoing request headers', async () => { interceptor.on('request', ({ request }) => { + if (request.method === 'OPTIONS') { + return + } + request.headers.delete('X-Delete-Header') request.headers.append('X-Append-Header', '2') request.headers.set('X-Set-Header', 'new-value') }) - const req = await createXMLHttpRequest((req) => { - req.open('GET', server.http.url('/user')) - req.setRequestHeader('X-Delete-Header', 'a') - req.setRequestHeader('X-Append-Header', '1') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', server.http.url('/user')) + request.setRequestHeader('X-Delete-Header', 'a') + request.setRequestHeader('X-Append-Header', '1') + request.send() - // Cannot delete XMLHttpRequest headers. - expect(req.getResponseHeader('x-delete-header')).toBe('a') - expect(console.warn).toHaveBeenCalledWith( - expect.stringMatching( - `XMLHttpRequest: Cannot remove a "X-Delete-Header" header from the Fetch API representation of the "GET http://127.0.0.1:\\d+/user" request. XMLHttpRequest headers cannot be removed.` - ) - ) + await waitForXMLHttpRequest(request) - // Adding and modifying XMLHttpRequest headers is allowed. - expect(req.getResponseHeader('x-append-header')).toBe('1, 2') - expect(req.getResponseHeader('x-set-header')).toBe('new-value') + expect.soft(request.status).toBe(200) + expect + .soft( + request.getResponseHeader('x-delete-header'), + 'XMLHttpRequest headers cannot be deleted' + ) + .toBe('a') + expect + .soft( + request.getResponseHeader('x-append-header'), + 'Appends a new header value' + ) + .toBe('1, 2') + expect + .soft(request.getResponseHeader('x-set-header'), 'Replace a header value') + .toBe('new-value') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts deleted file mode 100644 index b4dcda3bb..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-no-response-headers.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() - -const httpServer = new HttpServer((app) => { - app.get('/user', (_req, res) => { - res.header('access-control-allow-origin', '*') - res.header('content-type', 'plain/text') - res.send('hello world') - }) -}) - -beforeAll(async () => { - interceptor.apply() - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('handles original response without any headers', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/user')) - request.setRequestHeader('accept', 'plain/text') - request.send() - }) - - expect(request.status).toEqual(200) - expect(request.statusText).toEqual('OK') - expect(request.responseText).toEqual('hello world') - /** - * @note Having an XHR response with no headers is virtually impossible - * due to the CORS and preflight requests policies. - */ - expect(request.getAllResponseHeaders()).toEqual( - ['content-type: plain/text; charset=utf-8', 'content-length: 11'].join( - '\r\n' - ) - ) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts deleted file mode 100644 index 97b629648..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('exposes ready state enums both as static and public properties', () => { - expect(XMLHttpRequest.UNSENT).toEqual(0) - expect(XMLHttpRequest.OPENED).toEqual(1) - expect(XMLHttpRequest.HEADERS_RECEIVED).toEqual(2) - expect(XMLHttpRequest.LOADING).toEqual(3) - expect(XMLHttpRequest.DONE).toEqual(4) - - const xhr = new XMLHttpRequest() - expect(xhr.UNSENT).toEqual(0) - expect(xhr.OPENED).toEqual(1) - expect(xhr.HEADERS_RECEIVED).toEqual(2) - expect(xhr.LOADING).toEqual(3) - expect(xhr.DONE).toEqual(4) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.v-browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.v-browser.test.ts new file mode 100644 index 000000000..14da4697f --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-ready-state-enums.v-browser.test.ts @@ -0,0 +1,28 @@ +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('exposes ready state enums as static properties on XMLHttpRequest', () => { + expect.soft(XMLHttpRequest.UNSENT).toEqual(0) + expect.soft(XMLHttpRequest.OPENED).toEqual(1) + expect.soft(XMLHttpRequest.HEADERS_RECEIVED).toEqual(2) + expect.soft(XMLHttpRequest.LOADING).toEqual(3) + expect.soft(XMLHttpRequest.DONE).toEqual(4) +}) + +it('exposes ready state enums as instance-level properties', () => { + const xhr = new XMLHttpRequest() + expect.soft(xhr.UNSENT).toEqual(0) + expect.soft(xhr.OPENED).toEqual(1) + expect.soft(xhr.HEADERS_RECEIVED).toEqual(2) + expect.soft(xhr.LOADING).toEqual(3) + expect.soft(xhr.DONE).toEqual(4) +}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts index 13fbbf78a..e72f4d8c5 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts @@ -1,8 +1,8 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' interface ResponseType { requestRawHeaders: Array @@ -37,14 +37,15 @@ afterAll(async () => { }) it('sends the request headers to the server', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/')) - req.setRequestHeader('X-ClienT-HeadeR', 'abc-123') - req.setRequestHeader('X-Multi-Value', 'value1; value2') - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/')) + request.setRequestHeader('X-ClienT-HeadeR', 'abc-123') + request.setRequestHeader('X-Multi-Value', 'value1; value2') + request.send() + + await waitForXMLHttpRequest(request) // Normalized request headers list all headers in lower-case. - expect(req.getResponseHeader('x-client-header')).toEqual('abc-123') - expect(req.getResponseHeader('x-multi-value')).toEqual('value1; value2') + expect(request.getResponseHeader('x-client-header')).toEqual('abc-123') + expect(request.getResponseHeader('x-multi-value')).toEqual('value1; value2') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts index 77aa6e0b3..70483efa4 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-method.test.ts @@ -1,7 +1,6 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() @@ -24,10 +23,11 @@ it('supports lowercase HTTP request method', async () => { } }) - const request = await createXMLHttpRequest((request) => { - request.open('post', 'http://localhost/resource') - request.send() - }) + const request = new XMLHttpRequest() + request.open('post', 'http://localhost/resource') + request.send() + + await waitForXMLHttpRequest(request) expect(request.responseText).toBe('hello world') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts deleted file mode 100644 index 8415bbd4e..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ controller }) => { - controller.respondWith( - new Response(null, { - status: 401, - statusText: 'Unauthorized', - }) - ) -}) - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('sends a mocked response with an empty response body', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/arbitrary-url') - req.send() - }) - - expect(req.status).toEqual(401) - expect(req.response).toEqual('') -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts index 21850fdb1..777461ea0 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts @@ -1,7 +1,6 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() @@ -36,25 +35,27 @@ afterAll(() => { }) it('handles response of type "json" and missing response JSON body', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('PUT', '/no-body') - req.responseType = 'json' - req.send() - }) + const request = new XMLHttpRequest() + request.open('PUT', '/no-body') + request.responseType = 'json' + request.send() + + await waitForXMLHttpRequest(request) // When XHR fails to parse a given response JSON body, // fall back to null, as the failed JSON parsing result. - expect(req.response).toBe(null) - expect(req.responseType).toBe('json') + expect(request.response).toBe(null) + expect(request.responseType).toBe('json') }) it('handles response of type "json" and invalid response JSON body', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/invalid-json') - req.responseType = 'json' - req.send() - }) - - expect(req.response).toBe(null) - expect(req.responseType).toEqual('json') + const request = new XMLHttpRequest() + request.open('GET', '/invalid-json') + request.responseType = 'json' + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.response).toBe(null) + expect(request.responseType).toEqual('json') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts deleted file mode 100644 index f6301e858..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @vitest-environment jsdom -import { it, expect, describe, beforeAll, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const XML_STRING = 'Content' - -describe('Content-Type: application/xml', () => { - const interceptor = new XMLHttpRequestInterceptor() - interceptor.on('request', ({ controller }) => { - controller.respondWith( - new Response(XML_STRING, { - status: 200, - headers: { 'Content-Type': 'application/xml' }, - }) - ) - }) - - beforeAll(() => { - interceptor.apply() - }) - - afterAll(() => { - interceptor.dispose() - }) - - it('supports a mocked response with an XML response body', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '/arbitrary-url') - request.send() - }) - - expect(request.responseXML).toStrictEqual( - new DOMParser().parseFromString(XML_STRING, 'application/xml') - ) - }) -}) - -describe('Content-Type: text/xml', () => { - const interceptor = new XMLHttpRequestInterceptor() - interceptor.on('request', ({ controller }) => { - controller.respondWith( - new Response(XML_STRING, { - status: 200, - headers: { 'Content-Type': 'text/xml' }, - }) - ) - }) - - beforeAll(() => { - interceptor.apply() - }) - - afterAll(() => { - interceptor.dispose() - }) - - it('supports a mocked response with an XML response body', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '/arbitrary-url') - request.send() - }) - - expect(request.responseXML).toStrictEqual( - new DOMParser().parseFromString(XML_STRING, 'text/xml') - ) - }) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts new file mode 100644 index 000000000..7259e555f --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-forbidden-headers.neutral.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('does not propagate the forbidden "cookie" header on the bypassed response', async () => { + const request = new XMLHttpRequest() + request.open('POST', server.https.url('/cookie')) + request.setRequestHeader('Set-Cookie', 'foo=bar') + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.getAllResponseHeaders()).not.toMatch(/cookie/) +}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts index 7b5cbec7d..6b959bdc3 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers-case-sensitivity.test.ts @@ -1,8 +1,8 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { createXMLHttpRequest, useCors } from '../../../helpers' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { useCors } from '#/test/helpers' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) @@ -28,12 +28,13 @@ afterAll(async () => { }) it('ignores casing when retrieving response headers via "getResponseHeader"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/account')) - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/account')) + request.send() + + await waitForXMLHttpRequest(request) - expect(req.getResponseHeader('x-response-type')).toEqual('bypass') - expect(req.getResponseHeader('X-response-Type')).toEqual('bypass') - expect(req.getResponseHeader('X-RESPONSE-TYPE')).toEqual('bypass') + expect(request.getResponseHeader('x-response-type')).toEqual('bypass') + expect(request.getResponseHeader('X-response-Type')).toEqual('bypass') + expect(request.getResponseHeader('X-RESPONSE-TYPE')).toEqual('bypass') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts index 3a422f4ad..c59feb3c6 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts @@ -1,12 +1,12 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment happy-dom import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) - app.head('/', (_req, res) => { + app.get('/', (_req, res) => { res .set({ // Specify which response headers to expose to the client. @@ -19,51 +19,64 @@ const httpServer = new HttpServer((app) => { }) const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - if (!url.searchParams.has('mock')) { - return - } - - controller.respondWith( - new Response(null, { - headers: { - etag: '123', - 'x-response-type': 'mock', - }, - }) - ) -}) beforeAll(async () => { interceptor.apply() await httpServer.listen() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(async () => { interceptor.dispose() await httpServer.close() }) -it('retrieves the mocked response headers when called ".getAllResponseHeaders()"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/?mock=true') - req.send() - }) +it('returns the bypass response headers when called ".getAllResponseHeaders()"', async () => { + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/')) + request.send() - const responseHeaders = req.getAllResponseHeaders() - expect(responseHeaders).toEqual('etag: 123\r\nx-response-type: mock') + await waitForXMLHttpRequest(request) + + expect(request.getAllResponseHeaders()).toContain( + 'etag: 456\r\nx-response-type: bypass' + ) }) -it('returns the bypass response headers when called ".getAllResponseHeaders()"', async () => { - const req = await createXMLHttpRequest((req) => { - // Perform a HEAD request so that the response has no "Content-Type" header - // always appended by Express. - req.open('HEAD', httpServer.http.url('/')) - req.send() +it('retrieves the mocked response headers when called ".getAllResponseHeaders()"', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Expose-Headers': 'etag, x-response-type', + }, + }) + ) + } + + controller.respondWith( + new Response(null, { + headers: { + etag: '123', + 'x-response-type': 'mock', + }, + }) + ) }) - const responseHeaders = req.getAllResponseHeaders() - expect(responseHeaders).toEqual('etag: 456\r\nx-response-type: bypass') + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.getAllResponseHeaders()).toBe( + 'etag: 123\r\nx-response-type: mock' + ) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts index ac6aa5f98..8f84fba58 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-non-configurable.test.ts @@ -1,59 +1,73 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/msw/issues/2307 */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { FetchResponse } from '../../../../src/utils/fetchUtils' -import { createXMLHttpRequest, useCors } from '../../../helpers' -import { DeferredPromise } from '@open-draft/deferred-promise' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { FetchResponse } from '#/src/utils/fetchUtils' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/resource', (_req, res) => { - res.writeHead(101, 'Switching Protocols') - res.end() - }) -}) - -beforeAll(async () => { +beforeAll(() => { interceptor.apply() - await httpServer.listen() }) afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await httpServer.close() }) it('handles non-configurable responses from the actual server', async () => { - const responsePromise = new DeferredPromise() - interceptor.on('response', ({ response }) => { - responsePromise.resolve(response) - }) + const responseListener = vi.fn() + interceptor.on('response', responseListener) - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/resource')) - request.send() - }) + const url = server.http.url('/status') + const request = new XMLHttpRequest() + request.open('POST', url) + request.send('101') + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(101) + expect.soft(request.statusText).toBe('Switching Protocols') + expect.soft(request.responseText).toBe('') + + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] - expect(request.status).toBe(101) - expect(request.statusText).toBe('Switching Protocols') - expect(request.responseText).toBe('') + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + expect.soft(response.status).toBe(200) + } - // Must expose the exact response in the listener. - await expect(responsePromise).resolves.toHaveProperty('status', 101) + { + const [{ request, response }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('POST') + expect.soft(request.url).toBe(url.href) + expect.soft(response.status).toBe(101) + } }) it('supports mocking non-configurable responses', async () => { - interceptor.on('request', ({ controller }) => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + /** * @note The Fetch API `Response` will still error on * non-configurable status codes. Instead, use this helper class. @@ -61,19 +75,34 @@ it('supports mocking non-configurable responses', async () => { controller.respondWith(new FetchResponse(null, { status: 101 })) }) - const responsePromise = new DeferredPromise() - interceptor.on('response', ({ response }) => { - responsePromise.resolve(response) - }) + const responseListener = vi.fn() + interceptor.on('response', responseListener) - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/resource')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(101) + expect.soft(request.response).toBe('') + + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe('http://any.host.here/irrelevant') + expect.soft(response.status).toBe(204) + } - expect(request.status).toBe(101) - expect(request.responseText).toBe('') + { + const [{ request, response }] = responseListener.mock.calls[1] - // Must expose the exact response in the listener. - await expect(responsePromise).resolves.toHaveProperty('status', 101) + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe('http://any.host.here/irrelevant') + expect.soft(response.status).toBe(101) + } }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-progress.browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-progress.browser.test.ts index f9e72897d..d54b2d5b5 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-progress.browser.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-progress.browser.test.ts @@ -2,9 +2,9 @@ * @see https://github.com/mswjs/interceptors/issues/614 */ import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' declare global { interface Window { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts index 1f942d860..636dda91c 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts @@ -1,9 +1,9 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll } from 'vitest' -import { encodeBuffer } from '../../../../src' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { toArrayBuffer } from '../../../../src/utils/bufferUtils' -import { createXMLHttpRequest, readBlob } from '../../../helpers' +// @vitest-environment happy-dom +import { encodeBuffer } from '#/src/index' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { toArrayBuffer } from '#/src/utils/bufferUtils' +import { readBlob } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', ({ controller }) => { @@ -31,25 +31,27 @@ afterAll(() => { }) it('responds with an object when "responseType" equals "json"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/arbitrary-url') - req.responseType = 'json' - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.responseType = 'json' + request.send() + + await waitForXMLHttpRequest(request) - expect(typeof req.response).toBe('object') - expect(req.response).toEqual({ + expect(typeof request.response).toBe('object') + expect(request.response).toEqual({ firstName: 'John', lastName: 'Maverick', }) }) it('responds with a Blob when "responseType" equals "blob"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/arbitrary-url') - req.responseType = 'blob' - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.responseType = 'blob' + request.send() + + await waitForXMLHttpRequest(request) const expectedBlob = new Blob( [ @@ -63,7 +65,7 @@ it('responds with a Blob when "responseType" equals "blob"', async () => { } ) - const responseBlob: Blob = req.response + const responseBlob: Blob = request.response const expectedBlobContents = await readBlob(responseBlob) expect(responseBlob).toBeInstanceOf(Blob) @@ -79,11 +81,12 @@ it('responds with a Blob when "responseType" equals "blob"', async () => { }) it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', '/arbitrary-url') - req.responseType = 'arraybuffer' - req.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/arbitrary-url') + request.responseType = 'arraybuffer' + request.send() + + await waitForXMLHttpRequest(request) const expectedArrayBuffer = toArrayBuffer( encodeBuffer( @@ -94,7 +97,7 @@ it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', asyn ) ) - const responseBuffer = req.response as ArrayBuffer + const responseBuffer = request.response as ArrayBuffer const isBufferEqual = (left: ArrayBuffer, right: ArrayBuffer): boolean => { const first = new Uint8Array(left) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts new file mode 100644 index 000000000..320ffa915 --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-without-body.test.ts @@ -0,0 +1,200 @@ +// @vitest-environment happy-dom +import { HttpServer } from '@open-draft/test-server/http' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { useCors } from '#/test/helpers' +import type { HttpRequestEventMap } from '#/src/index' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' + +const httpServer = new HttpServer((app) => { + app.use(useCors) + app.get('/cacheable', (req, res) => { + if (req.headers['if-none-match'] === '"etag-value"') { + return res.status(304).end() + } + res.set('ETag', '"etag-value"') + res.set('Cache-Control', 'max-age=0, must-revalidate') + res.status(200).send('original-response') + }) + + app.get('/:statusCode', (req, res) => + res.status(+req.params.statusCode).end() + ) +}) + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(async () => { + await httpServer.listen() + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('intercepts a bypassed request with a 204 response', async () => { + const responseListener = + vi.fn<(event: HttpRequestEventMap['response']) => void>() + interceptor.on('response', responseListener) + + const url = httpServer.http.url('/204') + const request = new XMLHttpRequest() + request.open('GET', url) + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.response).toBe('') + expect(responseListener).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + response: expect.objectContaining({ + status: 204, + body: null, + } satisfies Partial), + }) + ) + + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(204) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } + + { + const [{ request, response }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(204) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } +}) + +it('intercepts a bypassed request with a 202 response', async () => { + const responseListener = + vi.fn<(event: HttpRequestEventMap['response']) => void>() + interceptor.on('response', responseListener) + + const url = httpServer.http.url('/205') + const request = new XMLHttpRequest() + request.open('GET', url) + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.response).toBe('') + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(204) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } + + { + const [{ request, response }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(205) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } + + // expect(responseListener).toHaveBeenNthCalledWith( + // 1, + // expect.objectContaining({ + // request: expect.objectContaining>({ + // method: 'OPTIONS', + // }), + // }) + // ) + // expect(responseListener).toHaveBeenNthCalledWith( + // 2, + // expect.objectContaining({ + // request: expect.objectContaining>({ + // method: 'GET', + // }), + // response: expect.objectContaining>({ + // status: 205, + // body: null, + // }), + // }) + // ) +}) + +it('exposes a fetch api reference for a 304 response without body', async () => { + const responseListener = + vi.fn<(event: HttpRequestEventMap['response']) => void>() + interceptor.on('response', responseListener) + + const url = httpServer.http.url('/cacheable') + + // First request populates the cache with ETag + max-age=0. + { + const request = new XMLHttpRequest() + request.open('GET', url) + request.send() + await waitForXMLHttpRequest(request) + } + + responseListener.mockClear() + + // Second request to the same URL triggers revalidation (If-None-Match), + // and the server responds with 304. + const request = new XMLHttpRequest() + request.open('GET', url) + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.response).toBe('original-response') + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ request, response }] = responseListener.mock.calls[0] + + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(204) + expect.soft(response.url).toBe(url) + expect.soft(response.body).toBeNull() + } + + // Transparently resolved 304 from the server (HappyDOM responds from cache). + { + const [{ request, response }] = responseListener.mock.calls[1] + + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url) + + expect.soft(response.status).toBe(200) + expect.soft(response.url).toBe(url) + await expect.soft(response.text()).resolves.toBe('original-response') + } +}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts index 9ce9a6f12..c3135819e 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts @@ -1,10 +1,9 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/interceptors/issues/281 */ -import { it, expect, beforeAll, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const interceptor = new XMLHttpRequestInterceptor() @@ -33,28 +32,31 @@ afterAll(() => { }) it('keeps "status" as 0 if the request fails', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '/cors') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/cors') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(0) }) it('respects error response status', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '?status=500') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '?status=500') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(500) }) it('respects a custom "status" from the response', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', '/?status=201') - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', '/?status=201') + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toBe(201) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts deleted file mode 100644 index 160c625f8..000000000 --- a/test/modules/XMLHttpRequest/compliance/xhr-timeout.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// @vitest-environment jsdom -/** - * @see https://github.com/mswjs/interceptors/issues/7 - */ -import { it, expect, beforeAll, afterAll } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { sleep } from '../../../../test/helpers' -import { createXMLHttpRequest } from '../../../helpers' -import { DeferredPromise } from '@open-draft/deferred-promise' - -const httpServer = new HttpServer((app) => { - app.get('/', async (_req, res) => { - await sleep(50) - res.send('ok') - }) -}) - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(async () => { - interceptor.apply() - await httpServer.listen() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('handles request timeout via the "ontimeout" callback', async () => { - const timeoutCalled = new DeferredPromise() - - createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/')) - req.timeout = 1 - req.ontimeout = function customTimeoutCallback() { - timeoutCalled.resolve(this.readyState) - } - req.send() - }).catch(console.error) - - const nextReadyState = await timeoutCalled - expect(nextReadyState).toBe(4) -}) - -it('handles request timeout via the "timeout" event listener', async () => { - const timeoutCalled = new DeferredPromise() - - createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/')) - req.timeout = 1 - req.addEventListener('timeout', function customTimeoutListener() { - expect(this.readyState).toBe(4) - timeoutCalled.resolve(this.readyState) - }) - req.send() - }).catch(console.error) - - const nextReadyState = await timeoutCalled - expect(nextReadyState).toBe(4) -}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-timeout.v-browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-timeout.v-browser.test.ts new file mode 100644 index 000000000..fc2320f0c --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-timeout.v-browser.test.ts @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom +/** + * @see https://github.com/mswjs/interceptors/issues/7 + */ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + setTimeout, + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('fires the "ontimeout" callback for a bypassed request', async () => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/delay')) + + request.timeout = 5 + const timeoutCallback = vi.fn() + request.ontimeout = timeoutCallback + + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.readyState).toBe(4) + expect.soft(timeoutCallback).toHaveBeenCalledOnce() + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['timeout', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) +}) + +it('dispatches the "timeout" event for a bypassed request', async () => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/delay')) + + request.timeout = 5 + const timeoutListener = vi.fn() + request.addEventListener('timeout', timeoutListener) + + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.readyState).toBe(4) + expect.soft(timeoutListener).toHaveBeenCalledOnce() + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['timeout', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) +}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts index 0d7ee9a5b..b20562e7d 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts @@ -3,9 +3,9 @@ */ import fileUpload from 'express-fileupload' import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' declare global { interface Window { diff --git a/test/modules/XMLHttpRequest/features/events.test.ts b/test/modules/XMLHttpRequest/features/events.test.ts index e068ceaf0..d66c9b73f 100644 --- a/test/modules/XMLHttpRequest/features/events.test.ts +++ b/test/modules/XMLHttpRequest/features/events.test.ts @@ -1,133 +1,202 @@ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { - createXMLHttpRequest, - useCors, - REQUEST_ID_REGEXP, -} from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src' -import { RequestController } from '../../../../src/RequestController' - -const server = new HttpServer((app) => { - app.use(useCors) - app.get('/bypassed', (req, res) => { - res.status(201).set('Content-Type', 'text/plain').send('original response') - }) -}) - +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '#/src/interceptors/XMLHttpRequest/node' +import { REQUEST_ID_REGEXP } from '#/test/helpers' +import { HttpRequestEventMap } from '#/src/index' +import { RequestController } from '#/src/RequestController' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - if (request.url.endsWith('/user')) { - return controller.respondWith( - new Response('mocked response', { - status: 200, - statusText: 'OK', - }) - ) - } +beforeAll(() => { + interceptor.apply() }) -beforeAll(async () => { - interceptor.apply() - await server.listen() +afterEach(() => { + interceptor.removeAllListeners() }) -afterAll(async () => { +afterAll(() => { interceptor.dispose() - await server.close() }) it('emits events for a handled request', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET', + }, + }) + ) + } + + controller.respondWith( + new Response('mocked response', { + status: 200, + statusText: 'OK', + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('request', requestListener) interceptor.on('response', responseListener) - await createXMLHttpRequest((request) => { - request.open('GET', server.http.url('/user')) - request.send() - }) + const url = server.http.url('/user') + const request = new XMLHttpRequest() + request.open('GET', url) + request.send() - // Must call the "request" event listener. - expect(requestListener).toHaveBeenCalledTimes(1) - const [requestParams] = requestListener.mock.calls[0] + await waitForXMLHttpRequest(request) - expect(requestParams.request).toBeInstanceOf(Request) - expect(requestParams.request.method).toBe('GET') - expect(requestParams.request.url).toBe(server.http.url('/user')) + /** + * @note XMLHttpRequest in JSDOM/HappyDOM issues a preflight OPTIONS request. + */ + expect.soft(requestListener).toHaveBeenCalledTimes(2) - expect(requestParams.requestId).toMatch(REQUEST_ID_REGEXP) + // Preflight request. + { + const [{ request, requestId }] = requestListener.mock.calls[0] + + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + { + const [{ request, requestId }] = requestListener.mock.calls[1] + + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url.href) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } // Must call the "response" event listener. - expect(responseListener).toHaveBeenCalledTimes(1) - const [responseParams] = responseListener.mock.calls[0] - - expect(responseParams.response).toBeInstanceOf(Response) - expect(responseParams.response.status).toBe(200) - expect(responseParams.response.statusText).toBe('OK') - expect(responseParams.response.headers.get('Content-Type')).toBe( - 'text/plain;charset=UTF-8' - ) - expect(responseParams.response.bodyUsed).toBe(false) - expect(await responseParams.response.text()).toBe('mocked response') - - expect(responseParams.request).toBeInstanceOf(Request) - expect(responseParams.request.method).toBe('GET') - expect(responseParams.request.url).toBe(server.http.url('/user')) - - expect(responseParams.requestId).toMatch(REQUEST_ID_REGEXP) + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ response, request, requestId }] = responseListener.mock.calls[0] + + expect.soft(response).toBeInstanceOf(Response) + expect.soft(response.status).toBe(200) + expect.soft(response.body).toBeNull() + + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + // Mocked response. + { + const [{ response, request, requestId }] = responseListener.mock.calls[1] + + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('Content-Type')).toBe( + 'text/plain;charset=UTF-8' + ) + expect(response.bodyUsed).toBe(false) + await expect(response.text()).resolves.toBe('mocked response') + + expect(request).toBeInstanceOf(Request) + expect(request.method).toBe('GET') + expect(request.url).toBe(url.href) + + expect(requestId).toMatch(REQUEST_ID_REGEXP) + } }) it('emits events for a bypassed request', async () => { const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.on('request', requestListener) interceptor.on('response', responseListener) - await createXMLHttpRequest((request) => { - request.open('GET', server.http.url('/bypassed')) - request.send() - }) + const url = server.http.url('/bypassed') + const request = new XMLHttpRequest() + request.open('GET', url) + request.send() - // Must call the "request" event listener. - expect(requestListener).toHaveBeenCalledTimes(1) - const [requestParams] = requestListener.mock.calls[0] + await waitForXMLHttpRequest(request) - expect(requestParams.request).toBeInstanceOf(Request) - expect(requestParams.request.method).toBe('GET') - expect(requestParams.request.url).toBe(server.http.url('/bypassed')) - expect(requestParams.controller).toBeInstanceOf(RequestController) + expect(requestListener).toHaveBeenCalledTimes(2) - // The last argument of the request listener is the request ID. - expect(requestParams.requestId).toMatch(REQUEST_ID_REGEXP) + // Preflight request. + { + const [{ request, controller, requestId }] = requestListener.mock.calls[0] - // Must call the "response" event listener. - expect(responseListener).toHaveBeenCalledTimes(1) - const [responseParams] = responseListener.mock.calls[0] - - expect(responseParams.response).toBeInstanceOf(Response) - expect(responseParams.response.status).toBe(201) - // Note that Express infers status texts from the code. - expect(responseParams.response.statusText).toBe('Created') - // Express also adds whitespace between the header pairs. - expect(responseParams.response.headers.get('Content-Type')).toBe( - 'text/plain; charset=utf-8' - ) - expect(responseParams.response.bodyUsed).toBe(false) - expect(await responseParams.response.text()).toBe('original response') - - // Response listener must provide a relevant request. - expect(responseParams.request).toBeInstanceOf(Request) - expect(responseParams.request.method).toBe('GET') - expect(responseParams.request.url).toBe(server.http.url('/bypassed')) - - // The last argument of the response listener is the request ID. - expect(responseParams.requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + expect.soft(controller).toBeInstanceOf(RequestController) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + { + const [{ request, controller, requestId }] = requestListener.mock.calls[1] + + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('GET') + expect.soft(request.url).toBe(url.href) + expect.soft(controller).toBeInstanceOf(RequestController) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + expect(responseListener).toHaveBeenCalledTimes(2) + + // Preflight response. + { + const [{ response, request, requestId }] = responseListener.mock.calls[0] + + expect.soft(response).toBeInstanceOf(Response) + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('') + + expect.soft(request).toBeInstanceOf(Request) + expect.soft(request.method).toBe('OPTIONS') + expect.soft(request.url).toBe(url.href) + + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) + } + + { + const [responseParams] = responseListener.mock.calls[1] + + expect.soft(responseParams.response).toBeInstanceOf(Response) + expect.soft(responseParams.response.status).toBe(200) + expect.soft(responseParams.response.statusText).toBe('OK') + expect + .soft(responseParams.response.headers.get('Content-Type')) + .toBe('text/plain; charset=utf-8') + expect.soft(responseParams.response.bodyUsed).toBe(false) + await expect + .soft(responseParams.response.text()) + .resolves.toBe('original-response') + + // Response listener must provide a relevant request. + expect.soft(responseParams.request).toBeInstanceOf(Request) + expect.soft(responseParams.request.method).toBe('GET') + expect.soft(responseParams.request.url).toBe(url.href) + + // The last argument of the response listener is the request ID. + expect.soft(responseParams.requestId).toMatch(REQUEST_ID_REGEXP) + } }) diff --git a/test/modules/XMLHttpRequest/features/xhr-initiator.v-browser.test.ts b/test/modules/XMLHttpRequest/features/xhr-initiator.v-browser.test.ts new file mode 100644 index 000000000..2dd9bdc5e --- /dev/null +++ b/test/modules/XMLHttpRequest/features/xhr-initiator.v-browser.test.ts @@ -0,0 +1,35 @@ +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral.js' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('exposes the initiator for a mocked XMLHttpRequest', async () => { + const pendingInitiator = new DeferredPromise() + + interceptor.on('request', ({ initiator, controller }) => { + pendingInitiator.resolve(initiator as XMLHttpRequest) + controller.respondWith(new Response('hello world')) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/resource') + request.send() + + await waitForXMLHttpRequest(request) + + await expect.soft(pendingInitiator).resolves.toEqual(request) + expect.soft(request.responseText).toBe('hello world') +}) diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js deleted file mode 100644 index fa3abcda3..000000000 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js +++ /dev/null @@ -1,19 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', async ({ request, requestId }) => { - window.dispatchEvent( - new CustomEvent('resolver', { - detail: { - id: requestId, - method: request.method, - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - credentials: request.credentials, - body: await request.text(), - }, - }) - ) -}) - -interceptor.apply() diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts deleted file mode 100644 index e0a36d6ba..000000000 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { invariant } from 'outvariant' -import { HttpServer } from '@open-draft/test-server/http' -import { RequestHandler } from 'express-serve-static-core' -import { test, expect } from '../../../playwright.extend' -import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../../../src/Interceptor' - -const httpServer = new HttpServer((app) => { - const strictCorsMiddleware: RequestHandler = (req, res, next) => { - invariant( - !req.header(INTERNAL_REQUEST_ID_HEADER_NAME), - 'Found unexpected internal "%s" request header in the browser for "%s %s"', - INTERNAL_REQUEST_ID_HEADER_NAME, - req.method, - req.url - ) - - res - .set('Access-Control-Allow-Origin', req.headers.origin) - .set('Access-Control-Allow-Methods', 'GET, POST') - .set('Access-Control-Allow-Headers', ['content-type', 'x-request-header']) - .set('Access-Control-Allow-Credentials', 'true') - return next() - } - - app.use(strictCorsMiddleware) - - const requestHandler: RequestHandler = (req, res) => { - res.status(200).send('user-body') - } - - app.options('/user', (req, res) => res.status(200).end()) - app.get('/user', requestHandler) - app.post('/user', requestHandler) -}) - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - -test('intercepts an HTTP GET request', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request, response] = await callXMLHttpRequest({ - method: 'GET', - url, - headers: { - 'x-request-header': 'yes', - }, - }) - - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(request.headers.get('x-request-header')).toBe('yes') - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.body).toBe('user-body') -}) - -test('intercepts an HTTP POST request', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request, response] = await callXMLHttpRequest({ - method: 'POST', - url, - headers: { - 'content-type': 'application/json', - 'x-request-header': 'yes', - }, - body: JSON.stringify({ user: 'john' }), - }) - - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(request.credentials).toBe('same-origin') - expect(await request.json()).toEqual({ user: 'john' }) - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.body).toBe('user-body') -}) - -test('sets "credentials" to "include" on isomorphic request when "withCredentials" is true', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request] = await callXMLHttpRequest({ - method: 'POST', - url, - withCredentials: true, - }) - - expect(request.credentials).toBe('include') -}) - -test('sets "credentials" to "same-origin" on isomorphic request when "withCredentials" is false', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request] = await callXMLHttpRequest({ - method: 'POST', - url, - withCredentials: false, - }) - - expect(request.credentials).toBe('same-origin') -}) - -test('sets "credentials" to "same-origin" on isomorphic request when "withCredentials" is not set', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const [request] = await callXMLHttpRequest({ - method: 'POST', - url, - }) - - expect(request.credentials).toBe('same-origin') -}) - -test('ignores the body for HEAD requests', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const call = callXMLHttpRequest({ - method: 'HEAD', - url, - body: 'test', - }) - - await expect(call).resolves.not.toThrowError() - - const [request] = await call - expect(request.body).toBe(null) -}) - -test('ignores the body for GET requests', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const call = callXMLHttpRequest({ - method: 'GET', - url, - body: 'test', - }) - - await expect(call).resolves.not.toThrowError() - - const [request] = await call - expect(request.body).toBe(null) -}) - -test('intercepts a synchronous XMLHttpRequest', async ({ - loadExample, - callXMLHttpRequest, -}) => { - await loadExample(require.resolve('./XMLHttpRequest.browser.runtime.js')) - - const url = httpServer.http.url('/user') - const call = callXMLHttpRequest({ - method: 'POST', - url, - body: 'hello world', - async: false, - }) - - await expect(call).resolves.not.toThrowError() - - const [request] = await call - expect(request.method).toBe('POST') - await expect(request.text()).resolves.toBe('hello world') -}) diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts deleted file mode 100644 index 4dd3dc2b1..000000000 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import type { RequestHandler } from 'express' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { - createXMLHttpRequest, - useCors, - REQUEST_ID_REGEXP, -} from '../../../helpers' -import { toArrayBuffer, encodeBuffer } from '../../../../src/utils/bufferUtils' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestEventMap } from '../../../../src/glossary' - -declare namespace window { - export const _resourceLoader: { - _strictSSL: boolean - } -} - -const httpServer = new HttpServer((app) => { - app.use(useCors, (req, res, next) => { - res.set({ - 'Access-Control-Allow-Credentials': 'true', - }) - return next() - }) - - const handleUserRequest: RequestHandler = (_req, res) => { - res.status(200).send('user-body').end() - } - - app.get('/user', handleUserRequest) - app.post('/user', handleUserRequest) - app.put('/user', handleUserRequest) - app.delete('/user', handleUserRequest) - app.patch('/user', handleUserRequest) - app.head('/user', handleUserRequest) -}) - -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', resolver) - -beforeAll(async () => { - // Allow XHR requests to the local HTTPS server with a self-signed certificate. - window._resourceLoader._strictSSL = false - - await httpServer.listen() - - interceptor.apply() -}) - -afterEach(() => { - vi.resetAllMocks() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('intercepts an HTTP HEAD request', async () => { - const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('HEAD', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('HEAD') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTP GET request', async () => { - const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('GET', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTP POST request', async () => { - const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('POST', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send('post-payload') - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTP PUT request', async () => { - const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('PUT', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send('put-payload') - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('PUT') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTP DELETE request', async () => { - const url = httpServer.http.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('DELETE', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('DELETE') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTPS HEAD request', async () => { - const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('HEAD', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('HEAD') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTPS GET request', async () => { - const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('GET', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('GET') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTPS POST request', async () => { - const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('POST', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send('post-payload') - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('POST') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTPS PUT request', async () => { - const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('PUT', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send('put-payload') - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('PUT') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('intercepts an HTTPS DELETE request', async () => { - const url = httpServer.https.url('/user?id=123') - await createXMLHttpRequest((req) => { - req.open('DELETE', url) - req.setRequestHeader('x-custom-header', 'yes') - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request, requestId, controller }] = resolver.mock.calls[0] - - expect(request.method).toBe('DELETE') - expect(request.url).toBe(url) - expect(Object.fromEntries(request.headers.entries())).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) -}) - -it('sets "credentials" to "include" on isomorphic request when "withCredentials" is true', async () => { - await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/user')) - req.withCredentials = true - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('include') -}) - -it('sets "credentials" to "omit" on isomorphic request when "withCredentials" is not set', async () => { - await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/user')) - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('same-origin') -}) - -it('sets "credentials" to "omit" on isomorphic request when "withCredentials" is false', async () => { - await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/user')) - req.withCredentials = false - req.send() - }) - - expect(resolver).toHaveBeenCalledTimes(1) - const [{ request }] = resolver.mock.calls[0] - expect(request.credentials).toBe('same-origin') -}) - -it('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.https.url('/user')) - request.responseType = 'arraybuffer' - request.send() - }) - - const expectedArrayBuffer = toArrayBuffer(encodeBuffer('user-body')) - const responseBuffer = request.response as ArrayBuffer - - // Must return an "ArrayBuffer" instance for "arraybuffer" response type. - expect(request.responseType).toBe('arraybuffer') - expect(responseBuffer).toBeInstanceOf(ArrayBuffer) - expect(responseBuffer.byteLength).toBe(expectedArrayBuffer.byteLength) - expect( - Buffer.from(responseBuffer).compare(Buffer.from(expectedArrayBuffer)) - ).toBe(0) -}) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts deleted file mode 100644 index 2696e3508..000000000 --- a/test/modules/XMLHttpRequest/regressions/xhr-0-status-code.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -// @vitest-environment jsdom -/** - * @see https://github.com/mswjs/interceptors/issues/335 - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('handles Response.error() as a request error', async () => { - interceptor.once('request', ({ controller }) => { - controller.respondWith(Response.error()) - }) - - const loadListener = vi.fn() - const errorListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost') - request.addEventListener('load', loadListener) - request.addEventListener('error', errorListener) - request.send() - }) - - expect(request.status).toBe(0) - expect(request.readyState).toBe(4) - expect(request.response).toBe('') - expect(loadListener).not.toBeCalled() - expect(errorListener).toHaveBeenCalledTimes(1) -}) - -it('handles interceptor exceptions as 500 error responses', async () => { - interceptor.once('request', () => { - throw new Error('Network error') - }) - - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', 'http://localhost') - request.send() - }) - - expect(request.status).toBe(500) - expect(request.statusText).toBe('Unhandled Exception') - expect(request.readyState).toBe(4) - expect(request.response).toEqual({ - name: 'Error', - message: 'Network error', - stack: expect.any(String), - }) -}) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts index 06f2d40f1..bf982fa69 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-axios-xhr-adapter.test.ts @@ -1,9 +1,8 @@ +// @vitest-environment happy-dom /** - * @vitest-environment happy-dom * @note This issue is only reproducible in "happy-dom". * @see https://github.com/mswjs/msw/issues/1816 */ -import { beforeAll, afterAll, it, expect } from 'vitest' import axios from 'axios' /** * @note Use `Response` from Undici because "happy-dom" @@ -11,7 +10,7 @@ import axios from 'axios' * Node's Readable instead, which is completely incompatible. */ import { Response as UndiciResponse } from 'undici' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' const request = axios.create({ baseURL: 'http://localhost', @@ -35,7 +34,7 @@ it('performs a request with the "xhr" axios adapter', async () => { ) }) - const res = await request('/resource') - expect(res.status).toBe(200) - expect(res.data).toBe('Hello world') + const response = await request('/resource') + expect.soft(response.status).toBe(200) + expect.soft(response.data).toBe('Hello world') }) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.browser.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.browser.test.ts index 91838d839..cb0f22c92 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.browser.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.browser.test.ts @@ -3,7 +3,7 @@ */ import { HttpServer } from '@open-draft/test-server/http' import zlib from 'zlib' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' import { test, expect } from '../../../playwright.extend' const httpServer = new HttpServer((app) => { diff --git a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts index dfd688902..e856c29ec 100644 --- a/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts +++ b/test/modules/XMLHttpRequest/regressions/xhr-compressed-response.test.ts @@ -1,12 +1,12 @@ -// @vitest-environment jsdom +// @vitest-environment happy-dom /** * @see https://github.com/mswjs/interceptors/issues/308 */ -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import zlib from 'zlib' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { useCors } from '#/test/helpers' +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' const httpServer = new HttpServer((app) => { app.use(useCors) @@ -31,10 +31,11 @@ afterAll(async () => { }) it('bypasses a compressed HTTP request', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/compressed')) - request.send() - }) + const request = new XMLHttpRequest() + request.open('GET', httpServer.http.url('/compressed')) + request.send() + + await waitForXMLHttpRequest(request) expect(request.status).toEqual(200) expect(request.response).toEqual('compressed-body') diff --git a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts deleted file mode 100644 index 0aa492a7f..000000000 --- a/test/modules/XMLHttpRequest/regressions/xhr-location-undefined.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// @vitest-environment react-native-like -import { it, expect, beforeAll, afterAll } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('responds to a request with an absolute URL', async () => { - interceptor.once('request', ({ controller }) => { - controller.respondWith(new Response('Hello world')) - }) - - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'https://example.com/resource') - request.send() - }) - - expect(request.status).toBe(200) - expect(request.response).toBe('Hello world') -}) - -it('throws on a request with a relative URL', async () => { - const createRequest = () => { - return createXMLHttpRequest((request) => { - /** - * @note Since the "location" is not present in React Native, - * relative requests will throw (nothing to be relative to). - * This is the correct behavior in React Native, where relative - * requests are a no-op. - */ - request.open('GET', '/relative/url') - request.send() - }) - } - - expect(createRequest).toThrow('Invalid URL: /relative/url') -}) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts deleted file mode 100644 index 2d7db060c..000000000 --- a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// @vitest-environment jsdom -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('does not lock the request body stream when calculating the body size', async () => { - interceptor.on('request', async ({ request, controller }) => { - // Read the request body in the interceptor. - const buffer = await request.arrayBuffer() - controller.respondWith(new Response(buffer)) - }) - - const uploadLoadStartListener = vi.fn() - const request = await createXMLHttpRequest((request) => { - request.upload.addEventListener('loadstart', uploadLoadStartListener) - request.open('POST', '/resource') - request.send('request-body') - }) - - // Must calculate the total request body size for the upload event. - const progressEvent = uploadLoadStartListener.mock.calls[0][0] - expect(progressEvent.total).toBe(12) - - // Must be able to read the request in the interceptor - // and use its body as the mocked response body. - expect(request.responseText).toBe('request-body') -}) diff --git a/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.v-browser.test.ts b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.v-browser.test.ts new file mode 100644 index 000000000..90a1a8dad --- /dev/null +++ b/test/modules/XMLHttpRequest/regressions/xhr-request-body-length.v-browser.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + spyOnXMLHttpRequestUpload, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('does not lock the request body stream when calculating the total request body size for uploads', async () => { + interceptor.on('request', async ({ request, controller }) => { + const buffer = await request.arrayBuffer() + controller.respondWith(new Response(buffer)) + }) + + const request = new XMLHttpRequest() + const { events: uploadEvents } = spyOnXMLHttpRequestUpload(request.upload) + request.open('POST', '/resource') + request.send('hello world') + + await waitForXMLHttpRequest(request) + + expect.soft(request.responseText).toBe('hello world') + expect(uploadEvents).toEqual([ + ['loadstart', { loaded: 0, total: 11 }], + ['progress', { loaded: 11, total: 11 }], + ['load', { loaded: 11, total: 11 }], + ['loadend', { loaded: 11, total: 11 }], + ]) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-error-with.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-error-with.neutral.test.ts new file mode 100644 index 000000000..c79577c16 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-error-with.neutral.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('treats "controller.errorWith()" as a request error for an HTTP request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.errorWith(new Error('Network failure')) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.response).toBe('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) + +it('treats "controller.errorWith()" as a request error for an HTTPS request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.errorWith(new Error('Network failure')) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.response).toBe('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-interceptor-dispose.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-interceptor-dispose.neutral.test.ts new file mode 100644 index 000000000..867535ba7 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-interceptor-dispose.neutral.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('bypasses the requests after the interceptor is disposed', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '11', + }, + }) + ) + }) + + interceptor.dispose() + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/')) + request.send('hello world') + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + expect(request.response).toBe('hello world') + expect(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-request-relative-url.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-request-relative-url.neutral.test.ts new file mode 100644 index 000000000..6bc62b844 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-request-relative-url.neutral.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks a response to a request with a relative url', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = new XMLHttpRequest() + request.open('GET', '/resource') + request.send() + + await waitForXMLHttpRequest(request) + + expect(request.response).toBe('hello world') +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts new file mode 100644 index 000000000..3c4c897bf --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-array-buffer.neutral.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + toArrayBuffer, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with an ArrayBuffer response', async ({ + task, +}) => { + const buffer = new TextEncoder().encode('hello world') + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'arraybuffer' + request.open('POST', server.http.url('/arraybuffer')) + request.setRequestHeader('content-type', 'application/octet-stream') + request.send(buffer) + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/octet-stream') + expect.soft(toArrayBuffer(request.response)).toEqual(buffer.buffer) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +it('responds with a mocked ArrayBuffer response to an HTTP request', async ({ + task, +}) => { + const buffer = new TextEncoder().encode('hello world') + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(buffer, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'application/octet-stream', + 'content-length': '11', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/octet-stream') + expect.soft(toArrayBuffer(request.response)).toEqual(buffer.buffer) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +it('responds with a mocked ArrayBuffer response to an HTTPS request', async ({ + task, +}) => { + const buffer = new TextEncoder().encode('hello world') + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(buffer, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'application/octet-stream', + 'content-length': '11', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'arraybuffer' + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/octet-stream') + expect.soft(toArrayBuffer(request.response)).toEqual(buffer.buffer) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts new file mode 100644 index 000000000..712a0ae59 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-blob.neutral.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a blob response', async ({ task }) => { + const blob = new Blob(['hello world'], { type: 'text/plain' }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'blob' + request.open('POST', server.http.url('/blob')) + request.send(blob) + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + /** + * @note Strict equality won't work here because Undici appends "charset=utf-8" + * to the response Blob. The browser, however, does not do that. + */ + expect(request.response).instanceOf(Blob) + await expect + .soft(request.response.arrayBuffer()) + .resolves.toEqual(await blob.arrayBuffer()) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +it('responds with a mocked Blob response to an HTTP request', async ({ + task, +}) => { + const blob = new Blob(['hello world'], { type: 'text/plain' }) + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(blob, { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '11', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'blob' + request.open('GET', server.http.url('/blob')) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + /** + * @note Strict equality won't work here because Undici appends "charset=utf-8" + * to the response Blob. The browser, however, does not do that. + */ + expect(request.response).instanceOf(Blob) + await expect + .soft(request.response.arrayBuffer()) + .resolves.toEqual(await blob.arrayBuffer()) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +it('responds with a mocked Blob response to an HTTP request', async ({ + task, +}) => { + const blob = new Blob(['hello world'], { type: 'text/plain' }) + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(blob, { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '11', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'blob' + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + /** + * @note Strict equality won't work here because Undici appends "charset=utf-8" + * to the response Blob. The browser, however, does not do that. + */ + expect(request.response).instanceOf(Blob) + await expect + .soft(request.response.arrayBuffer()) + .resolves.toEqual(await blob.arrayBuffer()) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts new file mode 100644 index 000000000..f72f066b8 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-empty.neutral.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with an empty response', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/empty')) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 4], + ['load', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) + +it('responds with an empty mocked response to an HTTP request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(204) + expect.soft(request.response).toEqual('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 4], + ['load', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) + +it('responds with an empty mocked response to an HTTPS request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + status: 204, + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(204) + expect.soft(request.response).toEqual('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 4], + ['load', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr-response-error.browser.runtime.js deleted file mode 100644 index b90c6a452..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.browser.runtime.js +++ /dev/null @@ -1,9 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() - -interceptor.on('request', ({ controller }) => { - controller.respondWith(Response.error()) -}) - -interceptor.apply() diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.browser.test.ts deleted file mode 100644 index 75b38e4f0..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.browser.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { test, expect } from '../../../playwright.extend' - -test('treats "Response.error()" as request error', async ({ - loadExample, - page, -}) => { - await loadExample(require.resolve('./xhr-response-error.browser.runtime.js')) - - const requestAfterError = await page.evaluate(() => { - const request = new XMLHttpRequest() - request.open('GET', 'http://localhost/resource') - - return new Promise<{ - status: number - statusText: string - response: unknown - }>((resolve) => { - request.addEventListener('error', () => { - resolve({ - status: request.status, - statusText: request.statusText, - response: request.response, - }) - }) - - request.send() - }) - }) - - expect(requestAfterError.status).toBe(0) - expect(requestAfterError.statusText).toBe('') - expect(requestAfterError.response).toBe('') -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts new file mode 100644 index 000000000..18a092355 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-error.neutral.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a network error response', async ({ + task, +}) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/network-error')) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.statusText).toBe('') + expect.soft(request.response).toBe('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) + +it('treats Response.error() as a request error for an HTTP request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.error()) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.statusText).toBe('') + expect.soft(request.response).toBe('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) + +it('treats Response.error() as a request error for an HTTPS request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.error()) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(0) + expect.soft(request.statusText).toBe('') + expect.soft(request.response).toBe('') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 4], + ['error', 4, { loaded: 0, total: 0 }], + ['loadend', 4, { loaded: 0, total: 0 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts deleted file mode 100644 index f3b50ffd0..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-error.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterAll, vi } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest } from '../../../helpers' - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ controller }) => { - controller.respondWith(Response.error()) -}) - -beforeAll(async () => { - interceptor.apply() -}) - -afterAll(async () => { - interceptor.dispose() -}) - -it('treats "Response.error()" as request error', async () => { - const requestErrorListener = vi.fn() - - const request = await createXMLHttpRequest((request) => { - request.open('GET', 'http://localhost:3001/resource') - request.addEventListener('error', requestErrorListener) - request.send() - }) - - // Request must reflect the request error state. - expect(request.readyState).toBe(request.DONE) - expect(request.status).toBe(0) - expect(request.statusText).toBe('') - expect(request.response).toBe('') - - // Network error must propagate to the "error" request event. - expect(requestErrorListener).toHaveBeenCalledTimes(1) -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts new file mode 100644 index 000000000..fe602d3a1 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-json.neutral.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a JSON response', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'json' + request.open('POST', server.http.url('/empty')) + request.setRequestHeader('content-type', 'application/json') + request.send(JSON.stringify({ name: 'John Maverick' })) + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/json') + expect.soft(request.response).toEqual({ name: 'John Maverick' }) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 24, total: 24 }], + ['readystatechange', 4], + ['load', 4, { loaded: 24, total: 24 }], + ['loadend', 4, { loaded: 24, total: 24 }], + ]) + } +}) + +it('responds with a mocked text response to an HTTP request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json( + { name: 'John Maverick' }, + { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '24', + }, + } + ) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/json') + expect.soft(request.response).toEqual({ name: 'John Maverick' }) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 24, total: 24 }], + ['readystatechange', 4], + ['load', 4, { loaded: 24, total: 24 }], + ['loadend', 4, { loaded: 24, total: 24 }], + ]) + } +}) + +it('responds with a mocked text response to an HTTPS request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json( + { name: 'John Maverick' }, + { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '24', + }, + } + ) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.responseType = 'json' + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: application/json') + expect.soft(request.response).toEqual({ name: 'John Maverick' }) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 24, total: 24 }], + ['readystatechange', 4], + ['load', 4, { loaded: 24, total: 24 }], + ['loadend', 4, { loaded: 24, total: 24 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js deleted file mode 100644 index b835d21d3..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js +++ /dev/null @@ -1,46 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() - -interceptor.on('request', async ({ request, requestId, controller }) => { - window.dispatchEvent( - new CustomEvent('resolver', { - detail: { - id: requestId, - method: request.method, - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - credentials: request.credentials, - body: await request.clone().text(), - }, - }) - ) - - const url = new URL(request.url) - - if (url.pathname === '/mocked') { - await new Promise((resolve) => setTimeout(resolve, 0)) - - const req = new XMLHttpRequest() - req.open('GET', window.originalUrl, true) - req.send() - await new Promise((resolve, reject) => { - req.addEventListener('loadend', resolve) - req.addEventListener('error', reject) - }) - - controller.respondWith( - new Response(`${req.responseText} world`, { - status: req.status, - statusText: req.statusText, - headers: { - 'X-Custom-Header': req.getResponseHeader('X-Custom-Header'), - }, - }) - ) - } -}) - -interceptor.apply() - -window.interceptor = interceptor diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts deleted file mode 100644 index dcadf32f5..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { HttpServer } from '@open-draft/test-server/http' -import { useCors } from '../../../helpers' -import { test, expect } from '../../../playwright.extend' - -declare namespace window { - export let originalUrl: string -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/original', (req, res) => { - res - .set('access-control-expose-headers', 'x-custom-header') - .set('x-custom-header', 'yes') - .send('hello') - }) -}) - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - -test('responds to an HTTP request handled in the resolver', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample( - require.resolve('./xhr-response-patching.browser.runtime.js') - ) - await page.evaluate((url) => { - window.originalUrl = url - }, httpServer.http.url('/original')) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: 'http://localhost/mocked', - }) - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.headers).toBe( - 'content-type: text/plain;charset=UTF-8\r\nx-custom-header: yes' - ) - expect(response.body).toBe('hello world') -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts new file mode 100644 index 000000000..bab5c3b42 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.neutral.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('patches the original XMLHttpRequest response', async ({ task }) => { + interceptor.on('request', async ({ request, controller }) => { + const url = new URL(request.url) + + if (url.searchParams.get('type') === 'passthrough') { + return controller.passthrough() + } + + const originalRequest = new XMLHttpRequest() + url.searchParams.set('type', 'passthrough') + originalRequest.open(request.method, url.href) + originalRequest.send(await request.text()) + originalRequest.onerror = () => console.log('ERROR') + originalRequest.onabort = () => console.trace('ABORT') + await waitForXMLHttpRequest(originalRequest) + + controller.respondWith( + new Response(`${originalRequest.responseText}-patched`, { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '15', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/')) + request.send('payload') + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('payload-patched') + expect.soft(request.responseURL).toBe(server.http.url('/').href) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 15, total: 15 }], + ['readystatechange', 4], + ['load', 4, { loaded: 15, total: 15 }], + ['loadend', 4, { loaded: 15, total: 15 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts new file mode 100644 index 000000000..adc80d849 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-progress.neutral.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + setTimeout, + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const encoder = new TextEncoder() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a stream response', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/stream')) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response.replace(/\s+/g, '')).toBe('helloworld') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 1024, total: 3072 }], + ['readystatechange', 3], + ['progress', 3, { loaded: 2048, total: 3072 }], + ['readystatechange', 3], + ['progress', 3, { loaded: 3072, total: 3072 }], + ['readystatechange', 4], + ['load', 4, { loaded: 3072, total: 3072 }], + ['loadend', 4, { loaded: 3072, total: 3072 }], + ]) + } +}) + +it('responds with a mocked immediate chunked response', async ({ task }) => { + interceptor.on('request', ({ request, controller }) => { + const pad = (value: string) => value + ' '.repeat(1024 - value.length) + const chunks = [pad('hello'), pad(' '), pad('world')] + + const stream = new ReadableStream({ + async pull(controller) { + const chunk = chunks.shift() + + if (chunk) { + return controller.enqueue(encoder.encode(chunk)) + } + + controller.close() + }, + }) + + controller.respondWith( + new Response(stream, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + 'content-length': chunks.join('').length.toString(), + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response.replace(/\s+/g, '')).toBe('helloworld') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 3072, total: 3072 }], + ['readystatechange', 4], + ['load', 4, { loaded: 3072, total: 3072 }], + ['loadend', 4, { loaded: 3072, total: 3072 }], + ]) + } +}) + +it('responds with a mocked delayed chunked response', async ({ task }) => { + interceptor.on('request', ({ request, controller }) => { + /** + * @note The browser buffers the incoming response chunks unless a chunk exceeds + * 1KB. Simulate three chunks, each 1KB in size to trigger the "progress" event for each chunk. + */ + const pad = (value: string) => value + ' '.repeat(1024 - value.length) + const chunks = [pad('hello'), pad(' '), pad('world')] + + const stream = new ReadableStream({ + async pull(controller) { + const chunk = chunks.shift() + + if (chunk) { + await setTimeout(100) + return controller.enqueue(encoder.encode(chunk)) + } + + controller.close() + }, + }) + + controller.respondWith( + new Response(stream, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + 'content-length': chunks.join('').length.toString(), + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response.replace(/\s+/g, '')).toBe('helloworld') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 1024, total: 3072 }], + ['readystatechange', 3], + ['progress', 3, { loaded: 2048, total: 3072 }], + ['readystatechange', 3], + ['progress', 3, { loaded: 3072, total: 3072 }], + ['readystatechange', 4], + ['load', 4, { loaded: 3072, total: 3072 }], + ['loadend', 4, { loaded: 3072, total: 3072 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts new file mode 100644 index 000000000..2c42954c4 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-redirect.neutral.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a redirect response', async ({ + task, +}) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/redirect')) + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getResponseHeader('content-type')) + .toBe('text/html; charset=utf-8') + expect.soft(request.response).toBe('destination-body') + expect + .soft(request.responseURL) + .toBe(server.http.url('/redirect/destination').href) + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 16, total: 16 }], + ['readystatechange', 4], + ['load', 4, { loaded: 16, total: 16 }], + ['loadend', 4, { loaded: 16, total: 16 }], + ]) + } +}) + +it('responds with a mocked redirect response', async ({ task }) => { + interceptor.on('request', ({ request, controller }) => { + if (request.url.endsWith('/original')) { + return controller.respondWith( + new Response(null, { + status: 301, + headers: { + location: new URL('/destination', request.url).href, + }, + }) + ) + } + + controller.respondWith( + new Response('destination-body', { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '16', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/original') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getResponseHeader('content-type')) + .toBe('text/plain;charset=UTF-8') + expect.soft(request.response).toBe('destination-body') + expect.soft(request.responseURL).toBe('http://any.host.here/destination') + + if (task.file.projectName === 'browser') { + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 16, total: 16 }], + ['readystatechange', 4], + ['load', 4, { loaded: 16, total: 16 }], + ['loadend', 4, { loaded: 16, total: 16 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts new file mode 100644 index 000000000..a749c7fb3 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-text.neutral.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a text response', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/')) + request.send('hello world') + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain') + expect(request.response).toBe('hello world') + expect(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +it('responds with a mocked text response to an HTTP request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '11', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain;charset=utf-8') + expect.soft(request.response).toBe('hello world') + expect.soft(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) + +it('responds with a mocked text response to an HTTPS request', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-length': '11', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(request.getAllResponseHeaders().toLowerCase()) + .toContain('content-type: text/plain;charset=utf-8') + expect.soft(request.response).toBe('hello world') + expect.soft(request.responseText).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-type-arraybuffer.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-type-arraybuffer.neutral.test.ts new file mode 100644 index 000000000..8b1054540 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-type-arraybuffer.neutral.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom +import { + arrayBufferFrom, + toArrayBuffer, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('reads the mocked ArrayBuffer response as-is if "responseType" is set to "arraybuffer"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new TextEncoder().encode('hello world'), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(toArrayBuffer(request.response)) + .toStrictEqual(arrayBufferFrom('hello world')) +}) + +it('reads the empty mocked text response as an empty ArrayBuffer if "responseType" is set to "arraybuffer"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(toArrayBuffer(request.response)).toStrictEqual(new ArrayBuffer(0)) +}) + +it('reads the mocked text response as ArrayBuffer if "responseType" is set to "arraybuffer"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(toArrayBuffer(request.response)) + .toStrictEqual(arrayBufferFrom('hello world')) +}) + +it('reads the mocked json response as ArrayBuffer if "responseType" is set to "arraybuffer"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json(json, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(toArrayBuffer(request.response)) + .toStrictEqual(arrayBufferFrom(JSON.stringify(json))) +}) + +it('reads the mocked blob response as ArrayBuffer if "responseType" is set to "arraybuffer"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new Blob(['hello world'], { type: 'text/plain' }), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'arraybuffer' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect + .soft(toArrayBuffer(request.response)) + .toStrictEqual(arrayBufferFrom('hello world')) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-type-blob.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-type-blob.neutral.test.ts new file mode 100644 index 000000000..bee31c83f --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-type-blob.neutral.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('reads the mocked blob response as-is if "responseType" is set to "blob"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new Blob(['hello world'], { type: 'text/plain' }), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(await request.response.text()).toBe('hello world') +}) + +it('reads the empty mocked response as an empty Blob if "responseType" is set to "blob"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(request.response.size).toBe(0) +}) + +it('reads the mocked text response as Blob if "responseType" is set to "blob"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(await request.response.text()).toBe('hello world') +}) + +it('reads the mocked json response as Blob if "responseType" is set to "blob"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json(json, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(await request.response.text()).toBe(JSON.stringify(json)) +}) + +it('reads the mocked ArrayBuffer response as Blob if "responseType" is set to "blob"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new TextEncoder().encode('hello world'), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'blob' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeInstanceOf(Blob) + expect.soft(await request.response.text()).toBe('hello world') +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-type-json.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-type-json.neutral.test.ts new file mode 100644 index 000000000..28c39712b --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-type-json.neutral.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('reads the mocked json response as json if "responseType" is set to "json"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json(json, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(json) +}) + +it('reads the empty mocked response as null if "responseType" is set to "json"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBeNull() +}) + +it('reads the mocked text response as json if "responseType" is set to "json"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(JSON.stringify(json), { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'application/json', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(json) +}) + +it('reads the mocked ArrayBuffer response as json if "responseType" is set to "json"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new TextEncoder().encode(JSON.stringify(json)), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(json) +}) + +it('reads the mocked blob response as json if "responseType" is set to "json"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response( + new Blob([JSON.stringify(json)], { type: 'application/json' }), + { + headers: { 'access-control-allow-origin': '*' }, + } + ) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(json) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-type-text.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-type-text.neutral.test.ts new file mode 100644 index 000000000..cf31accd1 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-type-text.neutral.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('reads the mocked text response as-is if "responseType" is set to "text"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') +}) + +it('reads the empty mocked text response as an empty string if "responseType" is set to "text"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(null, { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('') +}) + +it('reads the mocked json response as text if "responseType" is set to "text"', async () => { + const json = { greeting: 'hello world' } + + interceptor.on('request', ({ controller }) => { + controller.respondWith( + Response.json(json, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe(JSON.stringify(json)) +}) + +it('reads the mocked ArrayBuffer response as text if "responseType" is set to "text"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new TextEncoder().encode('hello world'), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') +}) + +it('reads the mocked blob response as text if "responseType" is set to "text"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response(new Blob(['hello world'], { type: 'text/plain' }), { + headers: { 'access-control-allow-origin': '*' }, + }) + ) + }) + + const request = new XMLHttpRequest() + request.responseType = 'text' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts deleted file mode 100644 index d5f53728f..000000000 --- a/test/modules/XMLHttpRequest/response/xhr-response-without-body.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// @vitest-environment jsdom -import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' -import type { HttpRequestEventMap } from '../../../../src' - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/:statusCode', (req, res) => - res.status(+req.params.statusCode).end() - ) -}) - -const interceptor = new XMLHttpRequestInterceptor() - -const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() -interceptor.on('response', responseListener) - -beforeAll(async () => { - await httpServer.listen() - interceptor.apply() -}) - -afterEach(() => { - vi.resetAllMocks() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('represents a 204 response without body using fetch api response', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/204')) - request.send() - }) - - expect(request.response).toBe('') - expect(responseListener).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - response: expect.objectContaining({ - status: 204, - body: null, - } satisfies Partial), - }) - ) - expect(responseListener).toHaveBeenCalledTimes(1) -}) - -it('represents a 205 response without body using fetch api response', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/205')) - request.send() - }) - - expect(request.response).toBe('') - expect(responseListener).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - response: expect.objectContaining({ - status: 205, - body: null, - } satisfies Partial), - }) - ) - expect(responseListener).toHaveBeenCalledTimes(1) -}) - -it('represents a 304 response without body using fetch api response', async () => { - const request = await createXMLHttpRequest((request) => { - request.open('GET', httpServer.http.url('/304')) - request.send() - }) - - expect(request.response).toBe('') - expect(responseListener).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - response: expect.objectContaining({ - status: 304, - body: null, - } satisfies Partial), - }) - ) - expect(responseListener).toHaveBeenCalledTimes(1) -}) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-xml.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-xml.neutral.test.ts new file mode 100644 index 000000000..4720df553 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-xml.neutral.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { + spyOnXMLHttpRequest, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +const XML_STRING = 'Content' +const domParser = new DOMParser() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with an XML response', async ({ task }) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('POST', server.http.url('/xml')) + request.setRequestHeader('content-type', 'application/xml') + request.send(XML_STRING) + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toEqual(XML_STRING) + + if (task.file.projectName === 'browser') { + /** + * @note XML response parsing in HappyDOM is broken. + */ + expect + .soft(request.responseXML) + .toEqual(domParser.parseFromString(XML_STRING, 'application/xml')) + + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 32, total: 32 }], + ['readystatechange', 4], + ['load', 4, { loaded: 32, total: 32 }], + ['loadend', 4, { loaded: 32, total: 32 }], + ]) + } +}) + +it('responds with a mocked "application/xml" response', async ({ task }) => { + const XML_STRING = 'Content' + + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith( + new Response(XML_STRING, { + headers: { + 'content-type': 'application/xml', + 'content-length': '32', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', '/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe(XML_STRING) + + if (task.file.projectName === 'browser') { + expect + .soft(request.responseXML) + .toStrictEqual(domParser.parseFromString(XML_STRING, 'application/xml')) + + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 32, total: 32 }], + ['readystatechange', 4], + ['load', 4, { loaded: 32, total: 32 }], + ['loadend', 4, { loaded: 32, total: 32 }], + ]) + } +}) + +it('responds with a mocked "text/html" response', async ({ task }) => { + const XML_STRING = 'Content' + + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + status: 204, + headers: { 'access-control-allow-origin': '*' }, + }) + ) + } + + controller.respondWith( + new Response(XML_STRING, { + headers: { + 'content-type': 'text/xml', + 'content-length': '32', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', '/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe(XML_STRING) + + if (task.file.projectName === 'browser') { + expect + .soft(request.responseXML) + .toStrictEqual(domParser.parseFromString(XML_STRING, 'text/xml')) + + expect(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 32, total: 32 }], + ['readystatechange', 4], + ['load', 4, { loaded: 32, total: 32 }], + ['loadend', 4, { loaded: 32, total: 32 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts new file mode 100644 index 000000000..8c8ceb636 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-synchronous.neutral.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment happy-dom +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { spyOnXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + interceptor.removeAllListeners() + vi.clearAllMocks() +}) + +afterAll(() => { + interceptor.dispose() + vi.restoreAllMocks() +}) + +/** + * @note The way HappyDOM performs synchronous XMLHttpRequest is via "ChildProcess.execFileSync", + * which bypassed the "net.connect()" and makes such requests invisible to our interceptor. + * We do not support synchronous XMLHttpRequest. + */ +it('prints a warning upon attempts to handle a synchronous XMLHttpRequest', async ({ + task, +}) => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('must not receive this', { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + request.open('GET', server.http.url('/'), false) + request.send() + + expect.soft(request.readyState).toBe(4) + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('original-response') + + if (task.file.projectName === 'browser') { + await expect + .poll(() => console.warn) + .toHaveBeenCalledExactlyOnceWith( + `Failed to intercept an XMLHttpRequest (GET ${server.http.url('/')}): synchronous requests are not supported. This request will be performed as-is.` + ) + expect(events).toEqual([ + ['readystatechange', 1], + ['readystatechange', 4], + ['load', 4, { loaded: 17, total: 17 }], + ['loadend', 4, { loaded: 17, total: 17 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts new file mode 100644 index 000000000..6532daeef --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-unhandled-exception.neutral.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('treats an unhandled exception as a 500 response for an HTTP request', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + + throw new Error('Custom error message') + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'http://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(500) + expect.soft(request.statusText).toBe('Unhandled Exception') + expect.soft(request.response).toEqual({ + name: 'Error', + message: 'Custom error message', + stack: expect.any(String), + }) +}) + +it('treats an unhandled exception as a 500 response for an HTTPS request', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'OPTIONS') { + return controller.respondWith( + new Response(null, { + headers: { + 'access-control-allow-origin': '*', + }, + }) + ) + } + + throw new Error('Custom error message') + }) + + const request = new XMLHttpRequest() + request.responseType = 'json' + request.open('GET', 'https://any.host.here/irrelevant') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(500) + expect.soft(request.statusText).toBe('Unhandled Exception') + expect.soft(request.response).toEqual({ + name: 'Error', + message: 'Custom error message', + stack: expect.any(String), + }) +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts new file mode 100644 index 000000000..20166c3e5 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-upload.neutral.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +import { + spyOnXMLHttpRequest, + spyOnXMLHttpRequestUpload, + waitForXMLHttpRequest, +} from '#/test/setup/helpers-neutral' +import { getTestServer } from '#/test/setup/vitest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const server = getTestServer() +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with the upload listeners', async ({ + task, +}) => { + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + const { events: uploadEvents } = spyOnXMLHttpRequestUpload(request.upload) + request.open('POST', server.http.url('/upload')) + request.send(new Blob(['hello', ' ', 'world'])) + + await waitForXMLHttpRequest(request) + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + /** + * @note This is the response progress event since the test server + * responds with the same payload that the request sent. + */ + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + expect.soft(uploadEvents).toEqual([ + ['loadstart', { loaded: 0, total: 11 }], + ['progress', { loaded: 11, total: 11 }], + ['load', { loaded: 11, total: 11 }], + ['loadend', { loaded: 11, total: 11 }], + ]) + } +}) + +it('fires the upload events for a mocked request', async ({ task }) => { + interceptor.on('request', ({ request, controller }) => { + controller.respondWith( + new Response(request.body, { + headers: { + 'access-control-allow-origin': '*', + 'content-type': 'text/plain', + 'content-length': '11', + }, + }) + ) + }) + + const request = new XMLHttpRequest() + const { events } = spyOnXMLHttpRequest(request) + const { events: uploadEvents } = spyOnXMLHttpRequestUpload(request.upload) + request.open('POST', 'http://any.host.here/irrelevant') + request.send(new Blob(['hello', ' ', 'world'])) + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') + + if (task.file.projectName === 'browser') { + expect.soft(events).toEqual([ + ['readystatechange', 1], + ['loadstart', 1, { loaded: 0, total: 0 }], + ['readystatechange', 2], + ['readystatechange', 3], + ['progress', 3, { loaded: 11, total: 11 }], + ['readystatechange', 4], + ['load', 4, { loaded: 11, total: 11 }], + ['loadend', 4, { loaded: 11, total: 11 }], + ]) + expect.soft(uploadEvents).toEqual([ + ['loadstart', { loaded: 0, total: 11 }], + ['progress', { loaded: 11, total: 11 }], + ['load', { loaded: 11, total: 11 }], + ['loadend', { loaded: 11, total: 11 }], + ]) + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts b/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts new file mode 100644 index 000000000..f79b44850 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-with-credentials.neutral.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom +import { waitForXMLHttpRequest } from '#/test/setup/helpers-neutral' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('sets "credentials" to "same-origin" for the request that does not have "withCredentials" set', async () => { + const pendingRequestFromRequestListener = Promise.withResolvers() + interceptor.on('request', ({ request, controller }) => { + pendingRequestFromRequestListener.resolve(request) + + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + }) + ) + }) + + const pendingRequestFromResponseListener = Promise.withResolvers() + interceptor.on('response', ({ request }) => { + pendingRequestFromResponseListener.resolve(request) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevants') + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') + + { + const request = await pendingRequestFromRequestListener.promise + expect(request.credentials).toBe('same-origin') + } + + { + const request = await pendingRequestFromResponseListener.promise + expect(request.credentials).toBe('same-origin') + } +}) + +it('sets "credentials" to "include" for the request that has "withCredentials" set to true', async () => { + const pendingRequestFromRequestListener = Promise.withResolvers() + interceptor.on('request', ({ request, controller }) => { + pendingRequestFromRequestListener.resolve(request) + + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + }) + ) + }) + + const pendingRequestFromResponseListener = Promise.withResolvers() + interceptor.on('response', ({ request }) => { + pendingRequestFromResponseListener.resolve(request) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.withCredentials = true + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') + + { + const request = await pendingRequestFromRequestListener.promise + expect(request.credentials).toBe('include') + } + + { + const request = await pendingRequestFromResponseListener.promise + expect(request.credentials).toBe('include') + } +}) + +it('sets "credentials" to "same-origin" for the request that has "withCredentials" set to false', async () => { + const pendingRequestFromRequestListener = Promise.withResolvers() + interceptor.on('request', ({ request, controller }) => { + pendingRequestFromRequestListener.resolve(request) + + controller.respondWith( + new Response('hello world', { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-credentials': 'true', + }, + }) + ) + }) + + const pendingRequestFromResponseListener = Promise.withResolvers() + interceptor.on('response', ({ request }) => { + pendingRequestFromResponseListener.resolve(request) + }) + + const request = new XMLHttpRequest() + request.open('GET', 'http://any.host.here/irrelevant') + request.withCredentials = false + request.send() + + await waitForXMLHttpRequest(request) + + expect.soft(request.status).toBe(200) + expect.soft(request.response).toBe('hello world') + + { + const request = await pendingRequestFromRequestListener.promise + expect(request.credentials).toBe('same-origin') + } + + { + const request = await pendingRequestFromResponseListener.promise + expect(request.credentials).toBe('same-origin') + } +}) diff --git a/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js deleted file mode 100644 index de9aca402..000000000 --- a/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js +++ /dev/null @@ -1,36 +0,0 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' - -const interceptor = new XMLHttpRequestInterceptor() - -interceptor.on('request', async ({ request, requestId, controller }) => { - window.dispatchEvent( - new CustomEvent('resolver', { - detail: { - id: requestId, - method: request.method, - url: request.url, - headers: Object.fromEntries(request.headers.entries()), - credentials: request.credentials, - body: await request.clone().text(), - }, - }) - ) - - const { serverHttpUrl, serverHttpsUrl } = window - - if ([serverHttpUrl, serverHttpsUrl].includes(request.url)) { - controller.respondWith( - new Response(JSON.stringify({ mocked: true }), { - status: 201, - statusText: 'Created', - headers: { - 'Content-Type': 'application/hal+json', - }, - }) - ) - } -}) - -interceptor.apply() - -window.interceptor = interceptor diff --git a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr.browser.test.ts deleted file mode 100644 index a951db6cf..000000000 --- a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Page } from '@playwright/test' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' - -declare namespace window { - export const interceptor: XMLHttpRequestInterceptor - export let serverHttpUrl: string - export let serverHttpsUrl: string -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/', (req, res) => { - res.status(200).json({ route: '/' }) - }) - app.get('/get', (req, res) => { - res.status(200).json({ route: '/get' }) - }) -}) - -async function forwardServerUrls(page: Page): Promise { - await page.evaluate((httpUrl) => { - window.serverHttpUrl = httpUrl - }, httpServer.http.url('/')) - - await page.evaluate((httpsUrl) => { - window.serverHttpsUrl = httpsUrl - }, httpServer.https.url('/')) -} - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - -test('responds to an HTTP request handled in the resolver', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/'), - }) - - expect(response.status).toBe(201) - expect(response.statusText).toBe('Created') - expect(response.headers).toBe('content-type: application/hal+json') - expect(response.body).toEqual(JSON.stringify({ mocked: true })) -}) - -test('responds to an HTTPS request handled in the resolver', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: httpServer.https.url('/'), - }) - - expect(response.status).toBe(201) - expect(response.statusText).toBe('Created') - expect(response.headers).toBe('content-type: application/hal+json') - expect(response.body).toEqual(JSON.stringify({ mocked: true })) -}) - -test('bypasses a request not handled in the resolver', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/get'), - }) - - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(response.body).toEqual(JSON.stringify({ route: '/get' })) -}) - -test('bypasses any request when the interceptor is restored', async ({ - loadExample, - callRawXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - await page.evaluate(() => { - window.interceptor.dispose() - }) - - // Using the "createRawBrowserXMLHttpRequest" because when the interceptor - // is restored, it won't dispatch the "resolver" event. - const firstResponse = await callRawXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/'), - }) - - expect(firstResponse.status).toBe(200) - expect(firstResponse.statusText).toBe('OK') - expect(firstResponse.body).toEqual(JSON.stringify({ route: '/' })) - - const secondResponse = await callRawXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/get'), - }) - expect(secondResponse.status).toBe(200) - expect(secondResponse.statusText).toBe('OK') - expect(secondResponse.body).toEqual(JSON.stringify({ route: '/get' })) -}) - -test('mocks response to a synchronous XMLHttpRequest', async ({ - loadExample, - callXMLHttpRequest, - page, -}) => { - await loadExample(require.resolve('./xhr.browser.runtime.js')) - await forwardServerUrls(page) - - const [, response] = await callXMLHttpRequest({ - method: 'GET', - url: httpServer.http.url('/'), - async: false, - }) - - expect(response.status).toBe(201) - expect(response.statusText).toBe('Created') - expect(response.headers).toBe('content-type: application/hal+json') - expect(response.body).toEqual(JSON.stringify({ mocked: true })) -}) diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts deleted file mode 100644 index c1bfe3491..000000000 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import { HttpServer } from '@open-draft/test-server/http' -import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' -import { createXMLHttpRequest, useCors } from '../../../helpers' - -declare namespace window { - export const _resourceLoader: { - _strictSSL: boolean - } -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/', (req, res) => { - res.status(200).send('/') - }) - app.get('/get', (req, res) => { - res.status(200).send('/get') - }) - app.post('/cookies', (req, res) => { - return res - .cookie('authToken', 'SECRET', { - secure: true, - expires: new Date(Date.now() + 90000), - }) - .send('ok') - }) -}) - -const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - const shouldMock = - [httpServer.http.url(), httpServer.https.url()].includes(request.url) || - ['/login'].includes(url.pathname) - - if (shouldMock) { - return controller.respondWith( - new Response('foo', { - status: 301, - statusText: 'Moved Permanently', - headers: { - 'Content-Type': 'application/hal+json', - }, - }) - ) - } - - if (request.url.endsWith('/network-error')) { - return controller.respondWith(Response.error()) - } - - if (request.url.endsWith('/exception')) { - throw new Error('Custom message') - } -}) - -beforeAll(async () => { - // Allow XHR requests to the local HTTPS server with a self-signed certificate. - window._resourceLoader._strictSSL = false - - await httpServer.listen() - interceptor.apply() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() - vi.restoreAllMocks() -}) - -it('responds to an HTTP request handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/')) - req.send() - }) - const responseHeaders = req.getAllResponseHeaders() - - expect(req.status).toEqual(301) - expect(responseHeaders).toContain('content-type: application/hal+json') - expect(req.response).toEqual('foo') -}) - -it('bypasses an HTTP request not handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/get')) - req.send() - }) - - expect(req.status).toEqual(200) - expect(req.response).toEqual('/get') -}) - -it('responds to an HTTPS request handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/')) - req.send() - }) - const responseHeaders = req.getAllResponseHeaders() - - expect(req.status).toEqual(301) - expect(responseHeaders).toContain('content-type: application/hal+json') - expect(req.response).toEqual('foo') - expect(req.responseURL).toEqual(httpServer.https.url('/')) -}) - -it('bypasses an HTTPS request not handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/get')) - req.send() - }) - - expect(req.status).toEqual(200) - expect(req.response).toEqual('/get') - expect(req.responseURL).toEqual(httpServer.https.url('/get')) -}) - -it('responds to an HTTP request to a relative URL that is handled in the middleware', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('POST', httpServer.https.url('/login')) - req.send() - }) - const responseHeaders = req.getAllResponseHeaders() - - expect(req.status).toEqual(301) - expect(responseHeaders).toContain('content-type: application/hal+json') - expect(req.response).toEqual('foo') - expect(req.responseURL).toEqual(httpServer.https.url('/login')) -}) - -it('produces a request error for a mocked Response.error() response', async () => { - const errorListener = vi.fn() - const req = await createXMLHttpRequest((req) => { - req.open('GET', 'http://localhost/network-error') - req.addEventListener('error', errorListener) - req.send() - }) - - expect(errorListener).toHaveBeenCalledTimes(1) - - // XMLHttpRequest request exception propagates as "ProgressEvent". - const [progressEvent] = errorListener.mock.calls[0] - expect(progressEvent).toBeInstanceOf(ProgressEvent) - - // Request must still exist. - expect(req.status).toBe(0) -}) - -it('produces a 500 response for an unhandled exception in the interceptor', async () => { - const request = await createXMLHttpRequest((request) => { - request.responseType = 'json' - request.open('GET', 'http://localhost/exception') - request.send() - }) - - expect(request.status).toBe(500) - expect(request.statusText).toBe('Unhandled Exception') - expect(request.response).toEqual({ - name: 'Error', - message: 'Custom message', - stack: expect.any(String), - }) -}) - -it('does not propagate the forbidden "cookie" header on the bypassed response', async () => { - const req = await createXMLHttpRequest((req) => { - req.open('POST', httpServer.https.url('/cookies')) - req.send() - }) - const responseHeaders = req.getAllResponseHeaders() - expect(responseHeaders).not.toMatch(/cookie/) -}) - -it('bypasses any request when the interceptor is restored', async () => { - interceptor.dispose() - - const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.https.url('/')) - req.send() - }) - - expect(req.status).toEqual(200) - expect(req.response).toEqual('/') - expect(req.responseURL).toEqual(httpServer.https.url('/')) -}) diff --git a/test/modules/fetch/compliance/abort-conrtoller.test.ts b/test/modules/fetch/compliance/abort-conrtoller.test.ts index bcb07b6d5..b63d745c0 100644 --- a/test/modules/fetch/compliance/abort-conrtoller.test.ts +++ b/test/modules/fetch/compliance/abort-conrtoller.test.ts @@ -1,10 +1,8 @@ // @vitest-environment node -import { afterEach, afterAll, beforeAll, expect, it } from 'vitest' import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { sleep } from '../../../helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -57,7 +55,7 @@ it('aborts a pending request when the original request is aborted', async () => interceptor.on('request', async ({ controller }) => { requestListenerCalled.resolve() - await sleep(1000) + await setTimeout(1000) controller.respondWith(new Response()) }) @@ -104,7 +102,7 @@ it('forwards custom abort reason to the request if pending', async () => { interceptor.once('request', async ({ controller }) => { requestListenerCalled.resolve() - await sleep(1000) + await setTimeout(1000) controller.respondWith(new Response()) }) diff --git a/test/modules/fetch/compliance/fetch-follow-redirects.test.ts b/test/modules/fetch/compliance/fetch-follow-redirects.test.ts index b9eca3bb2..cc75432b7 100644 --- a/test/modules/fetch/compliance/fetch-follow-redirects.test.ts +++ b/test/modules/fetch/compliance/fetch-follow-redirects.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() @@ -54,7 +53,10 @@ it('follows a mocked relative redirect to the original server', async () => { interceptor.on('request', ({ request, controller }) => { if (request.url.endsWith('/original')) { return controller.respondWith( - new Response(null, { status: 302, headers: { location: '/redirected' } }) + new Response(null, { + status: 302, + headers: { location: '/redirected' }, + }) ) } }) diff --git a/test/modules/fetch/compliance/fetch-request-body-used.test.ts b/test/modules/fetch/compliance/fetch-request-body-used.test.ts index 214f5321d..2be2be2c9 100644 --- a/test/modules/fetch/compliance/fetch-request-body-used.test.ts +++ b/test/modules/fetch/compliance/fetch-request-body-used.test.ts @@ -1,8 +1,7 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import * as express from 'express' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-error.test.ts b/test/modules/fetch/compliance/fetch-response-error.test.ts index 481b38cef..50cd34b23 100644 --- a/test/modules/fetch/compliance/fetch-response-error.test.ts +++ b/test/modules/fetch/compliance/fetch-response-error.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-headers.test.ts b/test/modules/fetch/compliance/fetch-response-headers.test.ts index 494704ca8..ee9a6adac 100644 --- a/test/modules/fetch/compliance/fetch-response-headers.test.ts +++ b/test/modules/fetch/compliance/fetch-response-headers.test.ts @@ -1,5 +1,4 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts index 41c7e8efd..ed1fb4d3c 100644 --- a/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts +++ b/test/modules/fetch/compliance/fetch-response-non-configurable.test.ts @@ -1,9 +1,8 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { FetchResponse } from '../../../../src/utils/fetchUtils' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' +import { FetchResponse } from '#/src/utils/fetchUtils' const interceptor = new FetchInterceptor() @@ -49,7 +48,12 @@ it('supports mocking non-configurable responses', async () => { * @note The Fetch API `Response` will still error on * non-configurable status codes. Instead, use this helper class. */ - controller.respondWith(new FetchResponse(null, { status: 101 })) + controller.respondWith( + new FetchResponse(null, { + status: 101, + statusText: 'Switching Protocols', + }) + ) }) const responsePromise = new DeferredPromise() @@ -59,7 +63,8 @@ it('supports mocking non-configurable responses', async () => { const response = await fetch('http://localhost/irrelevant') - expect(response.status).toBe(101) + expect.soft(response.status).toBe(101) + expect.soft(response.statusText).toBe('Switching Protocols') // Must expose the exact response in the listener. await expect(responsePromise).resolves.toHaveProperty('status', 101) diff --git a/test/modules/fetch/compliance/fetch-response-url.test.ts b/test/modules/fetch/compliance/fetch-response-url.test.ts index a8f6c6299..4386e0d4f 100644 --- a/test/modules/fetch/compliance/fetch-response-url.test.ts +++ b/test/modules/fetch/compliance/fetch-response-url.test.ts @@ -1,6 +1,5 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() @@ -84,6 +83,7 @@ it('returns the last response url in case of redirects', async () => { }) const response = await fetch('http://localhost/target') + expect(response.url).toBe('http://localhost/destination') await expect(response.text()).resolves.toBe('hello world') }) diff --git a/test/modules/fetch/compliance/response-content-encoding.browser.test.ts b/test/modules/fetch/compliance/response-content-encoding.browser.test.ts index 4c0f3c03e..759e3634f 100644 --- a/test/modules/fetch/compliance/response-content-encoding.browser.test.ts +++ b/test/modules/fetch/compliance/response-content-encoding.browser.test.ts @@ -1,8 +1,8 @@ import { HttpServer } from '@open-draft/test-server/http' import { test, expect } from '../../../playwright.extend' -import { compressResponse, useCors } from '../../../helpers' -import { parseContentEncoding } from '../../../../src/interceptors/fetch/utils/decompression' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { compressResponse, useCors } from '#/test/helpers' +import { parseContentEncoding } from '#/src/interceptors/fetch/utils/decompression' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' declare namespace window { const interceptor: FetchInterceptor diff --git a/test/modules/fetch/compliance/response-content-encoding.test.ts b/test/modules/fetch/compliance/response-content-encoding.test.ts index f861c0f21..467604bde 100644 --- a/test/modules/fetch/compliance/response-content-encoding.test.ts +++ b/test/modules/fetch/compliance/response-content-encoding.test.ts @@ -1,9 +1,8 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { compressResponse } from '../../../helpers' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { parseContentEncoding } from '../../../../src/interceptors/fetch/utils/decompression' +import { compressResponse } from '#/test/helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' +import { parseContentEncoding } from '#/src/interceptors/fetch/utils/decompression' const httpServer = new HttpServer((app) => { app.get('/compressed', (req, res) => { diff --git a/test/modules/fetch/features/fetch-initiator.v-browser.test.ts b/test/modules/fetch/features/fetch-initiator.v-browser.test.ts new file mode 100644 index 000000000..48d5a57b1 --- /dev/null +++ b/test/modules/fetch/features/fetch-initiator.v-browser.test.ts @@ -0,0 +1,35 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { DeferredPromise } from '@open-draft/deferred-promise' + +const interceptor = new FetchInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('exposes the initiator for a mocked fetch request', async () => { + const pendingInitiator = new DeferredPromise() + + interceptor.on('request', ({ initiator, controller }) => { + pendingInitiator.resolve(initiator) + controller.respondWith(new Response('hello world')) + }) + + const response = await fetch('http://any.host.here/resource') + + const initiator = await pendingInitiator + expect.soft(initiator).toBeInstanceOf(Request) + expect.soft(initiator).toMatchObject({ + method: 'GET', + url: 'http://any.host.here/resource', + }) + await expect.soft(response.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/fetch/fetch-exception.browser.test.ts b/test/modules/fetch/fetch-exception.browser.test.ts index a94eef0a4..5ac9b8ac3 100644 --- a/test/modules/fetch/fetch-exception.browser.test.ts +++ b/test/modules/fetch/fetch-exception.browser.test.ts @@ -1,4 +1,4 @@ -import { FetchInterceptor } from '../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' import { test, expect } from '../../playwright.extend' declare global { diff --git a/test/modules/fetch/fetch-exception.test.ts b/test/modules/fetch/fetch-exception.test.ts index 4a2a9b6a1..f19fa7f37 100644 --- a/test/modules/fetch/fetch-exception.test.ts +++ b/test/modules/fetch/fetch-exception.test.ts @@ -1,6 +1,5 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { FetchInterceptor } from '../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/fetch-modify-request.browser.test.ts b/test/modules/fetch/fetch-modify-request.browser.test.ts index 2369933e6..b438433f7 100644 --- a/test/modules/fetch/fetch-modify-request.browser.test.ts +++ b/test/modules/fetch/fetch-modify-request.browser.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { useCors } from '../../helpers' +import { useCors } from '#/test/helpers' import { test, expect } from '../../playwright.extend' const server = new HttpServer((app) => { diff --git a/test/modules/fetch/fetch-request-controller.test.ts b/test/modules/fetch/fetch-request-controller.test.ts index fd6d4f4ac..446643ba3 100644 --- a/test/modules/fetch/fetch-request-controller.test.ts +++ b/test/modules/fetch/fetch-request-controller.test.ts @@ -1,8 +1,7 @@ // @vitest-environment node -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' import { DeferredPromise } from '@open-draft/deferred-promise' -import { InterceptorError } from '../../../src/InterceptorError' -import { FetchInterceptor } from '../../../src/interceptors/fetch' +import { InterceptorError } from '#/src/InterceptorError' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/intercept/fetch-relative-url.test.ts b/test/modules/fetch/intercept/fetch-relative-url.test.ts index 49b4c9006..64cdeb6a1 100644 --- a/test/modules/fetch/intercept/fetch-relative-url.test.ts +++ b/test/modules/fetch/intercept/fetch-relative-url.test.ts @@ -1,8 +1,5 @@ -/** - * @vitest-environment jsdom - */ -import { it, expect, afterAll, afterEach, beforeAll } from 'vitest' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +// @vitest-environment jsdom +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const interceptor = new FetchInterceptor() diff --git a/test/modules/fetch/intercept/fetch.browser.test.ts b/test/modules/fetch/intercept/fetch.browser.test.ts index 3c8dd3019..cf387250c 100644 --- a/test/modules/fetch/intercept/fetch.browser.test.ts +++ b/test/modules/fetch/intercept/fetch.browser.test.ts @@ -2,7 +2,7 @@ import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { Page, Response } from '@playwright/test' import { test, expect } from '../../../playwright.extend' -import { extractRequestFromPage, useCors } from '../../../helpers' +import { extractRequestFromPage, useCors } from '#/test/helpers' const EXAMPLE_PATH = require.resolve('./fetch.browser.runtime.js') diff --git a/test/modules/fetch/intercept/fetch.clone.browser.test.ts b/test/modules/fetch/intercept/fetch.clone.browser.test.ts index 064a68c17..cb3332974 100644 --- a/test/modules/fetch/intercept/fetch.clone.browser.test.ts +++ b/test/modules/fetch/intercept/fetch.clone.browser.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { useCors } from '../../../helpers' +import { useCors } from '#/test/helpers' import { test, expect } from '../../../playwright.extend' declare namespace window { diff --git a/test/modules/fetch/intercept/fetch.request.browser.test.ts b/test/modules/fetch/intercept/fetch.request.browser.test.ts index 053b16587..3c28eabce 100644 --- a/test/modules/fetch/intercept/fetch.request.browser.test.ts +++ b/test/modules/fetch/intercept/fetch.request.browser.test.ts @@ -1,5 +1,5 @@ import { HttpServer } from '@open-draft/test-server/http' -import { extractRequestFromPage, useCors } from '../../../helpers' +import { extractRequestFromPage, useCors } from '#/test/helpers' import { test, expect } from '../../../playwright.extend' const httpServer = new HttpServer((app) => { diff --git a/test/modules/fetch/intercept/fetch.request.test.ts b/test/modules/fetch/intercept/fetch.request.test.ts index 80412c753..0aea24790 100644 --- a/test/modules/fetch/intercept/fetch.request.test.ts +++ b/test/modules/fetch/intercept/fetch.request.test.ts @@ -1,10 +1,9 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '../../../../src' -import { REQUEST_ID_REGEXP } from '../../../helpers' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { RequestController } from '../../../../src/RequestController' +import { HttpRequestEventMap } from '#/src/index' +import { REQUEST_ID_REGEXP } from '#/test/helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' +import { RequestController } from '#/src/RequestController' const httpServer = new HttpServer((app) => { app.post('/user', (_req, res) => { @@ -30,7 +29,7 @@ afterAll(async () => { it('intercepts fetch requests constructed via a "Request" instance', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ diff --git a/test/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index bd37f27cf..4226aa5a1 100644 --- a/test/modules/fetch/intercept/fetch.test.ts +++ b/test/modules/fetch/intercept/fetch.test.ts @@ -1,12 +1,11 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { HttpRequestEventMap } from '../../../../src' -import { REQUEST_ID_REGEXP } from '../../../helpers' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' -import { RequestController } from '../../../../src/RequestController' +import { HttpRequestEventMap } from '#/src/index' +import { REQUEST_ID_REGEXP } from '#/test/helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' +import { encodeBuffer } from '#/src/utils/bufferUtils' +import { RequestController } from '#/src/RequestController' process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' @@ -41,7 +40,7 @@ afterAll(async () => { it('intercepts an HTTP HEAD request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -71,7 +70,7 @@ it('intercepts an HTTP HEAD request', async () => { it('intercepts an HTTP GET request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -100,7 +99,7 @@ it('intercepts an HTTP GET request', async () => { it('intercepts an HTTP POST request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -133,7 +132,7 @@ it('intercepts an HTTP POST request', async () => { it('intercepts an HTTP PUT request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -166,7 +165,7 @@ it('intercepts an HTTP PUT request', async () => { it('intercepts an HTTP DELETE request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -198,7 +197,7 @@ it('intercepts an HTTP DELETE request', async () => { it('intercepts an HTTP PATCH request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -231,7 +230,7 @@ it('intercepts an HTTP PATCH request', async () => { it('intercepts an HTTPS HEAD request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -263,7 +262,7 @@ it('intercepts an HTTPS HEAD request', async () => { it('intercepts an HTTPS GET request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -294,7 +293,7 @@ it('intercepts an HTTPS GET request', async () => { it('intercepts an HTTPS POST request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -328,7 +327,7 @@ it('intercepts an HTTPS POST request', async () => { it('intercepts an HTTPS PUT request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -361,7 +360,7 @@ it('intercepts an HTTPS PUT request', async () => { it('intercepts an HTTPS DELETE request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ @@ -393,7 +392,7 @@ it('intercepts an HTTPS DELETE request', async () => { it('intercepts an HTTPS PATCH request', async () => { const requestListenerArgs = new DeferredPromise< - HttpRequestEventMap['request'][0] + HttpRequestEventMap['request'] >() interceptor.on('request', (args) => { requestListenerArgs.resolve({ diff --git a/test/modules/fetch/response/fetch-await-response-event.test.ts b/test/modules/fetch/response/fetch-await-response-event.test.ts index 0a8b673a9..6df4a7686 100644 --- a/test/modules/fetch/response/fetch-await-response-event.test.ts +++ b/test/modules/fetch/response/fetch-await-response-event.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { diff --git a/test/modules/fetch/response/fetch-response-json.neutral.test.ts b/test/modules/fetch/response/fetch-response-json.neutral.test.ts new file mode 100644 index 000000000..4fd5a8f90 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-json.neutral.test.ts @@ -0,0 +1,53 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new FetchInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a json response', async () => { + const response = await fetch(server.http.url('/json'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ message: 'original' }), + }) + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.json()).resolves.toEqual({ message: 'original' }) +}) + +it('responds with a json response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.json({ message: 'hello world' })) + }) + + const response = await fetch('http://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.json()).resolves.toEqual({ message: 'hello world' }) +}) + +it('responds with a json response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(Response.json({ message: 'hello world' })) + }) + + const response = await fetch('https://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.json()).resolves.toEqual({ message: 'hello world' }) +}) diff --git a/test/modules/fetch/response/fetch-response-patching.browser.test.ts b/test/modules/fetch/response/fetch-response-patching.browser.test.ts deleted file mode 100644 index a7d6d011a..000000000 --- a/test/modules/fetch/response/fetch-response-patching.browser.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { HttpServer } from '@open-draft/test-server/http' -import { Page } from '@playwright/test' -import { test, expect } from '../../../playwright.extend' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' -import { useCors } from '../../../helpers' - -declare namespace window { - export const interceptor: FetchInterceptor - export let originalUrl: string -} - -const httpServer = new HttpServer((app) => { - app.use(useCors) - app.get('/original', (req, res) => { - res - .set('access-control-expose-headers', 'x-custom-header') - .set('x-custom-header', 'yes') - .send('hello') - }) -}) - -async function forwardServerUrl(page: Page): Promise { - await page.evaluate((url) => { - window.originalUrl = url - }, httpServer.http.url('/original')) -} - -test.beforeAll(async () => { - await httpServer.listen() -}) - -test.afterAll(async () => { - await httpServer.close() -}) - -test('supports response patching', async ({ loadExample, page }) => { - await loadExample(require.resolve('./fetch-response-patching.runtime.js')) - await forwardServerUrl(page) - - const res = await page.evaluate(() => { - return fetch('http://localhost/mocked').then((res) => { - return res.text().then((text) => { - return { - status: res.status, - statusText: res.statusText, - headers: Array.from(res.headers.entries()), - text, - } - }) - }) - }) - const headers = new Headers(res.headers) - - expect(res.status).toBe(200) - expect(res.statusText).toBe('OK') - expect(headers.get('x-custom-header')).toBe('yes') - expect(res.text).toBe('hello world') -}) - -test('throws an AbortError, when the request has been aborted', async ({ - loadExample, - page, -}) => { - await loadExample(require.resolve('./fetch-response-patching.runtime.js')) - await forwardServerUrl(page) - - const result = await page.evaluate(async () => { - const controller = new AbortController() - const response = fetch('http://localhost/mocked', { - signal: controller.signal, - }).then( - () => { - throw new Error('Fetch did not reject') - }, - (error) => ({ - isDomException: error instanceof DOMException, - name: error.name, - }) - ) - controller.abort() - return await response - }) - - expect(result.name).toBe('AbortError') - expect(result.isDomException).toBe(true) -}) diff --git a/test/modules/fetch/response/fetch-response-patching.neutral.test.ts b/test/modules/fetch/response/fetch-response-patching.neutral.test.ts new file mode 100644 index 000000000..fb67635a7 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-patching.neutral.test.ts @@ -0,0 +1,63 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new FetchInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('patches the original response', async () => { + interceptor.on('request', async ({ request, controller }) => { + const url = new URL(request.url) + + if (url.searchParams.get('passthrough') === '1') { + return controller.passthrough() + } + + url.searchParams.set('passthrough', '1') + const originalResponse = await fetch( + new Request(url, { + method: request.method, + headers: request.headers, + /** + * @note Read the request's body to prevent Chrome from throwing + * "TypeError: Failed to fetch" due to refusing to send a streaming + * request body over HTTP/1.1. + */ + body: await request.text(), + }) + ) + const originalData = await originalResponse.json() + + controller.respondWith( + Response.json({ + ...originalData, + mock: true, + }) + ) + }) + + const response = await fetch(server.http.url('/'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ message: 'hello world' }), + }) + + expect.soft(response.status).toBe(200) + await expect.soft(response.json()).resolves.toEqual({ + message: 'hello world', + mock: true, + }) +}) diff --git a/test/modules/fetch/response/fetch-response-patching.runtime.js b/test/modules/fetch/response/fetch-response-patching.runtime.js deleted file mode 100644 index 6eff78be2..000000000 --- a/test/modules/fetch/response/fetch-response-patching.runtime.js +++ /dev/null @@ -1,22 +0,0 @@ -import { FetchInterceptor } from '@mswjs/interceptors/fetch' - -const interceptor = new FetchInterceptor() - -interceptor.on('request', async ({ request, controller }) => { - const url = new URL(request.url) - - if (url.pathname === '/mocked') { - await new Promise((resolve) => setTimeout(resolve, 0)) - - const originalResponse = await fetch(window.originalUrl) - const originalText = await originalResponse.text() - - controller.respondWith( - new Response(`${originalText} world`, originalResponse) - ) - } -}) - -interceptor.apply() - -window.interceptor = interceptor diff --git a/test/modules/fetch/response/fetch-response-stream.neutral.test.ts b/test/modules/fetch/response/fetch-response-stream.neutral.test.ts new file mode 100644 index 000000000..9da7cc927 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-stream.neutral.test.ts @@ -0,0 +1,81 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { getTestServer } from '#/test/setup/vitest' +import { setTimeout } from '#/test/setup/helpers-neutral' + +const server = getTestServer() +const interceptor = new FetchInterceptor() + +const encoder = new TextEncoder() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a stream response', async () => { + const response = await fetch(server.http.url('/stream'), { + method: 'POST', + body: 'original', + }) + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('original') +}) + +it('responds with a stream response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response( + new ReadableStream({ + async pull(controller) { + controller.enqueue(encoder.encode('hello')) + await setTimeout(100) + controller.enqueue(encoder.encode(' ')) + await setTimeout(100) + controller.enqueue(encoder.encode('world')) + controller.close() + }, + }) + ) + ) + }) + + const response = await fetch('http://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('hello world') +}) + +it('responds with a stream response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response( + new ReadableStream({ + async pull(controller) { + controller.enqueue(encoder.encode('hello')) + await setTimeout(100) + controller.enqueue(encoder.encode(' ')) + await setTimeout(100) + controller.enqueue(encoder.encode('world')) + controller.close() + }, + }) + ) + ) + }) + + const response = await fetch('https://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/fetch/response/fetch-response-text.neutral.test.ts b/test/modules/fetch/response/fetch-response-text.neutral.test.ts new file mode 100644 index 000000000..f0683d286 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-text.neutral.test.ts @@ -0,0 +1,52 @@ +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { getTestServer } from '#/test/setup/vitest' + +const server = getTestServer() +const interceptor = new FetchInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts a bypassed request with a text response', async () => { + const response = await fetch(server.http.url('/'), { + method: 'POST', + body: 'original', + }) + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('original') +}) + +it('responds with a text response to an HTTP request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const response = await fetch('http://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('hello world') +}) + +it('responds with a text response to an HTTPS request', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const response = await fetch('https://localhost/irrelevant') + + expect(response.status).toBe(200) + expect(response.body).toBeInstanceOf(ReadableStream) + await expect(response.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/fetch/response/fetch.browser.test.ts b/test/modules/fetch/response/fetch.browser.test.ts index 0a5c266ed..dd111e786 100644 --- a/test/modules/fetch/response/fetch.browser.test.ts +++ b/test/modules/fetch/response/fetch.browser.test.ts @@ -1,8 +1,8 @@ import { HttpServer } from '@open-draft/test-server/http' import { Page } from '@playwright/test' import { test, expect } from '../../../playwright.extend' -import { useCors } from '../../../helpers' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { useCors } from '#/test/helpers' +import { FetchInterceptor } from '#/src/interceptors/fetch/web' declare namespace window { export const interceptor: FetchInterceptor diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index 1f82d2e8e..51b7560ca 100644 --- a/test/modules/http/compliance/events.test.ts +++ b/test/modules/http/compliance/events.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node 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' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { HttpRequestEventMap } from '#/src/events/http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { @@ -14,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -28,10 +25,10 @@ afterAll(async () => { it('emits the "request" event for an outgoing request without body', async () => { const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.once('request', requestListener) - await waitForClientRequest( + await toWebResponse( http.get(httpServer.http.url('/'), { headers: { 'x-custom-header': 'yes', @@ -53,7 +50,7 @@ it('emits the "request" event for an outgoing request without body', async () => it('emits the "request" event for an outgoing request with a body', async () => { const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.once('request', requestListener) const request = http.request(httpServer.http.url('/'), { @@ -65,7 +62,7 @@ it('emits the "request" event for an outgoing request with a body', async () => }) request.write('post-payload') request.end() - await waitForClientRequest(request) + await toWebResponse(request) expect(requestListener).toHaveBeenCalledTimes(1) @@ -84,7 +81,7 @@ it('emits the "request" event for an outgoing request with a body', async () => it('emits the "response" event for a mocked response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.once('request', ({ controller }) => { controller.respondWith(new Response('hello world')) }) @@ -95,40 +92,42 @@ it('emits the "response" event for a mocked response', async () => { 'x-custom-header': 'yes', }, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must emit the "response" interceptor event. expect(responseListener).toHaveBeenCalledTimes(1) - const { - response, - requestId, - request: requestFromListener, - isMockedResponse, - } = responseListener.mock.calls[0][0] - expect(response).toBeInstanceOf(Response) - expect(response.status).toBe(200) - expect(await response.text()).toBe('hello world') - expect(isMockedResponse).toBe(true) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - expect(requestFromListener).toBeInstanceOf(Request) - expect(requestFromListener.method).toBe('GET') - expect(requestFromListener.url).toBe('http://localhost/') - expect( - Object.fromEntries(requestFromListener.headers.entries()) - ).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(requestFromListener.body).toBe(null) + { + const { + response, + requestId, + request: requestFromListener, + responseType, + } = responseListener.mock.calls[0][0] + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') + expect(responseType).toBe('mock') + + expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestFromListener).toBeInstanceOf(Request) + expect(requestFromListener.method).toBe('GET') + expect(requestFromListener.url).toBe('http://localhost/') + expect( + Object.fromEntries(requestFromListener.headers.entries()) + ).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(requestFromListener.body).toBe(null) + } // Must respond with the mocked response. - expect(res.statusCode).toBe(200) - expect(await text()).toBe('hello world') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello world') }) it('emits the "response" event for a bypassed response', async () => { const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() interceptor.once('response', responseListener) const request = http.get(httpServer.http.url('/'), { @@ -136,33 +135,35 @@ it('emits the "response" event for a bypassed response', async () => { 'x-custom-header': 'yes', }, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must emit the "response" interceptor event. expect(responseListener).toHaveBeenCalledTimes(1) - const { - response, - requestId, - request: requestFromListener, - isMockedResponse, - } = responseListener.mock.calls[0][0] - expect(response).toBeInstanceOf(Response) - expect(response.status).toBe(200) - expect(await response.text()).toBe('original-response') - expect(isMockedResponse).toBe(false) - - expect(requestId).toMatch(REQUEST_ID_REGEXP) - expect(requestFromListener).toBeInstanceOf(Request) - expect(requestFromListener.method).toBe('GET') - expect(requestFromListener.url).toBe(httpServer.http.url('/')) - expect( - Object.fromEntries(requestFromListener.headers.entries()) - ).toMatchObject({ - 'x-custom-header': 'yes', - }) - expect(requestFromListener.body).toBe(null) + { + const { + response, + requestId, + request: requestFromListener, + responseType, + } = responseListener.mock.calls[0][0] + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('original-response') + expect(responseType).toBe('original') + + expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect(requestFromListener).toBeInstanceOf(Request) + expect(requestFromListener.method).toBe('GET') + expect(requestFromListener.url).toBe(httpServer.http.url('/')) + expect( + Object.fromEntries(requestFromListener.headers.entries()) + ).toMatchObject({ + 'x-custom-header': 'yes', + }) + expect(requestFromListener.body).toBe(null) + } // Must respond with the mocked response. - expect(res.statusCode).toBe(200) - expect(await text()).toBe('original-response') + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original-response') }) diff --git a/test/modules/http/compliance/http-abort-controller.test.ts b/test/modules/http/compliance/http-abort-controller.test.ts index 630404d00..c223b7d50 100644 --- a/test/modules/http/compliance/http-abort-controller.test.ts +++ b/test/modules/http/compliance/http-abort-controller.test.ts @@ -1,20 +1,19 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { sleep, waitForClientRequest } from '../../../helpers' -import { setTimeout } from 'node:timers/promises' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { - await sleep(200) + await setTimeout(200) res.status(500).end() }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -89,7 +88,7 @@ it('handles a request within a timeout', async () => { }) request.on('timeout', timeoutListener) - await waitForClientRequest(request) + await toWebResponse(request) expect(request.destroyed).toBe(false) expect(timeoutListener).not.toHaveBeenCalled() @@ -108,7 +107,7 @@ it('respects "AbortSignal.timeout()" for a handled request', async () => { }) request.on('timeout', timeoutListener) - const abortError = await waitForClientRequest(request).then( + const abortError = await toWebResponse(request).then( () => expect.fail('must not return any response'), (error) => error ) diff --git a/test/modules/http/compliance/http-custom-agent.test.ts b/test/modules/http/compliance/http-custom-agent.test.ts index d902b17a1..7d202bff1 100644 --- a/test/modules/http/compliance/http-custom-agent.test.ts +++ b/test/modules/http/compliance/http-custom-agent.test.ts @@ -1,12 +1,11 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => res.send('hello world')) @@ -37,7 +36,7 @@ it('preserves the context of the "createConnection" function in a custom http ag const agent = new CustomHttpAgent() const request = http.get(httpServer.http.url('/resource'), { agent }) - await waitForClientRequest(request) + await toWebResponse(request) const [context] = createConnectionContextSpy.mock.calls[0] || [] expect(context.constructor.name).toBe('CustomHttpAgent') @@ -57,7 +56,7 @@ it('preserves the context of the "createConnection" function in a custom https a agent, rejectUnauthorized: false, }) - await waitForClientRequest(request) + await toWebResponse(request) const [context] = createConnectionContextSpy.mock.calls[0] expect(context.constructor.name).toBe('CustomHttpsAgent') diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index 6a9f70442..ca22a843f 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -1,13 +1,11 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +// @vitest-environment node +import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { sleep, waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interface NotFoundError extends NodeJS.ErrnoException { hostname: string @@ -22,14 +20,18 @@ beforeAll(() => { interceptor.apply() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(() => { interceptor.dispose() }) it('suppresses ECONNREFUSED error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { - await sleep(250) - controller.respondWith(new Response('Mocked')) + await setTimeout(250) + controller.respondWith(new Response('mocked')) }) // Connecting to a non-existing host will @@ -38,10 +40,10 @@ it('suppresses ECONNREFUSED error given a mocked response', async () => { const errorListener = vi.fn() request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked') expect(errorListener).not.toHaveBeenCalled() }) @@ -53,11 +55,12 @@ it('forwards ECONNREFUSED error given a bypassed request', async () => { // result in the "ECONNREFUSED" error in Node.js. // In this case, nothing is handling a response for this // request, so the connection error must be forwarded. - const request = http.get('http://localhost:9876') - request.on('error', (error: ConnectionError) => { - errorPromise.resolve(error) - }) - request.on('response', responseListener) + const request = http.get('http://localhost/non-existing') + request + .on('error', (error: ConnectionError) => { + errorPromise.resolve(error) + }) + .on('response', responseListener) const requestError = await errorPromise @@ -66,24 +69,24 @@ it('forwards ECONNREFUSED error given a bypassed request', async () => { * because Node.js v20 will aggregate connection errors * into a single "AggregateError" instance that doesn't have those. */ - expect(requestError.code).toBe('ECONNREFUSED') - expect(responseListener).not.toHaveBeenCalled() + expect.soft(requestError.code).toBe('ECONNREFUSED') + expect.soft(responseListener).not.toHaveBeenCalled() }) it('suppresses ENOTFOUND error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { - await sleep(250) - controller.respondWith(new Response('Mocked')) + await setTimeout(250) + controller.respondWith(new Response('mocked')) }) const request = http.get('http://non-existing-url.com') const errorListener = vi.fn() request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked') expect(errorListener).not.toHaveBeenCalled() }) @@ -105,8 +108,8 @@ it('forwards ENOTFOUND error for a bypassed request', async () => { it('suppresses EHOSTUNREACH error given a mocked response', async () => { interceptor.once('request', async ({ controller }) => { - await sleep(250) - controller.respondWith(new Response('Mocked')) + await setTimeout(250) + controller.respondWith(new Response('mocked')) }) // Connecting to an IPv6 address that's out of the network's @@ -115,10 +118,10 @@ it('suppresses EHOSTUNREACH error given a mocked response', async () => { const errorListener = vi.fn() request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked') }) it('forwards EHOSTUNREACH error for a bypassed request', async () => { @@ -147,7 +150,10 @@ it('allows throwing connection errors in the request listener', async () => { errno?: number syscall?: string - constructor(public address: string, public port: number) { + constructor( + public address: string, + public port: number + ) { super() this.code = 'ECONNREFUSED' this.errno = -61 @@ -157,7 +163,7 @@ it('allows throwing connection errors in the request listener', async () => { } interceptor.on('request', async () => { - await sleep(250) + await setTimeout(250) // A connection error thrown in the request listener // will not be suppressed, and will forward to the consumer. @@ -172,8 +178,9 @@ it('allows throwing connection errors in the request listener', async () => { const requestError = await errorPromise - expect(requestError.message).toBe('connect ECONNREFUSED ::1 4444') - expect(requestError.code).toBe('ECONNREFUSED') - expect(requestError.address).toBe('::1') - expect(requestError.port).toBe(4444) + expect(requestError).toMatchObject({ + code: 'ECONNREFUSED', + address: '::1', + port: 4444, + }) }) diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts index ca745e1cc..41575e00d 100644 --- a/test/modules/http/compliance/http-event-connect.test.ts +++ b/test/modules/http/compliance/http-event-connect.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +// @vitest-environment node 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' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { @@ -14,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -30,7 +27,7 @@ afterAll(async () => { await httpServer.close() }) -it('emits the "connect" event for a mocked request', async () => { +it('emits the "connect" event for a mocked HTTP request', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith(new Response('hello world')) }) @@ -41,21 +38,21 @@ it('emits the "connect" event for a mocked request', async () => { socket.on('connect', connectListener) }) - await waitForClientRequest(request) + await toWebResponse(request) - expect(connectListener).toHaveBeenCalledTimes(1) + expect(connectListener).toHaveBeenCalledOnce() }) -it('emits the "connect" event for a bypassed request', async () => { - const connectListener = vi.fn() +it('emits the "connect" event for a bypassed HTTP request', async () => { const request = http.get(httpServer.http.url('/')) + + const socketConnectListener = vi.fn() request.on('socket', (socket) => { - socket.on('connect', connectListener) + socket.on('connect', socketConnectListener) }) - await waitForClientRequest(request) - - expect(connectListener).toHaveBeenCalledTimes(1) + await toWebResponse(request) + expect(socketConnectListener).toHaveBeenCalledOnce() }) it('emits the "secureConnect" event for a mocked HTTPS request', async () => { @@ -66,30 +63,32 @@ it('emits the "secureConnect" event for a mocked HTTPS request', async () => { const connectListener = vi.fn<(input: string) => void>() const request = https.get(httpServer.https.url('/')) request.on('socket', (socket) => { - socket.on('connect', () => connectListener('connect')) - socket.on('secureConnect', () => connectListener('secureConnect')) + socket + .on('connect', () => connectListener('connect')) + .on('secureConnect', () => connectListener('secureConnect')) }) - await waitForClientRequest(request) + await toWebResponse(request) - expect(connectListener).toHaveBeenNthCalledWith(1, 'connect') - expect(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') - expect(connectListener).toHaveBeenCalledTimes(2) + expect.soft(connectListener).toHaveBeenNthCalledWith(1, 'connect') + expect.soft(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') + expect.soft(connectListener).toHaveBeenCalledTimes(2) }) -it('emits the "secureConnect" event for a mocked HTTPS request', async () => { +it('emits the "secureConnect" event for a bypassed HTTPS request', async () => { const connectListener = vi.fn<(input: string) => void>() const request = https.get(httpServer.https.url('/'), { rejectUnauthorized: false, }) request.on('socket', (socket) => { - socket.on('connect', () => connectListener('connect')) - socket.on('secureConnect', () => connectListener('secureConnect')) + socket + .on('connect', () => connectListener('connect')) + .on('secureConnect', () => connectListener('secureConnect')) }) - await waitForClientRequest(request) + await toWebResponse(request) - expect(connectListener).toHaveBeenNthCalledWith(1, 'connect') - expect(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') - expect(connectListener).toHaveBeenCalledTimes(2) + expect.soft(connectListener).toHaveBeenNthCalledWith(1, 'connect') + expect.soft(connectListener).toHaveBeenNthCalledWith(2, 'secureConnect') + expect.soft(connectListener).toHaveBeenCalledTimes(2) }) diff --git a/test/modules/http/compliance/http-head-response-body.test.ts b/test/modules/http/compliance/http-head-response-body.test.ts index 4ea5e3e7f..afb2f3630 100644 --- a/test/modules/http/compliance/http-head-response-body.test.ts +++ b/test/modules/http/compliance/http-head-response-body.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -28,11 +25,9 @@ it('ignores response body in a mocked response to a HEAD request', async () => { }) const request = http.request('http://example.com', { method: 'HEAD' }).end() - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - // Must return the correct mocked response. - expect(res.statusCode).toBe(200) - expect(res.headers).toHaveProperty('x-custom-header', 'yes') - // Must ignore the response body. - expect(await text()).toBe('') + expect(response.status).toBe(200) + expect(response.headers.get('x-custom-header')).toBe('yes') + await expect(response.text(), 'Ignores the response body').resolves.toBe('') }) diff --git a/test/modules/http/compliance/http-import.test.ts b/test/modules/http/compliance/http-import.test.ts new file mode 100644 index 000000000..ae5365bac --- /dev/null +++ b/test/modules/http/compliance/http-import.test.ts @@ -0,0 +1,43 @@ +// @vitest-environment node +import * as http from 'node:http' +import * as https from 'node:https' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' + +const interceptor = new HttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks a response to a request made via * as http import', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = http.request('http://localhost/api').end() + const [response] = await toWebResponse(request) + + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') +}) + +it('mocks a response to a request made via * as https import', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = https.request('https://localhost/api').end() + const [response] = await toWebResponse(request) + + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/http/compliance/http-max-header-fields-count.test.ts b/test/modules/http/compliance/http-max-header-fields-count.test.ts index fb2bc42b0..28e50cc75 100644 --- a/test/modules/http/compliance/http-max-header-fields-count.test.ts +++ b/test/modules/http/compliance/http-max-header-fields-count.test.ts @@ -1,11 +1,10 @@ // @vitest-environment node import http from 'node:http' -import { afterAll, afterEach, beforeAll, it, expect } from 'vitest' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -42,11 +41,11 @@ it('supports requests with more than default maximum header fields count', async request.setHeaders(new Headers(headersPairs)) request.end() - await waitForClientRequest(request) + await toWebResponse(request) const requestHeaders = await requestHeadersPromise expect(Array.from(requestHeaders)).toEqual([ - ['connection', 'close'], + ['connection', 'keep-alive'], ['host', 'localhost'], ...headersPairs, ]) @@ -77,11 +76,11 @@ it('supports multiple parallel "slow" requests', async () => { request.setHeaders(new Headers(headersPairs)) request.end() - await waitForClientRequest(request) + await toWebResponse(request) const requestHeaders = await requestHeadersPromise expect(Array.from(requestHeaders)).toEqual([ - ['connection', 'close'], + ['connection', 'keep-alive'], ['host', 'localhost'], ...headersPairs, ]) @@ -100,9 +99,9 @@ it('supports responses with more than default maximum header fields count', asyn interceptor.on('request', ({ controller }) => { const response = new Response(null, { status: 200, - headers: new Headers(responseHeadersPairs) + headers: new Headers(responseHeadersPairs), }) - + controller.respondWith(response) }) @@ -113,7 +112,7 @@ it('supports responses with more than default maximum header fields count', asyn const request = http.get('http://localhost/irrelevant') request.end() - await waitForClientRequest(request) + await toWebResponse(request) const responseHeaders = await responseHeadersPromise expect(Array.from(responseHeaders)).toEqual(responseHeadersPairs) diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index cfbbff407..d1f8d3e6b 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -1,22 +1,14 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' +import { getTestServer } from '#/test/setup/vitest' -const server = new HttpServer((app) => { - app.use('/user', (req, res) => { - res.set('x-appended-header', req.headers['x-appended-header']).end() - }) -}) +const server = getTestServer() +const interceptor = new HttpRequestInterceptor() -const interceptor = new ClientRequestInterceptor() - -beforeAll(async () => { - await server.listen() +beforeAll(() => { interceptor.apply() }) @@ -24,23 +16,25 @@ afterEach(() => { interceptor.removeAllListeners() }) -afterAll(async () => { - await server.close() +afterAll(() => { interceptor.dispose() }) -it('allows modifying the outgoing request headers', async () => { +it('allows modifying the request headers for a request without a body', async () => { interceptor.on('request', ({ request }) => { request.headers.set('x-appended-header', 'modified') }) const request = http.get(server.http.url('/user')) - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.headers['x-appended-header']).toBe('modified') + expect(Object.fromEntries(response.headers)).toMatchObject({ + connection: 'keep-alive', + 'x-appended-header': 'modified', + }) }) -it('allows modifying the outgoing request headers in a request with body', async () => { +it('allows modifying the request headers for a request with a body', async () => { interceptor.on('request', ({ request }) => { request.headers.set('x-appended-header', 'modified') }) @@ -49,7 +43,7 @@ it('allows modifying the outgoing request headers in a request with body', async request.write('post-payload') request.end() - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.headers['x-appended-header']).toBe('modified') + expect(response.headers.get('x-appended-header')).toBe('modified') }) diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index e7a7ae63b..f9a4f2bac 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -1,9 +1,8 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import rateLimit from 'express-rate-limit' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.use( @@ -27,7 +26,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { const url = new URL(request.url) diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts deleted file mode 100644 index ebf86d255..000000000 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { IncomingMessage } from 'node:http' -import https from 'node:https' -import { HttpServer } from '@open-draft/test-server/http' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' - -const httpServer = new HttpServer((app) => { - app.get('/get', (req, res) => { - res.status(200).send('/') - }) -}) - -const interceptor = new ClientRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - if ([httpServer.https.url('/get')].includes(request.url)) { - return - } - - controller.respondWith( - new Response('mocked-body', { - status: 403, - statusText: 'Forbidden', - }) - ) -}) - -beforeAll(async () => { - interceptor.apply() - await httpServer.listen() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -afterAll(async () => { - interceptor.dispose() - await httpServer.close() -}) - -it('calls a custom callback once when the request is bypassed', async () => { - let text: string = '' - - const responseReceived = new DeferredPromise() - const responseCallback = vi.fn<(response: IncomingMessage) => void>( - (response) => { - response.on('data', (chunk) => (text += chunk)) - response.on('end', () => responseReceived.resolve()) - response.on('error', (error) => responseReceived.reject(error)) - } - ) - - https.get( - httpServer.https.url('/get'), - { - rejectUnauthorized: false, - }, - responseCallback - ) - - await responseReceived - - // Check that the request was bypassed. - expect(text).toEqual('/') - - // Custom callback to "https.get" must be called once. - expect(responseCallback).toBeCalledTimes(1) -}) - -it('calls a custom callback once when the response is mocked', async () => { - let text: string = '' - - const responseReceived = new DeferredPromise() - const responseCallback = vi.fn<(response: IncomingMessage) => void>( - (response) => { - response.on('data', (chunk) => (text += chunk)) - response.on('end', () => responseReceived.resolve()) - response.on('error', (error) => responseReceived.reject(error)) - } - ) - - https.get( - httpServer.https.url('/arbitrary'), - { - rejectUnauthorized: false, - }, - responseCallback - ) - - await responseReceived - - // Check that the response was mocked. - expect(text).toEqual('mocked-body') - - // Custom callback to `https.get` must be called once. - expect(responseCallback).toBeCalledTimes(1) -}) diff --git a/test/modules/http/compliance/http-req-end-after-connect.test.ts b/test/modules/http/compliance/http-req-end-after-connect.test.ts new file mode 100644 index 000000000..7e9d5ff36 --- /dev/null +++ b/test/modules/http/compliance/http-req-end-after-connect.test.ts @@ -0,0 +1,60 @@ +// @vitest-environment node +import http from 'node:http' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' + +const interceptor = new HttpRequestInterceptor() + +const httpServer = new HttpServer((app) => { + app.get('/resource', (req, res) => res.send('original')) +}) + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('intercepts an HTTP request that ends after the socket has connected', async () => { + const request = http.request(httpServer.http.url('/resource')) + request.on('socket', (socket) => { + socket.on('connect', () => request.end()) + }) + + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original') +}) + +it('mocks an HTTP request that ends after the socket has connected', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('hello world')) + }) + + const request = http.request(httpServer.http.url('/mocked'), { + /** + * Force no Agent so the opened socket from the previous test doesn't + * get reused for this one. When Node.js reuses a socket, it never emits + * the "connect" event on the socket again. + */ + agent: false, + }) + request.on('socket', (socket) => { + socket.on('connect', () => request.end()) + }) + + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/http/compliance/http-req-get-with-body.test.ts b/test/modules/http/compliance/http-req-get-with-body.test.ts index 5d3107240..10e3a4d93 100644 --- a/test/modules/http/compliance/http-req-get-with-body.test.ts +++ b/test/modules/http/compliance/http-req-get-with-body.test.ts @@ -1,13 +1,10 @@ -/** - * @see https://github.com/nock/nock/issues/2826 - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @see https://github.com/nock/nock/issues/2826 import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new http.Server((req, res) => { if (req.url === '/resource') { @@ -67,8 +64,8 @@ it('allows an HTTP GET request with a body', async () => { request.write('hello world') request.end() - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('hello world') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('hello world') const interceptedRequest = await interceptedRequestPromise // The Fetch API representation of this request must NOT have any body. diff --git a/test/modules/http/compliance/http-req-method.test.ts b/test/modules/http/compliance/http-req-method.test.ts index 3e767fae9..6bd2ccc1a 100644 --- a/test/modules/http/compliance/http-req-method.test.ts +++ b/test/modules/http/compliance/http-req-method.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -35,8 +32,8 @@ it('supports lowercase HTTP methods', async () => { const request = http .request('http://localhost/resource', { method: 'get' }) .end() - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('hello world') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') }) diff --git a/test/modules/http/compliance/http-req-url-to-http-options.test.ts b/test/modules/http/compliance/http-req-url-to-http-options.test.ts index 927589d65..0ab894757 100644 --- a/test/modules/http/compliance/http-req-url-to-http-options.test.ts +++ b/test/modules/http/compliance/http-req-url-to-http-options.test.ts @@ -1,11 +1,10 @@ // @vitest-environment node import { urlToHttpOptions } from 'node:url' import http from 'node:http' -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -29,10 +28,10 @@ it('supports `urlToHttpOptions()` as the ClientRequest options', async () => { const request = http .request(urlToHttpOptions(new URL('http://localhost'))) .end() - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('hello world') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') expect(requestCallback).toHaveBeenCalledOnce() const [fetchRequest] = requestCallback.mock.calls[0] @@ -56,10 +55,10 @@ it('supports augmented `urlToHttpOptions()` as the ClientRequest options', async }, }) .end(JSON.stringify({ hello: 'world' })) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - await expect(text()).resolves.toBe('hello world') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') expect(requestCallback).toHaveBeenCalledOnce() const [fetchRequest] = requestCallback.mock.calls[0] diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 8eb55a390..9aa31fc3a 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,26 +1,20 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { Readable } from 'node:stream' -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import express from 'express' +import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { sleep, waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { - app.post('/resource', express.text({ type: '*/*' }), (req, res) => { + app.post('/resource/*', express.text({ type: '*/*' }), (req, res) => { res.send(req.body) }) }) -const interceptedRequestBody = vi.fn() - -const interceptor = new ClientRequestInterceptor() -interceptor.on('request', async ({ request }) => { - interceptedRequestBody(await request.clone().text()) -}) +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -28,17 +22,22 @@ beforeAll(async () => { }) afterEach(() => { - vi.resetAllMocks() + interceptor.removeAllListeners() }) afterAll(async () => { interceptor.dispose() - vi.restoreAllMocks() await httpServer.close() }) it('writes string request body', async () => { - const req = http.request(httpServer.http.url('/resource'), { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request }) => { + requestBodyPromise.resolve(await request.clone().text()) + }) + + const req = http.request(httpServer.http.url('/resource/write-string'), { method: 'POST', headers: { 'Content-Type': 'text/plain', @@ -49,15 +48,20 @@ it('writes string request body', async () => { req.write('two') req.end('three') - const { text } = await waitForClientRequest(req) - const expectedBody = 'onetwothree' + const [response] = await toWebResponse(req) - expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(await text()).toEqual(expectedBody) + await expect(requestBodyPromise).resolves.toBe('onetwothree') + await expect(response.text()).resolves.toEqual('onetwothree') }) it('writes JSON request body', async () => { - const req = http.request(httpServer.http.url('/resource'), { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request }) => { + requestBodyPromise.resolve(await request.clone().text()) + }) + + const req = http.request(httpServer.http.url('/resource/write-json'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -68,15 +72,20 @@ it('writes JSON request body', async () => { req.write(':"value"') req.end('}') - const { text } = await waitForClientRequest(req) - const expectedBody = `{"key":"value"}` + const [response] = await toWebResponse(req) - expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(await text()).toEqual(expectedBody) + await expect(requestBodyPromise).resolves.toBe(`{"key":"value"}`) + await expect(response.text()).resolves.toEqual(`{"key":"value"}`) }) it('writes Buffer request body', async () => { - const req = http.request(httpServer.http.url('/resource'), { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request }) => { + requestBodyPromise.resolve(await request.clone().text()) + }) + + const req = http.request(httpServer.http.url('/resource/write-buffer'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -87,15 +96,20 @@ it('writes Buffer request body', async () => { req.write(Buffer.from(':"value"')) req.end(Buffer.from('}')) - const { text } = await waitForClientRequest(req) - const expectedBody = `{"key":"value"}` + const [response] = await toWebResponse(req) - expect(interceptedRequestBody).toHaveBeenCalledWith(expectedBody) - expect(await text()).toEqual(expectedBody) + await expect(requestBodyPromise).resolves.toBe(`{"key":"value"}`) + await expect(response.text()).resolves.toEqual(`{"key":"value"}`) }) it('supports Readable as the request body', async () => { - const request = http.request(httpServer.http.url('/resource'), { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request }) => { + requestBodyPromise.resolve(await request.clone().text()) + }) + + const request = http.request(httpServer.http.url('/resource/readable'), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -105,137 +119,186 @@ it('supports Readable as the request body', async () => { const input = ['hello', ' ', 'world', null] const readable = new Readable({ read: async function () { - await sleep(10) + await setTimeout(10) this.push(input.shift()) }, }) readable.pipe(request) - await waitForClientRequest(request) - expect(interceptedRequestBody).toHaveBeenCalledWith('hello world') + await toWebResponse(request) + await expect(requestBodyPromise).resolves.toBe('hello world') }) it('calls the write callback when writing an empty string', async () => { - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - }) + const request = http.request( + httpServer.http.url('/resource/write-empty-cb'), + { + method: 'POST', + } + ) const writeCallback = vi.fn() request.write('', writeCallback) request.end() - await waitForClientRequest(request) + await toWebResponse(request) - expect(writeCallback).toHaveBeenCalledTimes(1) + expect(writeCallback).toHaveBeenCalledOnce() }) it('calls the write callback when writing an empty Buffer', async () => { - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - }) + const request = http.request( + httpServer.http.url('/resource/write-callback'), + { + method: 'POST', + } + ) const writeCallback = vi.fn() request.write(Buffer.from(''), writeCallback) request.end() - await waitForClientRequest(request) + await toWebResponse(request) - expect(writeCallback).toHaveBeenCalledTimes(1) + expect(writeCallback).toHaveBeenCalledOnce() }) it('emits "finish" for a passthrough request', async () => { const prefinishListener = vi.fn() const finishListener = vi.fn() - const request = http.request(httpServer.http.url('/resource')) + + const request = http.request(httpServer.http.url('/resource/real-finish')) + request.on('prefinish', prefinishListener) request.on('finish', finishListener) request.end() - await waitForClientRequest(request) + await toWebResponse(request) - expect(prefinishListener).toHaveBeenCalledTimes(1) - expect(finishListener).toHaveBeenCalledTimes(1) + expect(prefinishListener).toHaveBeenCalledOnce() + expect(finishListener).toHaveBeenCalledOnce() }) it('emits "finish" for a mocked request', async () => { - interceptor.once('request', ({ controller }) => { + interceptor.on('request', ({ controller }) => { controller.respondWith(new Response()) }) const prefinishListener = vi.fn() const finishListener = vi.fn() - const request = http.request(httpServer.http.url('/resource')) + + const request = http.request(httpServer.http.url('/resource/mocked-finish')) + request.on('prefinish', prefinishListener) request.on('finish', finishListener) request.end() - await waitForClientRequest(request) + await toWebResponse(request) - expect(prefinishListener).toHaveBeenCalledTimes(1) - expect(finishListener).toHaveBeenCalledTimes(1) + expect(prefinishListener).toHaveBeenCalledOnce() + expect(finishListener).toHaveBeenCalledOnce() }) -it('calls all write callbacks before the mocked response', async () => { - const requestBodyCallback = vi.fn() - interceptor.once('request', async ({ request, controller }) => { - requestBodyCallback(await request.text()) +it('supports ending a mocked request in a write callback', async () => { + const requestBodyPromise = new DeferredPromise() + + interceptor.on('request', async ({ request, controller }) => { + requestBodyPromise.resolve(await request.text()) controller.respondWith(new Response('hello world')) }) - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - }) - request.write('one', () => { - request.end() - }) + const request = http.request( + httpServer.http.url('/resource/mocked-end-after-write'), + { + method: 'POST', + } + ) - const { text } = await waitForClientRequest(request) + const firstWriteCallback = vi.fn() + const secondWriteCallback = vi.fn() + const requestEndCallback = vi.fn() - expect(requestBodyCallback).toHaveBeenCalledWith('one') - expect(await text()).toBe('hello world') -}) + request.write('one', () => { + firstWriteCallback() -it('calls the write callbacks when reading request body in the interceptor', async () => { - const requestBodyCallback = vi.fn() - const requestWriteCallback = vi.fn() + request.write('two', () => { + secondWriteCallback() - interceptor.once('request', async ({ request }) => { - requestBodyCallback(await request.text()) + request.end(requestEndCallback) + }) }) - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - }) - request.write('one', requestWriteCallback) - request.write('two', requestWriteCallback) - request.end('three', requestWriteCallback) - - const { text } = await waitForClientRequest(request) - - // Must call each write callback once. - expect(requestWriteCallback).toHaveBeenCalledTimes(3) - // Must be able to read the request stream in the interceptor. - expect(requestBodyCallback).toHaveBeenCalledWith('onetwothree') - // Must send the correct request body to the server. - expect(await text()).toBe('onetwothree') + const [response] = await toWebResponse(request) + + expect(firstWriteCallback).toHaveBeenCalledBefore(secondWriteCallback) + expect(secondWriteCallback).toHaveBeenCalledBefore(requestEndCallback) + expect(requestEndCallback).toHaveBeenCalledOnce() + + await expect(requestBodyPromise).resolves.toBe('onetwo') + await expect(response.text()).resolves.toBe('hello world') }) /** * @see https://github.com/mswjs/interceptors/issues/684 */ -it('calls the write callback once for a request that ends inside a write', async () => { +it('supports ending a bypassed request in a write callback', async () => { + const request = http.request( + httpServer.http.url('/resource/real-end-after-write'), + { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + } + ) + + const firstWriteCallback = vi.fn() + const secondWriteCallback = vi.fn() + const requestEndCallback = vi.fn() + + request.write('hello ', () => { + firstWriteCallback() + + request.write('world', () => { + secondWriteCallback() + + request.end(requestEndCallback) + }) + }) + + const [response] = await toWebResponse(request) + + expect(firstWriteCallback).toHaveBeenCalledBefore(secondWriteCallback) + expect(secondWriteCallback).toHaveBeenCalledBefore(requestEndCallback) + expect(requestEndCallback).toHaveBeenCalledOnce() + + await expect(response.text()).resolves.toBe('hello world') +}) + +it('calls the write callbacks when reading request body in the interceptor', async () => { + const requestBodyCallback = vi.fn() const requestWriteCallback = vi.fn() - const request = http.request(httpServer.http.url('/resource'), { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - }) - request.write('one', 'utf8', () => { - requestWriteCallback() - request.end() + interceptor.on('request', async ({ request }) => { + requestBodyCallback(await request.text()) }) - await waitForClientRequest(request) - expect(requestWriteCallback).toHaveBeenCalledTimes(1) + const request = http.request( + httpServer.http.url('/resource/write-callback'), + { + method: 'POST', + headers: { + 'content-type': 'text/plain', + }, + } + ) + request.write('ash', requestWriteCallback) + request.write('amber', requestWriteCallback) + request.end('fire', requestWriteCallback) + + const [response] = await toWebResponse(request) + + expect.soft(requestWriteCallback).toHaveBeenCalledTimes(3) + expect + .soft(requestBodyCallback) + .toHaveBeenCalledExactlyOnceWith('ashamberfire') + await expect.soft(response.text()).resolves.toBe('ashamberfire') }) diff --git a/test/modules/http/compliance/http-request-ipv6.test.ts b/test/modules/http/compliance/http-request-ipv6.test.ts index d7e0a839a..0694d7dce 100644 --- a/test/modules/http/compliance/http-request-ipv6.test.ts +++ b/test/modules/http/compliance/http-request-ipv6.test.ts @@ -1,9 +1,10 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' -import { httpGet } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +// @vitest-environment node +import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' +import { toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -22,8 +23,10 @@ it('supports requests with IPv6 request url', async () => { controller.respondWith(new Response('test')) }) - const { resBody } = await httpGet(url) + const request = http.get(url) + const [response] = await toWebResponse(request) + const requestUrl = await listenerUrlPromise - expect(resBody).toBe('test') - expect(requestUrl).toBe(url) + expect.soft(requestUrl).toBe(url) + await expect.soft(response.text()).resolves.toBe('test') }) diff --git a/test/modules/http/compliance/http-request-without-options.test.ts b/test/modules/http/compliance/http-request-without-options.test.ts index 0b87faabe..e1d4bd364 100644 --- a/test/modules/http/compliance/http-request-without-options.test.ts +++ b/test/modules/http/compliance/http-request-without-options.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { if (request.url === 'http://localhost/') { @@ -34,7 +31,7 @@ it('supports "http.request()" without any arguments', async () => { request.on('response', responseListener) request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) expect(errorListener).not.toHaveBeenCalled() expect(responseListener).toHaveBeenCalledTimes(1) @@ -42,8 +39,8 @@ it('supports "http.request()" without any arguments', async () => { expect(request.method).toBe('GET') expect(request.protocol).toBe('http:') expect(request.host).toBe('localhost') - expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('Mocked') }) it('supports "http.get()" without any arguments', async () => { @@ -58,7 +55,7 @@ it('supports "http.get()" without any arguments', async () => { request.on('response', responseListener) request.on('error', errorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) expect(errorListener).not.toHaveBeenCalled() expect(responseListener).toHaveBeenCalledTimes(1) @@ -66,6 +63,6 @@ it('supports "http.get()" without any arguments', async () => { expect(request.method).toBe('GET') expect(request.protocol).toBe('http:') expect(request.host).toBe('localhost') - expect(res.statusCode).toBe(200) - expect(await text()).toBe('Mocked') + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('Mocked') }) diff --git a/test/modules/http/compliance/http-res-callback.test.ts b/test/modules/http/compliance/http-res-callback.test.ts new file mode 100644 index 000000000..2f3584f9d --- /dev/null +++ b/test/modules/http/compliance/http-res-callback.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import https from 'node:https' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' + +const httpServer = new HttpServer((app) => { + app.get('/get', (req, res) => { + res.status(200).send('original') + }) +}) + +const interceptor = new HttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() + vi.restoreAllMocks() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('calls a custom callback once when the request is bypassed', async () => { + const responseCallback = vi.fn() + + const request = https.get( + httpServer.https.url('/get'), + { + rejectUnauthorized: false, + }, + responseCallback + ) + + const [response] = await toWebResponse(request) + + await expect.soft(response.text()).resolves.toBe('original') + expect.soft(responseCallback).toHaveBeenCalledOnce() +}) + +it('calls a custom callback once when the response is mocked', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('mocked', { + status: 403, + statusText: 'Forbidden', + }) + ) + }) + + const responseCallback = vi.fn() + + const request = https.get( + httpServer.https.url('/arbitrary'), + { + rejectUnauthorized: false, + }, + responseCallback + ) + + const [response] = await toWebResponse(request) + + await expect.soft(response.text()).resolves.toBe('mocked') + expect.soft(response.status).toBe(403) + expect.soft(response.statusText).toBe('Forbidden') + expect.soft(responseCallback).toHaveBeenCalledOnce() +}) diff --git a/test/modules/http/compliance/http-res-destroy.test.ts b/test/modules/http/compliance/http-res-destroy.test.ts index 12ce630ea..b84deb04f 100644 --- a/test/modules/http/compliance/http-res-destroy.test.ts +++ b/test/modules/http/compliance/http-res-destroy.test.ts @@ -1,15 +1,14 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { HttpServer } from '@open-draft/test-server/lib/http' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => res.sendStatus(200)) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -37,11 +36,12 @@ it('emits the "error" event when a bypassed response is destroyed', async () => response.destroy(new Error('reason')) }) - const { res } = await waitForClientRequest(request) + const [, rawResponse] = await toWebResponse(request) - expect(res.destroyed).toBe(true) - expect(socketErrorListener).toHaveBeenCalledOnce() - expect(socketErrorListener).toHaveBeenCalledWith(new Error('reason')) + expect.soft(rawResponse.destroyed).toBe(true) + expect + .soft(socketErrorListener) + .toHaveBeenCalledExactlyOnceWith(new Error('reason')) }) it('emits the "error" event when a mocked response is destroyed', async () => { @@ -60,9 +60,10 @@ it('emits the "error" event when a mocked response is destroyed', async () => { response.destroy(new Error('reason')) }) - const { res } = await waitForClientRequest(request) + const [, rawResponse] = await toWebResponse(request) - expect(res.destroyed).toBe(true) - expect(socketErrorListener).toHaveBeenCalledOnce() - expect(socketErrorListener).toHaveBeenCalledWith(new Error('reason')) + expect.soft(rawResponse.destroyed).toBe(true) + expect + .soft(socketErrorListener) + .toHaveBeenCalledExactlyOnceWith(new Error('reason')) }) diff --git a/test/modules/http/compliance/http-res-non-configurable.test.ts b/test/modules/http/compliance/http-res-non-configurable.test.ts index 70e69891b..597111acf 100644 --- a/test/modules/http/compliance/http-res-non-configurable.test.ts +++ b/test/modules/http/compliance/http-res-non-configurable.test.ts @@ -3,14 +3,13 @@ * @see https://github.com/mswjs/msw/issues/2307 */ import http from 'node:http' -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { FetchResponse } from '../../../../src/utils/fetchUtils' -import { waitForClientRequest } from '../../../helpers' import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { FetchResponse } from '#/src/utils/fetchUtils' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { @@ -40,12 +39,12 @@ it('handles non-configurable responses from the actual server', async () => { }) const request = http.get(httpServer.http.url('/resource')) - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must passthrough non-configurable responses // (i.e. those that cannot be created using the Fetch API). - expect(res.statusCode).toBe(101) - expect(res.statusMessage).toBe('Switching Protocols') + expect(response.status).toBe(101) + expect(response.statusText).toBe('Switching Protocols') // Must expose the exact response in the listener. await expect(responsePromise).resolves.toHaveProperty('status', 101) @@ -66,9 +65,9 @@ it('supports mocking non-configurable responses', async () => { }) const request = http.get('http://localhost/irrelevant') - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(101) + expect(response.status).toBe(101) // Must expose the exact response in the listener. await expect(responsePromise).resolves.toHaveProperty('status', 101) diff --git a/test/modules/http/compliance/http-res-raw-headers.test.ts b/test/modules/http/compliance/http-res-raw-headers.test.ts index da5e23240..b7fd70893 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -1,11 +1,10 @@ /** * @vitest-environment node */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' // The actual server is here for A/B purpose only. const httpServer = new HttpServer((app) => { @@ -15,7 +14,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -43,12 +42,14 @@ it('preserves raw response headers (object init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(Object.fromEntries(response.headers)).toStrictEqual({ + 'x-custom-header': 'Yes', + }) }) it('preserves raw response headers (array init)', async () => { @@ -61,12 +62,14 @@ it('preserves raw response headers (array init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(Object.fromEntries(response.headers)).toStrictEqual({ + 'x-custom-header': 'Yes', + }) }) it('preserves raw response headers (set after init)', async () => { @@ -80,12 +83,12 @@ it('preserves raw response headers (set after init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes', 'x-My-Header', '1']) ) - expect(res.headers).toStrictEqual({ + expect(Object.fromEntries(response.headers)).toStrictEqual({ 'x-custom-header': 'Yes', 'x-my-header': '1', }) @@ -103,9 +106,9 @@ it('preserves raw response headers (append after init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining([ 'X-CustoM-HeadeR', 'Yes', @@ -115,7 +118,7 @@ it('preserves raw response headers (append after init)', async () => { '2', ]) ) - expect(res.headers).toStrictEqual({ + expect(Object.fromEntries(response.headers)).toStrictEqual({ 'x-custom-header': 'Yes', 'x-my-header': '1, 2', }) @@ -136,12 +139,12 @@ it('preserves raw response headers (delete after init)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers).toStrictEqual({ + expect(Object.fromEntries(response.headers)).toStrictEqual({ 'x-custom-header': 'Yes', }) }) @@ -155,20 +158,22 @@ it('preserves raw response headers (standalone Headers)', async () => { }) const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(Object.fromEntries(response.headers)).toStrictEqual({ + 'x-custom-header': 'Yes', + }) }) it('preserves raw response headers for unmocked request', async () => { const request = http.get(httpServer.http.url('/')) - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual( + expect(rawResponse.rawHeaders).toEqual( expect.arrayContaining(['X-CustoM-HeadeR', 'Yes']) ) - expect(res.headers['x-custom-header']).toEqual('Yes') + expect(response.headers.get('x-custom-header')).toBe('Yes') }) diff --git a/test/modules/http/compliance/http-res-read-multiple-times.test.ts b/test/modules/http/compliance/http-res-read-multiple-times.test.ts index a1df70ef9..a6bd1b18b 100644 --- a/test/modules/http/compliance/http-res-read-multiple-times.test.ts +++ b/test/modules/http/compliance/http-res-read-multiple-times.test.ts @@ -4,11 +4,10 @@ * event does not lock that stream for any further reading. * @see https://github.com/mswjs/interceptors/issues/161 */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestEventMap } from '../../../../src' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestEventMap } from '#/src/index' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -16,9 +15,9 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { diff --git a/test/modules/http/compliance/http-res-set-encoding.test.ts b/test/modules/http/compliance/http-res-set-encoding.test.ts index d7e5ac4bc..5396d3635 100644 --- a/test/modules/http/compliance/http-res-set-encoding.test.ts +++ b/test/modules/http/compliance/http-res-set-encoding.test.ts @@ -1,11 +1,8 @@ -/** - * @vitest-environment node - */ -import { it, expect, describe, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/resource', (request, res) => { @@ -13,7 +10,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { const url = new URL(request.url) diff --git a/test/modules/http/compliance/http-response-headers-folding.test.ts b/test/modules/http/compliance/http-response-headers-folding.test.ts index ac9c1b4c3..8c3ed2a90 100644 --- a/test/modules/http/compliance/http-response-headers-folding.test.ts +++ b/test/modules/http/compliance/http-response-headers-folding.test.ts @@ -1,10 +1,9 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -30,10 +29,12 @@ it('preserves the original mocked response headers casing in "rawHeaders"', asyn }) const request = http.get('http://localhost/resource') - const { res } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toStrictEqual(['X-CustoM-HeadeR', 'Yes']) - expect(res.headers).toStrictEqual({ 'x-custom-header': 'Yes' }) + expect(rawResponse.rawHeaders).toStrictEqual(['X-CustoM-HeadeR', 'Yes']) + expect(Object.fromEntries(response.headers)).toStrictEqual({ + 'x-custom-header': 'Yes', + }) }) it('folds duplicate response headers for a mocked response', async () => { @@ -67,7 +68,7 @@ it('folds duplicate response headers for a mocked response', async () => { }) const request = http.get('http://localhost/resource') - const { res } = await waitForClientRequest(request) + const [, rawResponse] = await toWebResponse(request) - expect(res.rawHeaders).toEqual(responseHeaders) + expect(rawResponse.rawHeaders).toEqual(responseHeaders) }) diff --git a/test/modules/http/compliance/http-socket-listeners.test.ts b/test/modules/http/compliance/http-socket-listeners.test.ts index aa2623597..c91829103 100644 --- a/test/modules/http/compliance/http-socket-listeners.test.ts +++ b/test/modules/http/compliance/http-socket-listeners.test.ts @@ -3,13 +3,12 @@ * @see https://github.com/mswjs/msw/issues/2537 * @see https://github.com/mswjs/interceptors/pull/755 */ -import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { Socket } from 'node:net' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { @@ -17,7 +16,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -39,19 +38,8 @@ it('removes all event listeners from a passthrough socket after closing', async pendingSocket.resolve(socket) }) - const socket = await pendingSocket - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - await expect.soft(text()).resolves.toBe('ok') - - const passthroughSocket = Reflect.get(socket, 'originalSocket') as Socket - expect(passthroughSocket).toBeInstanceOf(Socket) - - await expect - .poll( - // @ts-expect-error Node.js internals - () => passthroughSocket._events - ) - .toEqual({}) + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('ok') }) diff --git a/test/modules/http/compliance/http-socket-reuse.test.ts b/test/modules/http/compliance/http-socket-reuse.test.ts new file mode 100644 index 000000000..8bb45ea67 --- /dev/null +++ b/test/modules/http/compliance/http-socket-reuse.test.ts @@ -0,0 +1,177 @@ +// @vitest-environment node +import https from 'node:https' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' + +const httpServer = new HttpServer((app) => { + app.get('/resource/*', (req, res) => { + res.status(200).send('original') + }) + app.post('/resource/*', (req, res) => { + res.status(200) + req.pipe(res) + }) +}) + +const interceptor = new HttpRequestInterceptor() + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() + + // Free open sockets between tests to scope reusing of the sockets to each test. + Object.values(https.globalAgent.freeSockets).forEach((sockets) => { + sockets?.forEach((socket) => socket.destroy()) + }) +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('allows reusing the same socket for mixed mocked/bypassed requests', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.url.endsWith('/mock')) { + controller.respondWith(new Response(null, { status: 301 })) + } + }) + + { + const request = https.get(httpServer.https.url('/resource/one'), { + rejectUnauthorized: false, + }) + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original') + } + + { + /** + * @note Performing a request to the same host with the same options + * will trigger https.Agent to reuse the socket created for the first request. + */ + const request = https.get(httpServer.https.url('/mock'), { + rejectUnauthorized: false, + }) + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(301) + await expect.soft(response.text()).resolves.toBe('') + } +}) + +it('allows reusing the same socket for multiple mocked requests', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) + }) + + { + const request = https.get(httpServer.https.url('/mock'), { + rejectUnauthorized: false, + }) + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('mocked') + } + + { + const request = https.get(httpServer.https.url('/mock'), { + rejectUnauthorized: false, + }) + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('mocked') + } +}) + +it('allows reusing the same socket for multiple bypassed requests', async () => { + const requestListener = vi.fn() + + interceptor.on('request', ({ request }) => { + requestListener(request) + }) + + { + const request = https.get(httpServer.https.url('/resource/one'), { + rejectUnauthorized: false, + }) + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original') + expect(requestListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + method: 'GET', + url: httpServer.https.url('/resource/one'), + }) + ) + } + + { + const request = https.request(httpServer.https.url('/resource/two'), { + rejectUnauthorized: false, + headers: { 'content-length': '6' }, + }) + request.write('second') + request.end() + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('original') + expect(requestListener).toHaveBeenCalledTimes(2) + expect(requestListener).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'GET', + url: httpServer.https.url('/resource/two'), + }) + ) + } +}) + +it('allows reusing the same socket for multiple bypassed requests with a body', async () => { + const requestListener = vi.fn() + + interceptor.on('request', async ({ request }) => { + requestListener(await request.text()) + }) + + { + const request = https.request(httpServer.https.url('/resource/one'), { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + rejectUnauthorized: false, + }) + request.write('first') + request.end() + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('first') + expect(requestListener).toHaveBeenCalledExactlyOnceWith('first') + } + + { + const request = https.request(httpServer.https.url('/resource/two'), { + method: 'POST', + rejectUnauthorized: false, + }) + request.write('second') + request.end() + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + await expect.soft(response.text()).resolves.toBe('second') + expect(requestListener).toHaveBeenCalledTimes(2) + expect(requestListener).toHaveBeenNthCalledWith(2, 'second') + } +}) diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index f86e702c1..17c2d0506 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -1,24 +1,28 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node import https from 'node:https' -import type { TLSSocket } from 'node:tls' +import { TLSSocket } from 'node:tls' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() -beforeAll(() => { +const httpServer = new HttpServer((app) => { + app.get('/', (req, res) => res.status(200).end()) +}) + +beforeAll(async () => { interceptor.apply() + await httpServer.listen() }) afterEach(() => { interceptor.removeAllListeners() }) -afterAll(() => { +afterAll(async () => { interceptor.dispose() + await httpServer.close() }) it('emits a correct TLS Socket instance for a handled HTTPS request', async () => { @@ -32,32 +36,48 @@ it('emits a correct TLS Socket instance for a handled HTTPS request', async () = const socket = await socketPromise - // Must be a TLS socket. + expect(socket).toBeInstanceOf(TLSSocket) expect(socket.encrypted).toBe(true) // The server certificate wasn't signed by one of the CA // specified in the Socket constructor. expect(socket.authorized).toBe(false) - expect(socket.getSession()).toBeUndefined() + expect(socket.getSession()).toBeInstanceOf(Buffer) expect(socket.getProtocol()).toBe('TLSv1.3') expect(socket.isSessionReused()).toBe(false) - expect(socket.getCipher()).toEqual({ name: 'AES256-SHA', standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', version: 'TLSv1.3' }) + expect(socket.getCipher()).toEqual({ + name: 'TLS_AES_256_GCM_SHA384', + standardName: 'TLS_AES_256_GCM_SHA384', + version: 'TLSv1.3', + }) }) it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () => { - const request = https.get('https://example.com') + const request = https.get(httpServer.https.url('/'), { + rejectUnauthorized: false, + }) const socketPromise = new DeferredPromise() - request.on('socket', socketPromise.resolve) + const secureConnectListener = vi.fn() + + request.on('socket', (socket) => { + socketPromise.resolve(socket as TLSSocket) + socket.on('secureConnect', secureConnectListener) + }) const socket = await socketPromise + await expect.poll(() => secureConnectListener).toHaveBeenCalledOnce() - // Must be a TLS socket. + expect(socket).toBeInstanceOf(TLSSocket) expect(socket.encrypted).toBe(true) // The server certificate wasn't signed by one of the CA // specified in the Socket constructor. expect(socket.authorized).toBe(false) - expect(socket.getSession()).toBeUndefined() + expect(socket.getSession()).toBeInstanceOf(Buffer) expect(socket.getProtocol()).toBe('TLSv1.3') - expect(socket.getCipher()).toEqual({ name: 'AES256-SHA', standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', version: 'TLSv1.3' }) + expect(socket.getCipher()).toEqual({ + name: 'TLS_AES_256_GCM_SHA384', + standardName: 'TLS_AES_256_GCM_SHA384', + version: 'TLSv1.3', + }) }) diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts index c93127b09..03cc9a88c 100644 --- a/test/modules/http/compliance/http-timeout.test.ts +++ b/test/modules/http/compliance/http-timeout.test.ts @@ -1,19 +1,17 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { sleep } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/resource', async (req, res) => { - await sleep(200) + await setTimeout(200) res.status(500).end() }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -31,27 +29,30 @@ afterAll(async () => { it('respects the "timeout" option for a handled request', async () => { interceptor.on('request', async ({ controller }) => { - await sleep(200) + await setTimeout(200) controller.respondWith(new Response('hello world')) }) - const errorListener = vi.fn() - const timeoutListener = vi.fn() - const responseListener = vi.fn() const request = http.get('http://localhost/resource', { timeout: 10, }) - request.on('error', errorListener) - request.on('timeout', () => { - timeoutListener() - // Request must be destroyed manually on timeout. - request.destroy() - }) - request.on('response', responseListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + const responseListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + const timeoutListener = vi.fn() + + request + .on('response', responseListener) + .on('timeout', () => { + timeoutListener() + // Request must be destroyed manually on timeout. + request.destroy() + }) + .on('error', errorListener) + .on('close', closeListener) + + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(timeoutListener).toHaveBeenCalledTimes(1) @@ -64,23 +65,26 @@ it('respects the "timeout" option for a handled request', async () => { }) it('respects the "timeout" option for a bypassed request', async () => { - const errorListener = vi.fn() - const timeoutListener = vi.fn() - const responseListener = vi.fn() const request = http.get(httpServer.http.url('/resource'), { timeout: 10, }) - request.on('error', errorListener) - request.on('timeout', () => { - timeoutListener() - // Request must be destroyed manually on timeout. - request.destroy() - }) - request.on('response', responseListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + const responseListener = vi.fn() + const timeoutListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + request + .on('response', responseListener) + .on('error', errorListener) + .on('timeout', () => { + timeoutListener() + // Request must be destroyed manually on timeout. + request.destroy() + }) + .on('close', closeListener) + + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(timeoutListener).toHaveBeenCalledTimes(1) @@ -98,18 +102,21 @@ it('respects a "setTimeout()" on a handled request', async () => { async start(controller) { // Emulate a long pending response stream // to trigger the request timeout. - await sleep(200) + await setTimeout(200) controller.enqueue(new TextEncoder().encode('hello')) }, }) + controller.respondWith(new Response(stream)) }) - const errorListener = vi.fn() - const timeoutListener = vi.fn() + const request = http.get('http://localhost/resource') + const setTimeoutCallback = vi.fn() const responseListener = vi.fn() - const request = http.get('http://localhost/resource') + const timeoutListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() /** * @note `request.setTimeout(n)` is NOT equivalent to @@ -127,16 +134,16 @@ it('respects a "setTimeout()" on a handled request', async () => { */ request.setTimeout(10, setTimeoutCallback) - request.on('error', errorListener) - request.on('timeout', () => { - timeoutListener() - request.destroy() - }) - request.on('response', responseListener) + request + .on('response', responseListener) + .on('timeout', () => { + timeoutListener() + request.destroy() + }) + .on('error', errorListener) + .on('close', closeListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(timeoutListener).toHaveBeenCalledTimes(1) @@ -150,22 +157,24 @@ it('respects a "setTimeout()" on a handled request', async () => { }) it('respects a "setTimeout()" on a bypassed request', async () => { - const errorListener = vi.fn() - const timeoutListener = vi.fn() - const responseListener = vi.fn() const request = http.get(httpServer.http.url('/resource')) request.setTimeout(10) - request.on('error', errorListener) - request.on('timeout', () => { - timeoutListener() - request.destroy() - }) - request.on('response', responseListener) + const responseListener = vi.fn() + const timeoutListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + request + .on('response', responseListener) + .on('timeout', () => { + timeoutListener() + request.destroy() + }) + .on('error', errorListener) + .on('close', closeListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(timeoutListener).toHaveBeenCalledTimes(1) @@ -181,53 +190,50 @@ it('respects the "socket.setTimeout()" for a handled request', async () => { interceptor.on('request', async ({ controller }) => { const stream = new ReadableStream({ async start(controller) { - // Emulate a long pending response stream - // to trigger the request timeout. - await sleep(200) + // Emulate a long pending response stream to trigger the request timeout. + await setTimeout(200) controller.enqueue(new TextEncoder().encode('hello')) }, }) + controller.respondWith(new Response(stream)) }) - const errorListener = vi.fn() - const setTimeoutCallback = vi.fn() - const responseListener = vi.fn() - const request = http.get('http://localhost/resource') + const request = http.get(httpServer.http.url('/resource')) - request.on('socket', (socket) => { - /** - * @note Setting timeout on the socket directly - * will NOT add the "timeout" listener to the request, - * unlike "request.setTimeout()". - */ - socket.setTimeout(10, () => { - setTimeoutCallback() - request.destroy() + const responseListener = vi.fn() + const setTimeoutCallback = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + request + .on('response', responseListener) + .on('socket', (socket) => { + /** + * @note Setting timeout on the socket directly will NOT add the "timeout" + * listener to the request, unlike "request.setTimeout()". + */ + socket + .setTimeout(10, setTimeoutCallback) + .on('timeout', () => socket.destroy()) }) - }) - - request.on('error', errorListener) - request.on('response', responseListener) + .on('error', errorListener) + .on('close', closeListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + await expect.poll(() => closeListener).toHaveBeenCalledOnce() - expect(request.destroyed).toBe(true) - expect(setTimeoutCallback).toHaveBeenCalledTimes(1) - expect(errorListener).toHaveBeenCalledWith( + expect.soft(request.destroyed).toBe(true) + expect.soft(setTimeoutCallback).toHaveBeenCalledTimes(1) + expect.soft(errorListener).toHaveBeenCalledWith( expect.objectContaining({ code: 'ECONNRESET', }) ) - expect(responseListener).not.toHaveBeenCalled() + expect.soft(responseListener).not.toHaveBeenCalled() }) it('respects the "socket.setTimeout()" for a bypassed request', async () => { - const errorListener = vi.fn() const setTimeoutCallback = vi.fn() - const responseListener = vi.fn() const request = http.get(httpServer.http.url('/resource')) request.on('socket', (socket) => { @@ -236,18 +242,21 @@ it('respects the "socket.setTimeout()" for a bypassed request', async () => { * will NOT add the "timeout" listener to the request, * unlike "request.setTimeout()". */ - socket.setTimeout(10, () => { - setTimeoutCallback() - request.destroy() - }) + socket + .setTimeout(10, setTimeoutCallback) + .on('timeout', () => socket.destroy()) }) - request.on('error', errorListener) - request.on('response', responseListener) + const responseListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + request + .on('response', responseListener) + .on('error', errorListener) + .on('close', closeListener) - const requestClosePromise = new DeferredPromise() - request.on('close', () => requestClosePromise.resolve()) - await requestClosePromise + await expect.poll(() => closeListener).toHaveBeenCalledOnce() expect(request.destroyed).toBe(true) expect(setTimeoutCallback).toHaveBeenCalledTimes(1) diff --git a/test/modules/http/compliance/http-unhandled-exception.test.ts b/test/modules/http/compliance/http-unhandled-exception.test.ts index 2adf5a81a..908658136 100644 --- a/test/modules/http/compliance/http-unhandled-exception.test.ts +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -1,10 +1,9 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -24,11 +23,11 @@ it('handles a thrown Response as a mocked response', async () => { }) const request = http.get('http://localhost/resource') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') - expect(await text()).toBe('hello world') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('hello world') }) it('treats unhandled interceptor errors as 500 responses', async () => { @@ -37,11 +36,11 @@ it('treats unhandled interceptor errors as 500 responses', async () => { }) const request = http.get('http://localhost/resource') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(500) - expect(res.statusMessage).toBe('Unhandled Exception') - expect(JSON.parse(await text())).toEqual({ + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + await expect(response.json()).resolves.toEqual({ name: 'Error', message: 'Custom error', stack: expect.any(String), @@ -61,7 +60,7 @@ it('handles exceptions by default if "unhandledException" listener is provided b const requestErrorListener = vi.fn() request.on('error', requestErrorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must emit the "unhandledException" interceptor event. expect(unhandledExceptionListener).toHaveBeenCalledWith( @@ -74,9 +73,9 @@ it('handles exceptions by default if "unhandledException" listener is provided b // Since the "unhandledException" listener didn't handle the // exception, it will be translated to the 500 error response // (the default behavior). - expect(res.statusCode).toBe(500) - expect(res.statusMessage).toBe('Unhandled Exception') - expect(JSON.parse(await text())).toEqual({ + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + await expect(response.json()).resolves.toEqual({ name: 'Error', message: 'Custom error', stack: expect.any(String), @@ -102,11 +101,11 @@ it('handles exceptions as instructed in "unhandledException" listener (mock resp const requestErrorListener = vi.fn() request.on('error', requestErrorListener) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') - expect(await text()).toBe('fallback response') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('fallback response') expect(unhandledExceptionListener).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/test/modules/http/compliance/http-unix-socket.test.ts b/test/modules/http/compliance/http-unix-socket.test.ts index 1599fc331..a3bfeb38c 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -2,14 +2,13 @@ /** * @see https://github.com/mswjs/interceptors/pull/722 */ -import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from 'vitest' +import fs from 'node:fs' import path from 'node:path' import http from 'node:http' import { promisify } from 'node:util' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -// const HTTP_SOCKET_PATH = mockFs.resolve('./test.sock') const HTTP_SOCKET_PATH = path.join(__dirname, './test-http.sock') const httpServer = http.createServer((req, res) => { @@ -22,9 +21,13 @@ const httpServer = http.createServer((req, res) => { } }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { + if (fs.existsSync(HTTP_SOCKET_PATH)) { + await fs.promises.rm(HTTP_SOCKET_PATH) + } + await new Promise((resolve) => { httpServer.listen(HTTP_SOCKET_PATH, resolve) }) @@ -39,6 +42,10 @@ afterEach(() => { afterAll(async () => { interceptor.dispose() await promisify(httpServer.close.bind(httpServer))() + + if (fs.existsSync(HTTP_SOCKET_PATH)) { + await fs.promises.rm(HTTP_SOCKET_PATH) + } }) it('supports passthrough HTTP GET requests over a unix socket', async () => { @@ -49,10 +56,10 @@ it('supports passthrough HTTP GET requests over a unix socket', async () => { 'X-Custom-Header': 'custom-value', }, }) - const { res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers['x-custom-header']).toBe('custom-value') + expect.soft(response.status).toBe(200) + expect.soft(response.headers.get('x-custom-header')).toBe('custom-value') }) it('supports passthrough HTTP POST requests over a unix socket', async () => { @@ -67,14 +74,14 @@ it('supports passthrough HTTP POST requests over a unix socket', async () => { }) request.end('hello world') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers).toMatchObject({ + expect.soft(response.status).toBe(200) + expect.soft(Object.fromEntries(response.headers)).toMatchObject({ 'content-type': 'application/json', 'content-length': '11', }) - await expect.soft(text()).resolves.toBe('hello world') + await expect.soft(response.text()).resolves.toBe('hello world') }) it('supports passthrough HTTP GET requests to a non-existing unix socket', async () => { @@ -83,7 +90,7 @@ it('supports passthrough HTTP GET requests to a non-existing unix socket', async path: '/irrelevant', }) - await expect(waitForClientRequest(request)).rejects.toThrow( + await expect(toWebResponse(request)).rejects.toThrow( expect.objectContaining({ code: 'ENOENT', message: 'connect ENOENT non-existing.sock', @@ -103,11 +110,11 @@ it('mocks a response to HTTP GET requests over a unix socket', async () => { 'X-Custom-Header': 'custom-value', }, }) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers['x-custom-header']).toBe('custom-value') - await expect.soft(text()).resolves.toBe('hello world') + expect.soft(response.status).toBe(200) + expect.soft(response.headers.get('x-custom-header')).toBe('custom-value') + await expect.soft(response.text()).resolves.toBe('hello world') }) it('mocks a response to HTTP POST requests over a unix socket', async () => { @@ -129,9 +136,9 @@ it('mocks a response to HTTP POST requests over a unix socket', async () => { }) request.end('request-payload') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect.soft(res.statusCode).toBe(200) - expect.soft(res.headers['x-custom-header']).toBe('custom-value') - await expect.soft(text()).resolves.toBe('request-payload') + expect.soft(response.status).toBe(200) + expect.soft(response.headers.get('x-custom-header')).toBe('custom-value') + await expect.soft(response.text()).resolves.toBe('request-payload') }) diff --git a/test/modules/http/compliance/http-upgrade.test.ts b/test/modules/http/compliance/http-upgrade.test.ts index ea286ae36..56ef857cb 100644 --- a/test/modules/http/compliance/http-upgrade.test.ts +++ b/test/modules/http/compliance/http-upgrade.test.ts @@ -2,12 +2,11 @@ * @see https://github.com/mswjs/interceptors/issues/682 */ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { Server } from 'socket.io' import { io } from 'socket.io-client' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const server = new Server(51678) beforeAll(() => { @@ -20,6 +19,7 @@ afterEach(() => { afterAll(async () => { interceptor.dispose() + await new Promise((resolve, reject) => { server.disconnectSockets() server.close((error) => { @@ -34,7 +34,5 @@ it('bypasses a WebSocket upgrade request', async () => { transports: ['websocket'], }) - await vi.waitFor(async () => { - expect(client.connected).toBe(true) - }) + await expect.poll(() => client.connected).toBe(true) }) diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index 5313cbff0..619539832 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -1,13 +1,12 @@ // @vitest-environment node -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' import 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' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.use(express.json()) @@ -42,7 +41,7 @@ it('bypasses a request to the existing host', async () => { }) request.write(JSON.stringify({ name: 'john' })) request.end() - const { text, res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] @@ -55,8 +54,8 @@ it('bypasses a request to the existing host', async () => { await expect(requestFromListener.json()).resolves.toEqual({ name: 'john' }) // Must receive the correct response. - expect(res.headers).toHaveProperty('x-custom-header', 'yes') - await expect(text()).resolves.toBe('hello, john') + expect(response.headers.get('x-custom-header')).toBe('yes') + await expect(response.text()).resolves.toBe('hello, john') expect(requestListener).toHaveBeenCalledTimes(1) }) @@ -70,7 +69,7 @@ it('errors on a request to a non-existing host', async () => { request.on('error', (error) => errorPromise.resolve(error)) request.end() - await expect(() => waitForClientRequest(request)).rejects.toThrow( + await expect(() => toWebResponse(request)).rejects.toThrow( 'getaddrinfo ENOTFOUND abc123-non-existing.lol' ) @@ -109,7 +108,7 @@ it('mocked request to an existing host', async () => { }) request.write(JSON.stringify({ name: 'john' })) request.end() - const { text, res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] @@ -121,8 +120,8 @@ it('mocked request to an existing host', async () => { await expect(requestFromListener.json()).resolves.toEqual({ name: 'john' }) // Must receive the correct response. - expect(res.headers).toHaveProperty('x-custom-header', 'mocked') - await expect(text()).resolves.toBe('howdy, john') + expect(response.headers.get('x-custom-header')).toBe('mocked') + await expect(response.text()).resolves.toBe('howdy, john') expect(requestListener).toHaveBeenCalledTimes(1) }) @@ -149,7 +148,7 @@ it('mocks response to a non-existing host', async () => { }) request.write(JSON.stringify({ name: 'john' })) request.end() - const { text, res } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) // Must expose the request reference to the listener. const [requestFromListener] = requestListener.mock.calls[0] @@ -161,20 +160,20 @@ it('mocks response to a non-existing host', async () => { await expect(requestFromListener.json()).resolves.toEqual({ name: 'john' }) // Must receive the correct response. - expect(res.headers).toHaveProperty('x-custom-header', 'mocked') - await expect(text()).resolves.toBe('howdy, john') + expect(response.headers.get('x-custom-header')).toBe('mocked') + await expect(response.text()).resolves.toBe('howdy, john') expect(requestListener).toHaveBeenCalledTimes(1) }) -it('returns socket address for a mocked request', async () => { +it('returns socket address for a mocked IPv4 request', async () => { interceptor.on('request', async ({ controller }) => { controller.respondWith(new Response()) }) const addressPromise = new DeferredPromise() - const request = http.get('http://example.com') - request.once('socket', (socket) => { - socket.once('connect', () => { + const request = http.get('http://any.localhost/path') + request.on('socket', (socket) => { + socket.on('connect', () => { addressPromise.resolve(socket.address()) }) }) @@ -186,15 +185,19 @@ it('returns socket address for a mocked request', async () => { }) }) -it('returns socket address for a mocked request with family: 6', async () => { +it('returns socket address for a mocked IPv6 request', async () => { interceptor.on('request', async ({ controller }) => { controller.respondWith(new Response()) }) const addressPromise = new DeferredPromise() - const request = http.get('http://example.com', { family: 6 }) - request.once('socket', (socket) => { - socket.once('connect', () => { + const request = http.get({ + hostname: '2001:0db8:85a3:0000:0000:8a2e:0370:7334', + port: 80, + path: '/', + }) + request.on('socket', (socket) => { + socket.on('connect', () => { addressPromise.resolve(socket.address()) }) }) @@ -213,8 +216,8 @@ it('returns socket address for a mocked request with IPv6 hostname', async () => const addressPromise = new DeferredPromise() const request = http.get('http://[::1]') - request.once('socket', (socket) => { - socket.once('connect', () => { + request.on('socket', (socket) => { + socket.on('connect', () => { addressPromise.resolve(socket.address()) }) }) @@ -229,13 +232,14 @@ it('returns socket address for a mocked request with IPv6 hostname', async () => it('returns socket address for a bypassed request', async () => { const addressPromise = new DeferredPromise() const request = http.get(httpServer.http.url('/user')) - request.once('socket', (socket) => { - socket.once('connect', () => { + + request.on('socket', (socket) => { + socket.on('connect', () => { addressPromise.resolve(socket.address()) }) }) - await waitForClientRequest(request) + await toWebResponse(request) await expect(addressPromise).resolves.toEqual({ address: httpServer.http.address.host, diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index 6732e62b8..1df8e38a2 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -2,21 +2,18 @@ * @vitest-environment node * @see https://github.com/mswjs/interceptors/issues/131 */ -import { it, expect, beforeAll, afterAll } from 'vitest' -import { IncomingMessage } from 'node:http' import https from 'node:https' import { URL } from 'node:url' -import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' -import { getIncomingMessageBody } from '../../../../src/interceptors/ClientRequest/utils/getIncomingMessageBody' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { res.status(200).send('hello') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await httpServer.listen() @@ -29,23 +26,15 @@ afterAll(async () => { }) it('performs the original HTTPS request', async () => { - const responseReceived = new DeferredPromise() - https - .request( - new URL(httpServer.https.url('/resource')), - { - method: 'GET', - rejectUnauthorized: false, - }, - async (response) => { - responseReceived.resolve(response) - } - ) + const request = https + .request(new URL(httpServer.https.url('/resource')), { + method: 'GET', + rejectUnauthorized: false, + }) .end() - const response = await responseReceived - expect(response.statusCode).toBe(200) + const [response] = await toWebResponse(request) - const responseText = await getIncomingMessageBody(response) - expect(responseText).toEqual('hello') + expect.soft(response.status).toBe(200) + await expect(response.text()).resolves.toEqual('hello') }) diff --git a/test/modules/http/compliance/https-custom-agent.test.ts b/test/modules/http/compliance/https-custom-agent.test.ts index 7308b6f26..ba8669546 100644 --- a/test/modules/http/compliance/https-custom-agent.test.ts +++ b/test/modules/http/compliance/https-custom-agent.test.ts @@ -1,10 +1,9 @@ // @vitest-environment node import http from 'node:http' import https from 'node:https' -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { @@ -12,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -37,8 +36,8 @@ it('supports https.Agent instance as a custom agent for a mocked request', async agent: new https.Agent(), }) - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('hello world') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('hello world') }) it('supports https.Agent instance as a custom agent for a passthrough request', async () => { @@ -48,8 +47,8 @@ it('supports https.Agent instance as a custom agent for a passthrough request', }), }) - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('original response') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('original response') }) it('supports http.Agent instance as a custom agent for a passthrough request', async () => { @@ -77,6 +76,6 @@ it('supports http.Agent instance as a custom agent for a passthrough request', a rejectUnauthorized: false, }) - const { text } = await waitForClientRequest(request) - await expect(text()).resolves.toBe('original response') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('original response') }) diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 2d392c454..1584081e8 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -1,38 +1,40 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { - res.send('hello') + res.send('original') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() await httpServer.listen() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(async () => { interceptor.dispose() await httpServer.close() }) it('emits correct events for a mocked HTTPS request', async () => { - interceptor.once('request', ({ controller }) => { + interceptor.on('request', ({ controller }) => { controller.respondWith(new Response()) }) - const request = https.get('https://example.com') + const request = https.get('https://any.localhost/api') const socketListener = vi.fn() + const socketConnectListener = vi.fn() const socketReadyListener = vi.fn() const socketSecureListener = vi.fn() const socketSecureConnectListener = vi.fn() @@ -42,24 +44,26 @@ it('emits correct events for a mocked HTTPS request', async () => { request.on('socket', (socket) => { socketListener(socket) - socket.on('ready', socketReadyListener) - socket.on('secure', socketSecureListener) - socket.on('secureConnect', socketSecureConnectListener) - socket.on('session', socketSessionListener) - socket.on('error', socketErrorListener) + socket + .on('connect', socketConnectListener) + .on('ready', socketReadyListener) + .on('secure', socketSecureListener) + .on('secureConnect', socketSecureConnectListener) + .on('session', socketSessionListener) + .on('error', socketErrorListener) }) - await waitForClientRequest(request) - - // 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() + await toWebResponse(request) + + expect.soft(socketListener).toHaveBeenCalledOnce() + expect.soft(socketReadyListener).toHaveBeenCalledOnce() + expect.soft(socketConnectListener).toHaveBeenCalledOnce() + expect.soft(socketSecureListener).toHaveBeenCalledOnce() + expect.soft(socketSecureConnectListener).toHaveBeenCalledOnce() + expect + .soft(socketSessionListener) + .toHaveBeenCalledExactlyOnceWith(expect.any(Buffer)) + expect.soft(socketErrorListener).not.toHaveBeenCalled() }) it('emits correct events for a passthrough HTTPS request', async () => { @@ -68,6 +72,7 @@ it('emits correct events for a passthrough HTTPS request', async () => { }) const socketListener = vi.fn() + const socketConnectListener = vi.fn() const socketReadyListener = vi.fn() const socketSecureListener = vi.fn() const socketSecureConnectListener = vi.fn() @@ -77,22 +82,28 @@ it('emits correct events for a passthrough HTTPS request', async () => { request.on('socket', (socket) => { socketListener(socket) - socket.on('ready', socketReadyListener) - socket.on('secure', socketSecureListener) - socket.on('secureConnect', socketSecureConnectListener) - socket.on('session', socketSessionListener) - socket.on('error', socketErrorListener) + socket + .on('connect', socketConnectListener) + .on('ready', socketReadyListener) + .on('secure', socketSecureListener) + .on('secureConnect', socketSecureConnectListener) + .on('session', socketSessionListener) + .on('error', socketErrorListener) }) - await waitForClientRequest(request) - - // 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() + await toWebResponse(request) + + expect.soft(socketListener).toHaveBeenCalledOnce() + expect.soft(socketConnectListener).toHaveBeenCalledOnce() + expect.soft(socketReadyListener).toHaveBeenCalledOnce() + expect.soft(socketSecureListener).toHaveBeenCalledOnce() + expect.soft(socketSecureConnectListener).toHaveBeenCalledOnce() + expect.soft(socketSessionListener).toHaveBeenCalledTimes(2) + expect + .soft(socketSessionListener) + .toHaveBeenNthCalledWith(1, expect.any(Buffer)) + expect + .soft(socketSessionListener) + .toHaveBeenNthCalledWith(2, expect.any(Buffer)) + expect.soft(socketErrorListener).not.toHaveBeenCalled() }) diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index a22cff9c4..0f7f69581 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,10 +1,8 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { httpGet, PromisifiedResponse, useCors } from '../../helpers' -import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' +import { PromisifiedResponse, toWebResponse, useCors } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' function arrayWith(length: number, mapFn: (index: number) => V): V[] { return new Array(length).fill(null).map((_, index) => mapFn(index)) @@ -14,11 +12,9 @@ function randomBetween(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1) + min) } -function parallelRequests( - makeRequest: (index: number) => Promise -) { +function parallelRequests(makeRequest: (index: number) => Promise) { return (index: number) => { - return new Promise((resolve) => { + return new Promise((resolve) => { setTimeout(() => resolve(makeRequest(index)), randomBetween(100, 500)) }) } @@ -31,7 +27,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { const url = new URL(request.url) @@ -56,24 +52,34 @@ it.skip('returns responses for 500 matching parallel requests', async () => { const responses = await Promise.all( arrayWith( 500, - parallelRequests((i) => httpGet(httpServer.http.url(`/user?id=${i + 1}`))) + parallelRequests(async (i) => { + const [response] = await toWebResponse( + http.get(httpServer.http.url(`/user?id=${i + 1}`)) + ) + return response + }) ) ) - const bodies = responses.map((response) => response.resBody) + const bodies = responses.map((response) => response.text()) const expectedBodies = arrayWith(500, (i) => `mocked ${i + 1}`) - expect(bodies).toEqual(expectedBodies) + await expect(Promise.all(bodies)).resolves.toEqual(expectedBodies) }) it.skip('returns responses for 500 bypassed parallel requests', async () => { const responses = await Promise.all( arrayWith( 500, - parallelRequests((i) => httpGet(httpServer.http.url(`/number/${i + 1}`))) + parallelRequests(async (i) => { + const [response] = await toWebResponse( + http.get(httpServer.http.url(`/number/${i + 1}`)) + ) + return response + }) ) ) - const bodies = responses.map((response) => response.resBody) + const bodies = responses.map((response) => response.text()) const expectedBodies = arrayWith(500, (i) => `real ${i + 1}`) - expect(bodies).toEqual(expectedBodies) + await expect(Promise.all(bodies)).resolves.toEqual(expectedBodies) }) diff --git a/test/modules/http/intercept/http-client-request-agent.test.ts b/test/modules/http/intercept/http-client-request-agent.test.ts index a0e1f3b68..312f6803e 100644 --- a/test/modules/http/intercept/http-client-request-agent.test.ts +++ b/test/modules/http/intercept/http-client-request-agent.test.ts @@ -1,20 +1,15 @@ /** - * @note This test suite makes sure that us patching both `ClientRequest` - * and `http.*`/`https.*` request-issuing methods that create that `ClientRequest` - * does not result in duplicate mock HTTP sockets/agents being created. + * @note Historically, we used to intercept "ClientRequest" via a custom agent. + * With the socket-based interception, that's no longer the case. I've rewritten + * this test suite to ensure we are *not* patching the agents anymore. */ -import { beforeAll, afterEach, afterAll, it, expect } from 'vitest' +import net from 'node:net' import http from 'node:http' import https from 'node:https' import { DeferredPromise } from '@open-draft/deferred-promise' -import type { MockHttpSocket } from '../../../../src/interceptors/ClientRequest/MockHttpSocket' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { - MockAgent, - MockHttpsAgent, -} from '../../../../src/interceptors/ClientRequest/agents' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -28,36 +23,24 @@ afterAll(() => { interceptor.dispose() }) -it('reuses the same mock socket for an HTTP ClientRequest and its agent', async () => { - const socketPromise = new DeferredPromise() +it('does not patch the agent for the HTTP request', async () => { + const socketPromise = new DeferredPromise() const request = http .get('http://localhost/does-not-matter') - .on('socket', (socket) => socketPromise.resolve(socket as MockHttpSocket)) + .on('socket', (socket) => socketPromise.resolve(socket as net.Socket)) .on('error', () => {}) - const requestAgent = Reflect.get(request, 'agent') as http.Agent - expect(requestAgent).toBeInstanceOf(MockAgent) - - const socket = await socketPromise - expect( - socket['connectionOptions'].agent, - 'Request agent must equal to the socket agent' - ).toEqual(requestAgent) + expect(Reflect.get(request, 'agent')).toBeInstanceOf(http.Agent) + await expect(socketPromise).resolves.toBeInstanceOf(net.Socket) }) -it('reuses the same mock socket for an HTTPS ClientRequest and its agent', async () => { - const socketPromise = new DeferredPromise() +it('does not patch the agent for the HTTPS request', async () => { + const socketPromise = new DeferredPromise() const request = https .get('https://localhost/does-not-matter') - .on('socket', (socket) => socketPromise.resolve(socket as MockHttpSocket)) + .on('socket', (socket) => socketPromise.resolve(socket as net.Socket)) .on('error', () => {}) - const requestAgent = Reflect.get(request, 'agent') as https.Agent - expect(requestAgent).toBeInstanceOf(MockHttpsAgent) - - const socket = await socketPromise - expect( - socket['connectionOptions'].agent, - 'Request agent must equal to the socket agent' - ).toEqual(requestAgent) + expect(Reflect.get(request, 'agent')).toBeInstanceOf(https.Agent) + await expect(socketPromise).resolves.toBeInstanceOf(net.Socket) }) diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index 5b395601f..4706d7071 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -1,23 +1,22 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node import http from 'node:http' -import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestEventMap } from '../../../../src/glossary' +import { httpsAgent, HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { RequestController } from '#/src/RequestController' +import { HttpRequestEventMap } from '#/src/glossary' const httpServer = new HttpServer((app) => { app.get('/user', (_req, res) => { - res.status(200).send('user-body') + res.status(200).send('original-body') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { - process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterEach(() => { @@ -33,7 +32,7 @@ afterAll(async () => { it('intercepts an HTTP ClientRequest request with request options', async () => { const url = new URL(httpServer.http.url('/user?id=123')) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) @@ -49,13 +48,13 @@ it('intercepts an HTTP ClientRequest request with request options', async () => }) req.end() - const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + const [response] = await toWebResponse(req) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url.href) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -66,26 +65,26 @@ it('intercepts an HTTP ClientRequest request with request options', async () => expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTP ClientRequest request with URL string', async () => { const url = httpServer.http.url('/user?id=123') const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) const req = new http.ClientRequest(url) req.setHeader('x-custom-header', 'yes') req.end() - const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + const [response] = await toWebResponse(req) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -96,26 +95,26 @@ it('intercepts an HTTP ClientRequest request with URL string', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTP ClientRequest request with URL instance', async () => { const url = new URL(httpServer.http.url('/user?id=123')) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) const req = new http.ClientRequest(url) req.setHeader('x-custom-header', 'yes') req.end() - const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + const [response] = await toWebResponse(req) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url.href) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -126,26 +125,29 @@ it('intercepts an HTTP ClientRequest request with URL instance', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with URL string', async () => { const url = httpServer.https.url('/user?id=123') const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) - const req = new http.ClientRequest(url) + const req = new http.ClientRequest(url, { + // @ts-expect-error Invalid Node.js types. + agent: httpsAgent, + }) req.setHeader('x-custom-header', 'yes') req.end() - const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + const [response] = await toWebResponse(req) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -156,26 +158,29 @@ it('intercepts an HTTPS ClientRequest request with URL string', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with URL instance', async () => { const url = new URL(httpServer.https.url('/user?id=123')) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) - const req = new http.ClientRequest(url) + const req = new http.ClientRequest(url, { + // @ts-expect-error Invalid Node.js types. + agent: httpsAgent, + }) req.setHeader('x-custom-header', 'yes') req.end() - const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + const [response] = await toWebResponse(req) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url.href) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -186,13 +191,13 @@ it('intercepts an HTTPS ClientRequest request with URL instance', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(response.text()).resolves.toBe('original-body') }) it('intercepts an HTTPS ClientRequest request with request options', async () => { const url = new URL(httpServer.https.url('/user?id=123')) const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() + vi.fn<(event: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) const req = new http.ClientRequest({ @@ -203,16 +208,17 @@ it('intercepts an HTTPS ClientRequest request with request options', async () => headers: { 'x-custom-header': 'yes', }, + agent: httpsAgent, }) req.end() - const { text } = await waitForClientRequest(req) - expect(requestListener).toHaveBeenCalledTimes(1) + const [response] = await toWebResponse(req) + expect(requestListener).toHaveBeenCalledOnce() const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') - expect(request.url).toBe(url.toString()) + expect(request.url).toBe(url.href) expect(Object.fromEntries(request.headers.entries())).toMatchObject({ 'x-custom-header': 'yes', }) @@ -223,7 +229,7 @@ it('intercepts an HTTPS ClientRequest request with request options', async () => expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(response.text()).resolves.toBe('original-body') }) it('restores the original ClientRequest class after disposal', async () => { diff --git a/test/modules/http/intercept/http-connect.test.ts b/test/modules/http/intercept/http-connect.test.ts new file mode 100644 index 000000000..6b19e1d67 --- /dev/null +++ b/test/modules/http/intercept/http-connect.test.ts @@ -0,0 +1,215 @@ +// @vitest-environment node +/** + * @see https://github.com/mswjs/interceptors/issues/481 + */ +import net from 'node:net' +import http from 'node:http' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpsProxyAgent } from 'https-proxy-agent' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { waitForClientRequest } from '#/test/helpers' + +const interceptor = new HttpRequestInterceptor() + +const httpServer = new HttpServer((app) => { + app.get('/resource', (req, res) => { + res.send('original') + }) +}) + +beforeAll(async () => { + interceptor.apply() + await httpServer.listen() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + await httpServer.close() +}) + +it('intercepts a "CONNECT" request using IP as the authority', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.respondWith(new Response()) + }) + + const connectListener = vi.fn() + const responseListener = vi.fn() + + const serverHost = `${httpServer.http.address.host}:${httpServer.http.address.port}` + + const request = http + .request({ + method: 'CONNECT', + host: '127.0.0.1', + port: 1337, + /** + * @note CONNECT requests use "path" to describe the requested authority + * in a "host:port" format. + */ + path: serverHost, + }) + .end() + + request.on('connect', connectListener).on('response', responseListener) + + await expect.poll(() => connectListener).toHaveBeenCalledOnce() + expect(connectListener).toHaveBeenCalledExactlyOnceWith( + // The mocked response sent from the interceptor. + expect.objectContaining({ + statusCode: 200, + statusMessage: 'OK', + }), + expect.any(net.Socket), + expect.any(Buffer) + ) + + // CONNECT requests do NOT produce an actual response. + expect(responseListener).not.toHaveBeenCalled() + + const interceptedRequest = await requestPromise + + expect.soft(interceptedRequest.method).toBe('CONNECT') + expect + .soft(interceptedRequest.url, 'Sets connect authority as the request URL') + .toBe(serverHost) + expect.soft(Array.from(interceptedRequest.headers)).toEqual([ + ['connection', 'keep-alive'], + ['host', '127.0.0.1:1337'], + ]) +}) + +/** + * @note This test exists only because Node.js has a bug parsing + * URLs like "http://127.0.0.1:1337/localhost:80". It would treat "localhost:" + * as a protocol. + */ +it('intercepts a "CONNECT" request using "localhost" as the authority', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.respondWith(new Response()) + }) + + const connectListener = vi.fn() + const responseListener = vi.fn() + + const serverHost = `localhost:${httpServer.http.address.port}` + + const request = http + .request({ + method: 'CONNECT', + host: '127.0.0.1', + port: 1337, + path: serverHost, + }) + .end() + + request.on('connect', connectListener).on('response', responseListener) + + await expect.poll(() => connectListener).toHaveBeenCalledOnce() + expect(connectListener).toHaveBeenCalledExactlyOnceWith( + // The mocked response sent from the interceptor. + expect.objectContaining({ + statusCode: 200, + statusMessage: 'OK', + }), + expect.any(net.Socket), + expect.any(Buffer) + ) + + // CONNECT requests do NOT produce an actual response. + expect(responseListener).not.toHaveBeenCalled() + + const interceptedRequest = await requestPromise + + expect.soft(interceptedRequest.method).toBe('CONNECT') + expect + .soft(interceptedRequest.url, 'Sets connect authority as the request URL') + .toBe(serverHost) + expect.soft(Array.from(interceptedRequest.headers)).toEqual([ + ['connection', 'keep-alive'], + ['host', '127.0.0.1:1337'], + ]) +}) + +it('errors the intercepted "CONNECT" request', async () => { + const requestPromise = new DeferredPromise() + + interceptor.on('request', ({ request, controller }) => { + requestPromise.resolve(request) + controller.errorWith(new Error('Custom reason')) + }) + + const connectListener = vi.fn() + const responseListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + const serverHost = `localhost:${httpServer.http.address.port}` + + const request = http + .request({ + method: 'CONNECT', + host: '127.0.0.1', + port: 1337, + path: serverHost, + }) + .end() + + request + .on('connect', connectListener) + .on('response', responseListener) + .on('error', errorListener) + .on('close', closeListener) + + await expect.poll(() => errorListener).toHaveBeenCalledOnce() + expect(errorListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ message: 'Custom reason' }) + ) + expect(closeListener).toHaveBeenCalledOnce() + expect(connectListener).not.toHaveBeenCalled() + expect(responseListener).not.toHaveBeenCalled() +}) + +it.skip('mocks the entire proxy flow end-to-end', async () => { + interceptor.on('request', ({ request, controller }) => { + if (request.method === 'CONNECT') { + return controller.respondWith(new Response()) + } + + controller.respondWith(new Response('mock')) + }) + + const connectListener = vi.fn() + const responseListener = vi.fn() + const errorListener = vi.fn() + const closeListener = vi.fn() + + const agent = new HttpsProxyAgent('http://non-existing.remote/server') + + const request = http + .request({ + hostname: '127.0.0.1', + port: 80, + path: '/', + agent, + }) + .end() + + request.on('connect', connectListener) + + await expect.poll(() => connectListener).toHaveBeenCalledOnce() + + /** + * @todo @fixme Finish this test. + */ +}) diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 609c4b90c..f45aca35f 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,10 +1,9 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' -import { RequestController } from '../../../../src/RequestController' -import { HttpRequestEventMap } from '../../../../src/glossary' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { RequestController } from '#/src/RequestController' +import { HttpRequestEventMap } from '#/src/glossary' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -12,9 +11,9 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { @@ -38,7 +37,8 @@ it('intercepts an http.get request', async () => { 'x-custom-header': 'yes', }, }) - const { text } = await waitForClientRequest(req) + + const [response] = await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -54,9 +54,7 @@ it('intercepts an http.get request', async () => { expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) - - // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(response.text()).resolves.toBe('user-body') }) it('intercepts an http.get request given RequestOptions without a protocol', async () => { @@ -67,7 +65,7 @@ it('intercepts an http.get request given RequestOptions without a protocol', asy port: httpServer.http.address.port, path: '/user?id=123', }) - const { text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -83,7 +81,5 @@ it('intercepts an http.get request given RequestOptions without a protocol', asy expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) - - // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(response.text()).resolves.toBe('user-body') }) diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 8c75a41aa..09c547f5d 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -1,11 +1,11 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +// @vitest-environment node +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src/glossary' -import { RequestController } from '../../../../src/RequestController' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { HttpRequestEventMap } from '#/src/events/http' +import { RequestController } from '#/src/RequestController' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -18,21 +18,22 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterEach(() => { - vi.resetAllMocks() + vi.clearAllMocks() }) afterAll(async () => { interceptor.dispose() + vi.restoreAllMocks() await httpServer.close() }) @@ -45,7 +46,7 @@ it('intercepts a HEAD request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -73,7 +74,7 @@ it('intercepts a GET request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -104,7 +105,7 @@ it('intercepts a POST request', async () => { req.write('post-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -117,7 +118,7 @@ it('intercepts a POST request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') + await expect(request.text()).resolves.toBe('post-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -134,7 +135,7 @@ it('intercepts a PUT request', async () => { }) req.write('put-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -147,7 +148,7 @@ it('intercepts a PUT request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') + await expect(request.text()).resolves.toBe('put-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -164,7 +165,7 @@ it('intercepts a PATCH request', async () => { }) req.write('patch-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -177,14 +178,14 @@ it('intercepts a PATCH request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('patch-payload') + await expect(request.text()).resolves.toBe('patch-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a DELETE request', async () => { - const url = httpServer.http.url('/user?id=123') + const url = httpServer.http.url('/user?id=1234') const req = http.request(url, { method: 'DELETE', headers: { @@ -192,7 +193,8 @@ it('intercepts a DELETE request', async () => { }, }) req.end() - await waitForClientRequest(req) + + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -220,7 +222,7 @@ it('intercepts an http.request given RequestOptions without a protocol', async ( path: '/user?id=123', }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -243,7 +245,7 @@ it('intercepts an http.request path in url and options', async () => { callback ) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -267,7 +269,7 @@ it('intercepts an http.request with custom "auth" option', async () => { auth, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -290,7 +292,7 @@ it('intercepts an http.request with a URL with "username" and "password"', async ) ) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 3c420d024..01ddb43de 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,10 +1,9 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import https from 'https' +import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src/glossary' -import { RequestController } from '../../../../src/RequestController' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { HttpRequestEventMap } from '#/src/events/http' +import { RequestController } from '#/src/RequestController' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -12,8 +11,8 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { @@ -39,7 +38,7 @@ it('intercepts a GET request', async () => { }, }) - await waitForClientRequest(request) + await toWebResponse(request) expect(resolver).toHaveBeenCalledTimes(1) @@ -70,7 +69,7 @@ it('intercepts an https.get request given RequestOptions without a protocol', as // Suppress the "certificate has expired" error. rejectUnauthorized: false, }) - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index e602d99c2..17b1a72e9 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -1,11 +1,11 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import https from 'https' +// @vitest-environment node +import https from 'node:https' import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' -import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' -import { HttpRequestEventMap } from '../../../../src' -import { RequestController } from '../../../../src/RequestController' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { REQUEST_ID_REGEXP, toWebResponse } from '#/test/helpers' +import { HttpRequestEventMap } from '#/src/index' +import { RequestController } from '#/src/RequestController' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (req, res) => { @@ -20,8 +20,8 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() +const resolver = vi.fn<(event: HttpRequestEventMap['request']) => void>() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { @@ -48,7 +48,7 @@ it('intercepts a HEAD request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -73,7 +73,7 @@ it('intercepts a GET request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -99,7 +99,7 @@ it('intercepts a POST request', async () => { }) req.write('post-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -108,7 +108,7 @@ it('intercepts a POST request', async () => { expect(request.method).toBe('POST') expect(request.url).toBe(httpServer.https.url('/user?id=123')) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') + await expect(request.text()).resolves.toBe('post-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -125,7 +125,7 @@ it('intercepts a PUT request', async () => { }) req.write('put-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -134,7 +134,7 @@ it('intercepts a PUT request', async () => { expect(request.method).toBe('PUT') expect(request.url).toBe(httpServer.https.url('/user?id=123')) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') + await expect(request.text()).resolves.toBe('put-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -151,7 +151,7 @@ it('intercepts a PATCH request', async () => { }) req.write('patch-payload') req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -160,7 +160,7 @@ it('intercepts a PATCH request', async () => { expect(request.method).toBe('PATCH') expect(request.url).toBe(httpServer.https.url('/user?id=123')) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('patch-payload') + await expect(request.text()).resolves.toBe('patch-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -176,7 +176,7 @@ it('intercepts a DELETE request', async () => { }, }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) @@ -185,7 +185,7 @@ it('intercepts a DELETE request', async () => { expect(request.method).toBe('DELETE') expect(request.url).toBe(httpServer.https.url('/user?id=123')) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('') + await expect(request.text()).resolves.toBe('') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) @@ -199,7 +199,7 @@ it('intercepts an http.request request given RequestOptions without a protocol', path: '/user?id=123', }) req.end() - await waitForClientRequest(req) + await toWebResponse(req) expect(resolver).toHaveBeenCalledTimes(1) diff --git a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts index c0b34c8bb..19580c647 100644 --- a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts +++ b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts @@ -1,20 +1,18 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { httpGet } from '../../../helpers' -import { sleep } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', async (req, res) => { - await sleep(300) + await setTimeout(300) res.status(200).send('original-response') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -36,23 +34,25 @@ it('handles concurrent requests with different response sources', async () => { return } - await sleep(250) + await setTimeout(250) controller.respondWith(new Response('mocked-response', { status: 201 })) }) const requests = await Promise.all([ - httpGet(httpServer.http.url('/')), - httpGet(httpServer.http.url('/'), { - headers: { - 'x-ignore-request': 'yes', - }, - }), + toWebResponse(http.get(httpServer.http.url('/'))), + toWebResponse( + http.get(httpServer.http.url('/'), { + headers: { + 'x-ignore-request': 'yes', + }, + }) + ), ]) - expect(requests[0].res.statusCode).toEqual(201) - expect(requests[0].resBody).toEqual('mocked-response') + expect(requests[0][0].status).toEqual(201) + await expect(requests[0][0].text()).resolves.toBe('mocked-response') - expect(requests[1].res.statusCode).toEqual(200) - expect(requests[1].resBody).toEqual('original-response') + expect(requests[1][0].status).toEqual(200) + await expect(requests[1][0].text()).resolves.toBe('original-response') }) diff --git a/test/modules/http/regressions/http-concurrent-same-host.test.ts b/test/modules/http/regressions/http-concurrent-same-host.test.ts index d90efbdab..d9fd65631 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -2,13 +2,12 @@ * @vitest-environment node * @see https://github.com/mswjs/interceptors/issues/2 */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '#/src/interceptors/http' let requests: Array = [] -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { requests.push(request) controller.respondWith(new Response()) diff --git a/test/modules/http/regressions/http-empty-readable-stream-response.test.ts b/test/modules/http/regressions/http-empty-readable-stream-response.test.ts index 67346e448..36d543e32 100644 --- a/test/modules/http/regressions/http-empty-readable-stream-response.test.ts +++ b/test/modules/http/regressions/http-empty-readable-stream-response.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -27,9 +24,9 @@ it('responds to a request with an empty ReadableStream', async () => { }) const request = http.get('http://example.com') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') - expect(await text()).toBe('') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('') }) diff --git a/test/modules/http/regressions/http-max-listeners-exceeded-warning.test.ts b/test/modules/http/regressions/http-max-listeners-exceeded-warning.test.ts index 53f1e53dc..25b059b43 100644 --- a/test/modules/http/regressions/http-max-listeners-exceeded-warning.test.ts +++ b/test/modules/http/regressions/http-max-listeners-exceeded-warning.test.ts @@ -2,11 +2,10 @@ /** * @see https://github.com/mswjs/interceptors/pull/706 */ -import { it, expect, beforeAll, afterAll } from 'vitest' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { @@ -18,7 +17,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -32,9 +31,9 @@ afterAll(async () => { it('does not buffer socket pushes for a passthrough request', async () => { const request = http.get(httpServer.http.url('/')) - const { text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) - await expect(text()).resolves.toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') expect( request.socket?.listenerCount('connect'), 'Must not add "connection" listeners to the socket. Those listeners mean no "_handle" exists on the mock socket.' diff --git a/test/modules/http/regressions/http-post-missing-first-bytes.test.ts b/test/modules/http/regressions/http-post-missing-first-bytes.test.ts index 5a3193bdf..17787eaf2 100644 --- a/test/modules/http/regressions/http-post-missing-first-bytes.test.ts +++ b/test/modules/http/regressions/http-post-missing-first-bytes.test.ts @@ -4,12 +4,11 @@ */ import http from 'node:http' import path from 'node:path' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { vi, afterAll, beforeAll, afterEach, it, expect } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import superagent from 'superagent' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.post('/upload', (req, res) => { @@ -34,7 +33,7 @@ afterAll(async () => { await httpServer.close() }) -it('does not skip first request bytes on passthrough POST request', async () => { +it('does not skip the first request bytes on passthrough POST request', async () => { const socketDataCallback = vi.fn() const underlyingServer = httpServer['_http'] as http.Server @@ -58,16 +57,13 @@ it('does not skip first request bytes on passthrough POST request', async () => expect.fail('Request must not error') }) - expect(response.status).toBe(200) - // Must send the uploaded file to the server. - expect(response.body).toEqual({ + expect.soft(response.status).toBe(200) + expect.soft(response.body).toEqual({ contentType: expect.stringMatching('multipart/form-data; boundary='), contentLength: '3723', }) - // Must send correct request headers. - expect(socketDataCallback).toHaveBeenNthCalledWith( - 1, + expect(socketDataCallback).toHaveBeenCalledExactlyOnceWith( expect.stringContaining('POST /upload HTTP/1.1\r\n') ) }) diff --git a/test/modules/http/regressions/http-socket-timeout.test.ts b/test/modules/http/regressions/http-socket-timeout.test.ts index 8e138fb15..7f0199f3f 100644 --- a/test/modules/http/regressions/http-socket-timeout.test.ts +++ b/test/modules/http/regressions/http-socket-timeout.test.ts @@ -1,7 +1,4 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import { ChildProcess, spawn } from 'child_process' let child: ChildProcess diff --git a/test/modules/http/regressions/http-socket-timeout.ts b/test/modules/http/regressions/http-socket-timeout.ts index 255ceaaa7..25dcde219 100644 --- a/test/modules/http/regressions/http-socket-timeout.ts +++ b/test/modules/http/regressions/http-socket-timeout.ts @@ -9,7 +9,7 @@ import { it, expect, beforeAll, afterAll } from 'vitest' import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { @@ -17,7 +17,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ controller }) => { controller.respondWith(new Response('hello world', { status: 301 })) }) diff --git a/test/modules/http/regressions/https-peer-certificate.test.ts b/test/modules/http/regressions/https-peer-certificate.test.ts new file mode 100644 index 000000000..fb1f43fb8 --- /dev/null +++ b/test/modules/http/regressions/https-peer-certificate.test.ts @@ -0,0 +1,131 @@ +// @vitest-environment node +/** + * @see https://github.com/nock/nock/issues/2930#issuecomment-3960523903 + */ +import net from 'node:net' +import tls from 'node:tls' +import https from 'node:https' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' + +const interceptor = new HttpRequestInterceptor() + +const httpServer = https.createServer( + { + cert: ` +-----BEGIN CERTIFICATE----- +MIIDRzCCAi8CFDtKJ0FurS/QAxK6A2cZY7yFCM6CMA0GCSqGSIb3DQEBCwUAMGAx +CzAJBgNVBAYTAkZSMQwwCgYDVQQIDANJZEYxDjAMBgNVBAcMBVBhcmlzMREwDwYD +VQQKDAhTdG9yZGF0YTEMMAoGA1UECwwDRGV2MRIwEAYDVQQDDAlsb2NhbGhvc3Qw +HhcNMjAwNDAxMTA1MTE3WhcNNDcwODE3MTA1MTE3WjBgMQswCQYDVQQGEwJGUjEM +MAoGA1UECAwDSWRGMQ4wDAYDVQQHDAVQYXJpczERMA8GA1UECgwIU3RvcmRhdGEx +DDAKBgNVBAsMA0RldjESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA7uEZtx/xuqr/BMNfFy/KVqNqncgUOqRpme6e+VQd +Y2Vcn+Xovtajkti9Luf6whUPRiCWj0312QWzBQqnryNcmrydAzMfD2MBEfiBZp0L +xKF9p+ETfeIyU5A+bTxo3ejD894QmqgEYiPCBKepWcboaM85NUihEUXbGMWS11n1 +MqU7nNTZhI+KWhCMV+EIn7cY0ckd6WWlhw+RPtHosjDQcS/j96HoY4YxyhUMceyD +npJgCffMTiI9Hw6huQSeNVWdnJ3DLUH4JCCAkLHxOWMmNGNKP0h9VwaXRVYnJU9Q +c0oOAHSvzMFznTs4erDsRR+OM/LI3z4sDNNFhKor5QrEYwIDAQABMA0GCSqGSIb3 +DQEBCwUAA4IBAQAl1gGK+NfK4A34ztoiMdyocW/WHdCKAmCBlioALc3yKtoc+w7w +wrLqyktmL7YH36gB5AIgpcNkov8RqvIckdc3t6vFs8hzmR6qadKd7SRxKofc/mvv +mAmJY9XweH7yGVqWDSEzisS8xW7rRydwfBNNT9l8JP6xjWOXNpMzm+/3dNrKGywp +XJAPJbp0p9UFdRS9z4Tqeirfi60il/iaM+YVYyMD/sPyyVunu5hRw4HKpK0yiXHC ++pJlWrAhCxBw7Ye0ItN/P1ONMqpDpyzVaExjUJUf2SEgS4E7nbDSRoD7bFjDlUTc +IheCQLxQiuY/x9s0PORBmRG7ozEJe8BKfGUa +-----END CERTIFICATE----- `, + key: ` +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA7uEZtx/xuqr/BMNfFy/KVqNqncgUOqRpme6e+VQdY2Vcn+Xo +vtajkti9Luf6whUPRiCWj0312QWzBQqnryNcmrydAzMfD2MBEfiBZp0LxKF9p+ET +feIyU5A+bTxo3ejD894QmqgEYiPCBKepWcboaM85NUihEUXbGMWS11n1MqU7nNTZ +hI+KWhCMV+EIn7cY0ckd6WWlhw+RPtHosjDQcS/j96HoY4YxyhUMceyDnpJgCffM +TiI9Hw6huQSeNVWdnJ3DLUH4JCCAkLHxOWMmNGNKP0h9VwaXRVYnJU9Qc0oOAHSv +zMFznTs4erDsRR+OM/LI3z4sDNNFhKor5QrEYwIDAQABAoIBAQCEjWgFk7ZBDM3B +yN+lMCGo/bkVoIaJG951SlHwrFo6Y26IU71Y2CWgQKCJvLQKqkD1evPQxUPcjysN +ayItLwQd4PeHZQChOyDG5gx38kErdSkS1PRJ8BBZCjt5xgGy0Yyab+jqyLzV8F2i +055HcPZZ4lMuXAT0Xrz6+/dFhGdpF/CPjLYq/KkX2QXO4Sx6iiVEHYr66Y5rcDGx +n9DHeBAeMh8W/FwGE7LBM5kEM9XkTGBh5u0HHVkauZsJ/3I4DEluDUVMkqayU5Z0 +/h2CfnGX1I5wlpHOIB9YniehOVYWfFYl4KwWtQTibJy3H1ZcXUsNI/YR5sUpeA5c +EjhImAZxAoGBAPiPrCknwvs4ud17vfRwi5m3JmSgGEWu0gi4GNetK0XXyLbVWb4N +rD57zVGuXt/MVE2eN+dAmP+bhokR3uLIM8JcOYzr87/6teB1ORfyJB71+Iog59Ti +hMOUNTUUbhlrSQXkmADMXayGGAX5oge5OgJrgcaejOQJ7cwAoRIeVck9AoGBAPYH +QDeQ6fvcAysYLWrbbFFmyKGLuT0sy4boj9qqUXkKcTvaU9uk73/oQrrdJJ+VH47L +J4KgLmLr4XxvJsW/9Lj7DAoIptL/JiraRI40T2bTJbuej38SYF1aHACHj0P1Bb5j +pUEllDLT1DShxxCduSULepoqwFo99rHRL1Kp/14fAoGAUq8wfQxOD1YCdkwYl3zs +43iKnASprlyGYAIluXFQqM4sZa25ScCwoKR8W4Se6OHG1X8hZ5sUiksJSQWZ2GTy +2t/lARzom99hq0YzdOTG4Um/oOtrU2T69ziRLpQaP/hxdTVi3zkcnCyLR0mQffM+ ++dkbdZ/+jElFQoyfCDDxJp0CgYEA9WmkMAlYrYf4jRsv6sB32vcZOLOkkpZFaww+ +utNcM84rx5VwQs/Sq5cmQTnol1rsQMb7YXyg6MH8ieBiH63r0j1x8+xPZHdpPiO9 +cNBTR/FlWTLAVvQgtd31wr12NkaKdTD2nfZ7TvwoWFvrsvJxxbcek/wDJcFbfGJ6 +vw2eEucCgYAH7UURHZBeTY6faj0jjtwwWEavFPlIN2ytg44cn5OXvlZWuJ/C36ve +UNj277WBVCQGTnbMRdj5CokdUz/Y2coLZCRsPv8dMCiymC7Wzib63PF0F2zApZrG +g2wjK5TFm7cVzl/Bm0EZnpf/kQ2+QQGroeVQqoGkELEqj3FlDFW1Dw== +-----END RSA PRIVATE KEY----- + `, + }, + (req, res) => { + res.writeHead(200).end() + } +) + +beforeAll(async () => { + interceptor.apply() + + await new Promise((resolve) => { + httpServer.listen(0, '127.0.0.1', resolve) + }) +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() + + await new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) { + reject(error) + } else { + resolve() + } + }) + }) +}) + +it('supports a custom HTTPS agent that assigns the socket certificate', async () => { + class CustomHttpsAgent extends https.Agent { + public certificate?: tls.PeerCertificate + + constructor() { + super({ rejectUnauthorized: false }) + + this.once('keylog', (line, socket: tls.TLSSocket) => { + socket.once('secureConnect', () => { + this.certificate = socket.getPeerCertificate(false) + }) + }) + } + } + + const agent = new CustomHttpsAgent() + const request = https.request({ + host: '127.0.0.1', + port: (httpServer.address() as net.AddressInfo).port, + agent, + }) + request.end() + + await toWebResponse(request) + + expect(agent.certificate).toEqual( + expect.objectContaining({ + fingerprint: + 'AC:E6:F1:6B:7A:41:2D:3F:13:E2:64:81:29:C3:4F:94:B1:A5:34:E6', + pubkey: expect.any(Buffer), + valid_from: expect.any(String), + valid_to: expect.any(String), + }) + ) +}) diff --git a/test/modules/http/response/http-await-response-event.test.ts b/test/modules/http/response/http-await-response-event.test.ts index f8786edf3..545b96083 100644 --- a/test/modules/http/response/http-await-response-event.test.ts +++ b/test/modules/http/response/http-await-response-event.test.ts @@ -1,8 +1,8 @@ -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +// @vitest-environment node import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { @@ -10,7 +10,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -27,49 +27,49 @@ afterAll(async () => { }) it('awaits asynchronous response event listener for a mocked response', async () => { - const markStep = vi.fn<(input: number) => void>() + const tag = vi.fn<(tag: string) => void>() interceptor.on('request', ({ controller }) => { controller.respondWith(new Response('hello world')) }) interceptor.on('response', async ({ response }) => { - markStep(2) + tag('response') await response.text() - markStep(3) + tag('after-response') }) - markStep(1) + tag('before-request') const request = http.get('http://localhost/') - const { text } = await waitForClientRequest(request) - markStep(4) + const [response] = await toWebResponse(request) + tag('after-request') - expect(await text()).toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') - expect(markStep).toHaveBeenNthCalledWith(1, 1) - expect(markStep).toHaveBeenNthCalledWith(2, 2) - expect(markStep).toHaveBeenNthCalledWith(3, 3) - expect(markStep).toHaveBeenNthCalledWith(4, 4) + expect.soft(tag).toHaveBeenNthCalledWith(1, 'before-request') + expect.soft(tag).toHaveBeenNthCalledWith(2, 'response') + expect.soft(tag).toHaveBeenNthCalledWith(3, 'after-response') + expect.soft(tag).toHaveBeenNthCalledWith(4, 'after-request') }) it('awaits asynchronous response event listener for the original response', async () => { - const markStep = vi.fn<(input: number) => void>() + const tag = vi.fn<(tag: string) => void>() interceptor.on('response', async ({ response }) => { - markStep(2) + tag('response') await response.text() - markStep(3) + tag('after-response') }) - markStep(1) + tag('before-request') const request = http.get(httpServer.http.url('/resource')) - const { text } = await waitForClientRequest(request) - markStep(4) + const [response] = await toWebResponse(request) + tag('after-request') - expect(await text()).toBe('original response') + await expect(response.text()).resolves.toBe('original response') - expect(markStep).toHaveBeenNthCalledWith(1, 1) - expect(markStep).toHaveBeenNthCalledWith(2, 2) - expect(markStep).toHaveBeenNthCalledWith(3, 3) - expect(markStep).toHaveBeenNthCalledWith(4, 4) + expect.soft(tag).toHaveBeenNthCalledWith(1, 'before-request') + expect.soft(tag).toHaveBeenNthCalledWith(2, 'response') + expect.soft(tag).toHaveBeenNthCalledWith(3, 'after-response') + expect.soft(tag).toHaveBeenNthCalledWith(4, 'after-request') }) diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts index 427ae1aa9..fb4da7392 100644 --- a/test/modules/http/response/http-empty-response.test.ts +++ b/test/modules/http/response/http-empty-response.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import http from 'node:http' -import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -17,19 +14,18 @@ afterAll(() => { }) it('supports responding with an empty mocked response', async () => { - interceptor.once('request', ({ controller }) => { - // Responding with an empty response must - // translate to 200 OK with an empty body. + interceptor.once('request', ({ request, controller }) => { + // Responding with an empty response must translate to 200 OK with an empty body. controller.respondWith(new Response()) }) const request = http.get('http://localhost') - const { res, text } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.statusCode).toBe(200) + expect.soft(response.status).toBe(200) // Must not set any response headers that were not // explicitly provided in the mocked response. - expect(res.headers).toEqual({}) - expect(res.rawHeaders).toEqual([]) - expect(await text()).toBe('') + expect.soft(Object.fromEntries(response.headers)).toEqual({}) + expect.soft(rawResponse.rawHeaders).toEqual([]) + await expect(response.text()).resolves.toBe('') }) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 074cb3f01..b3a8389e9 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,159 +1,137 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' -import https from 'https' -import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' - -const httpServer = new HttpServer((app) => { - app.get('/', (_req, res) => { - res.status(200).send('/') - }) - app.get('/get', (_req, res) => { - res.status(200).send('/get') - }) -}) +// @vitest-environment node +import http from 'node:http' +import https from 'node:https' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' +import { getTestServer } from '#/test/setup/vitest' -const interceptor = new ClientRequestInterceptor() -interceptor.on('request', ({ request, controller }) => { - const url = new URL(request.url) - - if (url.pathname === '/non-existing') { - controller.respondWith( - new Response('mocked', { - status: 301, - statusText: 'Moved Permanently', - headers: { - 'Content-Type': 'text/plain', - }, - }) - ) - } - - if (url.href === 'http://error.me/') { - throw new Error('Custom exception message') - } -}) +const server = getTestServer() -beforeAll(async () => { - await httpServer.listen() +const interceptor = new HttpRequestInterceptor() +beforeEach(() => { interceptor.apply() }) -afterAll(async () => { +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { interceptor.dispose() - await httpServer.close() }) it('responds to a handled request issued by "http.get"', async () => { - const req = http.get('http://any.thing/non-existing') - const { res, text } = await waitForClientRequest(req) - - expect(res).toMatchObject>({ - statusCode: 301, - statusMessage: 'Moved Permanently', - headers: { - 'content-type': 'text/plain', - }, + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) }) - expect(await text()).toEqual('mocked') + + const request = http.get('http://any.localhost/non-existing') + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect + .soft(response.headers.get('content-type')) + .toBe('text/plain;charset=UTF-8') + await expect(response.text()).resolves.toEqual('mocked') }) it('responds to a handled request issued by "https.get"', async () => { - const req = https.get('https://any.thing/non-existing') - const { res, text } = await waitForClientRequest(req) - - expect(res).toMatchObject>({ - statusCode: 301, - statusMessage: 'Moved Permanently', - headers: { - 'content-type': 'text/plain', - }, + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) }) - expect(await text()).toEqual('mocked') + + const request = https.get('https://any.localhost/non-existing') + const [response] = await toWebResponse(request) + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect + .soft(response.headers.get('content-type')) + .toBe('text/plain;charset=UTF-8') + await expect(response.text()).resolves.toEqual('mocked') }) it('bypasses an unhandled request issued by "http.get"', async () => { - const req = http.get(httpServer.http.url('/get')) - const { res, text } = await waitForClientRequest(req) + const request = http.get(server.http.url('/get')) + const [response] = await toWebResponse(request) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - expect(await text()).toEqual('/get') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('original-response') }) it('bypasses an unhandled request issued by "https.get"', async () => { - const req = https.get(httpServer.https.url('/get'), { + const request = https.get(server.https.url('/get'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(req) + const [response] = await toWebResponse(request) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - expect(await text()).toEqual('/get') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('original-response') }) it('responds to a handled request issued by "http.request"', async () => { - const req = http.request('http://any.thing/non-existing') - req.end() - const { res, text } = await waitForClientRequest(req) - - expect(res.statusCode).toBe(301) - expect(res.statusMessage).toEqual('Moved Permanently') - expect(res.headers).toHaveProperty('content-type', 'text/plain') - expect(await text()).toEqual('mocked') + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) + }) + + const request = http.request('http://any.localhost/non-existing') + request.end() + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect + .soft(response.headers.get('content-type')) + .toBe('text/plain;charset=UTF-8') + await expect(response.text()).resolves.toEqual('mocked') }) it('responds to a handled request issued by "https.request"', async () => { - const req = https.request('https://any.thing/non-existing') + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response('mocked')) + }) - req.end() - const { res, text } = await waitForClientRequest(req) + const request = https.request('https://any.localhost/non-existing') + request.end() - expect(res).toMatchObject>({ - statusCode: 301, - statusMessage: 'Moved Permanently', - headers: { - 'content-type': 'text/plain', - }, - }) - expect(await text()).toEqual('mocked') + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + expect + .soft(response.headers.get('content-type')) + .toBe('text/plain;charset=UTF-8') + await expect(response.text()).resolves.toEqual('mocked') }) it('bypasses an unhandled request issued by "http.request"', async () => { - const req = http.request(httpServer.http.url('/get')) - req.end() - const { res, text } = await waitForClientRequest(req) + const request = http.request(server.http.url('/get')) + request.end() + const [response] = await toWebResponse(request) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - expect(await text()).toEqual('/get') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('original-response') }) it('bypasses an unhandled request issued by "https.request"', async () => { - const req = https.request(httpServer.https.url('/get'), { + const request = https.request(server.https.url('/get'), { rejectUnauthorized: false, }) - req.end() - const { res, text } = await waitForClientRequest(req) + request.end() + const [response] = await toWebResponse(request) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', - }) - expect(await text()).toEqual('/get') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('original-response') }) it('throws a request error when the middleware throws an exception', async () => { const req = http.get('http://error.me') - await waitForClientRequest(req).catch((error) => { + await toWebResponse(req).catch((error) => { expect(error.message).toEqual('Custom exception message') }) }) @@ -161,12 +139,25 @@ it('throws a request error when the middleware throws an exception', async () => it('bypasses any request after the interceptor was restored', async () => { interceptor.dispose() - const req = http.get(httpServer.http.url('/')) - const { res, text } = await waitForClientRequest(req) + const request = http.get(server.http.url('/')) + const [response] = await toWebResponse(request) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('original-response') +}) - expect(res).toMatchObject>({ - statusCode: 200, - statusMessage: 'OK', +it('responds with a body larger than the high watermark', async () => { + const responseBody = new Uint8Array(1024 * 1024) + interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response(responseBody)) }) - expect(await text()).toEqual('/') + const request = http.get('http://any.localhost/non-existing') + const [response] = await toWebResponse(request) + + expect.soft(response.status).toBe(200) + expect.soft(response.statusText).toBe('OK') + await expect + .soft(response.arrayBuffer()) + .resolves.toEqual(responseBody.buffer) }) diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 339c3d7c7..9d9581ccc 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -1,10 +1,11 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +// @vitest-environment node +import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { sleep, waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { @@ -24,17 +25,17 @@ afterAll(async () => { it('supports custom delay before responding with a mock', async () => { interceptor.once('request', async ({ controller }) => { - await sleep(750) + await setTimeout(750) controller.respondWith(new Response('mocked response')) }) const requestStart = Date.now() const request = http.get('http://non-existing-host.com') - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) const requestEnd = Date.now() - expect(res.statusCode).toBe(200) - expect(await text()).toBe('mocked response') + expect.soft(response.status).toBe(200) + await expect(response.text()).resolves.toBe('mocked response') expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700) }) @@ -42,15 +43,15 @@ it('supports custom delay before receiving the original response', async () => { interceptor.once('request', async () => { // This will simply delay the request execution before // it receives the original response. - await sleep(750) + await setTimeout(750) }) const requestStart = Date.now() const request = http.get(httpServer.http.url('/resource')) - const { res, text } = await waitForClientRequest(request) + const [response] = await toWebResponse(request) const requestEnd = Date.now() - expect(res.statusCode).toBe(200) - expect(await text()).toBe('original response') + expect.soft(response.status).toBe(200) + await expect(response.text()).resolves.toBe('original response') expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700) }) diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index 7f657ee3d..6c0979a2e 100644 --- a/test/modules/http/response/http-response-error.test.ts +++ b/test/modules/http/response/http-response-error.test.ts @@ -1,9 +1,8 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 613cfd332..cbae946ec 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,8 +1,9 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import http from 'node:http' +import { setTimeout } from 'node:timers/promises' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { sleep, waitForClientRequest } from '../../../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' const server = new HttpServer((app) => { app.get('/original', async (req, res) => { @@ -10,7 +11,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() async function getResponse(request: Request): Promise { const url = new URL(request.url) @@ -20,22 +21,22 @@ async function getResponse(request: Request): Promise { return new Promise(async (resolve) => { // Defer the resolution of the promise to the next tick. // Request handlers in MSW resolve on the next tick. - await sleep(0) + await setTimeout(0) const originalRequest = http.get(server.http.url('/original')) - const { res, text } = await waitForClientRequest(originalRequest) + const [response, rawResponse] = await toWebResponse(originalRequest) const getHeader = (name: string): string | undefined => { - const value = res.headers[name] + const value = rawResponse.headers[name] return Array.isArray(value) ? value.join(', ') : value } - const responseText = (await text()) + ' world' + const responseText = (await response.text()) + ' world' resolve( new Response(responseText, { - status: res.statusCode, - statusText: res.statusMessage, + status: response.status, + statusText: response.statusText, headers: { 'X-Custom-Header': getHeader('x-custom-header') || '', }, @@ -66,10 +67,10 @@ afterAll(async () => { it('supports response patching', async () => { const req = http.get('http://localhost/mocked') - const { res, text } = await waitForClientRequest(req) + const [response] = await toWebResponse(req) - expect(res.statusCode).toBe(200) - expect(res.statusMessage).toBe('OK') - expect(res.headers['x-custom-header']).toBe('yes') - expect(await text()).toBe('hello world') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('x-custom-header')).toBe('yes') + await expect(response.text()).resolves.toBe('hello world') }) diff --git a/test/modules/http/response/http-response-readable-stream.test.ts b/test/modules/http/response/http-response-readable-stream.test.ts index 7b20f018b..98b616ff3 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -1,22 +1,66 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { performance } from 'node:perf_hooks' +// @vitest-environment node 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' +import { Readable } from 'node:stream' +import { performance } from 'node:perf_hooks' +import { setTimeout } from 'node:timers/promises' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> -const encoder = new TextEncoder() +const httpServer = new HttpServer((app) => { + app.get('/stream/immediate-error', (req, res) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('first-chunk')) + controller.error(new Error('Response stream error')) + }, + }) + + res.writeHead(200) + Readable.fromWeb(stream as any) + .on('error', (error) => res.destroy(error)) + .pipe(res) + }) + + app.get('/stream/delayed-error', (req, res) => { + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('first-chunk')) + await setTimeout(100) + controller.error(new Error('Response stream error')) + }, + }) + + res.writeHead(200) + Readable.fromWeb(stream as any) + .on('error', (error) => res.destroy(error)) + .pipe(res) + }) -const interceptor = new ClientRequestInterceptor() + app.get('/stream/exception', (req, res) => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('first-chunk')) + // Intentionally invalid input. + controller.enqueue({}) + }, + }) + + res.writeHead(200) + Readable.fromWeb(stream as any) + .on('error', (error) => res.destroy(error)) + .pipe(res) + }) +}) + +const encoder = new TextEncoder() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() + await httpServer.listen() }) afterEach(() => { @@ -25,11 +69,12 @@ afterEach(() => { afterAll(async () => { interceptor.dispose() + await httpServer.close() }) it('supports ReadableStream as a mocked response', async () => { const encoder = new TextEncoder() - interceptor.once('request', ({ controller }) => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode('hello')) @@ -41,23 +86,23 @@ it('supports ReadableStream as a mocked response', async () => { controller.respondWith(new Response(stream)) }) - const request = http.get('http://example.com/resource') - const { text } = await waitForClientRequest(request) - expect(await text()).toBe('hello world') + const request = http.get('http://localhost/resource') + const [response] = await toWebResponse(request) + await expect(response.text()).resolves.toBe('hello world') }) -it('supports delays when enqueuing chunks', async () => { - interceptor.once('request', ({ controller }) => { +it('supports delays between the mock response stream chunks', async () => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ async start(controller) { controller.enqueue(encoder.encode('first')) - await sleep(200) + await setTimeout(150) controller.enqueue(encoder.encode('second')) - await sleep(200) + await setTimeout(150) controller.enqueue(encoder.encode('third')) - await sleep(200) + await setTimeout(150) controller.close() }, @@ -72,133 +117,292 @@ it('supports delays when enqueuing chunks', async () => { ) }) - const responseChunksPromise = new DeferredPromise() + const request = http.get('http://localhost/stream') + const responseChunks: ResponseChunks = [] - const request = https.get('https://api.example.com/stream', (response) => { - const chunks: ResponseChunks = [] + const responseErrorListener = vi.fn() + const requestCloseListener = vi.fn() - response - .on('data', (data) => { - chunks.push({ - buffer: Buffer.from(data), + request + .on('response', (response) => { + response.on('data', (data) => { + responseChunks.push({ + buffer: data, timestamp: performance.now(), }) }) - .on('end', () => { - responseChunksPromise.resolve(chunks) - }) - .on('error', responseChunksPromise.reject) - }) + }) + .on('error', responseErrorListener) + .on('close', requestCloseListener) - request.on('error', responseChunksPromise.reject) + await expect.poll(() => requestCloseListener).toHaveBeenCalled() - const responseChunks = await responseChunksPromise - const textChunks = responseChunks.map((chunk) => { - return chunk.buffer.toString('utf8') - }) - expect(textChunks).toEqual(['first', 'second', 'third']) + expect + .soft(responseChunks.map((chunk) => chunk.buffer)) + .toEqual([ + Buffer.from('first'), + Buffer.from('second'), + Buffer.from('third'), + ]) // Ensure that the chunks were sent over time, // respecting the delay set in the mocked stream. const chunkTimings = responseChunks.map((chunk) => chunk.timestamp) - expect(chunkTimings[1] - chunkTimings[0]).toBeGreaterThanOrEqual(150) - expect(chunkTimings[2] - chunkTimings[1]).toBeGreaterThanOrEqual(150) + expect(chunkTimings[1] - chunkTimings[0]).toBeGreaterThanOrEqual(140) + expect(chunkTimings[2] - chunkTimings[1]).toBeGreaterThanOrEqual(140) }) -it('handles immediate response stream errors as request errors', async () => { - const requestErrorListener = vi.fn() - const responseErrorListener = vi.fn() - +it('handles immediate mock response stream errors as response errors', async () => { const streamError = new Error('stream error') - interceptor.once('request', ({ controller }) => { + + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - async start(controller) { - controller.enqueue(new TextEncoder().encode('original')) + start(controller) { + controller.enqueue(encoder.encode('first-chunk')) controller.error(streamError) }, }) controller.respondWith(new Response(stream)) }) - const request = http.get('http://localhost/resource') - request.on('error', requestErrorListener) + const request = http.get('http://localhost/stream') - await vi.waitFor(() => { - return new Promise((resolve, reject) => { - request.on('error', () => resolve()) - request.on('response', () => { - reject('Must not emit response') - }) + const socketErrorListener = vi.fn() + const socketCloseListener = vi.fn() + const requestResponseListener = vi.fn() + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + + request + .on('socket', (socket) => { + socket.on('error', socketErrorListener).on('close', socketCloseListener) }) + .on('response', requestResponseListener) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + const responseDataListener = vi.fn() + const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('data', responseDataListener).on('error', responseErrorListener) }) + /** + * @note Response stream errors are handled differently in Node.js + * depending on whether the response headers were flushed by the server. + * Normally, Node.js buffers the message headers until (1) response ends; + * (2) the first response chunk is sent. We maintain that default behavior + * for performance considerations. Whether headers are flushed has no effect + * on the client, that's an implementation detail. + */ + await expect + .poll(() => requestErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + code: 'ECONNRESET', + message: 'socket hang up', + }) + ) expect.soft(request.destroyed).toBe(true) - expect.soft(requestErrorListener).toHaveBeenCalledOnce() - expect.soft(requestErrorListener).toHaveBeenCalledWith( - expect.objectContaining({ - code: 'ECONNRESET', - message: 'socket hang up', - }) - ) + expect.soft(requestResponseListener).not.toHaveBeenCalled() + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + + expect.soft(responseDataListener).not.toHaveBeenCalled() expect.soft(responseErrorListener).not.toHaveBeenCalled() + + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) }) -it('handles delayed response stream errors as IncomingMessage errors', async () => { +it('handles immediate bypassed response stream errors as request errors', async () => { + const request = http.get(httpServer.http.url('/stream/immediate-error')) + + const socketErrorListener = vi.fn() + const socketCloseListener = vi.fn() + const requestResponseListener = vi.fn() const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + + request + .on('socket', (socket) => { + socket.on('error', socketErrorListener).on('close', socketCloseListener) + }) + .on('response', requestResponseListener) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + const responseDataListener = vi.fn() const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('data', responseDataListener).on('error', responseErrorListener) + }) + + await expect + .poll(() => requestErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + code: 'ECONNRESET', + message: 'socket hang up', + }) + ) + expect.soft(request.destroyed).toBe(true) + expect.soft(requestResponseListener).not.toHaveBeenCalled() + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + + expect.soft(responseDataListener).not.toHaveBeenCalled() + expect.soft(responseErrorListener).not.toHaveBeenCalled() + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) +}) + +it('handles delayed mock response stream errors as response errors', async () => { const streamError = new Error('stream error') - interceptor.once('request', ({ controller }) => { + + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ async start(controller) { - controller.enqueue(new TextEncoder().encode('original')) + controller.enqueue(encoder.encode('first-chunk')) /** * @note Pause is important here so that Node.js flushes the response headers * before the stream errors. If the error happens immediately, Node.js will * optimize for that and translate it into the request error. */ - await sleep(250) + await setTimeout(100) controller.error(streamError) }, }) controller.respondWith(new Response(stream)) }) - const request = http.get('http://localhost/resource') - request.on('error', requestErrorListener) - - const response = await vi.waitFor(() => { - return new Promise((resolve, reject) => { - request.on('error', () => reject('Must not emit request error')) - request.on('response', (response) => { - response.on('close', () => resolve(response)) - response.on('error', responseErrorListener) - }) + const request = http.get('http://localhost/stream') + + const socketErrorListener = vi.fn() + const socketCloseListener = vi.fn() + const requestResponseListener = vi.fn() + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + + request + .on('socket', (socket) => { + socket.on('error', socketErrorListener).on('close', socketCloseListener) }) + .on('response', requestResponseListener) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + const responseDataListener = vi.fn() + const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('data', responseDataListener).on('error', responseErrorListener) }) - expect.soft(response.statusCode).toBe(200) - expect.soft(response.statusMessage).toBe('OK') + await expect + .poll(() => responseErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'ECONNRESET' }) + ) + expect + .soft(responseDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('first-chunk')) expect.soft(request.destroyed).toBe(true) + expect.soft(requestResponseListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + statusCode: 200, + statusMessage: 'OK', + }) + ) expect.soft(requestErrorListener).not.toHaveBeenCalled() - expect.soft(responseErrorListener).toHaveBeenCalledOnce() - expect.soft(responseErrorListener).toHaveBeenCalledWith( + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) +}) + +it('handles delayed bypassed response stream errors as response errors', async () => { + const request = http.get(httpServer.http.url('/stream/delayed-error')) + + const socketErrorListener = vi.fn() + const socketCloseListener = vi.fn() + const requestResponseListener = vi.fn() + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + + request + .on('socket', (socket) => { + socket.on('error', socketErrorListener).on('close', socketCloseListener) + }) + .on('response', requestResponseListener) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + const responseDataListener = vi.fn() + const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('data', responseDataListener).on('error', responseErrorListener) + }) + + await expect + .poll(() => responseErrorListener) + .toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'ECONNRESET' }) + ) + expect + .soft(responseDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('first-chunk')) + + expect.soft(request.destroyed).toBe(true) + expect.soft(requestResponseListener).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ - code: 'ECONNRESET', - message: 'aborted', + statusCode: 200, + statusMessage: 'OK', }) ) + expect.soft(requestErrorListener).not.toHaveBeenCalled() + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(socketCloseListener).toHaveBeenCalledExactlyOnceWith(false) }) -it('treats unhandled exceptions during the response stream as request errors', async () => { +it('treats unhandled exceptions during bypass response stream as response errors', async () => { + const request = http.get(httpServer.http.url('/stream/exception')) + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + const responseDataListener = vi.fn() const responseErrorListener = vi.fn() - interceptor.once('request', ({ controller }) => { + request + .on('response', (response) => { + response + .on('data', responseDataListener) + .on('error', responseErrorListener) + }) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + + await expect.poll(() => requestCloseListener).toHaveBeenCalledOnce() + expect.soft(request.destroyed).toBe(true) + expect.soft(requestErrorListener).not.toHaveBeenCalled() + + expect + .soft(responseDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('first-chunk')) + expect.soft(responseErrorListener).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + code: 'ECONNRESET', + message: 'aborted', + }) + ) +}) + +it('treats unhandled exceptions during mock response stream as response errors', async () => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - async start(controller) { - await sleep(200) + start(controller) { + controller.enqueue(encoder.encode('first-chunk')) // Intentionally invalid input. controller.enqueue({}) }, @@ -207,24 +411,32 @@ it('treats unhandled exceptions during the response stream as request errors', a }) const request = http.get('http://localhost/resource') - request.on('error', requestErrorListener) - await vi.waitFor(() => { - return new Promise((resolve, reject) => { - request.on('error', () => resolve()) - request.on('response', (response) => { - reject('Must not emit response') - }) + const requestErrorListener = vi.fn() + const requestCloseListener = vi.fn() + const responseDataListener = vi.fn() + const responseErrorListener = vi.fn() + + request + .on('response', (response) => { + response + .on('data', responseDataListener) + .on('error', responseErrorListener) }) - }) + .on('error', requestErrorListener) + .on('close', requestCloseListener) + await expect.poll(() => requestCloseListener).toHaveBeenCalledOnce() expect.soft(request.destroyed).toBe(true) - expect.soft(requestErrorListener).toHaveBeenCalledOnce() - expect.soft(requestErrorListener).toHaveBeenCalledWith( + expect.soft(requestErrorListener).not.toHaveBeenCalled() + + expect + .soft(responseDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('first-chunk')) + expect.soft(responseErrorListener).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ code: 'ECONNRESET', - message: 'socket hang up', + message: 'aborted', }) ) - expect.soft(responseErrorListener).not.toHaveBeenCalled() }) diff --git a/test/modules/http/response/http-response-transfer-encoding.test.ts b/test/modules/http/response/http-response-transfer-encoding.test.ts index b65ea4e3c..dcb40f775 100644 --- a/test/modules/http/response/http-response-transfer-encoding.test.ts +++ b/test/modules/http/response/http-response-transfer-encoding.test.ts @@ -1,10 +1,9 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' -import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { toWebResponse } from '#/test/helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -21,17 +20,20 @@ afterAll(() => { it('responds with a mocked "transfer-encoding: chunked" response', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith( - new Response('mock', { - headers: { 'Transfer-Encoding': 'chunked' }, + new Response('hello world', { + headers: { + 'Content-Type': 'text/plain', + 'Transfer-Encoding': 'chunked', + }, }) ) }) const request = http.get('http://localhost') - const { res, text } = await waitForClientRequest(request) + const [response, rawResponse] = await toWebResponse(request) - expect(res.statusCode).toBe(200) - expect(res.headers).toHaveProperty('transfer-encoding', 'chunked') - expect(res.rawHeaders).toContain('Transfer-Encoding') - expect(await text()).toBe('mock') + expect.soft(response.status).toBe(200) + expect.soft(response.headers.get('transfer-encoding')).toBe('chunked') + expect.soft(rawResponse.rawHeaders).toContain('Transfer-Encoding') + await expect(response.text()).resolves.toBe('hello world') }) diff --git a/test/modules/http/third-party/undici.test.ts b/test/modules/http/third-party/undici.test.ts new file mode 100644 index 000000000..05f9c991e --- /dev/null +++ b/test/modules/http/third-party/undici.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment node +import { fetch, request } from 'undici' +import { HttpRequestInterceptor } from '@mswjs/interceptors/http' + +const interceptor = new HttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks an HTTP request made with "fetch"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { 'x-custom-header': 'yes' }, + }) + ) + }) + + const response = await fetch('http://any.host.here/api') + + expect.soft(response.status).toBe(200) + expect.soft(Object.fromEntries(response.headers)).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', + 'x-custom-header': 'yes', + }) + await expect.soft(response.text()).resolves.toBe('hello world') +}) + +it('mocks an HTTPS request made with "fetch"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { 'x-custom-header': 'yes' }, + }) + ) + }) + + const response = await fetch('https://any.host.here/api') + + expect.soft(response.status).toBe(200) + expect.soft(Object.fromEntries(response.headers)).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', + 'x-custom-header': 'yes', + }) + await expect.soft(response.text()).resolves.toBe('hello world') +}) + +it('mocks an HTTP request made with "request"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { 'x-custom-header': 'yes' }, + }) + ) + }) + + const response = await request('http://any.host.here/api') + + expect.soft(response.statusCode).toBe(200) + expect.soft(response.headers).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', + 'x-custom-header': 'yes', + }) + await expect.soft(response.body.text()).resolves.toBe('hello world') +}) + +it('mocks an HTTPS request made with "request"', async () => { + interceptor.on('request', ({ controller }) => { + controller.respondWith( + new Response('hello world', { + headers: { 'x-custom-header': 'yes' }, + }) + ) + }) + + const response = await request('https://any.host.here/api') + + expect.soft(response.statusCode).toBe(200) + expect.soft(response.headers).toEqual({ + 'content-type': 'text/plain;charset=UTF-8', + 'x-custom-header': 'yes', + }) + await expect.soft(response.body.text()).resolves.toBe('hello world') +}) diff --git a/test/modules/net/compliance/socket-events.test.ts b/test/modules/net/compliance/socket-events.test.ts new file mode 100644 index 000000000..4a6935f75 --- /dev/null +++ b/test/modules/net/compliance/socket-events.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('emits correct events for a passthrough connection', async () => { + const serverConnectionListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + serverConnectionListener(socket) + socket.end() + }) + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners, events } = spyOnSocket(socket) + + socket.resume() + + expect(socket.connecting).toBe(true) + + await expect.poll(() => listeners.connect).toHaveBeenCalledOnce() + expect.soft(socket.connecting).toBe(false) + expect + .soft(events) + .toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['end'], + ['close', false], + ]) + + expect(socket.connecting).toBe(false) +}) diff --git a/test/modules/net/compliance/socket-write.test.ts b/test/modules/net/compliance/socket-write.test.ts new file mode 100644 index 000000000..dcf46f95d --- /dev/null +++ b/test/modules/net/compliance/socket-write.test.ts @@ -0,0 +1,217 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' +import { setTimeout } from 'node:timers/promises' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts buffered writes for passthrough socket', async () => { + const serverDataListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', serverDataListener) + }) + }) + + const interceptorDataListener = vi.fn() + interceptor.on('connection', ({ socket, controller }) => { + controller.passthrough() + socket.on('data', interceptorDataListener) + }) + + const socket = net.connect(server.port, server.hostname) + + // Writing multiple chunks before socket connects buffers them into a single write. + socket.write('hello ') + socket.write('from ') + socket.end('client') + + await expect + .poll(() => serverDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('hello from client')) + + // Interceptor "data" events aren't buffered since the connection is pending. + expect.soft(interceptorDataListener).toHaveBeenCalledTimes(3) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) +}) + +it('intercepts separate writes for passthrough socket', async () => { + const serverDataListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', serverDataListener) + }) + }) + + const interceptorDataListener = vi.fn() + interceptor.on('connection', async ({ socket, controller }) => { + socket.on('data', interceptorDataListener) + + setTimeout(30) + controller.passthrough() + }) + + const socket = net.connect(server.port, server.hostname) + + socket.write('hello ') + await setTimeout(20) + socket.write('from ') + await setTimeout(20) + socket.end('client') + + await expect.poll(() => serverDataListener).toHaveBeenCalledTimes(3) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) + + expect.soft(interceptorDataListener).toHaveBeenCalledTimes(3) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) +}) + +it('does not duplicate writes while the socket is pending', async () => { + const serverDataListener = vi.fn() + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', serverDataListener) + }) + }) + + const interceptorDataListener = vi.fn() + interceptor.on('connection', async ({ socket, controller }) => { + socket.on('data', interceptorDataListener) + + socket.on('data', (chunk) => { + if (chunk.toString() === 'hello ') { + controller.passthrough() + } + }) + }) + + const socket = net.connect(server.port, server.hostname) + + socket.write('hello ') + await setTimeout(20) + socket.write('from ') + await setTimeout(20) + socket.end('client') + + await expect.poll(() => serverDataListener).toHaveBeenCalledTimes(3) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(serverDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) + + expect.soft(interceptorDataListener).toHaveBeenCalledTimes(3) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('hello ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('from ')) + expect + .soft(interceptorDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('client')) +}) + +it('invokes the write callbacks for a passthrough socket', async () => { + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', (data) => { + if (data.toString() === 'two') { + socket.end() + } + }) + }) + }) + + interceptor.on('connection', ({ controller }) => { + controller.passthrough() + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners } = spyOnSocket(socket) + + const writeOneCallback = vi.fn() + const writeTwoCallback = vi.fn() + const endCallback = vi.fn() + + socket.write('one', writeOneCallback) + socket.write('two', writeTwoCallback) + socket.end(endCallback) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect.soft(writeOneCallback).toHaveBeenCalledOnce() + expect.soft(writeTwoCallback).toHaveBeenCalledOnce() + expect.soft(endCallback).toHaveBeenCalledOnce() + + expect(writeOneCallback).toHaveBeenCalledBefore(writeTwoCallback) + expect(writeTwoCallback).toHaveBeenCalledBefore(endCallback) +}) + +it('invokes callbacks for nested writes for a passthrough socket', async () => { + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.on('data', (data) => { + if (data.toString() === 'two') { + socket.end() + } + }) + }) + }) + + interceptor.on('connection', ({ controller }) => { + controller.passthrough() + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners } = spyOnSocket(socket) + + const writeCallback = vi.fn(() => socket.end()) + + socket.write('one', () => { + socket.write('two', writeCallback) + }) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect.soft(writeCallback).toHaveBeenCalledOnce() +}) diff --git a/test/modules/net/socket-controller-claim.test.ts b/test/modules/net/socket-controller-claim.test.ts new file mode 100644 index 000000000..f9fe143b2 --- /dev/null +++ b/test/modules/net/socket-controller-claim.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('resolves the connection attempt when the socket is claimed', async () => { + interceptor.on('connection', ({ controller }) => { + controller.claim() + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + socket.on('connect', () => socket.destroy()) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['close', false], + ]) +}) + +it('throws an error claiming an already claimed connection', async () => { + expect.assertions(3) + + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() + + expect(() => controller.claim()).toThrow( + `Failed to claim a socket connection: already handled (1)` + ) + + socket.end() + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['end'], + ['close', false], + ]) +}) + +it('throws an error claiming an already passthrough connection', async () => { + expect.assertions(3) + + interceptor.on('connection', ({ controller }) => { + controller.passthrough() + + expect(() => controller.claim()).toThrow( + `Failed to claim a socket connection: already handled (2)` + ) + }) + + await using server = await createTestServer(() => { + return new net.Server((socket) => socket.end()) + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['end'], + ['close', false], + ]) +}) diff --git a/test/modules/net/socket-server-data.test.ts b/test/modules/net/socket-server-data.test.ts new file mode 100644 index 000000000..bf140dd4a --- /dev/null +++ b/test/modules/net/socket-server-data.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('emits "data" on the server socket for writes before connect', async () => { + const interceptorDataListener = vi.fn() + + interceptor.on('connection', ({ socket }) => { + socket.on('data', interceptorDataListener) + }) + + const socket = net.connect(80, 'any.host.com') + const { listeners, events } = spyOnSocket(socket) + + socket.write('hello', () => socket.destroy()) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([['close', false]]) + await expect + .poll(() => interceptorDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('hello')) +}) + +it('emits "data" on the server socket for writes after connect', async () => { + const interceptorDataListener = vi.fn() + + interceptor.on('connection', ({ socket }) => { + socket.on('data', interceptorDataListener) + }) + + const socket = net.connect(80, 'any.host.com') + const { listeners, events } = spyOnSocket(socket) + + socket.on('connect', () => { + socket.write('hello', () => socket.destroy()) + }) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([['connect'], ['close', false]]) + await expect + .poll(() => interceptorDataListener) + .toHaveBeenCalledExactlyOnceWith(Buffer.from('hello')) +}) + +it('emits "data" on the server for nested writes', async () => { + const interceptorDataListener = vi.fn() + + interceptor.on('connection', ({ socket, controller }) => { + socket.on('data', interceptorDataListener) + + socket.on('data', (chunk) => { + if (chunk.toString() === 'three') { + socket.destroy() + } + }) + }) + + const socket = net.connect(80, 'any.host.com') + const { listeners } = spyOnSocket(socket) + + socket.write('one', () => { + socket.write('two', () => { + socket.end('three') + }) + }) + + await expect + .poll(() => interceptorDataListener) + .toHaveBeenNthCalledWith(1, Buffer.from('one')) + await expect + .poll(() => interceptorDataListener) + .toHaveBeenNthCalledWith(2, Buffer.from('two')) + await expect + .poll(() => interceptorDataListener) + .toHaveBeenNthCalledWith(3, Buffer.from('three')) + + expect(listeners.close).toHaveBeenCalledOnce() +}) diff --git a/test/modules/net/socket-server-destroy.test.ts b/test/modules/net/socket-server-destroy.test.ts new file mode 100644 index 000000000..e3be5e7a0 --- /dev/null +++ b/test/modules/net/socket-server-destroy.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('ends the connection before it is open', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ socket }) => { + socket.destroy(reason) + }) + + const socket = net.connect(80, '127.0.0.1') + const { events, listeners } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) + expect(socket.closed).toBe(true) + expect(events).toEqual([ + ['error', reason], + ['close', true], + ]) +}) + +it('ends a mocked connection after it is open', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ socket, controller }) => { + socket.on('connect', () => socket.destroy(reason)) + controller.claim() + }) + + const socket = net.connect(80, '127.0.0.1') + const { events, listeners } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['error', reason], + ['close', true], + ]) +}) + +it('ends a passthrough connection after it is open', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ socket, controller }) => { + socket.on('connect', () => socket.destroy(reason)) + controller.passthrough() + }) + + await using server = await createTestServer(() => { + return new net.Server(() => {}) + }) + + const socket = net.connect(server.port, server.hostname) + const { events, listeners } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) + expect(socket.closed).toBe(true) + expect(events).toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['error', reason], + ['close', true], + ]) +}) + +it('ends the connection during a write', async () => { + const reason = new Error('Custom reason') + interceptor.on('connection', ({ socket, controller }) => { + socket.on('data', () => socket.destroy(reason)) + controller.claim() + }) + + const socket = net.connect(80, '127.0.0.1') + const { events, listeners } = spyOnSocket(socket) + + socket.on('connect', () => socket.write('hello')) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) + expect(socket.closed).toBe(true) + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['error', reason], + ['close', true], + ]) +}) + +it('has no effect if the client closed the connection', async () => { + const serverReason = new Error('Server reason') + interceptor.on('connection', ({ socket }) => { + socket.destroy(serverReason) + }) + + const socket = net.connect(80, '127.0.0.1') + const { events, listeners } = spyOnSocket(socket) + + const clientReason = new Error('Client reason') + socket.destroy(clientReason) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(socket.connecting).toBe(false) + expect(socket.closed).toBe(true) + expect(events).toEqual([ + ['error', clientReason], + ['close', true], + ]) +}) diff --git a/test/modules/net/socket-server-end.test.ts b/test/modules/net/socket-server-end.test.ts new file mode 100644 index 000000000..402a4d2f9 --- /dev/null +++ b/test/modules/net/socket-server-end.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('ends the connection the same way the actual server does', async () => { + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() + socket.end() + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['end'], + ['close', false], + ]) +}) diff --git a/test/modules/net/socket-server-write.test.ts b/test/modules/net/socket-server-write.test.ts new file mode 100644 index 000000000..5654805f1 --- /dev/null +++ b/test/modules/net/socket-server-write.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node +import net from 'node:net' +import { SocketInterceptor } from '#/src/interceptors/net' +import { createTestServer, spyOnSocket } from '#/test/helpers' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('writes to a claimed socket', async () => { + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() + socket.write('hello from server') + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + socket.on('data', () => socket.destroy()) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['data', Buffer.from('hello from server')], + ['close', false], + ]) +}) + +it('writes and immediately ends a claimed socket', async () => { + interceptor.on('connection', ({ socket, controller }) => { + controller.claim() + socket.write('hello from server') + socket.end() + }) + + const socket = net.connect(80, '127.0.0.1') + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', '127.0.0.1', 80, 4], + ['connect'], + ['ready'], + ['data', Buffer.from('hello from server')], + ['end'], + ['close', false], + ]) +}) + +it('writes to a passthrough socket from the interceptor', async () => { + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.write('hello from server') + socket.end() + }) + }) + + interceptor.on('connection', ({ socket, controller }) => { + controller.passthrough() + socket.write('hello from interceptor') + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['data', Buffer.from('hello from interceptor')], + ['data', Buffer.from('hello from server')], + ['end'], + ['close', false], + ]) +}) + +it('writes and immediately ends a passthrough socket', async () => { + await using server = await createTestServer(() => { + return new net.Server((socket) => { + socket.write('hello from server') + socket.end() + }) + }) + + const socket = net.connect(server.port, server.hostname) + const { listeners, events } = spyOnSocket(socket) + + await expect.poll(() => listeners.close).toHaveBeenCalledOnce() + expect(events).toEqual([ + ['connectionAttempt', server.hostname, server.port, 4], + ['connect'], + ['ready'], + ['data', Buffer.from('hello from server')], + ['end'], + ['close', false], + ]) +}) diff --git a/test/playwright.extend.ts b/test/playwright.extend.ts index 0a75cd10a..de0f77a1b 100644 --- a/test/playwright.extend.ts +++ b/test/playwright.extend.ts @@ -9,7 +9,7 @@ import { createBrowserXMLHttpRequest, createRawBrowserXMLHttpRequest, XMLHttpResponse, -} from './helpers' +} from '#/test/helpers' import { getWebpackHttpServer } from './webpackHttpServer' interface TestFixutures { diff --git a/test/setup/helpers-neutral.ts b/test/setup/helpers-neutral.ts new file mode 100644 index 000000000..652c9556c --- /dev/null +++ b/test/setup/helpers-neutral.ts @@ -0,0 +1,110 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' + +/** + * Environment-agnostic, Promise-based "setTimeout". + * The one from `node:timers/promises` is awesome but it won't run in the browser. + */ +export function setTimeout(duration: number): Promise { + return new Promise((resolve) => { + globalThis.setTimeout(resolve, duration) + }) +} + +export function waitForXMLHttpRequest( + request: XMLHttpRequest, + async = true +): Promise { + const pendingResponse = new DeferredPromise() + + if (async) { + request.addEventListener('abort', () => { + pendingResponse.reject(new Error('Request aborted')) + }) + request.addEventListener('loadend', () => { + pendingResponse.resolve() + }) + } else { + if (request.readyState === XMLHttpRequest.DONE) { + pendingResponse.resolve() + } else { + request.addEventListener('loadend', () => { + if (request.readyState === XMLHttpRequest.DONE) { + pendingResponse.resolve() + } + }) + } + } + + return pendingResponse +} + +export function spyOnXMLHttpRequest(request: XMLHttpRequest) { + const events: Array<[string, number] | [string, number, any]> = [] + + const addEvent = (name: string) => { + return (event: unknown) => { + if (event instanceof ProgressEvent) { + events.push([ + name, + request.readyState, + { loaded: event.loaded, total: event.total }, + ]) + } else { + events.push([name, request.readyState]) + } + } + } + + request.addEventListener('readystatechange', addEvent('readystatechange')) + request.addEventListener('progress', addEvent('progress')) + request.addEventListener('loadstart', addEvent('loadstart')) + request.addEventListener('load', addEvent('load')) + request.addEventListener('loadend', addEvent('loadend')) + request.addEventListener('timeout', addEvent('timeout')) + request.addEventListener('error', addEvent('error')) + request.addEventListener('abort', addEvent('abort')) + + return { + events, + } +} + +export function spyOnXMLHttpRequestUpload(upload: XMLHttpRequestUpload) { + const events: Array<[string, { loaded: number; total: number }]> = [] + + const addUploadEvent = (name: string) => { + return (event: ProgressEvent) => { + events.push([name, { loaded: event.loaded, total: event.total }]) + } + } + + upload.addEventListener('loadstart', addUploadEvent('loadstart')) + upload.addEventListener('progress', addUploadEvent('progress')) + upload.addEventListener('load', addUploadEvent('load')) + upload.addEventListener('loadend', addUploadEvent('loadend')) + upload.addEventListener('abort', addUploadEvent('abort')) + upload.addEventListener('error', addUploadEvent('error')) + upload.addEventListener('timeout', addUploadEvent('timeout')) + + return { + events, + } +} + +/** + * @note Use this utility because in Node.js (JSDOM), "request.response" + * becomes Uint8Array while in the browser it's correctly ArrayBuffer. + */ +export function toArrayBuffer( + value: ArrayBuffer | Uint8Array +): ArrayBufferLike { + if (value instanceof Uint8Array) { + return value.buffer + } + + return value +} + +export function arrayBufferFrom(input: string): ArrayBufferLike { + return new TextEncoder().encode(input).buffer +} diff --git a/test/setup/vitest.ts b/test/setup/vitest.ts new file mode 100644 index 000000000..d997f46b9 --- /dev/null +++ b/test/setup/vitest.ts @@ -0,0 +1,31 @@ +import { inject } from 'vitest' + +declare module 'vitest' { + export interface ProvidedContext { + server: { + http: string + https: string + } + } +} + +export function getTestServer() { + const server = inject('server') + + const createUrlBuilder = (protocol: 'http' | 'https') => { + return (path = '/'): URL => { + return new URL(path, server[protocol]) + } + } + + return { + http: { + href: server.http, + url: createUrlBuilder('http'), + }, + https: { + href: server.https, + url: createUrlBuilder('https'), + }, + } +} diff --git a/test/third-party/axios.test.ts b/test/third-party/axios.test.ts index 7ae7700ba..45bfac6e8 100644 --- a/test/third-party/axios.test.ts +++ b/test/third-party/axios.test.ts @@ -1,10 +1,9 @@ -// @vitest-environment jsdom -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment happy-dom 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 { HttpRequestInterceptor } from '#/src/interceptors/http' +import { useCors } from '#/test/helpers' function createMockResponse() { return new Response( @@ -37,7 +36,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await httpServer.listen() diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index 42ce1c587..f7097410e 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -1,11 +1,10 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { https } from 'follow-redirects' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' +import { toWebResponse } from '#/test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const server = new HttpServer((app) => { app.post('/resource', (req, res) => { @@ -72,7 +71,7 @@ it('intercepts a request issued by "follow-redirects"', async () => { request.end(payload) - const { text } = await waitForClientRequest(request as any) + const [response] = await toWebResponse(request as any) await vi.waitFor(() => { expect(requestListener).toHaveBeenCalledTimes(2) @@ -100,7 +99,7 @@ it('intercepts a request issued by "follow-redirects"', async () => { // Response (original). expect(catchResponseUrl).toHaveBeenCalledWith(server.https.url('/user')) - expect(await text()).toBe('hello from the server') + await expect(response.text()).resolves.toBe('hello from the server') }) it('supports mocking a redirect response to the original response', async () => { @@ -132,7 +131,7 @@ it('supports mocking a redirect response to the original response', async () => request.end() - const { text } = await waitForClientRequest(request as any) + const [response] = await toWebResponse(request as any) // Intercepted redirect request (issued by "follow-redirects"). const [redirectedRequest] = requestListener.mock.calls[0] @@ -142,7 +141,7 @@ it('supports mocking a redirect response to the original response', async () => // Response (original). expect(catchResponseUrl).toHaveBeenCalledWith(server.https.url('/redirected')) - expect(await text()).toBe('redirected response') + await expect(response.text()).resolves.toBe('redirected response') }) it('supports mocking a redirect response to a mocked response', async () => { @@ -179,7 +178,7 @@ it('supports mocking a redirect response to a mocked response', async () => { request.end() - const { text } = await waitForClientRequest(request as any) + const [response] = await toWebResponse(request as any) // Intercepted redirect request (issued by "follow-redirects"). const [redirectedRequest] = requestListener.mock.calls[0] @@ -191,5 +190,5 @@ it('supports mocking a redirect response to a mocked response', async () => { expect(catchResponseUrl).toHaveBeenCalledWith( 'https://localhost:3000/redirected' ) - expect(await text()).toBe('mocked response') + await expect(response.text()).resolves.toBe('mocked response') }) diff --git a/test/third-party/got.test.ts b/test/third-party/got.test.ts index 2c6b8604d..d72fef2c1 100644 --- a/test/third-party/got.test.ts +++ b/test/third-party/got.test.ts @@ -1,8 +1,8 @@ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { setTimeout } from 'node:timers/promises' import got from 'got' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' -import { sleep } from '../helpers' +import { HttpRequestInterceptor } from '#/src/interceptors/http' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -10,7 +10,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -46,7 +46,7 @@ it('bypasses an unhandled request made with "got"', async () => { it('supports timeout before resolving request as-is', async () => { interceptor.on('request', async ({ controller }) => { - await sleep(750) + await setTimeout(750) controller.respondWith(new Response('mocked response')) }) diff --git a/test/third-party/miniflare-xhr.test.ts b/test/third-party/miniflare-xhr.test.ts index 24636a2df..fac822a1b 100644 --- a/test/third-party/miniflare-xhr.test.ts +++ b/test/third-party/miniflare-xhr.test.ts @@ -1,6 +1,5 @@ // @vitest-environment miniflare -import { afterAll, expect, test } from 'vitest' -import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' let interceptor: XMLHttpRequestInterceptor diff --git a/test/third-party/miniflare.test.ts b/test/third-party/miniflare.test.ts index cd7102892..6b60f8a6c 100644 --- a/test/third-party/miniflare.test.ts +++ b/test/third-party/miniflare.test.ts @@ -1,18 +1,13 @@ // @vitest-environment miniflare -import { afterAll, afterEach, beforeAll, expect, test, vi } from 'vitest' -import { BatchInterceptor } from '../../src' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' -import { XMLHttpRequestInterceptor } from '../../src/interceptors/XMLHttpRequest' -import { FetchInterceptor } from '../../src/interceptors/fetch' -import { httpGet, httpsGet } from '../helpers' +import http from 'node:http' +import https from 'node:https' +import { BatchInterceptor } from '@mswjs/interceptors' +import nodeInterceptors from '@mswjs/interceptors/presets/node' +import { toWebResponse } from '#/test/helpers' const interceptor = new BatchInterceptor({ - name: 'setup-server', - interceptors: [ - new ClientRequestInterceptor(), - new XMLHttpRequestInterceptor(), - new FetchInterceptor(), - ], + name: 'interceptor', + interceptors: nodeInterceptors, }) beforeAll(() => { @@ -21,42 +16,41 @@ beforeAll(() => { afterEach(() => { interceptor.removeAllListeners() - vi.clearAllMocks() }) afterAll(() => { interceptor.dispose() }) -test('responds to fetch', async () => { +it('responds to fetch', async () => { interceptor.once('request', ({ controller }) => { controller.respondWith(new Response('mocked-body')) }) - const response = await fetch('https://example.com') + const response = await fetch('https://any.host.here/') expect(response.status).toEqual(200) - expect(await response.text()).toEqual('mocked-body') + await expect(response.text()).resolves.toEqual('mocked-body') }) -test('responds to http.get', async () => { +it('responds to http.get', async () => { interceptor.once('request', ({ controller }) => { controller.respondWith(new Response('mocked-body')) }) - const { resBody } = await httpGet('http://example.com') - expect(resBody).toEqual('mocked-body') + const [response] = await toWebResponse(http.get('http://any.host.here/')) + await expect(response.text()).resolves.toEqual('mocked-body') }) -test('responds to https.get', async () => { +it('responds to https.get', async () => { interceptor.once('request', ({ controller }) => { controller.respondWith(new Response('mocked-body')) }) - const { resBody } = await httpsGet('https://example.com') - expect(resBody).toEqual('mocked-body') + const [response] = await toWebResponse(https.get('https://any.host.here/')) + await expect(response.text()).resolves.toEqual('mocked-body') }) -test('throws when responding with a network error', async () => { +it('throws when responding with a network error', async () => { interceptor.once('request', ({ controller }) => { /** * @note "Response.error()" static method is NOT implemented in Miniflare. @@ -65,13 +59,13 @@ test('throws when responding with a network error', async () => { controller.respondWith(Response.error()) }) - const { res, resBody } = await httpGet('http://example.com') + const [response] = await toWebResponse(http.get('http://any.host.here/')) // Unhandled exceptions in the interceptor are coerced // to 500 error responses. - expect(res.statusCode).toEqual(500) - expect(res.statusMessage).toEqual('Unhandled Exception') - expect(JSON.parse(resBody)).toEqual({ + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + await expect(response.json()).resolves.toEqual({ name: 'TypeError', message: 'Response.error is not a function', stack: expect.any(String), diff --git a/test/third-party/node-fetch.test.ts b/test/third-party/node-fetch.test.ts index bde857172..6657e6967 100644 --- a/test/third-party/node-fetch.test.ts +++ b/test/third-party/node-fetch.test.ts @@ -1,8 +1,7 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' import fetch from 'node-fetch' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '#/src/interceptors/http' process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' @@ -15,7 +14,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', function testListener({ request, controller }) { if ([httpServer.http.url(), httpServer.https.url()].includes(request.url)) { @@ -89,7 +88,7 @@ it('bypasses any request when the interceptor is restored', async () => { }) it('does not throw an error if there are multiple interceptors', async () => { - const secondInterceptor = new ClientRequestInterceptor() + const secondInterceptor = new HttpRequestInterceptor() secondInterceptor.apply() const response = await fetch(httpServer.http.url('/get')) diff --git a/test/third-party/supertest.test.ts b/test/third-party/supertest.test.ts index 64594c1ee..cc8e59178 100644 --- a/test/third-party/supertest.test.ts +++ b/test/third-party/supertest.test.ts @@ -1,16 +1,14 @@ -// @vitest-environment jsdom -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment happy-dom import express from 'express' import supertest from 'supertest' -import { HttpRequestEventMap } from '../../src' -import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { HttpRequestEventMap } from '#/src/index' +import { HttpRequestInterceptor } from '#/src/interceptors/http' -const requestListener = - vi.fn<(...args: HttpRequestEventMap['request']) => void>() +const requestListener = vi.fn<(event: HttpRequestEventMap['request']) => void>() const responseListener = - vi.fn<(...args: HttpRequestEventMap['response']) => void>() + vi.fn<(event: HttpRequestEventMap['response']) => void>() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', requestListener) interceptor.on('response', responseListener) diff --git a/test/tsconfig.json b/test/tsconfig.json index 1aa2a4a40..6d175bc41 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,8 +1,8 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { - "target": "es6" + "target": "es6", + "types": ["node", "vitest/globals"], }, - "include": ["**/*.test.ts"], - "exclude": ["node_modules"] + "include": ["setup/vitest.ts", "**/*.test.ts"], } diff --git a/test/vitest.browser.config.js b/test/vitest.browser.config.js deleted file mode 100644 index daa262971..000000000 --- a/test/vitest.browser.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - root: __dirname, - include: ['**/*.browser.test.ts'], - }, -}) diff --git a/test/vitest.config.js b/test/vitest.config.js deleted file mode 100644 index 113b4fe64..000000000 --- a/test/vitest.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - root: __dirname, - include: ['**/*.test.ts'], - exclude: ['**/*.browser.test.ts'], - alias: { - 'vitest-environment-node-with-websocket': './envs/node-with-websocket', - 'vitest-environment-react-native-like': './envs/react-native-like', - }, - }, - resolve: { - alias: { - // Create a manual alias for Vitest so it could resolve this - // internal environment-dependent module in tests. - 'internal:brotli-decompress': - '../../../../src/interceptors/fetch/utils/brotli-decompress.ts', - }, - }, -}) diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 000000000..899c90c41 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "exclude": ["node_modules"], + "compilerOptions": { + "strict": true, + "target": "es2018", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "downlevelIteration": true, + "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator", "es2021", "es2024"], + "baseUrl": ".", + "paths": { + "#/src/*": ["./src/*"], + "#/test/*": ["./test/*"], + "internal:brotli-decompress": [ + "./src/interceptors/fetch/utils/brotli-decompress.ts", + ], + }, + }, +} diff --git a/tsconfig.json b/tsconfig.json index aa91334ac..5c6b4327c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,25 +1,6 @@ { - "compilerOptions": { - "strict": true, - "target": "es2018", - "module": "NodeNext", - "sourceMap": true, - "outDir": "lib", - "declaration": true, - "moduleResolution": "NodeNext", - "removeComments": false, - "esModuleInterop": true, - "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator", "es2021"], - "types": ["@types/node"], - "baseUrl": ".", - "paths": { - "_http_common": ["./_http_common.d.ts"], - "internal:brotli-decompress": [ - "./src/interceptors/fetch/utils/brotli-decompress.ts", - ], - }, - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "**/*.test.*"], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.vitest.json" }, + ], } diff --git a/tsconfig.src.json b/tsconfig.src.json new file mode 100644 index 000000000..cb7574bac --- /dev/null +++ b/tsconfig.src.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "composite": true, + "sourceMap": true, + "outDir": "lib", + "declaration": true, + "removeComments": false, + "baseUrl": ".", + "paths": { + "#/src/*": ["./src/*"], + "#/test/*": ["./test/*"], + "internal:brotli-decompress": [ + "./src/interceptors/fetch/utils/brotli-decompress.ts", + ], + }, + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "**/*.test.*"], +} diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json new file mode 100644 index 000000000..9fb8949b2 --- /dev/null +++ b/tsconfig.vitest.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "include": ["./vitest.config", "./vitest.setup"], + "compilerOptions": { + "composite": true, + "types": ["node", "./test/setup/vitest.d.ts"], + }, +} diff --git a/tsdown.config.mts b/tsdown.config.mts index ca1fe487f..d03525e1d 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -6,16 +6,19 @@ export default defineConfig([ entry: [ './src/index.ts', './src/presets/node.ts', - './src/utils/node/index.ts', './src/RemoteHttpInterceptor.ts', + './src/interceptors/http/index.ts', './src/interceptors/ClientRequest/index.ts', - './src/interceptors/XMLHttpRequest/index.ts', - './src/interceptors/fetch/index.ts', + './src/interceptors/XMLHttpRequest/node.ts', + './src/interceptors/fetch/node.ts', ], - external: ['_http_common'], + copy: { + from: './src/interceptors/http/http-parser/llhttp/**', + to: './lib/node/llhttp', + }, outDir: './lib/node', platform: 'node', - target: 'node20', + target: 'node22', outExtensions: (context) => ({ js: context.format === 'cjs' ? '.cjs' : '.mjs', dts: context.format === 'cjs' ? '.d.cts' : '.d.mts', @@ -23,20 +26,21 @@ export default defineConfig([ format: ['cjs', 'esm'], sourcemap: true, dts: true, + tsconfig: './tsconfig.src.json', }, { name: 'browser', entry: [ './src/index.ts', './src/presets/browser.ts', - './src/interceptors/XMLHttpRequest/index.ts', - './src/interceptors/fetch/index.ts', + './src/interceptors/XMLHttpRequest/web.ts', + './src/interceptors/fetch/web.ts', './src/interceptors/WebSocket/index.ts', ], outDir: './lib/browser', platform: 'browser', target: 'chrome120', - format: ['cjs', 'esm'], + format: ['esm'], outExtensions: (context) => ({ js: context.format === 'cjs' ? '.cjs' : '.mjs', dts: context.format === 'cjs' ? '.d.cts' : '.d.mts', diff --git a/utils/node/package.json b/utils/node/package.json deleted file mode 100644 index 1ce2fcab6..000000000 --- a/utils/node/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "main": "../../lib/node/utils/node.cjs", - "module": "../../lib/node/utils/node.mjs", - "browser": null, - "exports": { - ".": { - "import": "./../../lib/node/utils/node.mjs", - "default": "./../../lib/node/utils/node.cjs" - } - } -} diff --git a/vitest.config.mjs b/vitest.config.mjs deleted file mode 100644 index caadb14b1..000000000 --- a/vitest.config.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - include: ['./src/**/*.test.ts'], - }, - esbuild: { - target: 'es2022', - }, -}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..7572572ee --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,70 @@ +import { playwright } from '@vitest/browser-playwright' +import { defineConfig, defaultExclude } from 'vitest/config' + +declare module 'vitest' { + export interface ProvidedContext { + serverUrl: string + } +} + +export default defineConfig({ + test: { + globals: true, + globalSetup: './vitest.setup.ts', + hookTimeout: 5000, + projects: [ + { + extends: true, + test: { + name: 'unit', + root: './src', + include: ['**/*.test.ts'], + }, + esbuild: { + target: 'es2022', + }, + }, + { + extends: true, + test: { + name: 'node', + environment: 'node', + root: './test', + include: ['**/*.test.ts'], + exclude: [ + ...defaultExclude, + '**/*.browser.test.ts', + '**/*.v-browser.test.ts', + ], + alias: { + 'vitest-environment-node-with-websocket': + './envs/node-with-websocket', + }, + }, + }, + { + extends: true, + test: { + root: './test', + include: ['**/*.v-browser.test.ts', '**/*.neutral.test.ts'], + browser: { + enabled: true, + provider: playwright(), + instances: [{ name: 'browser', browser: 'chromium' }], + headless: true, + screenshotFailures: false, + }, + testTimeout: 4000, + }, + }, + ], + }, + resolve: { + alias: { + // Create a manual alias for Vitest so it could resolve this + // internal environment-dependent module in tests. + 'internal:brotli-decompress': + '../../../../src/interceptors/fetch/utils/brotli-decompress.ts', + }, + }, +}) diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..e260bca63 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,99 @@ +import { Readable } from 'node:stream' +import { setTimeout } from 'node:timers/promises' +import { TestProject } from 'vitest/node' +import * as express from 'express' +import { HttpServer } from '@open-draft/test-server/http' +import { useCors } from './test/helpers' + +const server = new HttpServer((app) => { + app.use(useCors) + + app.post('/status', express.text(), (req, res) => { + res.writeHead(req.body).end() + }) + + app.get('/redirect', (req, res) => { + const baseUrl = new URL( + `${req.secure ? 'https' : 'http'}://${req.get('host')}/` + ) + + res + .status(301) + .set({ location: new URL('/redirect/destination', baseUrl) }) + .end() + }) + app.get('/redirect/destination', (req, res) => { + res.status(200).send('destination-body') + }) + + app.get('/stream', (req, res) => { + const encoder = new TextEncoder() + const pad = (value: string) => value + ' '.repeat(1024 - value.length) + const chunks = [pad('hello'), pad(' '), pad('world')] + + res.status(200).set({ + 'content-type': 'text/plain', + 'content-length': chunks.join('').length, + }) + + const stream = new ReadableStream({ + async pull(controller) { + const chunk = chunks.shift() + + if (chunk) { + await setTimeout(100) + return controller.enqueue(encoder.encode(chunk)) + } + + controller.close() + }, + }) + Readable.fromWeb(stream as any).pipe(res) + }) + + app.get('/server-error', (req, res) => { + res.status(500).send('Internal Server Error') + }) + app.get('/network-error', (req, res) => { + res.destroy() + }) + + app.get('/delay', async (req, res) => { + await setTimeout(150) + res.send('original-response') + }) + + app.all('*', (req, res) => { + res.status(200).set(req.headers) + + if (req.headers['set-cookie']) { + res.cookie('cookie', 'supersecret', { + secure: true, + expires: new Date(Date.now() + 90000), + }) + } + + if (res.getHeader('content-type') == null) { + res.set('content-type', 'text/plain; charset=utf-8') + } + + if (req.method === 'GET') { + res.send('original-response') + } else { + req.pipe(res) + } + }) +}) + +export async function setup(project: TestProject) { + await server.listen() + + project.provide('server', { + http: server.http.address.href, + https: server.https.address.href, + }) +} + +export async function teardown() { + await server.close() +}