diff --git a/.changeset/runtime-agnostic-clock.md b/.changeset/runtime-agnostic-clock.md new file mode 100644 index 0000000..0909168 --- /dev/null +++ b/.changeset/runtime-agnostic-clock.md @@ -0,0 +1,18 @@ +--- +'assertron': patch +--- + +Drop the last Node builtin from the shipped code. + +`AssertOrder`'s clock imported the bare `perf_hooks` specifier and preferred +`process.hrtime`. It now uses `performance.now()`, which every runtime this package +supports provides as a global. The published `esm/` and `cjs/` output no longer references +any Node builtin, so it loads unchanged on Bun, Deno, browsers and edge runtimes. + +Elapsed times from `AssertOrder#end()` and `getTimeTaken()` are still high-resolution +milliseconds; only the clock behind them changed. The `browser` field's +`"perf_hooks": false` mapping is removed because there is no longer an import for a +bundler to stub. + +`node:assert` is unchanged and unaffected: it appears only in the test helpers and spec +files, neither of which is published. diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index bfad3a0..a242a57 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,3 +10,8 @@ jobs: with: os: '["ubuntu-latest"]' skip-playwright: true + + # Cross-runtime loadability of the built package. A separate job id (not `code`) so the + # required `code / all-checks` context is untouched. + runtimes: + uses: ./.github/workflows/runtimes.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8763fa4..94d9e3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,9 @@ jobs: os: '["ubuntu-latest"]' skip-playwright: true + runtimes: + uses: ./.github/workflows/runtimes.yml + # Runs on the default branch immediately before `changeset publish`, so a tarball that # trips the gate blocks the release. It used to sit on the changesets "Version # Packages" PR instead, where it could never fire: a PR opened with the built-in @@ -25,7 +28,7 @@ jobs: release: uses: cyberuni/.github/.github/workflows/pnpm-release-changeset-oidc.yml@main - needs: [code, publish-gate] + needs: [code, runtimes, publish-gate] permissions: id-token: write contents: write diff --git a/.github/workflows/runtimes.yml b/.github/workflows/runtimes.yml new file mode 100644 index 0000000..9f9d9e0 --- /dev/null +++ b/.github/workflows/runtimes.yml @@ -0,0 +1,46 @@ +# Proves the *published* output loads and works on every runtime the package claims to +# support, rather than asserting it in a README table that nothing executes. +# +# It deliberately runs the build output (`esm/` and `cjs/`) and not `ts/`: the build +# output is all a consumer ever sees, and a portability regression (a bare `fs` +# specifier, a Node-only builtin) shows up there and nowhere else. The vitest suite +# covers behaviour on Node; this covers loadability everywhere. +name: runtimes +on: + workflow_call: + +jobs: + runtimes: + name: ${{ matrix.runtime }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + runtime: [node, bun, deno] + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + # The build itself stays on Node + pnpm on every leg, so all three runtimes are + # handed byte-for-byte the same artifact. + - run: pnpm install --frozen-lockfile + - run: pnpm build + + - if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - if: matrix.runtime == 'deno' + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: smoke test on ${{ matrix.runtime }} + run: pnpm run ${{ matrix.runtime == 'node' && 'smoke' || format('smoke:{0}', matrix.runtime) }} diff --git a/README.md b/README.md index 2fe4a2e..cd9cd0e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,11 @@ [![Visual Studio Code][vscode-image]][vscode-url] -A supplementary assertion library that runs on both NodeJS and browser. +A supplementary assertion library that runs on Node.js, Bun, Deno and the browser. + +The shipped code imports no platform builtins, so the same `esm/` and `cjs/` output loads +everywhere. CI runs a smoke test against the build output on Node, Bun and Deno on every +pull request, so that claim is executed rather than asserted. ## Install diff --git a/biome.json b/biome.json index 878c5e7..50bb085 100644 --- a/biome.json +++ b/biome.json @@ -6,6 +6,25 @@ "clientKind": "git", "useIgnoreFile": true }, + "linter": { + "rules": { + "style": { + "useNodejsImportProtocol": "error" + } + } + }, + "overrides": [ + { + "includes": ["scripts/**"], + "linter": { + "rules": { + "suspicious": { + "noConsole": "off" + } + } + } + } + ], "files": { "includes": ["!cjs", "!esm", "!tslib", "!coverage", "!.turbo", "!skills-lock.json", "!CHANGELOG.md"] } diff --git a/package.json b/package.json index a84aff5..40c0ce8 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,6 @@ }, "main": "./cjs/index.js", "module": "./esm/index.js", - "browser": { - "perf_hooks": false - }, "types": "./esm/index.d.ts", "files": [ "cjs", @@ -55,10 +52,13 @@ "prepare": "husky", "release": "changeset publish", "size": "size-limit", + "smoke": "node scripts/smoke.mjs && node scripts/smoke.cjs", + "smoke:bun": "bun run scripts/smoke.mjs && bun run scripts/smoke.cjs", + "smoke:deno": "deno run --allow-read scripts/smoke.mjs && deno run --allow-read scripts/smoke.cjs", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc", - "verify": "biome ci . && turbo run build typecheck coverage knip size", + "verify": "biome ci . && turbo run build typecheck coverage knip size smoke", "version": "changeset version" }, "dependencies": { diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs new file mode 100644 index 0000000..e6f6f46 --- /dev/null +++ b/scripts/smoke.cjs @@ -0,0 +1,37 @@ +// The CJS half of the cross-runtime smoke test: proves `require('assertron')` resolves +// through the `require` export condition and that `cjs/index.js` loads on each runtime. +// The ESM half (`smoke.mjs`) is where the behavioural checks live. +const path = require('node:path') + +const entry = path.join(__dirname, '..', 'cjs', 'index.js') +const mod = require(entry) +const assertron = mod.assertron + +const runtime = globalThis.Bun ? `bun ${globalThis.Bun.version}` : `node ${process.versions.node}` +console.log(`assertron CJS smoke test on ${runtime}`) + +if (typeof assertron !== 'function' && typeof assertron !== 'object') { + throw new Error('`assertron` is not exported from the CJS build') +} +if (typeof mod.AssertOrder !== 'function') throw new Error('`AssertOrder` is not exported from the CJS build') +if (typeof mod.AssertionError !== 'function') throw new Error('`AssertionError` is not exported from the CJS build') + +assertron.truthy(1) +assertron.satisfies({ a: 1, b: 2 }, { a: 1 }) + +let threw = false +try { + assertron.truthy(false) +} catch (e) { + threw = e instanceof mod.AssertionError +} +if (!threw) throw new Error('a failed assertion did not throw the library AssertionError') + +const order = new mod.AssertOrder() +order.once(1) +const taken = order.end() +if (typeof taken !== 'number' || Number.isNaN(taken) || taken < 0) { + throw new Error(`end() returned ${taken}, expected an elapsed-milliseconds number`) +} + +console.log(`\nall CJS smoke checks passed on ${runtime}`) diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs new file mode 100644 index 0000000..f2b13ce --- /dev/null +++ b/scripts/smoke.mjs @@ -0,0 +1,99 @@ +// Cross-runtime smoke test for the *published* ESM output. +// +// Runs unmodified on Node, Bun and Deno. The vitest suite covers behaviour; this covers +// portability — that `esm/index.js` and everything it pulls in loads and works on a +// runtime that is not Node. It therefore imports the build output, not `ts/`, because the +// build output is the only thing consumers ever see. +import a, { AssertionError, AssertOrder, assertron } from '../esm/index.js' + +const _runtime = globalThis.Deno + ? `deno ${globalThis.Deno.version.deno}` + : globalThis.Bun + ? `bun ${globalThis.Bun.version}` + : `node ${globalThis.process.versions.node}` + +let failed = 0 + +function check(_name, fn) { + try { + fn() + } catch (_e) { + failed++ + } +} + +async function checkAsync(_name, fn) { + try { + await fn() + } catch (_e) { + failed++ + } +} + +function expectThrows(fn) { + try { + fn() + } catch (e) { + return e + } + throw new Error('expected to throw, did not') +} + +check('default export is assertron', () => { + if (a !== assertron) throw new Error('default export is not `assertron`') +}) + +check('truthy / falsy pass', () => { + assertron.truthy(1) + assertron.falsy(0) +}) + +check('truthy failure throws the library AssertionError', () => { + const err = expectThrows(() => assertron.truthy(false)) + if (!(err instanceof AssertionError)) throw new Error(`not an assertron AssertionError: ${err}`) +}) + +check('satisfies passes and fails', () => { + assertron.satisfies({ a: 1, b: 2 }, { a: 1 }) + expectThrows(() => assertron.satisfies({ a: 1 }, { a: 2 })) +}) + +check('isInstanceof', () => { + assertron.isInstanceof(new Error('x'), Error) + expectThrows(() => assertron.isInstanceof({}, Error)) +}) + +check('pathEqual', () => { + assertron.pathEqual('a/b/c', 'a/b/c') + expectThrows(() => assertron.pathEqual('a/b/c', 'a/b/d')) +}) + +await checkAsync('throws / rejects / resolves', async () => { + assertron.throws(() => { + throw new Error('boom') + }) + await assertron.rejects(Promise.reject(new Error('boom'))) + await assertron.resolves(Promise.resolve(1)) +}) + +// The one place the package used to reach for a Node builtin (`perf_hooks`): the +// AssertOrder clock. If the portability fix regressed, importing this module would fail +// on Deno before any of this ran. +check('AssertOrder step tracking and its clock', () => { + const order = new AssertOrder(2) + order.once(1) + order.once(2) + expectThrows(() => order.once(1)) + + // `end()` on an open-ended order returns the elapsed time from that clock. + const timed = new AssertOrder() + timed.once(1) + const taken = timed.end() + if (typeof taken !== 'number' || Number.isNaN(taken) || taken < 0) { + throw new Error(`end() returned ${taken}, expected an elapsed-milliseconds number`) + } +}) +if (failed > 0) { + if (globalThis.Deno) globalThis.Deno.exit(1) + else globalThis.process.exit(1) +} diff --git a/ts/assert-order/StateMachine.ts b/ts/assert-order/StateMachine.ts index deaefc4..5ece8b8 100644 --- a/ts/assert-order/StateMachine.ts +++ b/ts/assert-order/StateMachine.ts @@ -1,45 +1,20 @@ -// Deliberately the bare specifier, not `node:perf_hooks`: package.json's `browser` -// field maps `perf_hooks` to `false` for bundlers, and that mapping does not apply to -// the `node:` prefixed form. -// biome-ignore lint/style/useNodejsImportProtocol: see above -import * as perf from 'perf_hooks' import type { State } from './types.js' -let timeTracker: { start(): void; taken(): number } - -if (typeof globalThis.process?.hrtime === 'function') { - let tick: [number, number] - timeTracker = { - start() { - tick = globalThis.process.hrtime() - }, - taken() { - const [second, nanoSecond] = globalThis.process.hrtime(tick) - return second * 1000 + nanoSecond / 1e6 - }, - } -} else if (perf.performance && typeof perf.performance.now === 'function') { - const now = perf.performance.now - let tick: number - timeTracker = { +// `performance.now()` is the one high-resolution clock every target runtime agrees on: +// a global in Node (this package requires >= 20), Bun, Deno and the browser. There is no +// fallback arm because there is no supported host that lacks it, and an arm no test can +// reach is not a safety net. +const timeTracker = (() => { + let tick = 0 + return { start() { - tick = now() + tick = performance.now() }, taken() { - return now() - tick + return performance.now() - tick }, } -} else { - let tick: number - timeTracker = { - start() { - tick = Date.now() - }, - taken() { - return Date.now() - tick - }, - } -} +})() export class StateMachine { listeners: Record void>> = {} diff --git a/turbo.json b/turbo.json index 4eb88c7..da72c32 100644 --- a/turbo.json +++ b/turbo.json @@ -18,6 +18,10 @@ "nuke": { "cache": false }, + "smoke": { + "dependsOn": ["build"], + "inputs": ["scripts/**", "package.json"] + }, "size": { "dependsOn": ["build"], "inputs": [".size-limit.json", "package.json"]