From 84ad62b70b06c2d7d315257d4da0157fe02d3f3f Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 17 Dec 2025 13:08:19 -0800 Subject: [PATCH 1/2] chore: move to node:test --- .github/workflows/audit.yml | 4 +- .github/workflows/ci-release.yml | 16 +- .github/workflows/ci.yml | 16 +- .github/workflows/post-dependabot.yml | 4 +- .github/workflows/pull-request.yml | 4 +- .github/workflows/release-integration.yml | 4 +- .github/workflows/release.yml | 8 +- package.json | 18 +- test/fixtures/testdir.js | 104 ++++++++ test/make-spawn-args.js | 59 +++-- test/run-script-pkg.js | 120 +++++---- test/run-script.js | 94 +++---- test/signal-manager.js | 24 +- test/testfile.js | 304 ++++++++++++++++++++++ test/validate-options.js | 9 +- 15 files changed, 612 insertions(+), 176 deletions(-) create mode 100644 test/fixtures/testdir.js create mode 100644 test/testfile.js diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 85282bd..2543d79 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -30,8 +30,8 @@ jobs: uses: actions/setup-node@v4 id: node with: - node-version: 22.x - check-latest: contains('22.x', '.x') + node-version: 24.x + check-latest: contains('24.x', '.x') - name: Install Latest npm uses: ./.github/actions/install-latest-npm with: diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index e9ab5ff..94bcb30 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -51,8 +51,8 @@ jobs: uses: actions/setup-node@v4 id: node with: - node-version: 22.x - check-latest: contains('22.x', '.x') + node-version: 24.x + check-latest: contains('24.x', '.x') - name: Install Latest npm uses: ./.github/actions/install-latest-npm with: @@ -95,6 +95,7 @@ jobs: - 20.x - 22.9.0 - 22.x + - 24.x exclude: - platform: { name: macOS, os: macos-13, shell: bash } node-version: 20.17.0 @@ -104,6 +105,8 @@ jobs: node-version: 22.9.0 - platform: { name: macOS, os: macos-13, shell: bash } node-version: 22.x + - platform: { name: macOS, os: macos-13, shell: bash } + node-version: 24.x runs-on: ${{ matrix.platform.os }} defaults: run: @@ -137,9 +140,14 @@ jobs: node: ${{ steps.node.outputs.node-version }} - name: Install Dependencies run: npm i --ignore-scripts --no-audit --no-fund - - name: Add Problem Matcher - run: echo "::add-matcher::.github/matchers/tap.json" + - name: Test (with coverage on Node >= 24) + if: ${{ startsWith(matrix.node-version, '24') }} + run: npm run test:cover --ignore-scripts + - name: Test (on Node 20 with globbing workaround) + if: ${{ startsWith(matrix.node-version, '20') }} + run: npm run test:node20 --ignore-scripts - name: Test + if: ${{ !startsWith(matrix.node-version, '24') && !startsWith(matrix.node-version, '20') }} run: npm test --ignore-scripts - name: Conclude Check uses: LouisBrunner/checks-action@v1.6.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92a33b5..ecfdefb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,8 @@ jobs: uses: actions/setup-node@v4 id: node with: - node-version: 22.x - check-latest: contains('22.x', '.x') + node-version: 24.x + check-latest: contains('24.x', '.x') - name: Install Latest npm uses: ./.github/actions/install-latest-npm with: @@ -71,6 +71,7 @@ jobs: - 20.x - 22.9.0 - 22.x + - 24.x exclude: - platform: { name: macOS, os: macos-13, shell: bash } node-version: 20.17.0 @@ -80,6 +81,8 @@ jobs: node-version: 22.9.0 - platform: { name: macOS, os: macos-13, shell: bash } node-version: 22.x + - platform: { name: macOS, os: macos-13, shell: bash } + node-version: 24.x runs-on: ${{ matrix.platform.os }} defaults: run: @@ -103,7 +106,12 @@ jobs: node: ${{ steps.node.outputs.node-version }} - name: Install Dependencies run: npm i --ignore-scripts --no-audit --no-fund - - name: Add Problem Matcher - run: echo "::add-matcher::.github/matchers/tap.json" + - name: Test (with coverage on Node >= 24) + if: ${{ startsWith(matrix.node-version, '24') }} + run: npm run test:cover --ignore-scripts + - name: Test (on Node 20 with globbing workaround) + if: ${{ startsWith(matrix.node-version, '20') }} + run: npm run test:node20 --ignore-scripts - name: Test + if: ${{ !startsWith(matrix.node-version, '24') && !startsWith(matrix.node-version, '20') }} run: npm test --ignore-scripts diff --git a/.github/workflows/post-dependabot.yml b/.github/workflows/post-dependabot.yml index 3a91911..8e80d10 100644 --- a/.github/workflows/post-dependabot.yml +++ b/.github/workflows/post-dependabot.yml @@ -28,8 +28,8 @@ jobs: uses: actions/setup-node@v4 id: node with: - node-version: 22.x - check-latest: contains('22.x', '.x') + node-version: 24.x + check-latest: contains('24.x', '.x') - name: Install Latest npm uses: ./.github/actions/install-latest-npm with: diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index c69932d..9ecf311 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -34,8 +34,8 @@ jobs: uses: actions/setup-node@v4 id: node with: - node-version: 22.x - check-latest: contains('22.x', '.x') + node-version: 24.x + check-latest: contains('24.x', '.x') - name: Install Latest npm uses: ./.github/actions/install-latest-npm with: diff --git a/.github/workflows/release-integration.yml b/.github/workflows/release-integration.yml index 9ca9a2b..195f50a 100644 --- a/.github/workflows/release-integration.yml +++ b/.github/workflows/release-integration.yml @@ -45,8 +45,8 @@ jobs: uses: actions/setup-node@v4 id: node with: - node-version: 22.x - check-latest: contains('22.x', '.x') + node-version: 24.x + check-latest: contains('24.x', '.x') - name: Install Latest npm uses: ./.github/actions/install-latest-npm with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53ff3c2..863f9eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,8 +39,8 @@ jobs: uses: actions/setup-node@v4 id: node with: - node-version: 22.x - check-latest: contains('22.x', '.x') + node-version: 24.x + check-latest: contains('24.x', '.x') - name: Install Latest npm uses: ./.github/actions/install-latest-npm with: @@ -119,8 +119,8 @@ jobs: uses: actions/setup-node@v4 id: node with: - node-version: 22.x - check-latest: contains('22.x', '.x') + node-version: 24.x + check-latest: contains('24.x', '.x') - name: Install Latest npm uses: ./.github/actions/install-latest-npm with: diff --git a/package.json b/package.json index a3aebbc..8abdaf8 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,16 @@ "author": "GitHub Inc.", "license": "ISC", "scripts": { - "test": "tap", + "test": "node --test './test/**/*.js'", "eslint": "eslint \"**/*.{js,cjs,ts,mjs,jsx,tsx}\"", "lint": "npm run eslint", "lintfix": "npm run eslint -- --fix", "postlint": "template-oss-check", - "snap": "tap", + "snap": "node --test --test-update-snapshots './test/**/*.js'", "posttest": "npm run lint", - "template-oss-apply": "template-oss-apply --force" + "template-oss-apply": "template-oss-apply --force", + "test:node20": "node --test test", + "test:cover": "node --test --experimental-test-coverage --test-timeout=3000 --test-coverage-lines=100 --test-coverage-functions=100 --test-coverage-branches=100 './test/**/*.js'" }, "devDependencies": { "@npmcli/eslint-config": "^6.0.0", @@ -43,12 +45,8 @@ "templateOSS": { "//@npmcli/template-oss": "This file is partially managed by @npmcli/template-oss. Edits may be overwritten.", "version": "4.28.1", - "publish": "true" - }, - "tap": { - "nyc-arg": [ - "--exclude", - "tap-snapshots/**" - ] + "publish": "true", + "testRunner": "node:test", + "latestCiVersion": 24 } } diff --git a/test/fixtures/testdir.js b/test/fixtures/testdir.js new file mode 100644 index 0000000..fdba23d --- /dev/null +++ b/test/fixtures/testdir.js @@ -0,0 +1,104 @@ +const fs = require('node:fs') +const path = require('node:path') + +const SYMLINK = Symbol('symlink') +const LINK = Symbol('link') +const dirsToClean = [] +let cleanupRegistered = false + +function symlink (target) { + return { [SYMLINK]: true, target } +} + +function link (target) { + return { [LINK]: true, target } +} + +// Get the caller's file path (for Node versions where t.filePath isn't available) +// This can be removed once we no longer support Node version 20 +function getCallerFile () { + const originalPrepareStackTrace = Error.prepareStackTrace + Error.prepareStackTrace = (_, stack) => stack + const err = new Error() + const stack = err.stack + Error.prepareStackTrace = originalPrepareStackTrace + // stack[0] is getCallerFile, stack[1] is testdir, stack[2] is the caller + const callerFile = stack[2]?.getFileName() + return callerFile +} + +// Sanitize test name for use in file paths (removes Windows-invalid characters) +function sanitizeTestName (name) { + return name + .split(' ').join('-') + .replace(/[<>:"/\\|?*]/g, '_') +} + +function testdir (testContext, structure = {}, options = {}) { + registerCleanup() + + // Use t.filePath if available (Node 22.6+, 20.16+), otherwise get it from stack trace + const callerFile = testContext.filePath || getCallerFile() + const fixturePath = path.join(path.dirname(callerFile), 'testdir-' + sanitizeTestName(testContext.name)) + + // Remove any existing fixture to avoid EEXIST errors + fs.rmSync(fixturePath, { recursive: true, force: true }) + + if (!options.saveFixture) { + dirsToClean.push(fixturePath) + } + + // If structure is a string or Buffer, create a file instead of a directory + if (typeof structure === 'string' || Buffer.isBuffer(structure)) { + fs.mkdirSync(path.dirname(fixturePath), { recursive: true }) + fs.writeFileSync(fixturePath, structure) + return fixturePath + } + + // Create the temporary folder for testing + fs.mkdirSync(fixturePath, { recursive: true }) + createStructure(fixturePath, structure) + + return fixturePath +} + +function registerCleanup () { + if (!cleanupRegistered) { + cleanupRegistered = true + process.on('exit', () => { + for (const dir of dirsToClean) { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + } + }) + } +} + +function createStructure (basePath, structure) { + for (const [name, content] of Object.entries(structure)) { + const fullPath = path.join(basePath, name) + if (content && content[SYMLINK]) { + // Symlink - target is relative to the symlink's location + fs.symlinkSync(content.target, fullPath) + } else if (content && content[LINK]) { + // Hard link - target is relative to the link's location + const targetPath = path.resolve(path.dirname(fullPath), content.target) + fs.linkSync(targetPath, fullPath) + } else if (Buffer.isBuffer(content)) { + // Buffer content - write as binary file + fs.writeFileSync(fullPath, content) + } else if (typeof content === 'object' && content !== null) { + fs.mkdirSync(fullPath, { recursive: true }) + createStructure(fullPath, content) + } else { + fs.writeFileSync(fullPath, content) + } + } +} + +module.exports = testdir +module.exports.symlink = symlink +module.exports.link = link diff --git a/test/make-spawn-args.js b/test/make-spawn-args.js index 110f28f..dcd2d07 100644 --- a/test/make-spawn-args.js +++ b/test/make-spawn-args.js @@ -1,4 +1,6 @@ -const t = require('tap') +const { describe, it, before } = require('node:test') +const assert = require('node:assert') +const testdirFn = require('./fixtures/testdir.js') const spawk = require('spawk') const runScript = require('..') @@ -22,9 +24,12 @@ const pkg = { }, } -t.test('spawn args', async t => { - const testdir = t.testdir({}) - await t.test('defaults', async t => { +describe('spawn args', (t) => { + let testdir + before(() => { + testdir = testdirFn(t, {}) + }) + it('defaults', async () => { spawk.spawn( /.*/, a => a.includes('echo test'), @@ -49,15 +54,15 @@ t.test('spawn args', async t => { e.env.npm_config_node_gyp === require.resolve('node-gyp/bin/node-gyp.js') } ) - await t.resolves(() => runScript({ + await runScript({ pkg, path: testdir, event: 'test', - })) - t.ok(spawk.done()) + }) + assert.ok(spawk.done()) }) - await t.test('provided env', async t => { + it('provided env', async () => { spawk.spawn( /.*/, a => a.includes('echo test'), @@ -66,7 +71,7 @@ t.test('spawn args', async t => { e.env.npm_config_node_gyp === '/test/path.js' } ) - await t.resolves(() => runScript({ + await runScript({ pkg, path: testdir, env: { @@ -74,11 +79,11 @@ t.test('spawn args', async t => { test_fixture: 'a string', }, event: 'test', - })) - t.ok(spawk.done()) + }) + assert.ok(spawk.done()) }) - await t.test('provided options.nodeGyp', async t => { + it('provided options.nodeGyp', async () => { spawk.spawn( /.*/, a => a.includes('echo test'), @@ -86,30 +91,30 @@ t.test('spawn args', async t => { return e.env.npm_config_node_gyp === '/test/path.js' } ) - await t.resolves(() => runScript({ + await runScript({ pkg, path: testdir, nodeGyp: '/test/path.js', event: 'test', - })) - t.ok(spawk.done()) + }) + assert.ok(spawk.done()) }) - await t.test('provided args', async t => { + it('provided args', async () => { spawk.spawn( /.*/, a => a.find(arg => arg.includes('echo test') && arg.includes('argtest')) ) - await t.resolves(() => runScript({ + await runScript({ pkg, path: testdir, args: ['argtest'], event: 'test', - })) - t.ok(spawk.done()) + }) + assert.ok(spawk.done()) }) - t.test('event with invalid characters', async t => { + it('event with invalid characters', async () => { spawk.spawn( /.*/, a => a.includes('echo weird'), @@ -118,27 +123,27 @@ t.test('spawn args', async t => { e.env.npm_lifecycle_script === 'echo weird' } ) - await t.resolves(() => runScript({ + await runScript({ pkg, path: testdir, event: 'weird\x04', - })) - t.ok(spawk.done()) + }) + assert.ok(spawk.done()) }) - await t.test('provided binPaths', async t => { + it('provided binPaths', async () => { spawk.spawn( /.*/, false, e => (e.env.PATH || e.env.Path).startsWith('/tmp/test-fixture/binpath') ) - await t.resolves(() => runScript({ + await runScript({ pkg, binPaths: ['/tmp/test-fixture/binpath'], path: testdir, args: ['test arg'], event: 'test', - })) - t.ok(spawk.done()) + }) + assert.ok(spawk.done()) }) }) diff --git a/test/run-script-pkg.js b/test/run-script-pkg.js index b9ab86d..967ac68 100644 --- a/test/run-script-pkg.js +++ b/test/run-script-pkg.js @@ -1,9 +1,11 @@ -const t = require('tap') +const { describe, it, before, after, afterEach } = require('node:test') +const assert = require('node:assert') +const testdir = require('./fixtures/testdir.js') const spawk = require('spawk') const runScript = require('..') const isWindows = process.platform === 'win32' -const emptyDir = t.testdir({}) +let emptyDir const pkill = process.kill @@ -13,12 +15,18 @@ const appendOutput = (level, ...args) => { output.push([...args]) } } -process.on('output', appendOutput) -t.afterEach(() => output.length = 0) -t.teardown(() => process.removeListener('output', appendOutput)) -t.test('run-script-pkg', async t => { - await t.test('stdio inherit no args and a pkgid', async t => { +before((t) => { + emptyDir = testdir(t, {}) + process.on('output', appendOutput) +}) + +afterEach(() => output.length = 0) + +after(() => process.removeListener('output', appendOutput)) + +describe('run-script-pkg', () => { + it('stdio inherit no args and a pkgid', async () => { spawk.spawn('sh', a => a.includes('bar\nbaz\n')) await runScript({ event: 'foo', @@ -34,11 +42,11 @@ t.test('run-script-pkg', async t => { scripts: {}, }, }) - t.strictSame(output, [['\n> foo@1.2.3 foo\n> bar\n> baz\n']]) - t.ok(spawk.done()) + assert.deepStrictEqual(output, [['\n> foo@1.2.3 foo\n> bar\n> baz\n']]) + assert.ok(spawk.done()) }) - await t.test('stdio inherit args and no pkgid', async t => { + it('stdio inherit args and no pkgid', async () => { spawk.spawn('sh', a => a.includes('bar baz buzz')) await runScript({ event: 'foo', @@ -54,11 +62,11 @@ t.test('run-script-pkg', async t => { scripts: {}, }, }) - t.strictSame(output, [['\n> foo\n> bar baz buzz\n']]) - t.ok(spawk.done()) + assert.deepStrictEqual(output, [['\n> foo\n> bar baz buzz\n']]) + assert.ok(spawk.done()) }) - await t.test('pkg has foo script, with stdio pipe', async t => { + it('pkg has foo script, with stdio pipe', async () => { spawk.spawn('sh', a => a.includes('bar')) await runScript({ event: 'foo', @@ -75,11 +83,11 @@ t.test('run-script-pkg', async t => { }, }, }) - t.strictSame(output, []) - t.ok(spawk.done()) + assert.deepStrictEqual(output, []) + assert.ok(spawk.done()) }) - await t.test('pkg has foo script, with stdio pipe and args', async t => { + it('pkg has foo script, with stdio pipe and args', async () => { spawk.spawn('sh', a => a.includes('bar a b c')) await runScript({ event: 'foo', @@ -98,20 +106,20 @@ t.test('run-script-pkg', async t => { args: ['a', 'b', 'c'], binPaths: false, }) - t.strictSame(output, []) - t.ok(spawk.done()) + assert.deepStrictEqual(output, []) + assert.ok(spawk.done()) }) /* eslint-disable-next-line max-len */ - await t.test('pkg has no install or preinstall script, node-gyp files present, stdio pipe', async t => { - const testdir = t.testdir({ + it('pkg has no install or preinstall script, node-gyp files present, stdio pipe', async (t) => { + const dir = testdir(t, { 'binding.gyp': 'exists', }) spawk.spawn('sh', a => a.includes('node-gyp rebuild')) await runScript({ event: 'install', - path: testdir, + path: dir, scriptShell: 'sh', env: { environ: 'value', @@ -122,18 +130,18 @@ t.test('run-script-pkg', async t => { scripts: {}, }, }) - t.strictSame(output, []) - t.ok(spawk.done()) + assert.deepStrictEqual(output, []) + assert.ok(spawk.done()) }) - t.test('pkg has no install or preinstall script, but gypfile:false, stdio pipe', async t => { - const testdir = t.testdir({ + it('pkg has no install or preinstall script, but gypfile:false, stdio pipe', async (t) => { + const dir = testdir(t, { 'binding.gyp': 'exists', }) const res = await runScript({ event: 'install', - path: testdir, + path: dir, scriptShell: 'sh', env: { environ: 'value', @@ -146,11 +154,11 @@ t.test('run-script-pkg', async t => { }, }, }) - t.strictSame(output, []) - t.strictSame(res, { code: 0, signal: null }) + assert.deepStrictEqual(output, []) + assert.deepStrictEqual(res, { code: 0, signal: null }) }) - t.test('end stdin if present', async t => { + it('end stdin if present', async () => { const interceptor = spawk.spawn('sh', a => a.includes('cat')) await runScript({ event: 'cat', @@ -163,12 +171,12 @@ t.test('run-script-pkg', async t => { }, }, }) - t.ok(spawk.done()) - t.ok(interceptor.calledWith.stdio[0].writableEnded, 'stdin was ended properly') + assert.ok(spawk.done()) + assert.ok(interceptor.calledWith.stdio[0].writableEnded, 'stdin was ended properly') }) - await t.test('kill process when foreground process ends with signal, stdio inherit', async t => { - t.teardown(() => { + it('kill process when foreground process ends with signal, stdio inherit (first)', async () => { + after(() => { process.kill = pkill }) let pid @@ -180,7 +188,7 @@ t.test('run-script-pkg', async t => { throw new Error('process killed') } spawk.spawn('sh', a => a.includes('sleep 1000000')).signal('SIGFOO') - await t.rejects(runScript({ + await assert.rejects(runScript({ event: 'sleep', path: emptyDir, scriptShell: 'sh', @@ -196,16 +204,16 @@ t.test('run-script-pkg', async t => { }, }, })) - t.strictSame(output, [['\n> husky@1.2.3 sleep\n> sleep 1000000\n']]) - t.ok(spawk.done()) + assert.deepStrictEqual(output, [['\n> husky@1.2.3 sleep\n> sleep 1000000\n']]) + assert.ok(spawk.done()) if (!isWindows) { - t.equal(signal, 'SIGFOO', 'process.kill got expected signal') - t.equal(pid, process.pid, 'process.kill got expected pid') + assert.strictEqual(signal, 'SIGFOO', 'process.kill got expected signal') + assert.strictEqual(pid, process.pid, 'process.kill got expected pid') } }) - await t.test('kill process when foreground process ends with signal, stdio inherit', async t => { - t.teardown(() => { + it('kill process when foreground process ends with signal, stdio inherit (2)', async () => { + after(() => { process.kill = pkill }) let pid @@ -217,7 +225,7 @@ t.test('run-script-pkg', async t => { throw new Error('process killed') } spawk.spawn('sh', a => a.includes('sleep 1000000')).signal('SIGFOO') - await t.rejects(runScript({ + await assert.rejects(runScript({ event: 'sleep', path: emptyDir, scriptShell: 'sh', @@ -233,16 +241,16 @@ t.test('run-script-pkg', async t => { }, }, })) - t.strictSame(output, [['\n> husky@1.2.3 sleep\n> sleep 1000000\n']]) - t.ok(spawk.done()) + assert.deepStrictEqual(output, [['\n> husky@1.2.3 sleep\n> sleep 1000000\n']]) + assert.ok(spawk.done()) if (!isWindows) { - t.equal(signal, 'SIGFOO', 'process.kill got expected signal') - t.equal(pid, process.pid, 'process.kill got expected pid') + assert.strictEqual(signal, 'SIGFOO', 'process.kill got expected signal') + assert.strictEqual(pid, process.pid, 'process.kill got expected pid') } }) - t.test('rejects if process.kill fails to end process, stdio inherit', async t => { - t.teardown(() => { + it('rejects if process.kill fails to end process, stdio inherit', async () => { + after(() => { process.kill = pkill }) let pid @@ -253,7 +261,7 @@ t.test('run-script-pkg', async t => { // do nothing here to emulate process.kill not killing the process } spawk.spawn('sh', a => a.includes('sleep 1000000')).signal('SIGFOO') - await t.rejects(runScript({ + await assert.rejects(runScript({ event: 'sleep', path: emptyDir, stdio: 'inherit', @@ -269,17 +277,17 @@ t.test('run-script-pkg', async t => { }, }, })) - t.strictSame(output, [['\n> husky@1.2.3 sleep\n> sleep 1000000\n']]) - t.ok(spawk.done()) + assert.deepStrictEqual(output, [['\n> husky@1.2.3 sleep\n> sleep 1000000\n']]) + assert.ok(spawk.done()) if (!isWindows) { - t.equal(signal, 'SIGFOO', 'process.kill got expected signal') - t.equal(pid, process.pid, 'process.kill got expected pid') + assert.strictEqual(signal, 'SIGFOO', 'process.kill got expected signal') + assert.strictEqual(pid, process.pid, 'process.kill got expected pid') } }) - t.test('rejects if stdio is not inherit', async t => { + it('rejects if stdio is not inherit', async () => { spawk.spawn('sh', a => a.includes('sleep 1000000')).signal('SIGFOO') - await t.rejects(runScript({ + await assert.rejects(runScript({ event: 'sleep', path: emptyDir, banner: false, @@ -294,7 +302,7 @@ t.test('run-script-pkg', async t => { }, }, })) - t.strictSame(output, []) - t.ok(spawk.done()) + assert.deepStrictEqual(output, []) + assert.ok(spawk.done()) }) }) diff --git a/test/run-script.js b/test/run-script.js index c63a4cb..627327f 100644 --- a/test/run-script.js +++ b/test/run-script.js @@ -1,12 +1,14 @@ -const t = require('tap') +const { describe, it } = require('node:test') +const assert = require('node:assert') +const testdir = require('./fixtures/testdir.js') const spawk = require('spawk') const runScript = require('..') -t.test('run-script', async t => { - const emptyDir = t.testdir({}) - await t.test('no package provided, local package read', async t => { +describe('run-script', () => { + let emptyDir + it('no package provided, local package read', async (t) => { spawk.spawn(/.*/, a => a.includes('echo test')) - const testdir = t.testdir({ + const dir = testdir(t, { 'package.json': JSON.stringify({ name: '@npmcli/run-script-test-package', scripts: { @@ -14,16 +16,17 @@ t.test('run-script', async t => { }, }), }) - await t.resolves(() => runScript({ - path: testdir, + await runScript({ + path: dir, event: 'test', - })) - t.ok(spawk.done()) + }) + assert.ok(spawk.done()) }) - await t.test('package provided, skip look up', async t => { + it('package provided, skip look up', async (t) => { + emptyDir = testdir(t, {}) spawk.spawn(/.*/, a => a.includes('echo test')) - await t.resolves(() => runScript({ + await runScript({ pkg: { name: '@npmcli/run-script-test-package', scripts: { @@ -32,20 +35,20 @@ t.test('run-script', async t => { }, path: emptyDir, event: 'test', - })) - t.ok(spawk.done()) + }) + assert.ok(spawk.done()) }) - await t.test('non-install event, pkg has no scripts, early exit', async t => { + it('non-install event, pkg has no scripts, early exit', async () => { const res = await runScript({ event: 'foo', path: emptyDir, pkg: {}, }) - t.strictSame(res, { code: 0, signal: null }) + assert.deepStrictEqual(res, { code: 0, signal: null }) }) - await t.test('non-install event, pkg does not have requested script', async t => { + it('non-install event, pkg does not have requested script', async () => { const res = await runScript({ event: 'foo', path: emptyDir, @@ -53,29 +56,29 @@ t.test('run-script', async t => { scripts: {}, }, }) - t.strictSame(res, { code: 0, signal: null }) + assert.deepStrictEqual(res, { code: 0, signal: null }) }) - await t.test('install event, pkg has no scripts, early exit', async t => { + it('install event, pkg has no scripts, early exit', async () => { const res = await runScript({ event: 'install', path: emptyDir, pkg: {}, }) - t.strictSame(res, { code: 0, signal: null }) + assert.deepStrictEqual(res, { code: 0, signal: null }) }) - await t.test('start event, pkg has no scripts, no server.js', async t => { + it('start event, pkg has no scripts, no server.js', async () => { const res = await runScript({ event: 'start', path: emptyDir, pkg: {}, }) - t.strictSame(res, { code: 0, signal: null }) + assert.deepStrictEqual(res, { code: 0, signal: null }) }) - await t.test('start event, pkg has server.js but no start script', async t => { - const path = t.testdir({ 'server.js': '' }) + it('start event, pkg has server.js but no start script', async (t) => { + const path = testdir(t, { 'server.js': '' }) spawk.spawn(/.*/, a => a.includes('node server.js')) const res = await runScript({ event: 'start', @@ -85,14 +88,12 @@ t.test('run-script', async t => { scripts: {}, }, }) - t.match(res, { - event: 'start', - script: 'node server.js', - pkgid: '@npmcli/run-script-test@1.2.3', - }) + assert.strictEqual(res.event, 'start') + assert.strictEqual(res.script, 'node server.js') + assert.strictEqual(res.pkgid, '@npmcli/run-script-test@1.2.3') }) - await t.test('pkg does not have requested script, with custom cmd', async t => { + it('pkg does not have requested script, with custom cmd', async () => { spawk.spawn(/.*/, a => a.includes('testcmd')) const res = await runScript({ event: 'foo', @@ -102,31 +103,32 @@ t.test('run-script', async t => { scripts: {}, }, }) - t.match(res, { - event: 'foo', - script: 'testcmd', - code: 0, - signal: null, - }) - t.ok(spawk.done()) + assert.strictEqual(res.event, 'foo') + assert.strictEqual(res.script, 'testcmd') + assert.strictEqual(res.code, 0) + assert.strictEqual(res.signal ?? null, null) + assert.ok(spawk.done()) }) }) -t.test('isServerPackage', async t => { - await t.test('is server package', async t => { - const testdir = t.testdir({ +describe('isServerPackage', () => { + it('is server package', async (t) => { + const dir = testdir(t, { 'server.js': '', }) - await t.resolves(runScript.isServerPackage(testdir), true) + const result = await runScript.isServerPackage(dir) + assert.strictEqual(result, true) }) - await t.test('is not server package - no server.js', async t => { - const testdir = t.testdir({}) - await t.resolves(runScript.isServerPackage(testdir), false) + it('is not server package - no server.js', async (t) => { + const dir = testdir(t, {}) + const result = await runScript.isServerPackage(dir) + assert.strictEqual(result, false) }) - await t.test('is not server package - invalid server.js', async t => { - const testdir = t.testdir({ + it('is not server package - invalid server.js', async (t) => { + const dir = testdir(t, { 'server.js': {}, }) - await t.resolves(runScript.isServerPackage(testdir), false) + const result = await runScript.isServerPackage(dir) + assert.strictEqual(result, false) }) }) diff --git a/test/signal-manager.js b/test/signal-manager.js index afc0ab3..948b36c 100644 --- a/test/signal-manager.js +++ b/test/signal-manager.js @@ -1,21 +1,22 @@ const { EventEmitter } = require('events') -const { test } = require('tap') +const { it } = require('node:test') +const assert = require('node:assert') const signalManager = require('../lib/signal-manager') -test('adds only one handler for each signal, removes handlers when children have exited', t => { +it('adds only one handler for each signal, removes handlers when children have exited', () => { const procOne = new EventEmitter() const procTwo = new EventEmitter() for (const signal of signalManager.forwardedSignals) { - t.equal( + assert.strictEqual( process.listeners(signal).includes(signalManager.handleSignal), false, 'does not have a listener yet') } signalManager.add(procOne) for (const signal of signalManager.forwardedSignals) { - t.equal( + assert.strictEqual( process.listeners(signal).includes(signalManager.handleSignal), true, 'has a listener for forwarded signals') } @@ -23,13 +24,13 @@ test('adds only one handler for each signal, removes handlers when children have signalManager.add(procTwo) for (const signal of signalManager.forwardedSignals) { const handlers = process.listeners(signal).filter((fn) => fn === signalManager.handleSignal) - t.equal(handlers.length, 1, 'only has one handler') + assert.strictEqual(handlers.length, 1, 'only has one handler') } procOne.emit('exit', 0) for (const signal of signalManager.forwardedSignals) { - t.equal( + assert.strictEqual( process.listeners(signal).includes(signalManager.handleSignal), true, 'did not remove listeners yet') } @@ -37,25 +38,22 @@ test('adds only one handler for each signal, removes handlers when children have procTwo.emit('exit', 0) for (const signal of signalManager.forwardedSignals) { - t.equal( + assert.strictEqual( process.listeners(signal).includes(signalManager.handleSignal), false, 'listener has been removed') } - - t.end() }) -test('forwards signals to child process', t => { +it('forwards signals to child process', () => { const proc = new EventEmitter() proc.kill = (signal) => { - t.equal(signal, signalManager.forwardedSignals[0], 'child receives correct signal') + assert.strictEqual(signal, signalManager.forwardedSignals[0], 'child receives correct signal') proc.emit('exit', 0) for (const forwarded of signalManager.forwardedSignals) { - t.equal( + assert.strictEqual( process.listeners(forwarded).includes(signalManager.handleSignal), false, 'listener has been removed') } - t.end() } signalManager.add(proc) diff --git a/test/testfile.js b/test/testfile.js new file mode 100644 index 0000000..39bf4dc --- /dev/null +++ b/test/testfile.js @@ -0,0 +1,304 @@ +const testdir = require('./fixtures/testdir.js') +const { symlink, link } = require('./fixtures/testdir.js') +const { describe, it } = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const path = require('node:path') + +describe('testdir', () => { + describe('basic directory creation', () => { + it('creates an empty directory', (t) => { + const dir = testdir(t, {}) + assert.ok(dir, 'returns a path') + assert.ok(fs.existsSync(dir), 'directory exists') + assert.ok(fs.statSync(dir).isDirectory(), 'is a directory') + }) + + it('creates a directory with a single file', (t) => { + const dir = testdir(t, { 'file.txt': 'content' }) + const filePath = path.join(dir, 'file.txt') + assert.ok(fs.existsSync(filePath), 'file exists') + assert.strictEqual(fs.readFileSync(filePath, 'utf8'), 'content') + }) + + it('creates a directory with multiple files', (t) => { + const dir = testdir(t, { + 'file1.txt': 'content1', + 'file2.txt': 'content2', + 'file3.json': '{"key": "value"}', + }) + assert.strictEqual(fs.readFileSync(path.join(dir, 'file1.txt'), 'utf8'), 'content1') + assert.strictEqual(fs.readFileSync(path.join(dir, 'file2.txt'), 'utf8'), 'content2') + assert.strictEqual(fs.readFileSync(path.join(dir, 'file3.json'), 'utf8'), '{"key": "value"}') + }) + }) + + describe('nested directory structure', () => { + it('creates nested directories', (t) => { + const dir = testdir(t, { + subdir: { + 'nested.txt': 'nested content', + }, + }) + const nestedPath = path.join(dir, 'subdir', 'nested.txt') + assert.ok(fs.existsSync(nestedPath), 'nested file exists') + assert.strictEqual(fs.readFileSync(nestedPath, 'utf8'), 'nested content') + }) + + it('creates deeply nested directories', (t) => { + const dir = testdir(t, { + level1: { + level2: { + level3: { + 'deep.txt': 'deep content', + }, + }, + }, + }) + const deepPath = path.join(dir, 'level1', 'level2', 'level3', 'deep.txt') + assert.ok(fs.existsSync(deepPath), 'deeply nested file exists') + assert.strictEqual(fs.readFileSync(deepPath, 'utf8'), 'deep content') + }) + + it('creates empty nested directories', (t) => { + const dir = testdir(t, { + emptyDir: {}, + }) + const emptyDirPath = path.join(dir, 'emptyDir') + assert.ok(fs.existsSync(emptyDirPath), 'empty directory exists') + assert.ok(fs.statSync(emptyDirPath).isDirectory(), 'is a directory') + }) + }) + + describe('string as structure (file mode)', () => { + it('creates a file instead of directory when structure is a string', (t) => { + const file = testdir(t, 'file contents') + assert.ok(fs.existsSync(file), 'file exists') + assert.ok(fs.statSync(file).isFile(), 'is a file, not a directory') + assert.strictEqual(fs.readFileSync(file, 'utf8'), 'file contents') + }) + + it('creates a file with empty string content', (t) => { + const file = testdir(t, '') + assert.ok(fs.existsSync(file), 'file exists') + assert.strictEqual(fs.readFileSync(file, 'utf8'), '') + }) + + it('creates a file with multiline content', (t) => { + const content = 'line1\nline2\nline3' + const file = testdir(t, content) + assert.strictEqual(fs.readFileSync(file, 'utf8'), content) + }) + }) + + describe('symlinks', () => { + it('creates a symlink to a file', (t) => { + const dir = testdir(t, { + 'target.txt': 'target content', + 'link.txt': symlink('target.txt'), + }) + const linkPath = path.join(dir, 'link.txt') + assert.ok(fs.lstatSync(linkPath).isSymbolicLink(), 'is a symlink') + assert.strictEqual(fs.readFileSync(linkPath, 'utf8'), 'target content') + }) + + it('creates a symlink to a directory', (t) => { + const dir = testdir(t, { + targetDir: { + 'file.txt': 'content', + }, + linkDir: symlink('targetDir'), + }) + const linkPath = path.join(dir, 'linkDir') + assert.ok(fs.lstatSync(linkPath).isSymbolicLink(), 'is a symlink') + assert.strictEqual( + fs.readFileSync(path.join(linkPath, 'file.txt'), 'utf8'), + 'content' + ) + }) + + it('creates a symlink with relative path going up directories', (t) => { + const dir = testdir(t, { + packages: { + a: { + 'package.json': '{"name": "a"}', + }, + }, + node_modules: { + a: symlink('../packages/a'), + }, + }) + const linkPath = path.join(dir, 'node_modules', 'a') + assert.ok(fs.lstatSync(linkPath).isSymbolicLink(), 'is a symlink') + assert.strictEqual( + fs.readFileSync(path.join(linkPath, 'package.json'), 'utf8'), + '{"name": "a"}' + ) + }) + + it('creates multiple symlinks in node_modules style', (t) => { + const dir = testdir(t, { + packages: { + a: { 'index.js': 'module.exports = "a"' }, + b: { 'index.js': 'module.exports = "b"' }, + c: { 'index.js': 'module.exports = "c"' }, + }, + node_modules: { + a: symlink('../packages/a'), + b: symlink('../packages/b'), + c: symlink('../packages/c'), + }, + }) + for (const pkg of ['a', 'b', 'c']) { + const linkPath = path.join(dir, 'node_modules', pkg) + assert.ok(fs.lstatSync(linkPath).isSymbolicLink(), `${pkg} is a symlink`) + assert.strictEqual( + fs.readFileSync(path.join(linkPath, 'index.js'), 'utf8'), + `module.exports = "${pkg}"` + ) + } + }) + }) + + describe('hard links', () => { + it('creates a hard link to a file', (t) => { + const dir = testdir(t, { + 'target.txt': 'target content', + 'hardlink.txt': link('target.txt'), + }) + const targetPath = path.join(dir, 'target.txt') + const linkPath = path.join(dir, 'hardlink.txt') + // Hard links are not symlinks + assert.ok(!fs.lstatSync(linkPath).isSymbolicLink(), 'is not a symlink') + assert.ok(fs.statSync(linkPath).isFile(), 'is a file') + // Both have same content + assert.strictEqual(fs.readFileSync(linkPath, 'utf8'), 'target content') + // Hard links share the same inode + assert.strictEqual( + fs.statSync(targetPath).ino, + fs.statSync(linkPath).ino, + 'same inode (hard link)' + ) + }) + + it('creates a hard link in nested directory', (t) => { + const dir = testdir(t, { + 'original.txt': 'original content', + subdir: { + 'link.txt': link('../original.txt'), + }, + }) + const originalPath = path.join(dir, 'original.txt') + const linkPath = path.join(dir, 'subdir', 'link.txt') + assert.strictEqual(fs.readFileSync(linkPath, 'utf8'), 'original content') + assert.strictEqual( + fs.statSync(originalPath).ino, + fs.statSync(linkPath).ino, + 'same inode (hard link)' + ) + }) + }) + + describe('complex structures', () => { + it('creates a realistic package structure', (t) => { + const dir = testdir(t, { + 'package.json': JSON.stringify({ + name: 'test-package', + version: '1.0.0', + dependencies: { lodash: '^4.0.0' }, + }), + src: { + 'index.js': 'module.exports = {}', + utils: { + 'helper.js': 'module.exports = { help: () => {} }', + }, + }, + node_modules: { + lodash: { + 'package.json': JSON.stringify({ name: 'lodash', version: '4.17.21' }), + 'index.js': 'module.exports = {}', + }, + }, + }) + assert.ok(fs.existsSync(path.join(dir, 'package.json'))) + assert.ok(fs.existsSync(path.join(dir, 'src', 'index.js'))) + assert.ok(fs.existsSync(path.join(dir, 'src', 'utils', 'helper.js'))) + assert.ok(fs.existsSync(path.join(dir, 'node_modules', 'lodash', 'package.json'))) + }) + + it('creates workspaces-style structure with symlinks', (t) => { + const dir = testdir(t, { + 'package.json': JSON.stringify({ + name: 'workspaces-project', + workspaces: ['packages/*'], + }), + packages: { + a: { + 'package.json': JSON.stringify({ name: 'a', version: '1.0.0' }), + }, + b: { + 'package.json': JSON.stringify({ name: 'b', version: '1.0.0' }), + }, + }, + node_modules: { + a: symlink('../packages/a'), + b: symlink('../packages/b'), + }, + }) + // Verify symlinks resolve correctly + const aPkg = JSON.parse(fs.readFileSync(path.join(dir, 'node_modules', 'a', 'package.json'), 'utf8')) + const bPkg = JSON.parse(fs.readFileSync(path.join(dir, 'node_modules', 'b', 'package.json'), 'utf8')) + assert.strictEqual(aPkg.name, 'a') + assert.strictEqual(bPkg.name, 'b') + }) + }) + + describe('path generation', () => { + it('generates unique paths based on test name', (t) => { + const dir = testdir(t, {}) + assert.ok(dir.includes('testdir-'), 'path contains testdir prefix') + assert.ok(dir.includes('generates-unique-paths'), 'path contains sanitized test name') + }) + + it('replaces spaces with dashes in path', (t) => { + const dir = testdir(t, {}) + assert.ok(!dir.includes(' '), 'path has no spaces') + assert.ok(dir.includes('replaces-spaces-with-dashes'), 'spaces replaced with dashes') + }) + }) + + describe('buffer content', () => { + it('creates a file with Buffer content in structure', (t) => { + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff]) + const dir = testdir(t, { + 'binary.bin': binaryData, + }) + const filePath = path.join(dir, 'binary.bin') + assert.ok(fs.existsSync(filePath), 'file exists') + const content = fs.readFileSync(filePath) + assert.ok(Buffer.isBuffer(content), 'read content is a buffer') + assert.deepStrictEqual(content, binaryData) + }) + + it('creates a file instead of directory when structure is a Buffer', (t) => { + const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47]) // PNG header bytes + const file = testdir(t, binaryData) + assert.ok(fs.existsSync(file), 'file exists') + assert.ok(fs.statSync(file).isFile(), 'is a file, not a directory') + const content = fs.readFileSync(file) + assert.deepStrictEqual(content, binaryData) + }) + + it('creates mixed string and Buffer files', (t) => { + const dir = testdir(t, { + 'text.txt': 'hello world', + 'binary.bin': Buffer.from([0xde, 0xad, 0xbe, 0xef]), + }) + assert.strictEqual(fs.readFileSync(path.join(dir, 'text.txt'), 'utf8'), 'hello world') + assert.deepStrictEqual( + fs.readFileSync(path.join(dir, 'binary.bin')), + Buffer.from([0xde, 0xad, 0xbe, 0xef]) + ) + }) + }) +}) diff --git a/test/validate-options.js b/test/validate-options.js index d541931..dd586fd 100644 --- a/test/validate-options.js +++ b/test/validate-options.js @@ -1,5 +1,6 @@ /* eslint-disable max-len */ -const t = require('tap') +const { describe, it } = require('node:test') +const assert = require('node:assert') const runScript = require('..') const cases = [ @@ -17,10 +18,10 @@ const cases = [ ['invalid cmd', { event: 'x', path: 'x', args: ['x'], cmd: 7 }, 'invalid cmd option provided to runScript'], ] -t.test('validate options error cases', async t => { +describe('validate options error cases', () => { for (const [name, options, message] of cases) { - await t.test(name, async t => { - await t.rejects(runScript(options), { name: 'TypeError', message }) + it(name, async () => { + await assert.rejects(runScript(options), { name: 'TypeError', message }) }) } }) From 39182c67013f7818f2893225c75c37ca46430188 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 17 Dec 2025 13:08:39 -0800 Subject: [PATCH 2/2] chore: remove tap --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 8abdaf8..2c2fd9a 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,7 @@ "devDependencies": { "@npmcli/eslint-config": "^6.0.0", "@npmcli/template-oss": "4.28.1", - "spawk": "^1.8.1", - "tap": "^16.0.1" + "spawk": "^1.8.1" }, "dependencies": { "@npmcli/node-gyp": "^5.0.0",