From 3aa296dabc0c1fbd1db23a0ff774895559cf168c Mon Sep 17 00:00:00 2001 From: Jon Ursenbach Date: Mon, 11 Sep 2023 15:46:10 -0700 Subject: [PATCH] feat: dropping support for node 16 (#421) --- .eslintrc | 3 +- .github/dependabot.yml | 6 -- .github/workflows/ci.yml | 6 +- README.md | 34 +------ example.js | 5 - package-lock.json | 162 ++------------------------------ package.json | 15 +-- src/index.ts | 107 ++------------------- test/.eslintrc | 3 +- test/browser-quirks.test.ts | 61 ++++++------ test/file-upload-quirks.test.ts | 45 +-------- test/index.test.ts | 155 ++++++++++++------------------ test/mocking/fetch-mock.test.ts | 7 +- test/mocking/nock.test.ts | 29 ------ test/node-quirks.test.ts | 159 ++++++++++--------------------- 15 files changed, 172 insertions(+), 625 deletions(-) delete mode 100644 test/mocking/nock.test.ts diff --git a/.eslintrc b/.eslintrc index 5a46de6..a83d249 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,7 +10,8 @@ "rules": { "@typescript-eslint/no-explicit-any": "off", "global-require": "off", - "no-case-declarations": "off" + "no-case-declarations": "off", + "unicorn/prefer-node-protocol": "error" }, "settings": { "polyfills": [ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7c5ad0e..833ab7b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -32,12 +32,6 @@ updates: prefix-development: chore(deps-dev) ignore: # node-fetch is now an ESM package and can't be used here without a rewrite. - - dependency-name: formdata-node - versions: - - ">= 5" - - dependency-name: node-fetch - versions: - - ">= 3" - dependency-name: temp-dir versions: - ">= 3" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78da69c..a92ae18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 cache: 'npm' - run: npm ci @@ -28,9 +28,7 @@ jobs: - ubuntu-latest - windows-latest node: - - 16 - 18 - - 19 - 20 steps: @@ -71,7 +69,7 @@ jobs: # - name: Install Node # uses: actions/setup-node@v3 # with: - # node-version: 16 + # node-version: 18 # cache: 'npm' # - run: npm ci diff --git a/README.md b/README.md index 87871db..dc7b4af 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ Make a [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) reque ## Features -- Supports Node 14+ (including the native `fetch` implementation in Node 18!). +- Supports Node 18+ - Natively works in all browsers that support [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) without having to use any polyfils. - [Tested](https://github.com/readmeio/fetch-har/actions) across Chrome, Safari, Firefox on Mac, Windows, and Linux. -- Requests can be mocked with [nock](https://npm.im/nock) or [fetch-mock](https://npm.im/fetch-mock). +- Requests can be mocked with [fetch-mock](https://npm.im/fetch-mock) or [msw](https://npm.im/msw). ## Installation @@ -22,13 +22,8 @@ npm install --save fetch-har ## Usage ```js -require('isomorphic-fetch'); - -// If executing from an environment that doesn't normally provide `fetch()` we'll automatically -// polyfill in the `Blob`, `File`, and `FormData` APIs with the optional `formdata-node` package -// (provided you've installed it). -const fetchHAR = require('fetch-har').default; -// import fetchHAR from 'fetch-har'); // Or if you're in an ESM codebase. +import fetchHAR from 'fetch-har'; +// const fetchHAR = require('fetch-har').default; const har = { log: { @@ -67,12 +62,6 @@ fetchHAR(har) ``` ### API -If you are executing `fetch-har` in a browser environment that supports the [FormData API](https://developer.mozilla.org/en-US/docs/Web/API/FormData) then you don't need to do anything. If you arent, however, you'll need to polyfill it. - -Unfortunately the most popular NPM package [form-data](https://npm.im/form-data) ships with a [non-spec compliant API](https://github.com/form-data/form-data/issues/124), and for this we don't recommend you use it, as if you use `fetch-har` to upload files it may not work. - -Though we recommend either [formdata-node](https://npm.im/formdata-node) or [formdata-polyfill](https://npm.im/formdata-polyfill) we prefer [formdata-node](https://npm.im/formdata-node) right now as it's CJS-compatible. - #### Options ##### userAgent A custom `User-Agent` header to apply to your request. Please note that browsers have their own handling for these headers in `fetch()` calls so it may not work everywhere; it will always be sent in Node however. @@ -93,21 +82,6 @@ await fetchHAR(har, { files: { If you don't supply this option `fetch-har` will fallback to the data URL present within the supplied HAR. If no `files` option is present, and no data URL (via `param.value`) is present in the HAR, a fatal exception will be thrown. -##### multipartEncoder -> ❗ If you are using `fetch-har` in Node you may need this option to execute `multipart/form-data` requests! - -If you are running `fetch-har` within a Node environment and you're using `node-fetch@2`, or another `fetch` polyfill that does not support a spec-compliant `FormData` API, you will need to specify an encoder that will transform your `FormData` object into something that can be used with [Request.body](https://developer.mozilla.org/en-US/docs/Web/API/Request/body). - -We recommend [form-data-encoder](https://npm.im/form-data-encoder). - -```js -const { FormDataEncoder } = require('form-data-encoder'); - -await fetchHAR(har, { multipartEncoder: FormDataEncoder }); -``` - -You do **not**, and shouldn't, need to use this option in browser environments. - ##### init This optional argument lets you supply any option that's available to supply to the [Request constructor](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request). diff --git a/example.js b/example.js index d8e9ba3..059023c 100644 --- a/example.js +++ b/example.js @@ -1,8 +1,3 @@ -require('isomorphic-fetch'); - -// If executing from an environment that doesn't normally provide `fetch()` -// we'll automatically polyfill in the `Blob`, `File`, and `FormData` APIs -// with the optional `formdata-node` package (provided you've installed it). const fetchHAR = require('.').default; const har = { diff --git a/package-lock.json b/package-lock.json index b4b390f..a943309 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@readme/data-urls": "^1.0.1", "@types/har-format": "^1.2.12", - "readable-stream": "^3.6.0" + "readable-stream": "^3.6.0", + "undici": "^5.24.0" }, "devDependencies": { "@jsdevtools/host-environment": "^2.1.2", @@ -26,26 +27,16 @@ "eslint": "^8.48.0", "express": "^4.18.1", "fetch-mock": "^9.11.0", - "form-data": "^4.0.0", - "form-data-encoder": "^1.7.1", - "formdata-node": "^4.3.2", "har-examples": "^3.1.1", - "isomorphic-fetch": "^3.0.0", "multer": "^1.4.5-lts.1", - "nock": "^13.3.3", - "node-fetch": "^2.6.0", "prettier": "^3.0.3", "temp-dir": "^2.0.0", "typescript": "^5.2.2", - "undici": "^5.22.1", "vitest": "^0.34.3", "webdriverio": "^8.15.10" }, "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "formdata-node": "^4.3.2" + "node": ">=18" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2971,12 +2962,6 @@ "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", "dev": true }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true - }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -3274,7 +3259,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, "dependencies": { "streamsearch": "^1.1.0" }, @@ -3516,18 +3500,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", @@ -3968,15 +3940,6 @@ "node": ">= 14" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6392,39 +6355,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "dev": true - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "dev": true, - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -7686,16 +7616,6 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, - "node_modules/isomorphic-fetch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", - "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", - "dev": true, - "dependencies": { - "node-fetch": "^2.6.1", - "whatwg-fetch": "^3.4.1" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", @@ -7847,12 +7767,6 @@ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", "dev": true }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -8589,44 +8503,6 @@ "node": ">= 0.4.0" } }, - "node_modules/nock": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.3.tgz", - "integrity": "sha512-z+KUlILy9SK/RjpeXDiDUEAq4T94ADPHE3qaRkf66mpEhzc/ytOMm3Bwdrbq6k1tMWkbdujiKim3G2tfQARuJw==", - "dev": true, - "dependencies": { - "debug": "^4.1.0", - "json-stringify-safe": "^5.0.1", - "lodash": "^4.17.21", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">= 10.13" - } - }, - "node_modules/nock/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/nock/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -9339,15 +9215,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -10424,7 +10291,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true, "engines": { "node": ">=10.0.0" } @@ -11102,10 +10968,9 @@ } }, "node_modules/undici": { - "version": "5.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.23.0.tgz", - "integrity": "sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg==", - "dev": true, + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.24.0.tgz", + "integrity": "sha512-OKlckxBjFl0oXxcj9FU6oB8fDAaiRUq+D8jrFWGmOfI/gIyjk/IeS75LMzgYKUaeHzLUcYvf9bbJGSrUwTfwwQ==", "dependencies": { "busboy": "^1.6.0" }, @@ -11531,15 +11396,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, "node_modules/webdriver": { "version": "8.15.10", "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-8.15.10.tgz", @@ -11669,12 +11525,6 @@ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", "dev": true }, - "node_modules/whatwg-fetch": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", - "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==", - "dev": true - }, "node_modules/whatwg-url": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", diff --git a/package.json b/package.json index 87d3aa7..52e7354 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "engines": { - "node": ">=16" + "node": ">=18" }, "scripts": { "build": "tsc", @@ -29,10 +29,8 @@ "dependencies": { "@readme/data-urls": "^1.0.1", "@types/har-format": "^1.2.12", - "readable-stream": "^3.6.0" - }, - "optionalDependencies": { - "formdata-node": "^4.3.2" + "readable-stream": "^3.6.0", + "undici": "^5.24.0" }, "devDependencies": { "@jsdevtools/host-environment": "^2.1.2", @@ -47,18 +45,11 @@ "eslint": "^8.48.0", "express": "^4.18.1", "fetch-mock": "^9.11.0", - "form-data": "^4.0.0", - "form-data-encoder": "^1.7.1", - "formdata-node": "^4.3.2", "har-examples": "^3.1.1", - "isomorphic-fetch": "^3.0.0", "multer": "^1.4.5-lts.1", - "nock": "^13.3.3", - "node-fetch": "^2.6.0", "prettier": "^3.0.3", "temp-dir": "^2.0.0", "typescript": "^5.2.2", - "undici": "^5.22.1", "vitest": "^0.34.3", "webdriverio": "^8.15.10" }, diff --git a/src/index.ts b/src/index.ts index ea186d3..d1baa6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,34 +6,21 @@ import { Readable } from 'readable-stream'; if (!globalThis.Blob) { try { - // eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-extraneous-dependencies - globalThis.Blob = require('formdata-node').Blob; + // eslint-disable-next-line @typescript-eslint/no-var-requires + globalThis.Blob = require('node:buffer').Blob; } catch (e) { - throw new Error( - 'Since you do not have the Blob API available in this environment you must install the optional `formdata-node` dependency.', - ); + throw new Error('The Blob API is required for this library. https://developer.mozilla.org/en-US/docs/Web/API/Blob'); } } if (!globalThis.File) { try { - // eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-extraneous-dependencies - globalThis.File = require('formdata-node').File; + // Node's native `fetch` implementation unfortunately does not make this API global so we need + // to pull it in if we don't have it. + // eslint-disable-next-line @typescript-eslint/no-var-requires + globalThis.File = require('undici').File; } catch (e) { - throw new Error( - 'Since you do not have the File API available in this environment you must install the optional `formdata-node` dependency.', - ); - } -} - -if (!globalThis.FormData) { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires, import/no-extraneous-dependencies - globalThis.FormData = require('formdata-node').FormData; - } catch (e) { - throw new Error( - 'Since you do not have the FormData API available in this environment you must install the optional `formdata-node` dependency.', - ); + throw new Error('The File API is required for this library. https://developer.mozilla.org/en-US/docs/Web/API/File'); } } @@ -53,7 +40,6 @@ interface RequestInitWithDuplex extends RequestInit { export interface FetchHAROptions { files?: Record; init?: RequestInitWithDuplex; - multipartEncoder?: any; // form-data-encoder userAgent?: string; } @@ -85,34 +71,6 @@ function isFile(value: any) { return false; } -/** - * @license MIT - * @see {@link https://github.com/octet-stream/form-data-encoder/blob/master/lib/util/isFunction.ts} - */ -function isFunction(value: any) { - return typeof value === 'function'; -} - -/** - * We're using this library in here instead of loading it from `form-data-encoder` because that - * uses lookbehind regex in its main encoder that Safari doesn't support so it throws a fatal page - * exception. - * - * @license MIT - * @see {@link https://github.com/octet-stream/form-data-encoder/blob/master/lib/util/isFormData.ts} - */ -function isFormData(value: any) { - return ( - value && - isFunction(value.constructor) && - value[Symbol.toStringTag] === 'FormData' && - isFunction(value.append) && - isFunction(value.getAll) && - isFunction(value.entries) && - isFunction(value[Symbol.iterator]) - ); -} - function getFileFromSuppliedFiles(filename: string, files: FetchHAROptions['files']) { if (filename in files) { return files[filename]; @@ -227,33 +185,6 @@ export default function fetchHAR(har: Har, opts: FetchHAROptions = {}) { } const form = new FormData(); - if (!isFormData(form)) { - /** - * The `form-data` NPM module returns one of two things: a native `FormData` API or its - * own polyfill. Unfortunately this polyfill does not support the full API of the native - * FormData object so when you load `form-data` within a browser environment you'll - * have two major differences in API: - * - * - The `.append()` API in `form-data` requires that the third argument is an object - * containing various, undocumented, options. In the browser, `.append()`'s third - * argument should only be present when the second is a `Blob` or `USVString`, and - * when it is present, it should be a filename string. - * - `form-data` does not expose an `.entries()` API, so the only way to retrieve data - * out of it for construction of boundary-separated payload content is to use its - * `.pipe()` API. Since the browser doesn't have this API, you'll be unable to - * retrieve data out of it. - * - * Now since the native `FormData` API is iterable, and has the `.entries()` iterator, - * we can easily detect if we have a native copy of the FormData API. It's for all of - * these reasons that we're opting to hard crash here because supporting this - * non-compliant API is more trouble than its worth. - * - * @see {@link https://github.com/form-data/form-data/issues/124} - */ - throw new Error( - "We've detected you're using a non-spec compliant FormData library. We recommend polyfilling FormData with https://npm.im/formdata-node", - ); - } request.postData.params.forEach(param => { if ('fileName' in param) { @@ -306,27 +237,7 @@ export default function fetchHAR(har: Har, opts: FetchHAROptions = {}) { form.append(param.name, param.value); }); - /** - * If a the `fetch` polyfill that's being used here doesn't have spec-compliant handling - * for the `FormData` API (like `node-fetch@2`), then you should pass in a handler (like - * the `form-data-encoder` library) to transform its contents into something that can be - * used with the `Request` object. - * - * @see {@link https://www.npmjs.com/package/formdata-node} - */ - if (opts.multipartEncoder) { - // eslint-disable-next-line new-cap - const encoder = new opts.multipartEncoder(form); - Object.keys(encoder.headers).forEach(header => { - headers.set(header, encoder.headers[header]); - }); - - // @ts-expect-error "Property 'from' does not exist on type 'typeof Readable'." but it does! - options.body = Readable.from(encoder); - shouldSetDuplex = true; - } else { - options.body = form; - } + options.body = form; break; default: diff --git a/test/.eslintrc b/test/.eslintrc index 696fde0..b96f583 100644 --- a/test/.eslintrc +++ b/test/.eslintrc @@ -1,6 +1,7 @@ { "extends": "@readme/eslint-config/testing/vitest", "rules": { - "no-unused-expressions": "off" + "no-unused-expressions": "off", + "vitest/no-conditional-expect": "off" } } diff --git a/test/browser-quirks.test.ts b/test/browser-quirks.test.ts index e417e0c..2f5f973 100644 --- a/test/browser-quirks.test.ts +++ b/test/browser-quirks.test.ts @@ -1,19 +1,14 @@ import { host } from '@jsdevtools/host-environment'; import harExamples from 'har-examples'; -import 'isomorphic-fetch'; -import { describe, beforeEach, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; + +import fetchHAR from '../src'; import owlbertShrubDataURL from './fixtures/owlbert-shrub.dataurl.json'; import owlbert from './fixtures/owlbert.dataurl.json'; // eslint-disable-next-line vitest/require-hook describe.skipIf(host.node)('#fetchHAR (Browser-only quirks)', () => { - let fetchHAR; - - beforeEach(async () => { - ({ default: fetchHAR } = await import('../src')); - }); - describe('binary handling', () => { describe('supplemental overrides', () => { it('should support a File `files` mapping override for a raw payload data URL', async () => { @@ -25,14 +20,14 @@ describe.skipIf(host.node)('#fetchHAR (Browser-only quirks)', () => { }, }).then(r => r.json()); - expect(res.args).to.be.empty; - expect(res.data).to.equal(owlbertShrubDataURL); - expect(res.files).to.be.empty; - expect(res.form).to.be.empty; - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(877); - expect(res.headers['Content-Type']).to.equal('image/png'); - expect(res.json).to.be.null; - expect(res.url).to.equal('https://httpbin.org/post'); + expect(res.args).toStrictEqual({}); + expect(res.data).toBe(owlbertShrubDataURL); + expect(res.files).toStrictEqual({}); + expect(res.form).toStrictEqual({}); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(877); + expect(res.headers['Content-Type']).toBe('image/png'); + expect(res.json).toBeNull(); + expect(res.url).toBe('https://httpbin.org/post'); }); }); }); @@ -41,23 +36,23 @@ describe.skipIf(host.node)('#fetchHAR (Browser-only quirks)', () => { it("should support a `multipart/form-data` request that's a standard object", async () => { const res = await fetchHAR(harExamples['multipart-form-data']).then(r => r.json()); - expect(res.form).to.deep.equal({ foo: 'bar' }); - expect(parseInt(res.headers['Content-Length'], 10)).to.be.at.least(133); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(res.form).toStrictEqual({ foo: 'bar' }); + expect(parseInt(res.headers['Content-Length'], 10)).toBeGreaterThanOrEqual(133); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); it('should support a `multipart/form-data` request with a plaintext file encoded in the HAR', async () => { const res = await fetchHAR(harExamples['multipart-data']).then(r => r.json()); - expect(res.files).to.deep.equal({ foo: 'Hello World' }); + expect(res.files).toStrictEqual({ foo: 'Hello World' }); - expect(parseInt(res.headers['Content-Length'], 10)).to.be.at.least(189); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(parseInt(res.headers['Content-Length'], 10)).toBeGreaterThanOrEqual(189); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); it('should throw an error if `fileName` is present without `value` or a mapping', () => { expect(() => { fetchHAR(harExamples['multipart-file']); - }).to.throw(/doesn't have access to the filesystem/); + }).toThrow(/doesn't have access to the filesystem/); }); describe('`files` option', () => { @@ -68,9 +63,9 @@ describe.skipIf(host.node)('#fetchHAR (Browser-only quirks)', () => { }, }).then(r => r.json()); - expect(res.files).to.deep.equal({ foo: owlbert }); - expect(parseInt(res.headers['Content-Length'], 10)).to.be.at.least(737); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(res.files).toStrictEqual({ foo: owlbert }); + expect(parseInt(res.headers['Content-Length'], 10)).toBeGreaterThanOrEqual(737); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); it('should throw on an unsupported type', () => { @@ -80,7 +75,7 @@ describe.skipIf(host.node)('#fetchHAR (Browser-only quirks)', () => { 'owlbert.png': new Blob([owlbert], { type: 'image/png' }), }, }); - }).to.throw('An unknown object has been supplied into the `files` config for use.'); + }).toThrow('An unknown object has been supplied into the `files` config for use.'); }); }); @@ -88,9 +83,9 @@ describe.skipIf(host.node)('#fetchHAR (Browser-only quirks)', () => { it('should be able to handle a `multipart/form-data` payload with a base64-encoded data URL file', async () => { const res = await fetchHAR(harExamples['multipart-data-dataurl']).then(r => r.json()); - expect(res.files).to.deep.equal({ foo: owlbert }); - expect(parseInt(res.headers['Content-Length'], 10)).to.be.at.least(758); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(res.files).toStrictEqual({ foo: owlbert }); + expect(parseInt(res.headers['Content-Length'], 10)).toBeGreaterThanOrEqual(758); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); it('should be able to handle a `multipart/form-data` payload with a base64-encoded data URL filename that contains parentheses', async () => { @@ -103,9 +98,9 @@ describe.skipIf(host.node)('#fetchHAR (Browser-only quirks)', () => { ); const res = await fetchHAR(har).then(r => r.json()); - expect(res.files).to.deep.equal({ foo: owlbert.replace('owlbert.png', encodeURIComponent('owlbert (1).png')) }); - expect(parseInt(res.headers['Content-Length'], 10)).to.be.at.least(768); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(res.files).toStrictEqual({ foo: owlbert.replace('owlbert.png', encodeURIComponent('owlbert (1).png')) }); + expect(parseInt(res.headers['Content-Length'], 10)).toBeGreaterThanOrEqual(768); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); }); }); diff --git a/test/file-upload-quirks.test.ts b/test/file-upload-quirks.test.ts index 0d683c8..c864cd3 100644 --- a/test/file-upload-quirks.test.ts +++ b/test/file-upload-quirks.test.ts @@ -1,58 +1,24 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -import type { VersionInfo } from '@jsdevtools/host-environment'; import type { Express } from 'express'; -import { promises as fs } from 'fs'; +import fs from 'node:fs/promises'; -import { host } from '@jsdevtools/host-environment'; import DatauriParser from 'datauri/parser'; import express from 'express'; -import { FormDataEncoder } from 'form-data-encoder'; -import 'isomorphic-fetch'; import multer from 'multer'; import tempDirectory from 'temp-dir'; import { describe, beforeEach, afterEach, it, expect } from 'vitest'; +import fetchHAR from '../src'; + import arrayOfOwlbertsHAR from './fixtures/array-of-owlberts.har.json'; import owlbertShrubDataURL from './fixtures/owlbert-shrub.dataurl.json'; import owlbertDataURL from './fixtures/owlbert.dataurl.json'; -const hasNativeFetch = (host.node as VersionInfo).version >= 18; - describe('#fetchHAR (Node-only quirks)', () => { - let fetchHAR; let app: Express; let listener; beforeEach(async () => { - /** - * Under Node 18's native `fetch` implementation if a `File` global doesn't exist it'll polyfill - * its own implementation. Normally this works fine, but its implementation is **different** - * than the one that `formdata-node` ships and when we use the `formdata-node` one under Node - * 18 `type` options that we set into `File` instances don't get picked up, resulting in - * multipart payloads being sent as `application/octet-stream` instead of whatever content type - * was attached to that file. - * - * This behavior also extends to Undici's usage of `Blob` as well where the `Blob` that ships - * with `formdata-node` behaves differently than the `Blob` that is part of the Node `buffer` - * module, which Undici wants you to use. - */ - if (hasNativeFetch) { - globalThis.File = require('undici').File; - globalThis.Blob = require('buffer').Blob; - } else { - globalThis.File = require('formdata-node').File; - globalThis.Blob = require('formdata-node').Blob; - - // We only need to polyfill handlers for `multipart/form-data` requests below Node 18 as Node - // 18 natively supports `fetch`. - if (!globalThis.FormData) { - globalThis.FormData = require('formdata-node').FormData; - } - } - - ({ default: fetchHAR } = await import('../src')); - /** * Due to a bug with `multipart/form-data` handling on multiple files in HTTPBin we need to * spin up our own server for these tests. @@ -85,7 +51,6 @@ describe('#fetchHAR (Node-only quirks)', () => { 'owlbert.png': await fs.readFile(`${__dirname}/fixtures/owlbert.png`), 'owlbert-shrub.png': await fs.readFile(`${__dirname}/fixtures/owlbert-shrub.png`), }, - multipartEncoder: FormDataEncoder, }).then(r => r.json()); const parser = new DatauriParser(); @@ -96,11 +61,11 @@ describe('#fetchHAR (Node-only quirks)', () => { * data URI. Unfortunately the `datauri` package that we're using doesn't add filename `name` * metadata into ones it generates so we need to pop those off before we do our assertions. */ - expect(parser.format('.png', await fs.readFile(res[0].path)).base64).to.equal( + expect(parser.format('.png', await fs.readFile(res[0].path)).base64).toBe( owlbertDataURL.replace('data:image/png;name=owlbert.png;base64,', ''), ); - expect(parser.format('.png', await fs.readFile(res[1].path)).base64).to.equal( + expect(parser.format('.png', await fs.readFile(res[1].path)).base64).toBe( owlbertShrubDataURL.replace('data:image/png;name=owlbert-shrub.png;base64,', ''), ); }); diff --git a/test/index.test.ts b/test/index.test.ts index bbc42a7..53978c9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,58 +1,20 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -import type { VersionInfo } from '@jsdevtools/host-environment'; import type { Har } from 'har-format'; import { host } from '@jsdevtools/host-environment'; import harExamples from 'har-examples'; -import 'isomorphic-fetch'; -import { describe, beforeEach, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; + +import fetchHAR from '../src'; import invalidHeadersHAR from './fixtures/invalid-headers.har.json'; import owlbertDataURL from './fixtures/owlbert.dataurl.json'; import urlEncodedWithAuthHAR from './fixtures/urlencoded-with-auth.har.json'; -const hasNativeFetch = (host.node as VersionInfo).version >= 18; - describe('fetch-har', () => { - let fetchHAR; - - beforeEach(async () => { - /** - * Under Node 18's native `fetch` implementation if a `File` global doesn't exist it'll polyfill - * its own implementation. Normally this works fine, but its implementation is **different** - * than the one that `formdata-node` ships and when we use the `formdata-node` one under Node - * 18 `type` options that we set into `File` instances don't get picked up, resulting in - * multipart payloads being sent as `application/octet-stream` instead of whatever content type - * was attached to that file. - * - * This behavior also extends to Undici's usage of `Blob` as well where the `Blob` that ships - * with `formdata-node` behaves differently than the `Blob` that is part of the Node `buffer` - * module, which Undici wants you to use. - * - * `NODE_ENV` is set to `production` when Karma runs this code and we don't need to polyfill - * APIs that are already available in the browser. - */ - if (process.env.NODE_ENV !== 'production') { - if (!globalThis.File) { - globalThis.FormData = require('formdata-node').FormData; - } - - if (hasNativeFetch) { - globalThis.File = require('undici').File; - globalThis.Blob = require('buffer').Blob; - } else { - globalThis.File = require('formdata-node').File; - globalThis.Blob = require('formdata-node').Blob; - } - } - - ({ default: fetchHAR } = await import('../src')); - }); - it('should throw if it looks like you are missing a valid HAR definition', () => { - expect(fetchHAR).to.throw('Missing HAR definition'); - expect(fetchHAR.bind(null, { log: {} })).to.throw('Missing log.entries array'); - expect(fetchHAR.bind(null, { log: { entries: [] } })).to.throw('Missing log.entries array'); + expect(fetchHAR).toThrow('Missing HAR definition'); + expect(fetchHAR.bind(null, { log: {} })).toThrow('Missing log.entries array'); + expect(fetchHAR.bind(null, { log: { entries: [] } })).toThrow('Missing log.entries array'); }); // eslint-disable-next-line vitest/require-hook @@ -60,13 +22,14 @@ describe('fetch-har', () => { !host.node, // Custom user agents are not supported in browser environments. )('should make a request with a custom user agent if specified', async () => { const res = await fetchHAR(harExamples.short, { userAgent: 'test-app/1.0' }).then(r => r.json()); - expect(res.headers['User-Agent']).to.equal('test-app/1.0'); + // eslint-disable-next-line vitest/no-standalone-expect + expect(res.headers['User-Agent']).toBe('test-app/1.0'); }); it('should catch and toss invalid headers present in a HAR', async () => { const res = await fetchHAR(invalidHeadersHAR as Har).then(r => r.json()); - expect(res.headers['X-Api-Key']).to.equal('asdf1234'); - expect(res.headers['X-Api-Key (invalid)']).to.be.undefined; + expect(res.headers['X-Api-Key']).toBe('asdf1234'); + expect(res.headers['X-Api-Key (invalid)']).toBeUndefined(); }); describe('custom options', () => { @@ -79,7 +42,7 @@ describe('fetch-har', () => { }, }).then(r => r.json()); - expect(res.headers['X-Custom-Header']).to.equal('buster'); + expect(res.headers['X-Custom-Header']).toBe('buster'); }); it('should support supplying custom headers as an object', async () => { @@ -91,7 +54,7 @@ describe('fetch-har', () => { }, }).then(r => r.json()); - expect(res.headers['X-Custom-Header']).to.equal('buster'); + expect(res.headers['X-Custom-Header']).toBe('buster'); }); }); @@ -99,27 +62,27 @@ describe('fetch-har', () => { it('should support `text/plain` requests', async () => { const res = await fetchHAR(harExamples['text-plain']).then(r => r.json()); - expect(res.args).to.be.empty; - expect(res.data).to.equal('Hello World'); - expect(res.files).to.be.empty; - expect(res.form).to.be.empty; - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(11); - expect(res.headers['Content-Type']).to.equal('text/plain'); - expect(res.json).to.be.null; - expect(res.url).to.equal('https://httpbin.org/post'); + expect(res.args).toStrictEqual({}); + expect(res.data).toBe('Hello World'); + expect(res.files).toStrictEqual({}); + expect(res.form).toStrictEqual({}); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(11); + expect(res.headers['Content-Type']).toBe('text/plain'); + expect(res.json).toBeNull(); + expect(res.url).toBe('https://httpbin.org/post'); }); it('should support requests with array query parameters', async () => { const res = await fetchHAR(harExamples.query).then(r => r.json()); - expect(res.args).to.deep.equal({ baz: 'abc', foo: ['bar', 'baz'], key: 'value' }); - expect(res.url).to.equal('https://httpbin.org/get?key=value&foo=bar&foo=baz&baz=abc'); + expect(res.args).toStrictEqual({ baz: 'abc', foo: ['bar', 'baz'], key: 'value' }); + expect(res.url).toBe('https://httpbin.org/get?key=value&foo=bar&foo=baz&baz=abc'); }); it('should not double encode query parameters', async () => { const res = await fetchHAR(harExamples['query-encoded']).then(r => r.json()); - expect(res.args).to.deep.equal({ + expect(res.args).toStrictEqual({ array: ['something¬hing=true', 'nothing&something=false', 'another item'], stringArray: 'where[4]=10', stringHash: 'hash#data', @@ -127,7 +90,7 @@ describe('fetch-har', () => { stringWeird: 'properties["$email"] == "testing"', }); - expect(res.url).to.equal( + expect(res.url).toBe( 'https://httpbin.org/anything?stringPound=something%26nothing%3Dtrue&stringHash=hash%23data&stringArray=where[4]%3D10&stringWeird=properties["%24email"] %3D%3D "testing"&array=something%26nothing%3Dtrue&array=nothing%26something%3Dfalse&array=another item', ); }); @@ -144,9 +107,9 @@ describe('fetch-har', () => { * * @todo we should try mocking this request instead to make sure that cookies are sent */ - expect(res.cookies).to.be.empty; + expect(res.cookies).toStrictEqual({}); } else { - expect(res.cookies).to.deep.equal({ + expect(res.cookies).toStrictEqual({ bar: 'baz', foo: 'bar', }); @@ -156,34 +119,34 @@ describe('fetch-har', () => { it('should support `application/x-www-form-urlencoded` requests with auth', async () => { const res = await fetchHAR(urlEncodedWithAuthHAR as unknown as Har).then(r => r.json()); - expect(res.args).to.deep.equal({ a: '1', b: '2' }); - expect(res.data).to.equal(''); - expect(res.files).to.be.empty; - expect(res.form).to.deep.equal({ category: '{"id":6,"name":"name"}', id: '8', name: 'name' }); - expect(res.headers.Authorization).to.equal('Bearer api-key'); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(68); - expect(res.headers['Content-Type']).to.equal('application/x-www-form-urlencoded'); - expect(res.json).to.be.null; - expect(res.url).to.equal('https://httpbin.org/post?a=1&b=2'); + expect(res.args).toStrictEqual({ a: '1', b: '2' }); + expect(res.data).toBe(''); + expect(res.files).toStrictEqual({}); + expect(res.form).toStrictEqual({ category: '{"id":6,"name":"name"}', id: '8', name: 'name' }); + expect(res.headers.Authorization).toBe('Bearer api-key'); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(68); + expect(res.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); + expect(res.json).toBeNull(); + expect(res.url).toBe('https://httpbin.org/post?a=1&b=2'); }); it('should support requests that cover the entire HAR spec', async () => { const res = await fetchHAR(harExamples.full).then(r => r.json()); - expect(res.args).to.deep.equal({ baz: 'abc', foo: ['bar', 'baz'], key: 'value' }); - expect(res.data).to.equal(''); - expect(res.files).to.be.empty; - expect(res.form).to.deep.equal({ foo: 'bar' }); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(7); - expect(res.headers['Content-Type']).to.equal('application/x-www-form-urlencoded'); + expect(res.args).toStrictEqual({ baz: 'abc', foo: ['bar', 'baz'], key: 'value' }); + expect(res.data).toBe(''); + expect(res.files).toStrictEqual({}); + expect(res.form).toStrictEqual({ foo: 'bar' }); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(7); + expect(res.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); // We can't set cookies in the browser within this test environment. if (host.node) { - expect(res.headers.Cookie).to.equal('foo=bar; bar=baz'); + expect(res.headers.Cookie).toBe('foo=bar; bar=baz'); } - expect(res.json).to.be.null; - expect(res.url).to.equal('https://httpbin.org/post?key=value&foo=bar&foo=baz&baz=abc'); + expect(res.json).toBeNull(); + expect(res.url).toBe('https://httpbin.org/post?key=value&foo=bar&foo=baz&baz=abc'); }); describe('binary handling', () => { @@ -191,14 +154,14 @@ describe('fetch-har', () => { const har = harExamples['image-png']; const res = await fetchHAR(har).then(r => r.json()); - expect(res.args).to.be.empty; - expect(res.data).to.equal(har.log.entries[0].request.postData.text); - expect(res.files).to.be.empty; - expect(res.form).to.be.empty; - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(575); - expect(res.headers['Content-Type']).to.equal('image/png'); - expect(res.json).to.be.null; - expect(res.url).to.equal('https://httpbin.org/post'); + expect(res.args).toStrictEqual({}); + expect(res.data).toBe(har.log.entries[0].request.postData.text); + expect(res.files).toStrictEqual({}); + expect(res.form).toStrictEqual({}); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(575); + expect(res.headers['Content-Type']).toBe('image/png'); + expect(res.json).toBeNull(); + expect(res.url).toBe('https://httpbin.org/post'); }); }); @@ -206,7 +169,7 @@ describe('fetch-har', () => { it('should throw an error if `fileName` is present without `value` or a mapping', () => { expect(() => { fetchHAR(harExamples['multipart-file']); - }).to.throw(/doesn't have access to the filesystem/); + }).toThrow(/doesn't have access to the filesystem/); }); describe('`files` option', () => { @@ -217,7 +180,7 @@ describe('fetch-har', () => { 'owlbert.png': new Blob([owlbertDataURL], { type: 'image/png' }), }, }); - }).to.throw('An unknown object has been supplied into the `files` config for use.'); + }).toThrow('An unknown object has been supplied into the `files` config for use.'); }); }); }); @@ -250,11 +213,11 @@ describe('fetch-har', () => { }, ], }, - }; + } as Har; expect(() => { fetchHAR(har); - }).not.to.throw("Cannot read property 'length' of undefined"); + }).not.toThrow("Cannot read property 'length' of undefined"); }); it('should support urls with query parameters if the url has an anchor hash in it', async () => { @@ -289,12 +252,12 @@ describe('fetch-har', () => { }, ], }, - }; + } as Har; const res = await fetchHAR(har).then(r => r.json()); - expect(res.args).to.deep.equal({ dog: 'true', dog_id: 'buster18' }); - expect(res.url).to.equal('https://httpbin.org/anything?dog=true&dog_id=buster18'); + expect(res.args).toStrictEqual({ dog: 'true', dog_id: 'buster18' }); + expect(res.url).toBe('https://httpbin.org/anything?dog=true&dog_id=buster18'); }); }); }); diff --git a/test/mocking/fetch-mock.test.ts b/test/mocking/fetch-mock.test.ts index 48ce0de..f92ea0b 100644 --- a/test/mocking/fetch-mock.test.ts +++ b/test/mocking/fetch-mock.test.ts @@ -1,12 +1,11 @@ import fetchMock from 'fetch-mock'; import harExamples from 'har-examples'; -import 'isomorphic-fetch'; import { describe, it, expect } from 'vitest'; +import fetchHAR from '../../src'; + describe('#fetchHAR mocking (fetch-mock)', () => { it('should support mocking a request with `fetch-mock`', async () => { - const { default: fetchHAR } = await import('../../src'); - fetchMock.mock( { url: 'https://httpbin.org/get', @@ -16,7 +15,7 @@ describe('#fetchHAR mocking (fetch-mock)', () => { ); const res = await fetchHAR(harExamples.short); - expect(res.status).to.equal(429); + expect(res.status).toBe(429); fetchMock.restore(); }); diff --git a/test/mocking/nock.test.ts b/test/mocking/nock.test.ts deleted file mode 100644 index 84180ce..0000000 --- a/test/mocking/nock.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { VersionInfo } from '@jsdevtools/host-environment'; - -import { host } from '@jsdevtools/host-environment'; -import harExamples from 'har-examples'; -import 'isomorphic-fetch'; -import nock from 'nock'; -import { describe, it, expect } from 'vitest'; - -const hasNativeFetch = (host.node as VersionInfo).version >= 18; - -describe('#fetchHAR mocking (nock)', function () { - // eslint-disable-next-line vitest/require-hook - it.skipIf( - hasNativeFetch, // Nock does not support Node 18's native `fetch` implementation. - )('should support mocking a request with `nock`', async function () { - const { default: fetchHAR } = await import('../../src'); - - nock('https://httpbin.org') - .post('/post') - .reply(200, uri => ({ uri })); - - const res = await fetchHAR(harExamples['text-plain']).then(r => r.json()); - expect(res).to.deep.equal({ - uri: '/post', - }); - - nock.restore(); - }); -}); diff --git a/test/node-quirks.test.ts b/test/node-quirks.test.ts index cc8dc78..bc24cc7 100644 --- a/test/node-quirks.test.ts +++ b/test/node-quirks.test.ts @@ -1,68 +1,15 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -import type { VersionInfo } from '@jsdevtools/host-environment'; +import fs from 'node:fs/promises'; -import { promises as fs } from 'fs'; - -import { host } from '@jsdevtools/host-environment'; -import { FormDataEncoder } from 'form-data-encoder'; import harExamples from 'har-examples'; -import 'isomorphic-fetch'; -import { describe, beforeEach, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; + +import fetchHAR from '../src'; import owlbertScreenshotDataURL from './fixtures/owlbert-screenshot.dataurl.json'; import owlbertShrubDataURL from './fixtures/owlbert-shrub.dataurl.json'; import owlbertDataURL from './fixtures/owlbert.dataurl.json'; -const hasNativeFetch = (host.node as VersionInfo).version >= 18; - describe('#fetchHAR (Node-only quirks)', () => { - let fetchHAR; - - beforeEach(async () => { - /** - * Under Node 18's native `fetch` implementation if a `File` global doesn't exist it'll polyfill - * its own implementation. Normally this works fine, but its implementation is **different** - * than the one that `formdata-node` ships and when we use the `formdata-node` one under Node - * 18 `type` options that we set into `File` instances don't get picked up, resulting in - * multipart payloads being sent as `application/octet-stream` instead of whatever content type - * was attached to that file. - * - * This behavior also extends to Undici's usage of `Blob` as well where the `Blob` that ships - * with `formdata-node` behaves differently than the `Blob` that is part of the Node `buffer` - * module, which Undici wants you to use. - */ - if (hasNativeFetch) { - globalThis.File = require('undici').File; - globalThis.Blob = require('buffer').Blob; - } else { - globalThis.File = require('formdata-node').File; - globalThis.Blob = require('formdata-node').Blob; - } - - if (!hasNativeFetch) { - // We only need to polyfill handlers for `multipart/form-data` requests below Node 18 as Node - // 18 natively supports `fetch`. - if (!globalThis.FormData) { - globalThis.FormData = require('formdata-node').FormData; - } - } - - ({ default: fetchHAR } = await import('../src')); - }); - - it('should throw if you are using a non-compliant FormData polyfill', () => { - const ogFormData = globalThis.FormData; - globalThis.FormData = require('form-data'); - - expect(() => { - fetchHAR(harExamples['multipart-form-data']); - }).to.throw("We've detected you're using a non-spec compliant FormData library."); - - // Reset this to whatever it was originally so we don't corrupt any Node 18+ tests that use a - // native `FormData` API. - globalThis.FormData = ogFormData; - }); - describe('binary handling', () => { it('should support an `image/png` request that has a data URL with no file name', async () => { const har = JSON.parse(JSON.stringify(harExamples['image-png-no-filename'])); @@ -72,7 +19,7 @@ describe('#fetchHAR (Node-only quirks)', () => { const owlbert = await fs.readFile(`${__dirname}/fixtures/owlbert-shrub.png`); const res = await fetchHAR(har, { files: { 'owlbert.png': owlbert } }).then(r => r.json()); - expect(res.data).to.equal(har.log.entries[0].request.postData.text); + expect(res.data).toBe(har.log.entries[0].request.postData.text); }); describe('supplemental overrides', () => { @@ -81,8 +28,8 @@ describe('#fetchHAR (Node-only quirks)', () => { const owlbert = await fs.readFile(`${__dirname}/fixtures/owlbert.png`); const res = await fetchHAR(har, { files: { 'owlbert.png': owlbert } }).then(r => r.json()); - expect(res.args).to.be.empty; - expect(res.data).to.equal( + expect(res.args).toStrictEqual({}); + expect(res.data).toBe( // Since we uploaded a raw file buffer it isn't going to have `image/png` in the data URL // coming back from HTTPBin; that information will just exist within the `Content-Type` // header. @@ -92,12 +39,12 @@ describe('#fetchHAR (Node-only quirks)', () => { ), ); - expect(res.files).to.be.empty; - expect(res.form).to.be.empty; - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(400); - expect(res.headers['Content-Type']).to.equal('image/png'); - expect(res.json).to.be.null; - expect(res.url).to.equal('https://httpbin.org/post'); + expect(res.files).toStrictEqual({}); + expect(res.form).toStrictEqual({}); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(400); + expect(res.headers['Content-Type']).toBe('image/png'); + expect(res.json).toBeNull(); + expect(res.url).toBe('https://httpbin.org/post'); }); it('should support a File `files` mapping override for a raw payload data URL', async () => { @@ -108,47 +55,44 @@ describe('#fetchHAR (Node-only quirks)', () => { files: { 'owlbert.png': owlbert }, }).then(r => r.json()); - expect(res.args).to.be.empty; - expect(res.data).to.equal(owlbertShrubDataURL); - expect(res.files).to.be.empty; - expect(res.form).to.be.empty; - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(877); - expect(res.headers['Content-Type']).to.equal('image/png'); - expect(res.json).to.be.null; - expect(res.url).to.equal('https://httpbin.org/post'); + expect(res.args).toStrictEqual({}); + expect(res.data).toBe(owlbertShrubDataURL); + expect(res.files).toStrictEqual({}); + expect(res.form).toStrictEqual({}); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(877); + expect(res.headers['Content-Type']).toBe('image/png'); + expect(res.json).toBeNull(); + expect(res.url).toBe('https://httpbin.org/post'); }); it("should ignore a `files` mapping override if it's neither a Buffer or a File", async () => { const res = await fetchHAR(harExamples['image-png'], { files: { + // @ts-expect-error Intentional mistyping. 'owlbert.png': 'owlbert.png', }, }).then(r => r.json()); - expect(res.data).to.equal(harExamples['image-png'].log.entries[0].request.postData.text); + expect(res.data).toBe(harExamples['image-png'].log.entries[0].request.postData.text); }); }); }); - describe('`multipartEncoder` option', () => { + describe('multipart requests option', () => { it("should support a `multipart/form-data` request that's a standard object", async () => { - const res = await fetchHAR(harExamples['multipart-form-data'], { - multipartEncoder: FormDataEncoder, - }).then(r => r.json()); + const res = await fetchHAR(harExamples['multipart-form-data']).then(r => r.json()); - expect(res.form).to.deep.equal({ foo: 'bar' }); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(133); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(res.form).toStrictEqual({ foo: 'bar' }); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(123); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); it('should support a `multipart/form-data` request with a plaintext file encoded in the HAR', async () => { - const res = await fetchHAR(harExamples['multipart-data'], { - multipartEncoder: FormDataEncoder, - }).then(r => r.json()); + const res = await fetchHAR(harExamples['multipart-data']).then(r => r.json()); - expect(res.files).to.deep.equal({ foo: 'Hello World' }); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(189); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(res.files).toStrictEqual({ foo: 'Hello World' }); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(179); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); describe('`files` option', () => { @@ -159,17 +103,16 @@ describe('#fetchHAR (Node-only quirks)', () => { files: { 'owlbert.png': owlbert, }, - multipartEncoder: FormDataEncoder, }).then(r => r.json()); - expect(res.files).to.deep.equal({ + expect(res.files).toStrictEqual({ // This won't have `name=owlbert.png` in the data URL that comes back to us because we // sent a raw file buffer. foo: owlbertDataURL.replace('name=owlbert.png;', ''), }); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(579); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=form-data-boundary-(.*)$/); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(569); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); it('should support File objects', async () => { @@ -177,12 +120,11 @@ describe('#fetchHAR (Node-only quirks)', () => { files: { 'owlbert.png': new File([owlbertDataURL], 'owlbert.png', { type: 'image/png' }), }, - multipartEncoder: FormDataEncoder, }).then(r => r.json()); - expect(res.files).to.deep.equal({ foo: owlbertDataURL }); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(754); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(res.files).toStrictEqual({ foo: owlbertDataURL }); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(744); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); it('should support filenames with characters that are encoded with `encodeURIComponent`', async () => { @@ -202,24 +144,21 @@ describe('#fetchHAR (Node-only quirks)', () => { { type: 'image/png' }, ), }, - multipartEncoder: FormDataEncoder, }).then(r => r.json()); - expect(res.files).to.deep.equal({ foo: owlbertScreenshotDataURL }); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(36368); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=(.*)$/); + expect(res.files).toStrictEqual({ foo: owlbertScreenshotDataURL }); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(36358); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); }); describe('data URLs', () => { it('should be able to handle a `multipart/form-data` payload with a base64-encoded data URL file', async () => { - const res = await fetchHAR(harExamples['multipart-data-dataurl'], { - multipartEncoder: FormDataEncoder, - }).then(r => r.json()); + const res = await fetchHAR(harExamples['multipart-data-dataurl']).then(r => r.json()); - expect(res.files).to.deep.equal({ foo: owlbertDataURL }); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(754); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=form-data-boundary-(.*)$/); + expect(res.files).toStrictEqual({ foo: owlbertDataURL }); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(744); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); it('should be able to handle a `multipart/form-data` payload with a base64-encoded data URL filename that contains parentheses', async () => { @@ -231,13 +170,13 @@ describe('#fetchHAR (Node-only quirks)', () => { `name=${encodeURIComponent('owlbert (1).png')};`, ); - const res = await fetchHAR(har, { multipartEncoder: FormDataEncoder }).then(r => r.json()); - expect(res.files).to.deep.equal({ + const res = await fetchHAR(har).then(r => r.json()); + expect(res.files).toStrictEqual({ foo: owlbertDataURL.replace('owlbert.png', encodeURIComponent('owlbert (1).png')), }); - expect(parseInt(res.headers['Content-Length'], 10)).to.equal(764); - expect(res.headers['Content-Type']).to.match(/^multipart\/form-data; boundary=form-data-boundary-(.*)$/); + expect(parseInt(res.headers['Content-Length'], 10)).toBe(754); + expect(res.headers['Content-Type']).toMatch(/^multipart\/form-data; boundary=(.*)$/); }); }); });