From d141bf5e5d07708556c3243475ba65b8ca2f6886 Mon Sep 17 00:00:00 2001 From: "David I. Lehn" Date: Tue, 28 Jul 2026 03:00:26 +0000 Subject: [PATCH] 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; both projects still run 17 tests. - 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. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/main.yaml | 21 +++++- CHANGELOG.md | 20 ++++++ karma.conf.cjs | 109 ----------------------------- lib/httpClient.js | 2 +- package.json | 49 ++++++------- tests/10-client-api.spec.js | 132 ++---------------------------------- tests/20-node.spec.js | 80 ++++++++++++++++++++++ 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, 289 insertions(+), 298 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 03191b3..20fb329 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 9f4a747..d13e5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,26 @@ - 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. + +### Fixed +- 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. ### 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 30f74a8..5fd843a 100644 --- a/lib/httpClient.js +++ b/lib/httpClient.js @@ -1,7 +1,7 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ -import {convertAgent} from './agentCompatibility.js'; +import {convertAgent} from '@digitalbazaar/http-client/agentCompatibility.js'; import ky from 'ky'; export {ky}; diff --git a/package.json b/package.json index d64cde5..a2f746e 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.8.0", "express": "^5.2.1", - "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": "^11.7.6", - "webpack": "^5.109.0" + "playwright": "^1.62.0", + "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 49e316d..c622fb5 100644 --- a/tests/10-client-api.spec.js +++ b/tests/10-client-api.spec.js @@ -1,30 +1,18 @@ /*! * Copyright (c) 2020-2026 Digital Bazaar, Inc. */ -import * as utils from './utils.js'; import { DEFAULT_HEADERS, httpClient, ky } from '../lib/index.js'; -import isNode from 'detect-node'; +import {describe, inject, it} from 'vitest'; +// 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); @@ -49,51 +37,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; - }); - } - - // 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; @@ -156,27 +99,6 @@ describe('http-client API', () => { } }); - 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); - }); - } - it('handles a TimeoutError error', async () => { let err; let response; @@ -365,50 +287,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..2af8ff9 --- /dev/null +++ b/tests/20-node.spec.js @@ -0,0 +1,80 @@ +/*! + * Copyright (c) 2020-2026 Digital Bazaar, Inc. + */ +import {describe, inject, it} from 'vitest'; +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 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); + }); + + 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'}] + } + } + } + ] + } +});