Skip to content

v0.2.20 — staged publishing, self-hosted suite, concurrent tests, ESM-only, CLI-only surface

Choose a tag to compare

@brainkim brainkim released this 18 Aug 04:32
· 34 commits to main since this release

Added

  • Concurrent tests with correct snapshot attribution, via AsyncContext. Run-time current-test tracking is now an AsyncContext.Variable (@b9g/async-context, the TC39-shaped shim over AsyncLocalStorage) instead of a module global, so interleaved test bodies each see their own name: toMatchSnapshot() inside test.concurrent (bun) or options-form test(name, {concurrency}, fn) (node) files each snapshot under its own key - previously concurrent bodies would have silently misattributed each other's snapshots through the shared global, and .concurrent bodies weren't name-tracked or registration-counted at all. test.concurrent is also shimmed where the runtime lacks it (node backend, browser runner) as plain registration - sequential execution is a conforming implementation of concurrent semantics - so bun-authored concurrent suites run unchanged everywhere. Adds a @b9g/async-context dependency; node:async_hooks joins the browser-bundle stubs. .each is now implemented once by libuild for every runtime rather than delegated: rows register through the wrapped block, so each row gets its formatted name, a tracked body, and an accurate count identically on bun and node (delegating left rows sharing one snapshot bucket numbered in execution order - fine sequentially, order-dependent under concurrency). Sub-methods are wrapped recursively, so chains (test.concurrent.each, test.skip.each) survive and runtime-specific variants (skipIf, todoIf) get tracking for free.

    Known limitation: an async describe body suspends with its prefix pushed, so a sibling describe registering during that suspension inherits it. The prefix cannot be async-scoped: registering a bun:test test from inside an AsyncLocalStorage scope makes bun hang waiting for a done callback (verified on bun 1.3.14). Sync describe bodies - every real-world case - are unaffected.

  • libuild stage: staged publishing as its own command. Builds and runs npm stage publish inside dist/ - the version is uploaded to the registry in a not-installable state until a human runs npm stage approve <stage-id>, which always prompts for 2FA and therefore cannot be automated. A separate command rather than a publish flag: staging is a different operation, not a variant - the artifact does not go live. Shares publish's whitelist-validated flag parsing, --access public for scoped names, and --save semantics. Two preflights fail fast with clear errors before anything builds: staged publishing needs npm >= 11.15.0, and the package must already exist on the registry (a first release must be a normal libuild publish). The release workflow now runs libuild stage --provenance, so with a stage-only trusted publisher CI can never make a version live on its own.

  • --concurrency <n>: max test files running at once per platform (default: CPU count - 1). A suite whose every test spawns its own processes (builds, servers) multiplies at full parallelism and starves itself - libuild's own suite runs at 4.

Changed

  • libuild's own suite is self-hosted: npm test runs it through libuild test on bun AND node. "libuild works under node" is now a gate, not a claim - and the migration immediately caught four runner bugs consumers would have hit (below: __dirname, the require-shim collision, NODE_TEST_CONTEXT inheritance, and the missing concurrency knob). Raw bun test still works (test:bun): the suite imports the platform dispatcher, which picks bun's backend natively there.
  • typescript peer range widened to ^5.0.0 || ^6.0.0 - installs alongside TypeScript 6 no longer ERESOLVE.

Fixed

  • __dirname / __filename work in test files - raw bun provides the CJS globals even in transpiled TS, so jest-heritage suites are full of them, and ESM bundles have neither. They join the per-file import.meta rewrite, pointing at each source file's real location.
  • The require-shim banner no longer collides with consumer imports of createRequire - the banner now imports node:module under a private namespace, so a test file (or its imports) using createRequire itself bundles cleanly.
  • Spawned node --test shards no longer inherit NODE_TEST_CONTEXT - when libuild test is itself invoked from inside a node:test run, the inherited marker made every child conclude it was recursing and exit 0 having run nothing ("skipping running files"); the completed-guard turned that into loud failures, but now it just works. The env var is stripped from shard environments.
  • import.meta.url / .dirname / .filename point at the source test file, not the bundle (reported by the fold migration). Bundling collapsed every module's location to .libuild-test/bundle-*.js, silently breaking the fixtures-next-to-the-test pattern (new URL("./fixtures/x.json", import.meta.url)) with failures that read as bugs in the code under test. On node/bun bundles, esbuild's define now maps the three path-shaped members to per-file bindings injected (one line, no line-number shift) into each file that mentions import.meta. Browser bundles are unchanged - a page has no source filesystem. (import.meta used bare, or other properties, still see the bundle.)
  • A file that registers no tests counts as 0 passed, not 1 (fold's tests/helpers.js). node fabricates one passing test named with the file's path when a file registers nothing, and includes it in # pass; the synthetic entry is now recognized and excluded, so shared helpers swept up by the test glob report honestly.

Removed

  • The . / ./libuild root export is gone: libuild is a CLI, not a library. build()/publish() were never a designed API - the root export existed because zero-config discovery promoted a top-level implementation file. The implementation now lives in src/internal/ (with the other internals: esbuild recovery, snapshots, the test runners) and the package surface is exactly the libuild bin plus @b9g/libuild/test. Importing @b9g/libuild now fails with ERR_PACKAGE_PATH_NOT_EXPORTED.
  • Main-entry detection no longer guesses. With nothing declared (no exports["."], main, module, or index entry, multiple entries, no name match), the build now emits NO root export instead of alphabetically electing one - the old fallback would have silently made . point at cli.js here. Single-entry and name-matching packages are unaffected.