Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/runtime-agnostic-clock.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions .github/workflows/runtimes.yml
Original file line number Diff line number Diff line change
@@ -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) }}
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@
},
"main": "./cjs/index.js",
"module": "./esm/index.js",
"browser": {
"perf_hooks": false
},
"types": "./esm/index.d.ts",
"files": [
"cjs",
Expand All @@ -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": {
Expand Down
37 changes: 37 additions & 0 deletions scripts/smoke.cjs
Original file line number Diff line number Diff line change
@@ -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}`)
99 changes: 99 additions & 0 deletions scripts/smoke.mjs
Original file line number Diff line number Diff line change
@@ -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)
}
45 changes: 10 additions & 35 deletions ts/assert-order/StateMachine.ts
Original file line number Diff line number Diff line change
@@ -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<number, Array<() => void>> = {}
Expand Down
4 changes: 4 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"nuke": {
"cache": false
},
"smoke": {
"dependsOn": ["build"],
"inputs": ["scripts/**", "package.json"]
},
"size": {
"dependsOn": ["build"],
"inputs": [".size-limit.json", "package.json"]
Expand Down
Loading