From ee301e656179c0f4f1247a8fe4f6995911696995 Mon Sep 17 00:00:00 2001 From: Shaurya Kesarwani Date: Sat, 12 Sep 2026 13:22:38 +0530 Subject: [PATCH] ci: smoke test the build output on Node.js 18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #57 engines.node is ">=18" but the CI matrix starts at 20, so the oldest supported version was never exercised. #109 tried adding 18 to the existing matrix and stalled: tsdown, vitest and even node:test are unusable on 18.x, and downgrading the dev stack to reach it was not worth it. This takes the approach settled on in that thread instead — a standalone file using node:assert with no test runner, checking the happy path of the built bundle: - x(): stdout, stderr, argument forwarding, async iteration, exit codes, throwOnError, and env - xSync(): stdout, exit code, throwOnError - exec/execSync alias identity The job deliberately skips `npm ci`. The smoke test imports dist/ and node builtins and nothing else, so the dev dependencies that made 18.x unworkable are never installed. It runs on 18.0.0 and 18.x across ubuntu and windows, against the same dist/ artifact the other jobs consume. vitest only collects src/**/*_test.ts, so the file is excluded from the unit run without further configuration. Verified against a real v18.0.0: 10/10 pass. The suite is not vacuous — stubbing the exitCode getter in dist/ turns 3 of the 10 red and exits 1. On the current toolchain, vitest still reports 51 passed and lint, publint and format:check stay clean. --- .github/workflows/ci.yml | 26 +++++++ package.json | 3 +- test/smoke.mjs | 147 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 test/smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86f2825..7074674 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,32 @@ jobs: - run: npm ci --ignore-scripts - run: npm run test:unit + smoke: + name: Smoke test on Node.js ${{ matrix.node-version }} @ ${{ matrix.os }} + needs: build + strategy: + fail-fast: false + matrix: + node-version: + - 18.0.0 + - 18.x + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node-version }} + - uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + # No `npm ci` on purpose: the smoke test imports `dist/` and node + # builtins only. Installing dev dependencies is what made 18.x + # unworkable before, so this job never touches them. + - run: npm run test:smoke + bun-build: name: Build on Bun (latest) @ ubuntu-latest runs-on: ubuntu-latest diff --git a/package.json b/package.json index a1cb01e..556f759 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "lint": "tsc --noEmit && eslint src && publint", "prepare": "npm run build", "test": "npm run build && npm run test:unit", - "test:unit": "vitest run" + "test:unit": "vitest run", + "test:smoke": "node test/smoke.mjs" }, "repository": { "type": "git", diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..5ae9fae --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,147 @@ +// A dependency-free smoke test for the built output, run against Node.js +// versions that the dev stack no longer supports (see #57). +// +// Everything here is deliberately plain: `node:assert` and a `for` loop, no +// test runner and no `node_modules`. Vitest, tsdown and the node test runner +// all fail or flake on 18.x, which is what made the previous attempt at this +// stall (#109) — importing `dist/` and nothing else is the one approach that +// does not drag the toolchain along. +// +// It covers the happy path only. The exhaustive suite runs on supported +// versions; this exists to prove the published bundle still imports and runs +// where `engines.node` claims it does. + +import assert from 'node:assert'; +import {x, xSync, exec, execSync, NonZeroExitError} from '../dist/main.mjs'; + +// `process.execPath` keeps every case shell- and platform-independent, so the +// same file runs unmodified on Windows. +const node = process.execPath; + +const tests = []; +function test(name, fn) { + tests.push({name, fn}); +} + +test('x() captures stdout and a zero exit code', async () => { + const proc = x(node, ['-e', 'process.stdout.write("hello")']); + const result = await proc; + + assert.strictEqual(result.stdout, 'hello'); + assert.strictEqual(result.stderr, ''); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(proc.exitCode, 0); + assert.strictEqual(proc.signalCode, null); +}); + +test('x() captures stderr', async () => { + const result = await x(node, ['-e', 'process.stderr.write("oh no")']); + + assert.strictEqual(result.stdout, ''); + assert.strictEqual(result.stderr, 'oh no'); +}); + +test('x() forwards arguments verbatim', async () => { + const result = await x(node, [ + '-e', + 'process.stdout.write(process.argv.slice(1).join("|"))', + 'one', + 'two three' + ]); + + assert.strictEqual(result.stdout, 'one|two three'); +}); + +test('x() is async-iterable over output lines', async () => { + const lines = []; + for await (const line of x(node, [ + '-e', + 'console.log("first"); console.log("second")' + ])) { + lines.push(line); + } + + assert.deepStrictEqual(lines, ['first', 'second']); +}); + +test('x() reports a non-zero exit code without throwing by default', async () => { + const result = await x(node, ['-e', 'process.exit(3)']); + + assert.strictEqual(result.exitCode, 3); +}); + +test('x() throws NonZeroExitError when throwOnError is set', async () => { + // `x()` returns a PromiseLike, not a Promise, so it is awaited inside an + // async function rather than handed to assert.rejects directly. + await assert.rejects( + async () => { + await x(node, ['-e', 'process.exit(3)'], {throwOnError: true}); + }, + (err) => { + assert.ok( + err instanceof NonZeroExitError, + `expected a NonZeroExitError, got ${err && err.constructor.name}` + ); + assert.strictEqual(err.exitCode, 3); + return true; + } + ); +}); + +test('x() passes env through to the child', async () => { + const result = await x(node, ['-e', 'process.stdout.write(process.env.SMOKE)'], { + nodeOptions: {env: {SMOKE: 'value'}} + }); + + assert.strictEqual(result.stdout, 'value'); +}); + +test('xSync() captures stdout and a zero exit code', () => { + const result = xSync(node, ['-e', 'process.stdout.write("hello sync")']); + + assert.strictEqual(result.stdout, 'hello sync'); + assert.strictEqual(result.exitCode, 0); +}); + +test('xSync() throws NonZeroExitError when throwOnError is set', () => { + assert.throws( + () => xSync(node, ['-e', 'process.exit(4)'], {throwOnError: true}), + (err) => { + assert.ok( + err instanceof NonZeroExitError, + `expected a NonZeroExitError, got ${err && err.constructor.name}` + ); + assert.strictEqual(err.exitCode, 4); + return true; + } + ); +}); + +test('exec and execSync are exported as aliases', () => { + assert.strictEqual(exec, x); + assert.strictEqual(execSync, xSync); +}); + +const failures = []; + +for (const {name, fn} of tests) { + try { + await fn(); + console.log(`ok - ${name}`); + } catch (err) { + failures.push({name, err}); + console.log(`not ok - ${name}`); + } +} + +console.log(`\n${tests.length - failures.length}/${tests.length} passed`); + +if (failures.length > 0) { + for (const {name, err} of failures) { + console.error(`\n${name}:`); + console.error(err); + } + process.exit(1); +} + +console.log(`smoke test passed on Node.js ${process.version}`);