Skip to content

v0.2.17 — browser runner fixes, esbuild service recovery, x-of-y timeout reporting

Choose a tag to compare

@brainkim brainkim released this 12 Aug 08:10
· 3 commits to main since this release

Fixed

  • Browser test bundles no longer break from live bun:test/node:test imports. @b9g/libuild/test's never-run node/bun branches were marked external in browser bundles, surviving as live dynamic imports. That forced esbuild to give the dispatcher a lazy async wrapper, and — depending on the esbuild version's wrapper choice — the resulting await init_test() could land inside a non-async wrapper, making the entire bundle a syntax error before a single test ran (every browser run then died on Playwright's 30s timeout). Those specifiers now resolve inside the bundle to stubs whose module bodies throw: they link cleanly, the dispatcher stays plain ESM with its top-level await at genuine top level, and a browser-dead code path that ever actually evaluates one fails loudly.
  • Browser runner no longer starts before all tests register (crank PR #375). The dispatcher's top-level await makes it an async module, so every importing test file's body is deferred past the runner's old queueMicrotask start — it ran with zero tests registered and reported a green empty run. Scheduler-based starts (a setTimeout included) all lose some race with top-level await, so the generated entry now sets a ready flag as its last statement — ESM guarantees that runs only after every imported test file, including TLA continuations spanning any number of macrotasks, has fully evaluated — and the runner waits on that flag. The runner also sweeps for tests registered during or just after the run instead of iterating a fixed snapshot.
  • Uncaught page errors fail the browser run, even when tests passed around them. In a single browser bundle, one file throwing during load aborts every later file's registrations while earlier tests still run — previously that was N passed, 0 failed, exit 0, with the real cause visible only as a log line. Page errors are now part of the result, and a bundle that breaks before the runner starts is reported as a platform failure carrying those errors (instead of an uncaught Playwright timeout crashing the CLI and leaking the browser process).
  • A browser run that registers zero tests is a hard failure, not a green exit 0 (crank PR #375). Discovered-files-but-nothing-registered would merge as "suite silently disabled, CI green". A genuinely empty run (no files found) still exits 0, and all-skipped files count as skipped, not as zero.
  • The browser runner now has test.skip/test.todo/describe.skip. They were missing entirely, so a file shared with node/bun that used them died with a TypeError mid-load — killing every registration after it in the bundle. They register nothing and tally into the run's skipped count.
  • libuild test test/ discovers files without the .test. infix. A single directory argument becomes the discovery root, and the **/test/** default glob evaluated from inside a directory named test demands a nested test/test/ — so files that only matched by living under a test directory (crank's test/dom.tsx) stopped matching the moment you pointed at that directory. When the root itself is named test/tests/__tests__, everything under it now counts, same as the parent-rooted glob always claimed.
  • Browser stubs are scoped to libuild's own imports. expect and pretty-format were stubbed for the whole bundle graph, so a consumer's browser test importing them for itself got a libuild-branded throw instead of the real package. Only libuild's internal imports are stubbed now (matched by the package's resolved realpath, so linked installs behave); node builtins and bun:test/node:test stay stubbed globally — they can never meaningfully resolve in a browser bundle, and the deferred throw is what keeps consumers' runtime-guarded await import("fs") patterns building.
  • --platform node works in packages without "type": "module". Test bundles for node/bun are now written as .mjs, so node's format detection no longer falls back to the consumer's package type and refuse the ESM bundle ("Cannot use import statement outside a module").
  • A dead esbuild service no longer cascades into every remaining build. esbuild keeps one long-lived service child per process and never respawns it: if that child dies (OOM, reaped under load), every later build() fails forever — in libuild's own suite, one such death turned a green run into 139 failures. All builds now go through a wrapper that recovers by calling esbuild.stop() (dropping the dead handle so the retry spawns a fresh service) and retrying once. Recovery is single-flight via a service-generation counter: the runner races many concurrent builds, and if each failing caller ran its own stop(), the second would kill the fresh service the first retry just spawned — and esbuild strands (never settles) requests in flight on a stopped service, so that would hang the run forever. Both death spellings are matched: builds started after the death get "The service is no longer running", builds in flight when it died get "The service was stopped". Ordinary build errors are not retried.

Added

  • Timeout failures report "x of y": how many tests finished out of how many the file registered. @b9g/libuild/test counts registrations as they happen (in the same proxy that tracks names for snapshots) and emits the total on stdout, where the runner parses it back out and strips it from displayed output. A timed-out file now reports 40 of 47 test(s) finished instead of a bare 40. The count covers test/it plus .only/.skip/.todo/.failing and .each (one per table row), matching what the runtimes report (bun's skip/todo summary lines are now parsed into the numerator too); if an unwrapped registration path ever makes the denominator inconsistent, it is suppressed rather than shown wrong. The marker is parsed and stripped only as a full line, so a test's own output mentioning it can neither hijack the count nor vanish from failure dumps. Files that don't import @b9g/libuild/test keep the numerator-only message, and a shard that finishes cleanly right at the timeout buzzer is not misreported as timed out.