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',