chore: migrate cli workspace from tap to node:test

The cli workspace's tap-based tests were broken by transitive dep react-reconciler@0.29 (via tap → @tapjs/reporter → ink@5) not being compatible with React 19, which is now hoisted at the repo root. Switch the test runner via template-oss' `testRunner: node:test` option, then port the existing tests to node:test. `tap` remains in devDependencies — only the runner and runtime config change. - cli/test/transform.js: switch to node:test, stub `./gh` via require.cache so it does not try to read GITHUB_TOKEN at load. - cli/test/index.js: replicate the `mockRequire`/`testdir` parts of the test harness using a small require.cache-based mock helper and `fs.mkdtemp`. Cleanup runs via `t.after`.

Michael Smith committed May 11, 2026 at 08:53 UTC 36d5b9b11023bd5e7d14fad2cc86f205c2cfaf99
4 files changed +124 -44
.github/workflows/ci-cli.yml
+5 -3
@@ -82,7 +82,9 @@ jobs:
82 cache: npm
83 - name: Install Dependencies
84 run: npm i --no-audit --no-fund
85 - - name: Add Problem Matcher
86 - run: echo "::add-matcher::.github/matchers/tap.json"
87 - - name: Test
85 + - name: Test (with coverage on Node >= 24)
86 + if: ${{ startsWith(matrix.node-version, '24') }}
87 + run: npm run test:cover --ignore-scripts --workspace cli
88 + - name: Test (without coverage on Node < 24)
89 + if: ${{ !startsWith(matrix.node-version, '24') }}
90 run: npm test --ignore-scripts --workspace cli
cli/package.json
+8 -12
@@ -12,10 +12,11 @@
12 "postlint": "template-oss-check",
13 "template-oss-apply": "template-oss-apply --force",
14 "lintfix": "npm run eslint -- --fix",
15 - "snap": "tap",
16 - "test": "tap",
15 + "snap": "node --test --test-update-snapshots './test/**/*.js'",
16 + "test": "node --test './test/**/*.js'",
17 "posttest": "npm run lint",
18 - "eslint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\""
18 + "eslint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\"",
19 + "test:cover": "node --test --experimental-test-coverage --test-timeout=3000 './test/**/*.js'"
20 },
21 "dependencies": {
22 "@octokit/rest": "^22.0.0",
@@ -41,17 +42,12 @@
42 "templateOSS": {
43 "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.",
44 "version": "5.0.0",
44 - "content": "./scripts/template-oss"
45 + "content": "./scripts/template-oss",
46 + "testRunner": "node:test",
47 + "coverageThreshold": 0
48 },
49 "files": [
50 "bin/",
51 "lib/"
49 - ],
50 - "tap": {
51 - "allow-incomplete-coverage": true,
52 - "show-full-coverage": true
53 - },
54 - "nyc": {
55 - "exclude": []
56 - }
52 + ]
53 }
cli/test/index.js
+82 -13
@@ -1,12 +1,78 @@
1 -const t = require('tap')
2 -const {resolve, join, posix} = require('path')
3 -const fs = require('fs/promises')
1 +const {test} = require('node:test')
2 +const assert = require('node:assert/strict')
3 +const {resolve, join, posix} = require('node:path')
4 +const fs = require('node:fs/promises')
5 +const os = require('node:os')
6 const pacote = require('pacote')
7 const yaml = require('yaml')
8 const semver = require('semver')
9
10 const navPath = resolve(__dirname, '..', '..', 'content', 'nav.yml')
11
12 +const ANCHOR = __dirname
13 +const SUT_CHAIN = [
14 + require.resolve('../lib/build'),
15 + require.resolve('../lib/extract'),
16 + require.resolve('../lib/transform'),
17 +]
18 +
19 +// Install module mocks into `require.cache`, saving any prior entry so it can
20 +// be restored on cleanup. Cleanup also evicts the SUT chain so the next test
21 +// re-evaluates `lib/build` against fresh mocks.
22 +const installMocks = mocks => {
23 + const saved = new Map()
24 + const remember = path => {
25 + if (!saved.has(path)) {
26 + saved.set(path, require.cache[path])
27 + }
28 + }
29 +
30 + for (const [key, exports] of Object.entries(mocks)) {
31 + const resolved = require.resolve(key, {paths: [ANCHOR]})
32 + remember(resolved)
33 + require.cache[resolved] = {
34 + id: resolved,
35 + filename: resolved,
36 + exports,
37 + loaded: true,
38 + children: [],
39 + paths: [],
40 + }
41 + }
42 +
43 + for (const path of SUT_CHAIN) {
44 + remember(path)
45 + delete require.cache[path]
46 + }
47 +
48 + return () => {
49 + for (const [path, entry] of saved) {
50 + if (entry === undefined) {
51 + delete require.cache[path]
52 + } else {
53 + require.cache[path] = entry
54 + }
55 + }
56 + }
57 +}
58 +
59 +// Minimal stand-in for tap's `t.testdir` — only handles the shapes our tests
60 +// use: top-level string => file, top-level {} => empty directory.
61 +const makeTestdir = async (t, contents) => {
62 + const dir = await fs.mkdtemp(join(os.tmpdir(), 'cli-doc-test-'))
63 + t.after(() => fs.rm(dir, {recursive: true, force: true}))
64 +
65 + for (const [name, value] of Object.entries(contents)) {
66 + const target = join(dir, name)
67 + if (typeof value === 'string') {
68 + await fs.writeFile(target, value, 'utf-8')
69 + } else if (value && typeof value === 'object') {
70 + await fs.mkdir(target, {recursive: true})
71 + }
72 + }
73 + return dir
74 +}
75 +
76 const getReleases = () => [
77 {
78 id: 'v6',
@@ -30,7 +96,7 @@ const mockBuild = async (t, {releases = getReleases(), packument = {}, testdir:
96 const rawNav = await fs.readFile(navPath, 'utf-8')
97 const nav = yaml.parse(rawNav)
98
33 - const testdir = t.testdir({
99 + const testdir = await makeTestdir(t, {
100 'nav.yml': rawNav,
101 content: {},
102 ...testdirOpts,
@@ -67,7 +133,7 @@ const mockBuild = async (t, {releases = getReleases(), packument = {}, testdir:
133 }
134
135 let shaCounter = 0
70 - const build = t.mockRequire('../lib/build', {
136 + const restore = installMocks({
137 pacote: {
138 ...pacote,
139 packument: async () => {
@@ -98,6 +164,9 @@ const mockBuild = async (t, {releases = getReleases(), packument = {}, testdir:
164 nwo: `npm/cli`,
165 },
166 })
167 + t.after(restore)
168 +
169 + const build = require('../lib/build')
170
171 return {
172 testdir,
@@ -112,7 +181,7 @@ const mockBuild = async (t, {releases = getReleases(), packument = {}, testdir:
181 }
182 }
183
115 -t.test('basic', async t => {
184 +test('basic', async t => {
185 const {releases, build, testdir} = await mockBuild(t, {
186 testdir: {
187 'nav.yml': '- title: cli\n url: /cli',
@@ -120,29 +189,29 @@ t.test('basic', async t => {
189 })
190
191 await build()
123 - t.strictSame(
192 + assert.deepEqual(
193 await fs.readdir(join(testdir, 'content')),
194 releases.map(r => r.id),
195 )
196 })
197
129 -t.test('prereleases', async t => {
198 +test('prereleases', async t => {
199 const {build, releases, testdir} = await mockBuild(t, {
200 packument: {versions: ['6.14.18', '7.24.2', '8.19.3', '9.0.0-pre.2'], latest: '8.19.3'},
201 })
202
203 await build({prerelease: false})
204 const expectedReleases = releases.map(r => r.id).filter(r => r !== 'v9')
136 - t.strictSame(await fs.readdir(join(testdir, 'content')), expectedReleases)
205 + assert.deepEqual(await fs.readdir(join(testdir, 'content')), expectedReleases)
206
207 await build({prerelease: true})
139 - t.strictSame(
208 + assert.deepEqual(
209 await fs.readdir(join(testdir, 'content')),
210 releases.map(r => r.id),
211 )
212 })
213
145 -t.test('earlier release is latest', async t => {
214 +test('earlier release is latest', async t => {
215 const {build} = await mockBuild(t, {
216 packument: {latest: '8.19.3'},
217 })
@@ -150,13 +219,13 @@ t.test('earlier release is latest', async t => {
219 await build()
220 })
221
153 -t.test('can skip fetching latest', async t => {
222 +test('can skip fetching latest', async t => {
223 const {build} = await mockBuild(t)
224
225 await build({useCurrent: true})
226 })
227
159 -t.test('add variant to nav', async t => {
228 +test('add variant to nav', async t => {
229 const {build} = await mockBuild(t, {
230 testdir: {
231 'nav.yml': '- title: cli\n url: /cli\n variants:\n - url: /cli/v0',
cli/test/transform.js
+29 -16
@@ -1,10 +1,23 @@
1 -const t = require('tap')
1 +const {test} = require('node:test')
2 +const assert = require('node:assert/strict')
3 const fm = require('front-matter')
4
5 +// Stub `cli/lib/gh.js` in require.cache before requiring transform — gh.js
6 +// otherwise tries to read process.env.GITHUB_TOKEN / shell out to `gh auth
7 +// token` at load time.
8 +const ghPath = require.resolve('../lib/gh')
9 +require.cache[ghPath] = {
10 + id: ghPath,
11 + filename: ghPath,
12 + exports: {nwo: 'npm/cli'},
13 + loaded: true,
14 + children: [],
15 + paths: [],
16 +}
17 +
18 +const Transform = require('../lib/transform')
19 +
20 const transform = ({id, path}) => {
5 - const Transform = t.mockRequire('../lib/transform', {
6 - '../lib/gh.js': {nwo: 'npm/cli'},
7 - })
21 const transformed = Transform.sync('---\n---\n', {
22 release: {
23 id: id,
@@ -18,11 +31,11 @@ const transform = ({id, path}) => {
31 return fm(transformed).attributes
32 }
33
21 -t.test('v6 default page', async t => {
34 +test('v6 default page', () => {
35 const v6 = transform({id: 'v6', path: 'configuring-npm/package-locks'})
36 const v7 = transform({id: 'v7', path: 'configuring-npm/package-locks'})
37
25 - t.strictSame(v6.redirect_from, [
38 + assert.deepEqual(v6.redirect_from, [
39 '/cli/configuring-npm/package-locks',
40 '/cli/files/package-locks',
41 '/cli/v6/configuring-npm/package-locks',
@@ -30,14 +43,14 @@ t.test('v6 default page', async t => {
43 '/configuring-npm/package-locks',
44 '/files/package-locks',
45 ])
33 - t.strictSame(v7.redirect_from, ['/cli/v7/configuring-npm/package-locks', '/cli/v7/files/package-locks'])
46 + assert.deepEqual(v7.redirect_from, ['/cli/v7/configuring-npm/package-locks', '/cli/v7/files/package-locks'])
47 })
48
36 -t.test('command', async t => {
49 +test('command', () => {
50 const v7 = transform({id: 'v7', path: 'commands/npm-bin'})
51 const v8 = transform({id: 'v8', path: 'commands/npm-bin'})
52
40 - t.strictSame(v7.redirect_from, [
53 + assert.deepEqual(v7.redirect_from, [
54 '/cli/v7/bin',
55 '/cli/v7/cli-commands/bin',
56 '/cli/v7/cli-commands/npm-bin',
@@ -45,7 +58,7 @@ t.test('command', async t => {
58 '/cli/v7/commands/npm-bin',
59 '/cli/v7/npm-bin',
60 ])
48 - t.strictSame(v8.redirect_from, [
61 + assert.deepEqual(v8.redirect_from, [
62 '/cli-commands/bin',
63 '/cli-commands/npm-bin',
64 '/cli/bin',
@@ -65,17 +78,17 @@ t.test('command', async t => {
78 ])
79 })
80
68 -t.test('package-json files', async t => {
81 +test('package-json files', () => {
82 const v7 = transform({id: 'v7', path: 'configuring-npm/package-json'})
83 const v8 = transform({id: 'v8', path: 'configuring-npm/package-json'})
84
72 - t.strictSame(v7.redirect_from, [
85 + assert.deepEqual(v7.redirect_from, [
86 '/cli/v7/configuring-npm/package-json',
87 '/cli/v7/configuring-npm/package.json',
88 '/cli/v7/files/package-json',
89 '/cli/v7/files/package.json',
90 ])
78 - t.strictSame(v8.redirect_from, [
91 + assert.deepEqual(v8.redirect_from, [
92 '/cli/configuring-npm/package-json',
93 '/cli/configuring-npm/package.json',
94 '/cli/files/package-json',
@@ -91,8 +104,8 @@ t.test('package-json files', async t => {
104 ])
105 })
106
94 -t.test('registry signatures', async t => {
95 - t.strictSame(
107 +test('registry signatures', () => {
108 + assert.deepEqual(
109 transform({
110 id: 'v8',
111 path: 'about-pgp-signatures-for-packages-in-the-public-registry',
@@ -103,7 +116,7 @@ t.test('registry signatures', async t => {
116 '/cli/v8/about-pgp-signatures-for-packages-in-the-public-registry',
117 ],
118 )
106 - t.strictSame(
119 + assert.deepEqual(
120 transform({
121 id: 'v8',
122 path: 'verifying-the-pgp-signature-for-a-package-from-the-npm-public-registry',