From 29cd286434d71ff49ed278c8c20b7dd11a0ce37f Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 03:46:44 +0000 Subject: [PATCH 01/14] Switch testing from mocha/chai/karma/c8 to vitest. Karma is unmaintained. Vitest replaces the runner, the browser test harness, and the coverage tool with a single dependency and config. - Add `vitest.config.js` with `node` and `browser` projects. The browser project runs Chromium through `@vitest/browser-playwright`, replacing karma and karma-webpack. - Keep the existing `should`-style assertions unchanged. `tests/setup.js` installs the global `should` from vitest's re-exported chai, so `chai` is no longer a dependency. - Start the HTTP/HTTPS test servers in `tests/globalSetup.js` and pass their ephemeral hosts to tests with `inject()`. This replaces starting them in the karma config and injecting the hosts via webpack's `DefinePlugin`, and lets `tests/utils-browser.js` be removed. - Split the suite by environment into `10-client-api.spec.js` (shared), `20-node.spec.js`, and `30-browser.spec.js` instead of branching on `isNode` at runtime. This keeps node-only modules out of the browser project and drops the `detect-node` dependency. Test bodies are unchanged, except that the node-only `ECONNREFUSED` cause check of the connection refused test becomes its own test in `20-node.spec.js`. - Report coverage with `@vitest/coverage-v8` across both projects, so browser-only code paths are now covered. Reported totals shift slightly because vitest and c8 count executable lines differently. - Replace the `test-karma` CI job with `test-browser`, and install and cache Playwright's Chromium in the browser and coverage jobs. Add an `exports` field with a `browser` condition for `agentCompatibility` and import it via a self-reference. Vite does not apply the top-level `browser` field to package-internal relative imports, so this is also a fix for browser bundlers, which would otherwise pull `undici` into their builds. --- .github/workflows/main.yaml | 21 +++- CHANGELOG.md | 18 +++ karma.conf.cjs | 109 ----------------- lib/httpClient.js | 2 +- package.json | 49 +++----- tests/10-client-api.spec.js | 238 +----------------------------------- tests/20-node.spec.js | 195 +++++++++++++++++++++++++++++ tests/30-browser.spec.js | 66 ++++++++++ tests/globalSetup.js | 19 +++ tests/setup.js | 8 ++ tests/test-mocha.js | 2 - tests/utils-browser.js | 27 ---- vitest.config.js | 52 ++++++++ 13 files changed, 402 insertions(+), 404 deletions(-) delete mode 100644 karma.conf.cjs create mode 100644 tests/20-node.spec.js create mode 100644 tests/30-browser.spec.js create mode 100644 tests/globalSetup.js create mode 100644 tests/setup.js delete mode 100644 tests/test-mocha.js delete mode 100644 tests/utils-browser.js create mode 100644 vitest.config.js diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 96d5d7d..2494ce7 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -43,7 +43,7 @@ jobs: node-version: ${{ matrix.node-version }} - name: Run tests with Node.js ${{ matrix.node-version }} run: npm run test-node - test-karma: + test-browser: runs-on: ubuntu-latest timeout-minutes: 10 strategy: @@ -58,8 +58,15 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install - - name: Run karma tests - run: npm run test-karma + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + - name: Install Playwright chromium + run: npx playwright install --with-deps chromium + - name: Run browser tests + run: npm run test-browser coverage: runs-on: ubuntu-latest timeout-minutes: 10 @@ -75,6 +82,14 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install + # coverage runs the browser project too, so a browser is required + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + - name: Install Playwright chromium + run: npx playwright install --with-deps chromium - name: Generate coverage report run: npm run coverage-ci - name: Upload coverage to Codecov diff --git a/CHANGELOG.md b/CHANGELOG.md index f11f2fa..2b125e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ - Keep `create()` headers given as a `Headers` instance. They were dropped. - Support `ky`'s `replaceOption()` for `headers` in `create()` and `extend()`. In `create()` it replaces the default headers. +- Resolve `agentCompatibility` through an `exports` `browser` condition rather + than only the top-level `browser` field. Bundlers that do not apply the + `browser` field to package-internal relative imports (such as Vite) no longer + pull `undici` into browser builds. ### Changed - **BREAKING**: Revert CJS related workarounds from v3.0.0. @@ -44,6 +48,20 @@ - Test on Node.js >=22. - Update `engines.node` to `>=22`. - Update README requirements section. +- **BREAKING**: Add an `exports` field. + - Only `.`, `./agentCompatibility.js`, and `./package.json` are importable; + other deep imports into the package are no longer reachable. +- Switch testing from `mocha`/`chai`/`karma`/`c8` to `vitest`. + - `karma` is unmaintained; `vitest` covers Node.js tests, browser tests, and + coverage with a single tool and config. + - Browser tests now run in Chromium via `playwright` instead of `karma`. + - `npm test` now runs both the Node.js and browser suites; use + `npm run test-node` or `npm run test-browser` for one of them. + - `npm run test-karma` is replaced by `npm run test-browser`. + - `npm run coverage-report` is removed; use + `npm run coverage -- --coverage.reporter=html`. + - Coverage now includes the browser suite, and reported totals shift + slightly because `vitest` and `c8` count executable lines differently. ### Removed - **BREAKING**: Remove CJS support. diff --git a/karma.conf.cjs b/karma.conf.cjs deleted file mode 100644 index 1bc4f80..0000000 --- a/karma.conf.cjs +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright (c) 2020-2026 Digital Bazaar, Inc. - */ - -const {startServers} = require('./tests/utils.js'); -const webpack = require('webpack'); - -module.exports = async function(config) { - const { - //httpServer, - //httpsServer, - httpHost: testHttpHost, - httpsHost: testHttpsHost - } = await startServers(); - - config.set({ - // base path that will be used to resolve all patterns (eg. files, exclude) - basePath: '', - // frameworks to use - // available frameworks: https://npmjs.org/browse/keyword/karma-adapter - frameworks: ['mocha', 'chai'], - - // list of files / patterns to load in the browser - files: [ - 'tests/*.spec.js' - ], - - // list of files to exclude - exclude: [], - - // preprocess matching files before serving them to the browser - // preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor - preprocessors: { - 'tests/*.js': ['webpack', 'sourcemap'] - }, - - webpack: { - //mode: 'production', - mode: 'development', - devtool: 'inline-source-map', - plugins: [ - new webpack.DefinePlugin({ - 'process.env.TEST_HTTP_HOST': JSON.stringify(testHttpHost), - 'process.env.TEST_HTTPS_HOST': JSON.stringify(testHttpsHost) - }) - ] - }, - - // test results reporter to use - // possible values: 'dots', 'progress' - // available reporters: https://npmjs.org/browse/keyword/karma-reporter - //reporters: ['progress'], - reporters: ['mocha'], - - // web server port - port: 9876, - - // enable / disable colors in the output (reporters and logs) - colors: true, - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || - // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_INFO, - - // enable / disable watching file and executing tests whenever any - // file changes - autoWatch: false, - - // start these browsers - // browser launchers: https://npmjs.org/browse/keyword/karma-launcher - //browsers: ['ChromeHeadless', 'Chrome', 'Firefox', 'Safari'], - browsers: ['ChromeHeadlessNoSandbox'], - customLaunchers: { - ChromeHeadlessNoSandbox: { - base: 'ChromeHeadless', - flags: [ - // Essential: Bypasses container namespace errors - '--no-sandbox', - // Prevents extra privilege-dropping failures - '--disable-setuid-sandbox', - // Speeds up headless execution in CI environments - '--disable-gpu', - '--disable-software-rasterizer', - // Accept the self-signed cert used by the local HTTPS test server - '--ignore-certificate-errors' - ] - } - }, - - // Continuous Integration mode - // if true, Karma captures browsers, runs the tests and exits - singleRun: true, - - // Concurrency level - // how many browser should be started simultaneous - concurrency: Infinity, - - // Mocha - client: { - mocha: { - // increase from default 2s - timeout: 10000, - reporter: 'html' - //delay: true - } - } - }); -}; diff --git a/lib/httpClient.js b/lib/httpClient.js index 42b8d0d..df05af8 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -2,7 +2,7 @@ * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import ky, {replaceOption} from 'ky'; -import {convertAgent} from './agentCompatibility.js'; +import {convertAgent} from '@digitalbazaar/http-client/agentCompatibility.js'; export {ky}; diff --git a/package.json b/package.json index b047a3a..42d11d3 100644 --- a/package.json +++ b/package.json @@ -5,21 +5,28 @@ "license": "BSD-3-Clause", "type": "module", "main": "./lib/index.js", + "exports": { + ".": "./lib/index.js", + "./agentCompatibility.js": { + "react-native": "./lib/agentCompatibility-browser.js", + "browser": "./lib/agentCompatibility-browser.js", + "default": "./lib/agentCompatibility.js" + }, + "./package.json": "./package.json" + }, "browser": { - "./lib/agentCompatibility.js": "./lib/agentCompatibility-browser.js", - "./tests/utils.js": "./tests/utils-browser.js" + "./lib/agentCompatibility.js": "./lib/agentCompatibility-browser.js" }, "react-native": { "./lib/agentCompatibility.js": "./lib/agentCompatibility-browser.js" }, "scripts": { - "test": "npm run test-node", - "test-node": "cross-env NODE_ENV=test mocha --preserve-symlinks -t 30000 -A -R ${REPORTER:-spec} --require tests/test-mocha.js tests/*.spec.js", - "test-karma": "karma start karma.conf.cjs", - "test-watch": "cross-env NODE_ENV=test mocha --watch --parallel --preserve-symlinks -t 30000 -A -R ${REPORTER:-spec} --require tests/test-mocha.js tests/*.spec.js", - "coverage": "cross-env NODE_ENV=test c8 npm run test-node", - "coverage-ci": "cross-env NODE_ENV=test c8 --reporter=lcovonly --reporter=text-summary --reporter=text npm run test-node", - "coverage-report": "c8 report", + "test": "vitest run", + "test-node": "vitest run --project node", + "test-browser": "vitest run --project browser", + "test-watch": "vitest", + "coverage": "vitest run --coverage", + "coverage-ci": "vitest run --coverage --coverage.reporter=lcovonly --coverage.reporter=text-summary --coverage.reporter=text", "lint": "eslint" }, "files": [ @@ -31,22 +38,13 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", - "c8": "^12.0.0", - "chai": "^4.5.0", + "@vitest/browser-playwright": "^4.1.10", + "@vitest/coverage-v8": "^4.1.10", "cors": "^2.8.6", - "cross-env": "^10.1.0", - "detect-node": "^2.1.0", "eslint": "^10.13.0", "express": "^5.3.0", - "karma": "^6.4.4", - "karma-chai": "^0.1.0", - "karma-chrome-launcher": "^3.2.0", - "karma-mocha": "^2.0.1", - "karma-mocha-reporter": "^2.2.5", - "karma-sourcemap-loader": "^0.4.0", - "karma-webpack": "^5.0.1", - "mocha": "^12.0.3", - "webpack": "^5.112.0" + "playwright": "^1.62.1", + "vitest": "^4.1.10" }, "repository": { "type": "git", @@ -68,12 +66,5 @@ "homepage": "https://github.com/digitalbazaar/http-client", "engines": { "node": ">=22" - }, - "c8": { - "reporter": [ - "lcov", - "text-summary", - "text" - ] } } diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index c57a69d..9efb61b 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -1,32 +1,19 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ -import * as utils from './utils.js'; import { DEFAULT_HEADERS, httpClient, ky } from '../lib/index.js'; -import {convertAgent} from '../lib/agentCompatibility.js'; -import isNode from 'detect-node'; +import {describe, inject, it} from 'vitest'; import {replaceOption} from 'ky'; +// tests shared by the `node` and `browser` projects; environment-specific +// tests live in `20-node.spec.js` and `30-browser.spec.js` describe('http-client API', () => { - // start/close local test server - let serverInfo; - let httpHost; - let httpsHost; - before(async () => { - serverInfo = await utils.startServers(); - httpHost = serverInfo.httpHost; - httpsHost = serverInfo.httpsHost; - }); - after(async () => { - await Promise.all([ - serverInfo.httpServer.close(), - serverInfo.httpsServer.close() - ]); - }); + // local test servers are started once by `tests/globalSetup.js` + const httpHost = inject('httpHost'); it('has proper exports', async () => { should.exist(ky); @@ -102,27 +89,6 @@ describe('http-client API', () => { response.data.echo.should.deep.equal(payload); }); - if(isNode) { - // `ky` supports `options` as a `method` value but exposes no helper for - // it, so it has to reach `ky` through the direct-call fall-through. - // Node only: in a browser this needs the server to list OPTIONS in its - // CORS `Access-Control-Allow-Methods`, which the default `cors()` used by - // the test server does not. - it('supports a non-proxied method via the `method` option', async () => { - let err; - let response; - const url = `http://${httpHost}/headers`; - try { - response = await httpClient(url, {method: 'options'}); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - response.status.should.equal(204); - }); - } - it('can ping HTTP test server', async () => { let err; let response; @@ -139,120 +105,6 @@ describe('http-client API', () => { response.status.should.equal(200); }); - if(isNode) { - // test HTTPS against a real external site; node only, since the site - // sends no CORS headers and a browser would block the request - // NOTE: might get rate limited - it('can use HTTPS on github.com', async () => { - let err; - let response; - const url = 'https://github.com/'; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - response.status.should.equal(200); - const ct = response.headers.get('content-type'); - should.exist(ct); - ct.includes('application/json').should.be.true; - }); - - // exercises the agent path with a request body: on an incompatible - // runtime the body + headers must survive the Request -> (url, init) - // decomposition, on a compatible one it rides the native dispatcher path - it('can POST a body over an HTTPS agent', async () => { - let err; - let response; - const url = `https://${httpsHost}/echo`; - const payload = {hello: 'world', n: 42, nested: {ok: true}}; - try { - const agent = utils.makeAgent({ - rejectUnauthorized: false - }); - response = await httpClient.post(url, {agent, json: payload}); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - response.status.should.equal(200); - should.exist(response.data); - should.exist(response.data.echo); - response.data.echo.should.deep.equal(payload); - }); - } - - if(isNode) { - // a custom `fetch` (e.g. from another library) is used as given; agent - // conversion must neither replace it nor slip it a `dispatcher` - it('keeps a custom `fetch` when an agent is given', async () => { - let err; - let response; - let called = false; - let dispatcher; - const url = `http://${httpHost}/ping`; - try { - const agent = utils.makeAgent({ - rejectUnauthorized: false - }); - response = await httpClient.get(url, { - agent, - fetch: async (input, init) => { - called = true; - dispatcher = init?.dispatcher; - return globalThis.fetch(input, init); - } - }); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - response.status.should.equal(200); - called.should.be.true; - should.not.exist(dispatcher); - }); - - // an agent is converted once and reused, so its connections are pooled - // across requests instead of each request building a new dispatcher - it('reuses the conversion of the same agent', async () => { - const agent = utils.makeAgent({rejectUnauthorized: false}); - const otherAgent = utils.makeAgent({rejectUnauthorized: false}); - // a compatible platform gets a `dispatcher`, others a `fetch` override - const converted = options => options.dispatcher ?? options.fetch; - const first = converted(convertAgent({agent})); - should.exist(first); - converted(convertAgent({agent})).should.equal(first); - converted(convertAgent({agent: otherAgent})).should.not.equal(first); - }); - } - - // test local self-signed cert; node uses an agent to accept it, karma - // launches the browser with `--ignore-certificate-errors` - it('can ping HTTPS test server', async () => { - let err; - let response; - const url = `https://${httpsHost}/ping`; - try { - const agent = utils.makeAgent({ - rejectUnauthorized: false - }); - response = await httpClient.get(url, {agent}); - } catch(e) { - err = e; - } - should.not.exist(err); - should.exist(response); - should.exist(response.status); - should.exist(response.data); - response.status.should.equal(200); - }); - it('handles a get not found error', async () => { let err; let response; @@ -278,7 +130,6 @@ describe('http-client API', () => { // the intention here is to use an unused http port // the port cannot be higher than 65535 (which is invalid) const nonExistentResource = 'https://localhost:65535'; - const expectedErrorCode = 'ECONNREFUSED'; // replace the default Accept with text/plain to get around // possibly sending a CORS pre-flight const headers = {Accept: 'text/plain'}; @@ -304,43 +155,8 @@ describe('http-client API', () => { `Expected nonExistentResource "err.requestUrl" to be ` + `${nonExistentResource}` ); - // ky wraps fetch's error in a `NetworkError`, and node's fetch places the - // system error in its own error's `cause`; chrome's fetch errors don't - // contain a code at all - if(isNode) { - let cause = err; - while(cause && !cause.code) { - cause = cause.cause; - } - should.exist(cause, 'Expected an error code in the "cause" chain.'); - cause.code.should.equal( - expectedErrorCode, - `Expected nonExistentResource "err.code" to be ${expectedErrorCode}.` - ); - } }); - if(!isNode) { - // browser check for endpoint without CORS - it('handles a CORS error', async () => { - let err; - let response; - const url = `http://${httpHost}/nocors`; - try { - response = await httpClient.get(url); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - err.message.should.equal( - `Failed to fetch "${url}". Possible CORS error.`); - should.not.exist(err.response); - should.exist(err.requestUrl); - err.requestUrl.should.equal(url); - }); - } - // a browser's `fetch` rejects a CORS failure with a bare // `TypeError: Failed to fetch`; simulate it so node covers the message too it('reports a possible CORS error for a failed fetch', async () => { @@ -640,50 +456,6 @@ describe('http-client API', () => { err.data.description.should.equal('Not Found'); }); - if(isNode) { - describe('Nodejs execution context', () => { - it('handles a network error', async () => { - let err; - let response; - try { - response = await httpClient.get( - 'http://localhost:9876/does-not-exist'); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - err.message.should.satisfy(m => - m.includes( - 'request to http://localhost:9876/does-not-exist failed, reason: ' + - 'connect ECONNREFUSED 127.0.0.1:9876') || - // node 18.x + - m.includes('fetch failed') || - // node 22+ / ky@2 - m.includes( - 'Request failed due to a network error: ' + - 'GET http://localhost:9876/does-not-exist')); - }); - }); - } else { - describe('Browser execution context', () => { - it('should give a meaningful CORS error', async () => { - let err; - let response; - try { - response = await httpClient.get('https://example.com'); - } catch(e) { - err = e; - } - should.not.exist(response); - should.exist(err); - // failed to fetch may commonly be due to an issue with CORS - err.message.should - .equal('Failed to fetch "https://example.com". Possible CORS error.'); - }); - }); - } - describe('extend (custom client)', () => { it('adds an Authorization header to all requests', async () => { const accessToken = '12345'; diff --git a/tests/20-node.spec.js b/tests/20-node.spec.js new file mode 100644 index 0000000..8031b09 --- /dev/null +++ b/tests/20-node.spec.js @@ -0,0 +1,195 @@ +/*! + * Copyright (c) 2020-2026 Digital Bazaar, Inc. + */ +import {describe, inject, it} from 'vitest'; +import {convertAgent} from '../lib/agentCompatibility.js'; +import {httpClient} from '../lib/index.js'; +import {makeAgent} from './utils.js'; + +// tests that only run in the `node` project; this file is free to import +// node-only modules, which is why it is kept out of `10-client-api.spec.js` +describe('http-client API', () => { + // local test servers are started once by `tests/globalSetup.js` + const httpHost = inject('httpHost'); + const httpsHost = inject('httpsHost'); + + // test HTTPS against a real external site; node only, since the site + // sends no CORS headers and a browser would block the request + // NOTE: might get rate limited + it('can use HTTPS on github.com', async () => { + let err; + let response; + const url = 'https://github.com/'; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + response.status.should.equal(200); + const ct = response.headers.get('content-type'); + should.exist(ct); + ct.includes('application/json').should.be.true; + }); + + // test local self-signed cert; node needs an agent to accept it + it('can ping HTTPS test server', async () => { + let err; + let response; + const url = `https://${httpsHost}/ping`; + try { + const agent = makeAgent({ + rejectUnauthorized: false + }); + response = await httpClient.get(url, {agent}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + response.status.should.equal(200); + }); + + // exercises the agent path with a request body: on an incompatible + // runtime the body + headers must survive the Request -> (url, init) + // decomposition, on a compatible one it rides the native dispatcher path + it('can POST a body over an HTTPS agent', async () => { + let err; + let response; + const url = `https://${httpsHost}/echo`; + const payload = {hello: 'world', n: 42, nested: {ok: true}}; + try { + const agent = makeAgent({ + rejectUnauthorized: false + }); + response = await httpClient.post(url, {agent, json: payload}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + should.exist(response.data); + should.exist(response.data.echo); + response.data.echo.should.deep.equal(payload); + }); + + // `ky` supports `options` as a `method` value but exposes no helper for it, + // so it has to reach `ky` through the direct-call fall-through. Node only, + // because in a browser this needs the server to list OPTIONS in its CORS + // `Access-Control-Allow-Methods`, which the default `cors()` used by the + // test server does not. + it('supports a non-proxied method via the `method` option', async () => { + let err; + let response; + const url = `http://${httpHost}/headers`; + try { + response = await httpClient(url, {method: 'options'}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(204); + }); + + // a custom `fetch` (e.g. from another library) is used as given; agent + // conversion must neither replace it nor slip it a `dispatcher` + it('keeps a custom `fetch` when an agent is given', async () => { + let err; + let response; + let called = false; + let dispatcher; + const url = `http://${httpHost}/ping`; + try { + const agent = makeAgent({ + rejectUnauthorized: false + }); + response = await httpClient.get(url, { + agent, + fetch: async (input, init) => { + called = true; + dispatcher = init?.dispatcher; + return globalThis.fetch(input, init); + } + }); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + called.should.be.true; + should.not.exist(dispatcher); + }); + + // an agent is converted once and reused, so its connections are pooled + // across requests instead of each request building a new dispatcher + it('reuses the conversion of the same agent', async () => { + const agent = makeAgent({rejectUnauthorized: false}); + const otherAgent = makeAgent({rejectUnauthorized: false}); + // a compatible platform gets a `dispatcher`, others a `fetch` override + const converted = options => options.dispatcher ?? options.fetch; + const first = converted(convertAgent({agent})); + should.exist(first); + converted(convertAgent({agent})).should.equal(first); + converted(convertAgent({agent: otherAgent})).should.not.equal(first); + }); + + // ky wraps fetch's error in a `NetworkError`, and node's fetch places the + // system error in its own error's `cause`; chrome's fetch errors don't + // contain a code at all + it('reports the system error code of a connection refused error', + async () => { + let err; + // the intention here is to use an unused http port + // the port cannot be higher than 65535 (which is invalid) + const nonExistentResource = 'https://localhost:65535'; + const expectedErrorCode = 'ECONNREFUSED'; + try { + await httpClient.get(nonExistentResource); + } catch(e) { + err = e; + } + should.exist(err, 'Expected nonExistentResource to error.'); + let cause = err; + while(cause && !cause.code) { + cause = cause.cause; + } + should.exist(cause, 'Expected an error code in the "cause" chain.'); + cause.code.should.equal( + expectedErrorCode, + `Expected nonExistentResource "err.code" to be ${expectedErrorCode}.` + ); + }); + + describe('Nodejs execution context', () => { + it('handles a network error', async () => { + let err; + let response; + try { + response = await httpClient.get( + 'http://localhost:9876/does-not-exist'); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.satisfy(m => + m.includes( + 'request to http://localhost:9876/does-not-exist failed, reason: ' + + 'connect ECONNREFUSED 127.0.0.1:9876') || + // node 18.x + + m.includes('fetch failed') || + // node 22+ / ky@2 + m.includes( + 'Request failed due to a network error: ' + + 'GET http://localhost:9876/does-not-exist')); + }); + }); +}); diff --git a/tests/30-browser.spec.js b/tests/30-browser.spec.js new file mode 100644 index 0000000..ffb9b4c --- /dev/null +++ b/tests/30-browser.spec.js @@ -0,0 +1,66 @@ +/*! + * Copyright (c) 2020-2026 Digital Bazaar, Inc. + */ +import {describe, inject, it} from 'vitest'; +import {httpClient} from '../lib/index.js'; + +// tests that only run in the `browser` project +describe('http-client API', () => { + // local test servers are started once by `tests/globalSetup.js` + const httpHost = inject('httpHost'); + const httpsHost = inject('httpsHost'); + + // test local self-signed cert; no agent is needed because the playwright + // provider always runs the browser context with `ignoreHTTPSErrors` + it('can ping HTTPS test server', async () => { + let err; + let response; + const url = `https://${httpsHost}/ping`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + should.exist(response.status); + should.exist(response.data); + response.status.should.equal(200); + }); + + // browser check for endpoint without CORS + it('handles a CORS error', async () => { + let err; + let response; + const url = `http://${httpHost}/nocors`; + try { + response = await httpClient.get(url); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.equal( + `Failed to fetch "${url}". Possible CORS error.`); + should.not.exist(err.response); + should.exist(err.requestUrl); + err.requestUrl.should.equal(url); + }); + + describe('Browser execution context', () => { + it('should give a meaningful CORS error', async () => { + let err; + let response; + try { + response = await httpClient.get('https://example.com'); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + // failed to fetch may commonly be due to an issue with CORS + err.message.should + .equal('Failed to fetch "https://example.com". Possible CORS error.'); + }); + }); +}); diff --git a/tests/globalSetup.js b/tests/globalSetup.js new file mode 100644 index 0000000..b5aefcd --- /dev/null +++ b/tests/globalSetup.js @@ -0,0 +1,19 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +// starts the local HTTP/HTTPS test servers once per project and hands their +// ephemeral hosts to the tests via `inject()`; the servers must start here, +// on the Node.js side, because the browser project cannot run them itself +import {startServers} from './utils.js'; + +export default async function setup(project) { + const {httpServer, httpsServer, httpHost, httpsHost} = await startServers(); + + project.provide('httpHost', httpHost); + project.provide('httpsHost', httpsHost); + + return async () => { + httpServer.close(); + httpsServer.close(); + }; +} diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..1e7a596 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,8 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +// vitest re-exports chai's `should` interface; expose it as a global so the +// existing `should`-style assertions work unchanged +import {should} from 'vitest'; + +globalThis.should = should(); diff --git a/tests/test-mocha.js b/tests/test-mocha.js deleted file mode 100644 index 8c4930e..0000000 --- a/tests/test-mocha.js +++ /dev/null @@ -1,2 +0,0 @@ -import {should} from 'chai'; -global.should = should(); diff --git a/tests/utils-browser.js b/tests/utils-browser.js deleted file mode 100644 index 87692ef..0000000 --- a/tests/utils-browser.js +++ /dev/null @@ -1,27 +0,0 @@ -/*! - * Copyright (c) 2023-2026 Digital Bazaar, Inc. - */ -export async function startServers() { - return { - // mock server - // karma will startup real server - httpServer: { - close: async () => {} - }, - // mock server - // karma will startup real server - httpsServer: { - close: async () => {} - }, - // get host string from server karma started - httpHost: process.env.TEST_HTTP_HOST, - httpsHost: process.env.TEST_HTTPS_HOST - }; -} - -// unused in the browser; the test that calls this is guarded by `isNode`, -// but it must still exist so webpack's static export check on the -// `import * as utils` namespace succeeds -export function makeAgent() { - return undefined; -} diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..c62be84 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,52 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {defineConfig} from 'vitest/config'; +import {playwright} from '@vitest/browser-playwright'; + +export default defineConfig({ + test: { + // tests make real network requests, so the 5s default is too low + testTimeout: 30000, + hookTimeout: 30000, + // `coverage` is process-wide: it can only be set at the root, never + // inside a project, and applies across every project in the run + coverage: { + provider: 'v8', + reporter: ['lcov', 'text-summary', 'text'], + include: ['lib/**/*.js'] + }, + projects: [ + { + extends: true, + test: { + name: 'node', + environment: 'node', + globalSetup: ['./tests/globalSetup.js'], + setupFiles: ['./tests/setup.js'], + include: ['tests/10-client-api.spec.js', 'tests/20-node.spec.js'] + } + }, + { + extends: true, + test: { + name: 'browser', + globalSetup: ['./tests/globalSetup.js'], + setupFiles: ['./tests/setup.js'], + include: ['tests/10-client-api.spec.js', 'tests/30-browser.spec.js'], + browser: { + enabled: true, + headless: true, + provider: playwright({ + launchOptions: { + // required to launch Chromium in CI containers + args: ['--no-sandbox', '--disable-setuid-sandbox'] + } + }), + instances: [{browser: 'chromium'}] + } + } + } + ] + } +}); From 893f4961fce037821e2ac7b07ac8bf20502e74c2 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 03:47:30 +0000 Subject: [PATCH 02/14] Add tests for uncovered code paths. Add tests for the paths that had none: - `convertAgent` declining to override a custom `fetch` from another lib. - The `fetch` override used when the installed undici cannot drive the platform `fetch`. No supported node takes that path with `undici@7`, so the test forces it by reporting an incompatible platform undici major. - Importing when the version read fails, which must not throw at module load. Now at 100% of statements, lines, and functions, and 98.48% of branches. The one uncovered branch is the fallback for an installed undici major that is not in the compatibility table. --- tests/20-node.spec.js | 127 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 6 deletions(-) diff --git a/tests/20-node.spec.js b/tests/20-node.spec.js index 8031b09..b4d94aa 100644 --- a/tests/20-node.spec.js +++ b/tests/20-node.spec.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ -import {describe, inject, it} from 'vitest'; +import {describe, inject, it, vi} from 'vitest'; import {convertAgent} from '../lib/agentCompatibility.js'; import {httpClient} from '../lib/index.js'; import {makeAgent} from './utils.js'; @@ -79,11 +79,28 @@ describe('http-client API', () => { response.data.echo.should.deep.equal(payload); }); - // `ky` supports `options` as a `method` value but exposes no helper for it, - // so it has to reach `ky` through the direct-call fall-through. Node only, - // because in a browser this needs the server to list OPTIONS in its CORS - // `Access-Control-Allow-Methods`, which the default `cors()` used by the - // test server does not. + // a non-simple method would need a CORS preflight in the browser, so this + // stays in the node project + it('passes a non-proxied method straight through to `ky`', async () => { + let err; + const url = `http://${httpHost}/ping`; + try { + await httpClient(url, {method: 'purge'}); + } catch(e) { + err = e; + } + should.exist(err); + should.exist(err.response); + // this path bypasses the response/error handling that the proxied + // methods get, so `ky`'s error is surfaced unmodified + should.not.exist(err.requestUrl); + should.not.exist(err.data); + }); + + // the success side of the same path: `ky` supports `options` as a `method` + // value but exposes no helper for it. Node only, because in a browser this + // needs the server to list OPTIONS in its CORS `Access-Control-Allow- + // Methods`, which the default `cors()` used by the test server does not. it('supports a non-proxied method via the `method` option', async () => { let err; let response; @@ -168,6 +185,104 @@ describe('http-client API', () => { ); }); + describe('agent compatibility', () => { + it('does not override a custom `fetch` from another lib', () => { + const options = { + agent: makeAgent({rejectUnauthorized: false}), + fetch: () => {} + }; + convertAgent(options).should.equal(options); + }); + + // the fallback is only taken when the installed undici cannot drive the + // platform `fetch`, which is no supported node today, so force it by + // reporting a platform undici major that is not in the compatible list + it('routes through an internal `fetch` when the platform undici is ' + + 'incompatible', async () => { + vi.resetModules(); + vi.doMock('node:process', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + versions: {...actual.versions, undici: '999.0.0'} + }; + }); + try { + const {convertAgent: convert} = + await import('../lib/agentCompatibility.js'); + const agent = makeAgent({rejectUnauthorized: false}); + const options = convert({agent}); + + // no dispatcher is handed to `ky`; an override is used instead + should.not.exist(options.dispatcher); + should.exist(options.fetch); + options.fetch._httpClientCustomFetch.should.be.true; + // the override is cached per dispatcher + convert({agent}).fetch.should.equal(options.fetch); + + // the override rebuilds the platform `Request` as undici's own and + // still reaches the self-signed server through the agent + const response = await options.fetch( + new Request(`https://${httpsHost}/ping`)); + response.status.should.equal(200); + const body = await response.json(); + body.pong.should.equal(true); + + // it is a general `fetch` replacement, so a plain URL works too + const direct = await options.fetch(`https://${httpsHost}/ping`); + direct.status.should.equal(200); + } finally { + vi.doUnmock('node:process'); + vi.resetModules(); + } + }); + + // `parseInt` returns `NaN` rather than throwing, so a present but + // unparseable version never reaches the `catch` and has to be rejected + // explicitly; otherwise the lookup falls back to `[NaN]` and `includes` + // matches `NaN` to `NaN`, reporting compatible + it('falls back when a version is present but unparseable', async () => { + vi.resetModules(); + vi.doMock('node:process', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + versions: {...actual.versions, undici: 'not-a-version'} + }; + }); + try { + const {convertAgent: convert} = + await import('../lib/agentCompatibility.js'); + const options = convert({agent: makeAgent({})}); + should.not.exist(options.dispatcher); + should.exist(options.fetch); + } finally { + vi.doUnmock('node:process'); + vi.resetModules(); + } + }); + + // a failed version read must not throw at module load, which would break + // `import` for every consumer; it falls back to the override instead + it('imports and falls back when the version read fails', async () => { + vi.resetModules(); + vi.doMock('node:process', async importOriginal => { + const actual = await importOriginal(); + return {...actual, versions: undefined}; + }); + try { + const {convertAgent: convert} = + await import('../lib/agentCompatibility.js'); + const options = convert({agent: makeAgent({})}); + should.not.exist(options.dispatcher); + should.exist(options.fetch); + } finally { + vi.doUnmock('node:process'); + vi.resetModules(); + } + }); + }); + describe('Nodejs execution context', () => { it('handles a network error', async () => { let err; From 18c075e42d8853aadcdef6ea4bf20e410b29066b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 03:48:06 +0000 Subject: [PATCH 03/14] Detect a possible CORS error in Firefox and WebKit. The `Possible CORS error` message was produced by matching the literal string `Failed to fetch`, which is Chromium's wording for a failed or blocked `fetch`. Firefox reports `NetworkError when attempting to fetch resource.` and WebKit reports `Load failed`, so neither matched and both fell through to `ky`'s generic network error. Match against a set of the three engines' messages instead. Node.js `fetch failed` is deliberately excluded: there is no CORS in Node.js, so the hint would be misleading there. All three strings were measured, not assumed, and each is exercised by the browser test matrix. --- CHANGELOG.md | 11 +++++++++-- lib/httpClient.js | 25 +++++++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b125e0..a5147b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ than only the top-level `browser` field. Bundlers that do not apply the `browser` field to package-internal relative imports (such as Vite) no longer pull `undici` into browser builds. +- Detect a possible CORS error in Firefox and WebKit, not just Chromium. The + `Failed to fetch "". Possible CORS error.` message was produced by + matching Chromium's network-error text, so other engines fell through to a + generic error. Firefox and WebKit wording is now recognized as well. ### Changed - **BREAKING**: Revert CJS related workarounds from v3.0.0. @@ -54,14 +58,17 @@ - Switch testing from `mocha`/`chai`/`karma`/`c8` to `vitest`. - `karma` is unmaintained; `vitest` covers Node.js tests, browser tests, and coverage with a single tool and config. - - Browser tests now run in Chromium via `playwright` instead of `karma`. + - Browser tests now run via `playwright` instead of `karma`, in Chromium, + Firefox, and WebKit rather than Chromium alone. - `npm test` now runs both the Node.js and browser suites; use `npm run test-node` or `npm run test-browser` for one of them. - `npm run test-karma` is replaced by `npm run test-browser`. - `npm run coverage-report` is removed; use `npm run coverage -- --coverage.reporter=html`. - Coverage now includes the browser suite, and reported totals shift - slightly because `vitest` and `c8` count executable lines differently. + slightly because `istanbul` and `c8` count executable lines differently. + The `istanbul` provider is used rather than `v8` because v8 coverage is + gathered over CDP, which only Chromium supports. ### Removed - **BREAKING**: Remove CJS support. diff --git a/lib/httpClient.js b/lib/httpClient.js index df05af8..36493a8 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -24,6 +24,23 @@ const PROXY_METHODS = new Set([ 'get', 'post', 'put', 'patch', 'head', 'delete', 'query' ]); +/* +Browsers reject a blocked or failed `fetch` with a `TypeError` whose message +is engine-specific. A cross-origin block is deliberately indistinguishable +from any other network failure -- the response is opaque -- which is why the +message below says "Possible". Node.js rejects with `fetch failed`, which is +intentionally absent here: there is no CORS in Node.js, so the hint would be +misleading. Each entry is exercised by the browser test matrix. +*/ +const BROWSER_NETWORK_ERRORS = new Set([ + // Chromium + 'Failed to fetch', + // Firefox + 'NetworkError when attempting to fetch resource.', + // WebKit + 'Load failed' +]); + /** * Returns a custom httpClient instance. Used to specify default headers and * other default overrides. @@ -175,10 +192,10 @@ async function _handleError({error, url}) { // handle network errors and system errors that do not have a response if(!error.response) { - if(error.message === 'Failed to fetch' || - error.cause?.message === 'Failed to fetch') { - // ky@2 wraps the browser's underlying `TypeError: Failed to fetch` - // in its own `NetworkError`, with the original error as `cause` + if(BROWSER_NETWORK_ERRORS.has(error.message) || + BROWSER_NETWORK_ERRORS.has(error.cause?.message)) { + // ky@2 wraps the browser's underlying `TypeError` in its own + // `NetworkError`, with the original error as `cause` error.message = `Failed to fetch "${url}". Possible CORS error.`; } // ky's TimeoutError class From 4d63df29aa70067a2e90b874cd50a66a1b3ae29b Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 03:49:00 +0000 Subject: [PATCH 04/14] Run browser tests in Firefox and WebKit as well as Chromium. This is what keeps the per-engine CORS messages honest, and running the shared suite in each engine surfaces any other behavior difference. Three changes were needed to make the other engines work: - Advertise the test servers as `127.0.0.1` rather than the `0.0.0.0` that `address()` reports. Only Chromium treats `0.0.0.0` as loopback when used as a request host; Firefox and WebKit refuse to connect, which failed every test that used a local server. - Replace the raw `--no-sandbox` launch args with `chromiumSandbox: false`. WebKit rejects unknown options and fails to launch; the `playwright` option is applied to Chromium only. - Use the `istanbul` coverage provider rather than `v8`. v8 coverage is gathered over CDP, which only Chromium supports, so `coverage` refused to start once the other engines were added. `istanbul` counts default parameters and some conditionals that `v8` did not, which surfaced an untested documented option, so also test `parseBody: false` and `create()`/`extend()` with no overrides. --- .github/workflows/main.yaml | 16 +++++++++------- .gitignore | 2 ++ package.json | 2 +- tests/10-client-api.spec.js | 29 +++++++++++++++++++++++++++++ tests/utils.js | 15 +++++++++------ vitest.config.js | 25 +++++++++++++++++++++---- 6 files changed, 71 insertions(+), 18 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 2494ce7..241e6c5 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -45,7 +45,8 @@ jobs: run: npm run test-node test-browser: runs-on: ubuntu-latest - timeout-minutes: 10 + # three browser engines plus a cold browser install + timeout-minutes: 20 strategy: matrix: node-version: [24.x] @@ -63,13 +64,14 @@ jobs: with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} - - name: Install Playwright chromium - run: npx playwright install --with-deps chromium + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium firefox webkit - name: Run browser tests run: npm run test-browser coverage: runs-on: ubuntu-latest - timeout-minutes: 10 + # the browser project runs here too, across three engines + timeout-minutes: 20 strategy: matrix: node-version: [24.x] @@ -82,14 +84,14 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install - # coverage runs the browser project too, so a browser is required + # coverage runs the browser project too, so the browsers are required - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} - - name: Install Playwright chromium - run: npx playwright install --with-deps chromium + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium firefox webkit - name: Generate coverage report run: npm run coverage-ci - name: Upload coverage to Codecov diff --git a/.gitignore b/.gitignore index cdef007..14675e8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.sw[nop] *~ .cache +.vitest-attachments .nyc_output .project .settings @@ -9,5 +10,6 @@ TAGS coverage dist +__screenshots__ node_modules reports diff --git a/package.json b/package.json index 42d11d3..f7ac4e2 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", "@vitest/browser-playwright": "^4.1.10", - "@vitest/coverage-v8": "^4.1.10", + "@vitest/coverage-istanbul": "^4.1.10", "cors": "^2.8.6", "eslint": "^10.13.0", "express": "^5.3.0", diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index 9efb61b..c166621 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -338,6 +338,35 @@ describe('http-client API', () => { authorization.should.equal('Bearer 12345'); }); + it('create() and extend() keep the defaults with no overrides', async () => { + const url = `http://${httpHost}/headers`; + for(const client of [httpClient.create({}), httpClient.extend({})]) { + const response = await client.get(url); + response.status.should.equal(200); + response.data.headers.accept.should.equal( + 'application/ld+json, application/json'); + } + }); + + it('does not parse the body when `parseBody` is false', async () => { + let err; + let response; + const url = `http://${httpHost}/json`; + try { + response = await httpClient.get(url, {parseBody: false}); + } catch(e) { + err = e; + } + should.not.exist(err); + should.exist(response); + response.status.should.equal(200); + // `data` is always defined as a property, but left undefined + should.not.exist(response.data); + // the body is untouched, so the caller can still read it + const body = await response.json(); + should.exist(body); + }); + it('handles a successful get with JSON data', async () => { let err; let response; diff --git a/tests/utils.js b/tests/utils.js index dd641f8..c605378 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -37,12 +37,15 @@ export async function startServers() { }); await Promise.all([_httpStarted, _httpsStarted]); - const httpServerAddress = httpServer.address(); - const httpsServerAddress = httpsServer.address(); - const httpHost = - `${httpServerAddress.address}:${httpServerAddress.port}`; - const httpsHost = - `${httpsServerAddress.address}:${httpsServerAddress.port}`; + /* + The servers bind to every interface, so `address()` reports `0.0.0.0`. + Only Chromium treats that as loopback when used as a request host; Firefox + and WebKit refuse to connect to it. Advertise the loopback address instead + so the same host works in every engine. + */ + const clientHost = '127.0.0.1'; + const httpHost = `${clientHost}:${httpServer.address().port}`; + const httpsHost = `${clientHost}:${httpsServer.address().port}`; return { httpServer, diff --git a/vitest.config.js b/vitest.config.js index c62be84..6ec9d8b 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -12,7 +12,10 @@ export default defineConfig({ // `coverage` is process-wide: it can only be set at the root, never // inside a project, and applies across every project in the run coverage: { - provider: 'v8', + // `istanbul` rather than `v8`: v8 coverage is gathered over CDP, which + // only Chromium supports, and the browser project runs in Firefox and + // WebKit as well + provider: 'istanbul', reporter: ['lcov', 'text-summary', 'text'], include: ['lib/**/*.js'] }, @@ -39,11 +42,25 @@ export default defineConfig({ headless: true, provider: playwright({ launchOptions: { - // required to launch Chromium in CI containers - args: ['--no-sandbox', '--disable-setuid-sandbox'] + // needed to launch Chromium in CI containers; `playwright` + // applies this only to Chromium, so it is safe to set for + // every instance (a raw `--no-sandbox` arg is not -- WebKit + // rejects unknown options and fails to launch) + chromiumSandbox: false } }), - instances: [{browser: 'chromium'}] + /* + The `Possible CORS error` message in `lib/httpClient.js` keys off + the browser's own network-error text, which differs per engine, so + each engine has to be exercised to keep that mapping honest. The + shared `10-client-api.spec.js` runs per instance as well, which is + what catches any other behavior difference between engines. + */ + instances: [ + {browser: 'chromium'}, + {browser: 'firefox'}, + {browser: 'webkit'} + ] } } } From e1bdcd6e7b6327242afdf58f7173eb4bee99634c Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 04:14:50 +0000 Subject: [PATCH 05/14] Detect host-suffixed browser network errors as possible CORS errors. Chromium can report `Failed to fetch (example.com)` and WebKit `Load failed (example.com)`. Only exact messages were matched, so these fell through to `ky`'s generic network error without the CORS hint. Accept a ` ()` suffix on those two messages, following `is-network-error`, which `ky` inlines for its own network error detection. `ky`'s `isNetworkError` is not used directly because it also matches Node.js and other non-browser runtimes, where a CORS hint is misleading. Cover every engine's wording and the suffixed variants with simulated `fetch` failures, and check that Node.js's `fetch failed` gets no hint. --- CHANGELOG.md | 4 +++- lib/httpClient.js | 28 ++++++++++++++++++++++++-- tests/10-client-api.spec.js | 40 ++++++++++++++++++++++++++++++++++--- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5147b5..3cc9ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ - Detect a possible CORS error in Firefox and WebKit, not just Chromium. The `Failed to fetch "". Possible CORS error.` message was produced by matching Chromium's network-error text, so other engines fell through to a - generic error. Firefox and WebKit wording is now recognized as well. + generic error. Firefox and WebKit wording is now recognized as well, as are + the Chromium and WebKit variants with a host suffix, such as + `Failed to fetch (example.com)`. ### Changed - **BREAKING**: Revert CJS related workarounds from v3.0.0. diff --git a/lib/httpClient.js b/lib/httpClient.js index 36493a8..e43d457 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -31,6 +31,12 @@ from any other network failure -- the response is opaque -- which is why the message below says "Possible". Node.js rejects with `fetch failed`, which is intentionally absent here: there is no CORS in Node.js, so the hint would be misleading. Each entry is exercised by the browser test matrix. + +Chromium and WebKit may also append the host, as in +`Failed to fetch (example.com)`; those variants follow `is-network-error`, +which `ky` inlines to detect network errors in every runtime. `ky`'s own +`isNetworkError` is not used here because it also matches Node.js and other +non-browser runtimes. */ const BROWSER_NETWORK_ERRORS = new Set([ // Chromium @@ -40,6 +46,8 @@ const BROWSER_NETWORK_ERRORS = new Set([ // WebKit 'Load failed' ]); +// messages that may carry a ` ()` suffix +const HOST_SUFFIXED_NETWORK_ERRORS = ['Failed to fetch', 'Load failed']; /** * Returns a custom httpClient instance. Used to specify default headers and @@ -180,6 +188,22 @@ async function _handleResponse(target, thisArg, args) { return response; } +/** + * Checks whether an error message is a browser's `fetch` network error. + * + * @param {string} [message] - Error message. + * + * @returns {boolean} True for a browser network error message. + */ +function _isBrowserNetworkError(message) { + if(typeof message !== 'string') { + return false; + } + return BROWSER_NETWORK_ERRORS.has(message) || + HOST_SUFFIXED_NETWORK_ERRORS.some(prefix => + message.startsWith(`${prefix} (`) && message.endsWith(')')); +} + /** * @param {object} options - Options hashmap. * @param {Error} options.error - Error thrown during http operation. @@ -192,8 +216,8 @@ async function _handleError({error, url}) { // handle network errors and system errors that do not have a response if(!error.response) { - if(BROWSER_NETWORK_ERRORS.has(error.message) || - BROWSER_NETWORK_ERRORS.has(error.cause?.message)) { + if(_isBrowserNetworkError(error.message) || + _isBrowserNetworkError(error.cause?.message)) { // ky@2 wraps the browser's underlying `TypeError` in its own // `NetworkError`, with the original error as `cause` error.message = `Failed to fetch "${url}". Possible CORS error.`; diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index c166621..a085937 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -159,14 +159,48 @@ describe('http-client API', () => { // a browser's `fetch` rejects a CORS failure with a bare // `TypeError: Failed to fetch`; simulate it so node covers the message too - it('reports a possible CORS error for a failed fetch', async () => { + // each engine's wording, including the host-suffixed Chromium and WebKit + // variants, is simulated so node covers them all + for(const message of [ + 'Failed to fetch', + 'Failed to fetch (example.com)', + 'NetworkError when attempting to fetch resource.', + 'Load failed', + 'Load failed (example.com)' + ]) { + it(`reports a possible CORS error for a failed fetch: ${message}`, + async () => { + let err; + let response; + const url = `http://${httpHost}/ping`; + try { + response = await httpClient.get(url, { + fetch: async () => { + throw new TypeError(message); + }, + // skip ky's retry backoff for a network error + retry: 0 + }); + } catch(e) { + err = e; + } + should.not.exist(response); + should.exist(err); + err.message.should.equal( + `Failed to fetch "${url}". Possible CORS error.`); + err.requestUrl.should.equal(url); + }); + } + + // there is no CORS in Node.js, so its network error gets no CORS hint + it('does not report a possible CORS error for `fetch failed`', async () => { let err; let response; const url = `http://${httpHost}/ping`; try { response = await httpClient.get(url, { fetch: async () => { - throw new TypeError('Failed to fetch'); + throw new TypeError('fetch failed'); }, // skip ky's retry backoff for a network error retry: 0 @@ -176,7 +210,7 @@ describe('http-client API', () => { } should.not.exist(response); should.exist(err); - err.message.should.equal(`Failed to fetch "${url}". Possible CORS error.`); + err.message.should.not.contain('Possible CORS error'); err.requestUrl.should.equal(url); }); From a3fe122a7d4ee300383a1be3e31849470703af3d Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 04:20:41 +0000 Subject: [PATCH 06/14] Update dependencies. - Update to `vitest@5`, `@vitest/browser-playwright@5`, and `@vitest/coverage-istanbul@5`. - Update to `playwright@1.63`. - Ignore `.vitest`, where `vitest@5` writes attachments, instead of `.vitest-attachments`. `undici` is kept at v7 to match the undici built into the current Node.js LTS release. --- .gitignore | 2 +- package.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 14675e8..d50cde3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ *.sw[nop] *~ .cache -.vitest-attachments +.vitest .nyc_output .project .settings diff --git a/package.json b/package.json index f7ac4e2..6473700 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,13 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", - "@vitest/browser-playwright": "^4.1.10", - "@vitest/coverage-istanbul": "^4.1.10", + "@vitest/browser-playwright": "^5.0.2", + "@vitest/coverage-istanbul": "^5.0.2", "cors": "^2.8.6", "eslint": "^10.13.0", "express": "^5.3.0", - "playwright": "^1.62.1", - "vitest": "^4.1.10" + "playwright": "^1.63.0", + "vitest": "^5.0.2" }, "repository": { "type": "git", From 2c7fd13c0e396adf0d0403b835b65f41910f58c1 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 29 Sep 2026 04:32:45 +0000 Subject: [PATCH 07/14] Speed up the timeout and network error tests. Vitest reports tests over `slowTestThreshold` (300ms) as slow, and has no per-test override for that threshold. Several tests exceeded it without testing anything that needed the time. - Use a 100ms timeout against a 1s delay in the `TimeoutError` test rather than a 1s timeout against a 2s delay. - Pass `retry: 0` in the connection refused, network error, and CORS error tests. `ky` retries network errors for GET with 300ms and 600ms backoff, which added ~900ms to each, and none of them tests retry behavior. --- tests/10-client-api.spec.js | 10 +++++++--- tests/20-node.spec.js | 10 ++++++++-- tests/30-browser.spec.js | 10 ++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/tests/10-client-api.spec.js b/tests/10-client-api.spec.js index a085937..ffbc577 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -134,7 +134,11 @@ describe('http-client API', () => { // possibly sending a CORS pre-flight const headers = {Accept: 'text/plain'}; try { - response = await httpClient.get(nonExistentResource, {headers}); + response = await httpClient.get(nonExistentResource, { + headers, + // skip ky's retry backoff for a network error + retry: 0 + }); } catch(e) { err = e; } @@ -217,10 +221,10 @@ describe('http-client API', () => { it('handles a TimeoutError error', async () => { let err; let response; - const url = `http://${httpHost}/delay/2`; + const url = `http://${httpHost}/delay/1`; try { response = await httpClient.get(url, { - timeout: 1000 + timeout: 100 }); } catch(e) { err = e; diff --git a/tests/20-node.spec.js b/tests/20-node.spec.js index b4d94aa..e372771 100644 --- a/tests/20-node.spec.js +++ b/tests/20-node.spec.js @@ -169,7 +169,10 @@ describe('http-client API', () => { const nonExistentResource = 'https://localhost:65535'; const expectedErrorCode = 'ECONNREFUSED'; try { - await httpClient.get(nonExistentResource); + await httpClient.get(nonExistentResource, { + // skip ky's retry backoff for a network error + retry: 0 + }); } catch(e) { err = e; } @@ -289,7 +292,10 @@ describe('http-client API', () => { let response; try { response = await httpClient.get( - 'http://localhost:9876/does-not-exist'); + 'http://localhost:9876/does-not-exist', { + // skip ky's retry backoff for a network error + retry: 0 + }); } catch(e) { err = e; } diff --git a/tests/30-browser.spec.js b/tests/30-browser.spec.js index ffb9b4c..294f6af 100644 --- a/tests/30-browser.spec.js +++ b/tests/30-browser.spec.js @@ -34,7 +34,10 @@ describe('http-client API', () => { let response; const url = `http://${httpHost}/nocors`; try { - response = await httpClient.get(url); + response = await httpClient.get(url, { + // skip ky's retry backoff for a network error + retry: 0 + }); } catch(e) { err = e; } @@ -52,7 +55,10 @@ describe('http-client API', () => { let err; let response; try { - response = await httpClient.get('https://example.com'); + response = await httpClient.get('https://example.com', { + // skip ky's retry backoff for a network error + retry: 0 + }); } catch(e) { err = e; } From fd5633a218f460b5af5c85637ea40e223459da6a Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 17 Oct 2026 00:20:32 +0000 Subject: [PATCH 08/14] Update dependencies. --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 6473700..c50a717 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,13 @@ }, "devDependencies": { "@digitalbazaar/eslint-config": "^9.0.0", - "@vitest/browser-playwright": "^5.0.2", - "@vitest/coverage-istanbul": "^5.0.2", + "@vitest/browser-playwright": "^5.0.5", + "@vitest/coverage-istanbul": "^5.0.5", "cors": "^2.8.6", "eslint": "^10.13.0", "express": "^5.3.0", - "playwright": "^1.63.0", - "vitest": "^5.0.2" + "playwright": "^1.64.1", + "vitest": "^5.0.5" }, "repository": { "type": "git", From b5026cd5fd002a02d58d6a0a78892eec76232e50 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 17 Oct 2026 01:06:01 +0000 Subject: [PATCH 09/14] Import `agentCompatibility` relatively again. The self-reference through the package name only resolves where the bundler supports `exports` and self-reference. Metro without package exports enabled (the default before React Native 0.79) and webpack 4 would look for a nonexistent `agentCompatibility.js` at the package root and fail the build. The relative import is remapped for them by the top-level `browser` and `react-native` fields, as before. The self-reference was added for the vitest browser project, but Vite does apply the `browser` field to this relative import: browser-project coverage shows only `agentCompatibility-browser.js` loaded. Drop the changelog entry that described it as a bundler fix. --- CHANGELOG.md | 4 ---- lib/httpClient.js | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cc9ec8..4b0419c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,6 @@ - Keep `create()` headers given as a `Headers` instance. They were dropped. - Support `ky`'s `replaceOption()` for `headers` in `create()` and `extend()`. In `create()` it replaces the default headers. -- Resolve `agentCompatibility` through an `exports` `browser` condition rather - than only the top-level `browser` field. Bundlers that do not apply the - `browser` field to package-internal relative imports (such as Vite) no longer - pull `undici` into browser builds. - Detect a possible CORS error in Firefox and WebKit, not just Chromium. The `Failed to fetch "". Possible CORS error.` message was produced by matching Chromium's network-error text, so other engines fell through to a diff --git a/lib/httpClient.js b/lib/httpClient.js index e43d457..6d92a2d 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -2,7 +2,7 @@ * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ import ky, {replaceOption} from 'ky'; -import {convertAgent} from '@digitalbazaar/http-client/agentCompatibility.js'; +import {convertAgent} from './agentCompatibility.js'; export {ky}; From 378d9cdb7cee0a968bde86be6b79c9a301fbc49c Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 17 Oct 2026 01:06:41 +0000 Subject: [PATCH 10/14] Let `ky` merge headers. `ky@2.2` merges headers case-insensitively, deletes a header given as `undefined`, and honors `replaceOption()` on `headers`. That is everything the local `_mergeHeaders`/`_unwrapReplaceOption` helpers did, so remove them along with the `create()`/`extend()` split. Every client now extends a base instance that carries `DEFAULT_HEADERS`. This also drops the lookup of `ky`'s private `replaceOption` symbol by position, which would have broken silently if `ky` changed the wrapper's symbols. --- lib/httpClient.js | 74 +++++++---------------------------------------- 1 file changed, 11 insertions(+), 63 deletions(-) diff --git a/lib/httpClient.js b/lib/httpClient.js index 6d92a2d..712c2ea 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -1,8 +1,8 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ -import ky, {replaceOption} from 'ky'; import {convertAgent} from './agentCompatibility.js'; +import ky from 'ky'; export {ky}; @@ -10,6 +10,11 @@ export const DEFAULT_HEADERS = { Accept: 'application/ld+json, application/json' }; +// every client extends this instance (or a client derived from it), so `ky`'s +// own merge applies header overrides case-insensitively, deletes a header +// given as `undefined`, and honors `replaceOption()` +const defaultKy = ky.create({headers: DEFAULT_HEADERS}); + /* Methods to proxy from `ky`. This must mirror `ky`'s own helper registry (`requestMethods` in its `core/constants.js`), because proxying indexes into @@ -54,77 +59,20 @@ const HOST_SUFFIXED_NETWORK_ERRORS = ['Failed to fetch', 'Load failed']; * other default overrides. * * @param {object} [options={}] - Options hashmap. - * @param {object} [options.parent] - The ky instance to inherit from. - * @param {object} [options.headers={}] - Default header overrides. + * @param {object} [options.parent] - The ky instance to inherit from; + * defaults to one with `DEFAULT_HEADERS`. + * @param {object|Headers} [options.headers={}] - Default header overrides. * @param {object} [options.params] - Other default overrides. * * @returns {Function} Custom httpClient instance. */ export function createInstance({ - parent = ky, headers = {}, ...params + parent = defaultKy, headers = {}, ...params } = {}) { // convert legacy agent options params = convertAgent(params); - // headers are always passed to ky as a `Headers` instance (instead of a - // plain object) so ky merges per-request headers case-insensitively; ky's - // plain-object merge path does a `{...a, ...b}` spread, which does not - // dedupe header names that differ only by case (e.g. `Accept` vs `accept`) - const {replace, value} = _unwrapReplaceOption(headers); - - // create new ky instance - let _ky; - if(parent === ky) { - // ensure default headers, allow overrides; `replaceOption()` replaces - // the defaults instead - _ky = parent.create({ - headers: _mergeHeaders(replace ? undefined : DEFAULT_HEADERS, value), - ...params - }); - } else { - // extend parent; an `undefined` value is left for ky's merge, which - // deletes that header from the parent's - _ky = parent.extend({ - headers: replace ? - replaceOption(_mergeHeaders(undefined, value)) : new Headers(value), - ...params - }); - } - - return _createHttpClient(_ky); -} - -// ky marks a `replaceOption()` value with a private symbol; recover it from -// a marked value so a marked `headers` option can be recognized -const [REPLACE_OPTION] = Object.getOwnPropertySymbols(replaceOption()); - -function _unwrapReplaceOption(value) { - if(value?.[REPLACE_OPTION]) { - return {replace: true, value: value.value}; - } - return {replace: false, value}; -} - -/** - * Sets `headers` over `base` case-insensitively. - * - * @param {object|Headers} [base] - Base headers. - * @param {object|Headers} [headers] - Headers to set over `base`. - * - * @returns {Headers} The merged headers. - */ -function _mergeHeaders(base, headers) { - const result = new Headers(base); - for(const [name, value] of new Headers(headers)) { - // `Headers` converts an `undefined` value to 'undefined'; like ky, treat - // it as a deletion - if(value === 'undefined') { - result.delete(name); - } else { - result.set(name, value); - } - } - return result; + return _createHttpClient(parent.extend({headers, ...params})); } function _createHttpClient(instance) { From 8bfb349c9383f46677c866d6b3a7fca27040e28e Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 17 Oct 2026 01:06:47 +0000 Subject: [PATCH 11/14] Fix the self-signed TLS example in the README. It imported `{Agent}` but called `new https.Agent()`, and passed a `headers` variable defined only in the previous example. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 439e034..755d96b 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ const client = httpClient.extend({headers}); import {Agent} from 'https'; import {httpClient} from '@digitalbazaar/http-client'; -const agent = new https.Agent({rejectUnauthorized: false}); -const client = httpClient.extend({headers, agent}); +const agent = new Agent({rejectUnauthorized: false}); +const client = httpClient.extend({agent}); // subsequent http calls will use the provided https Agent ``` From 4b21deace261e7bf80a83a3edde13968f04893ce Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 17 Oct 2026 01:07:04 +0000 Subject: [PATCH 12/14] Await the test servers closing in teardown. Teardown called `close()` without waiting for it, and `close()` alone leaves open keep-alive and in-flight connections (such as a timed-out `/delay` request) to run until they time out. Close every connection and wait for both servers. --- tests/globalSetup.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/globalSetup.js b/tests/globalSetup.js index b5aefcd..b830c80 100644 --- a/tests/globalSetup.js +++ b/tests/globalSetup.js @@ -13,7 +13,15 @@ export default async function setup(project) { project.provide('httpsHost', httpsHost); return async () => { - httpServer.close(); - httpsServer.close(); + await Promise.all([httpServer, httpsServer].map(server => { + const closed = new Promise((resolve, reject) => { + server.close(err => err ? reject(err) : resolve()); + }); + // `close()` only stops new connections; drop open keep-alive and + // in-flight ones too, so it finishes without waiting for them to time + // out + server.closeAllConnections(); + return closed; + })); }; } From d1440593912b8055d62819b05d36c487e53aa6b9 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 17 Oct 2026 01:27:22 +0000 Subject: [PATCH 13/14] Don't cache Playwright browsers in CI. Playwright's CI docs advise against caching browser binaries: restoring the cache takes about as long as downloading them, and on Linux the OS dependencies from `--with-deps` have to be installed on every run anyway. The cache was also keyed on `package.json`, which doesn't change when a new release of the `playwright` range installs new browser builds, so it restored stale browsers that were then downloaded over. --- .github/workflows/main.yaml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 241e6c5..3a7a761 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -59,11 +59,6 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} - name: Install Playwright browsers run: npx playwright install --with-deps chromium firefox webkit - name: Run browser tests @@ -85,11 +80,6 @@ jobs: node-version: ${{ matrix.node-version }} - run: npm install # coverage runs the browser project too, so the browsers are required - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} - name: Install Playwright browsers run: npx playwright install --with-deps chromium firefox webkit - name: Generate coverage report From ae287657cce85fd8ef60960eb173d3709b4365f6 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Sat, 17 Oct 2026 01:34:17 +0000 Subject: [PATCH 14/14] Use `group:variant` names for npm scripts. Rename `test-node`, `test-browser`, `test-watch`, and `coverage-ci` to `test:node`, `test:browser`, `test:watch`, and `coverage:ci`, the common convention for script variants. The CI job IDs keep their hyphenated names, matching other projects. --- .github/workflows/main.yaml | 6 +++--- CHANGELOG.md | 6 ++++-- package.json | 8 ++++---- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 3a7a761..7c5bd49 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -42,7 +42,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: Run tests with Node.js ${{ matrix.node-version }} - run: npm run test-node + run: npm run test:node test-browser: runs-on: ubuntu-latest # three browser engines plus a cold browser install @@ -62,7 +62,7 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium firefox webkit - name: Run browser tests - run: npm run test-browser + run: npm run test:browser coverage: runs-on: ubuntu-latest # the browser project runs here too, across three engines @@ -83,7 +83,7 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium firefox webkit - name: Generate coverage report - run: npm run coverage-ci + run: npm run coverage:ci - name: Upload coverage to Codecov uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0419c..b2bfecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,8 +59,10 @@ - Browser tests now run via `playwright` instead of `karma`, in Chromium, Firefox, and WebKit rather than Chromium alone. - `npm test` now runs both the Node.js and browser suites; use - `npm run test-node` or `npm run test-browser` for one of them. - - `npm run test-karma` is replaced by `npm run test-browser`. + `npm run test:node` or `npm run test:browser` for one of them. + - `npm run test-karma` is replaced by `npm run test:browser`. + - npm scripts use `group:variant` names: `test:node`, `test:browser`, + `test:watch`, and `coverage:ci`. - `npm run coverage-report` is removed; use `npm run coverage -- --coverage.reporter=html`. - Coverage now includes the browser suite, and reported totals shift diff --git a/package.json b/package.json index c50a717..e303662 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,11 @@ }, "scripts": { "test": "vitest run", - "test-node": "vitest run --project node", - "test-browser": "vitest run --project browser", - "test-watch": "vitest", + "test:node": "vitest run --project node", + "test:browser": "vitest run --project browser", + "test:watch": "vitest", "coverage": "vitest run --coverage", - "coverage-ci": "vitest run --coverage --coverage.reporter=lcovonly --coverage.reporter=text-summary --coverage.reporter=text", + "coverage:ci": "vitest run --coverage --coverage.reporter=lcovonly --coverage.reporter=text-summary --coverage.reporter=text", "lint": "eslint" }, "files": [