From 419f686b6580e561a9b483f8de8ab9209c1e9e5f Mon Sep 17 00:00:00 2001 From: unional Date: Thu, 3 Sep 2026 00:12:49 -0700 Subject: [PATCH 1/2] feat: run on Bun and Deno, and prove it in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped output had exactly one platform coupling left: `ts/assert-order/ StateMachine.ts` imported the bare `perf_hooks` specifier to reach a clock. That was the only Node builtin anywhere in the published tarball. `node:assert` looks like a second one from a source grep, but it appears only in `ts/testUtils.ts` and the `.spec.ts` files, both excluded from `files` — it never ships, so there is nothing to remove and no observable behaviour to break. Consumers already catch this package's own `AssertionError` from `iso-error`, never Node's. The clock now uses `performance.now()`, a global on Node >= 16, Bun, Deno and the browser, with `Date.now()` as the fallback. The `process.hrtime` branch is gone with it: it bought no precision `performance.now()` does not already give, and it existed only because the module was written when the platform clock was not yet universal. `browser: { "perf_hooks": false }` goes too — it stubbed an import that no longer exists. Biome's `useNodejsImportProtocol` is pinned to "error" so a bare `fs`/`path` specifier cannot creep back in. The rule ships in the `style` group at `info`, where biome reports the finding and still exits 0, so inheriting it from the preset would have enforced nothing. Verified by reverting the import to its bare form: `biome ci` then exits 1 with a real `lint/style/useNodejsImportProtocol` error. The severity is set in `biome.json`, which is parsed as strict JSON — a `//` comment there makes biome fall back to its defaults silently, so the reasoning stays in this message. `scripts/smoke.{mjs,cjs}` exercise the build output rather than `ts/`, because the build output is all a consumer ever sees and a portability regression surfaces there and nowhere else. The new `runtimes` workflow runs both against Node, Bun and Deno on every pull request, all three fed the identical artifact built once on Node. It is a separate job id from `code`, so the required `code / all-checks` context is untouched, and `release` gains it as a `needs` so a runtime regression fails the release closed the way `publish-gate` does. Measured, not assumed: Deno 2.9 already resolves bare `perf_hooks` through its Node compatibility layer, so the old code did in fact load there. The fix still earns its place — it removes the builtin outright, which is what makes the output loadable on the web and on edge runtimes that have no Node compat layer, and it lets the lint rule guard the package with no suppression carved out of it. ESM-only is left alone deliberately: the package still ships a dual `esm/`+`cjs/` build, and dropping the CJS half is a breaking change for its many dependents that deserves its own PR. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ETjy9oQGyETyFmDBdR9Egz --- .changeset/runtime-agnostic-clock.md | 19 ++++++ .github/workflows/pull-request.yml | 5 ++ .github/workflows/release.yml | 5 +- .github/workflows/runtimes.yml | 46 +++++++++++++ README.md | 6 +- biome.json | 19 ++++++ package.json | 8 +-- scripts/smoke.cjs | 37 +++++++++++ scripts/smoke.mjs | 99 ++++++++++++++++++++++++++++ ts/assert-order/StateMachine.ts | 43 ++++-------- turbo.json | 4 ++ 11 files changed, 253 insertions(+), 38 deletions(-) create mode 100644 .changeset/runtime-agnostic-clock.md create mode 100644 .github/workflows/runtimes.yml create mode 100644 scripts/smoke.cjs create mode 100644 scripts/smoke.mjs diff --git a/.changeset/runtime-agnostic-clock.md b/.changeset/runtime-agnostic-clock.md new file mode 100644 index 0000000..8b513f3 --- /dev/null +++ b/.changeset/runtime-agnostic-clock.md @@ -0,0 +1,19 @@ +--- +'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 target runtime provides as +a global, falling back to `Date.now()`. 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..db1453a 100644 --- a/ts/assert-order/StateMachine.ts +++ b/ts/assert-order/StateMachine.ts @@ -1,27 +1,16 @@ -// 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 } +// `performance.now()` is the one high-resolution clock every target runtime agrees on: +// it is a global in Node (>=16), Bun, Deno and browsers. Reaching for `node:perf_hooks` +// or `process.hrtime` instead would make this module depend on a Node builtin for a +// clock the platform already provides. `Date.now()` remains the fallback for an exotic +// host that exposes neither. +const now: () => number = + typeof globalThis.performance?.now === 'function' ? () => globalThis.performance.now() : () => Date.now() -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 = { +const timeTracker = (() => { + let tick = 0 + return { start() { tick = now() }, @@ -29,17 +18,7 @@ if (typeof globalThis.process?.hrtime === 'function') { return 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"] From e7a157f1e4eaccf2931a390d62aaabaf937f8bcf Mon Sep 17 00:00:00 2001 From: unional Date: Thu, 3 Sep 2026 00:16:01 -0700 Subject: [PATCH 2/2] refactor: drop the unreachable Date.now fallback from the clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `performance` is a guaranteed global on every host this package supports — `engines` requires Node >= 20, and Bun, Deno and browsers have had it far longer. The `Date.now()` arm was therefore dead code that no test could enter, which codecov correctly reported as an uncovered half of the diff. Deleting it rather than annotating it away is the honest fix: an untestable fallback is not a safety net, it is a second code path nobody has ever run. Coverage is now 100% on statements, branches, functions and lines. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ETjy9oQGyETyFmDBdR9Egz --- .changeset/runtime-agnostic-clock.md | 7 +++---- ts/assert-order/StateMachine.ts | 14 +++++--------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/.changeset/runtime-agnostic-clock.md b/.changeset/runtime-agnostic-clock.md index 8b513f3..0909168 100644 --- a/.changeset/runtime-agnostic-clock.md +++ b/.changeset/runtime-agnostic-clock.md @@ -5,10 +5,9 @@ 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 target runtime provides as -a global, falling back to `Date.now()`. The published `esm/` and `cjs/` output no longer -references any Node builtin, so it loads unchanged on Bun, Deno, browsers and edge -runtimes. +`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 diff --git a/ts/assert-order/StateMachine.ts b/ts/assert-order/StateMachine.ts index db1453a..5ece8b8 100644 --- a/ts/assert-order/StateMachine.ts +++ b/ts/assert-order/StateMachine.ts @@ -1,21 +1,17 @@ import type { State } from './types.js' // `performance.now()` is the one high-resolution clock every target runtime agrees on: -// it is a global in Node (>=16), Bun, Deno and browsers. Reaching for `node:perf_hooks` -// or `process.hrtime` instead would make this module depend on a Node builtin for a -// clock the platform already provides. `Date.now()` remains the fallback for an exotic -// host that exposes neither. -const now: () => number = - typeof globalThis.performance?.now === 'function' ? () => globalThis.performance.now() : () => Date.now() - +// 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 }, } })()