Bun (Package Manager)
July 20, 2026 · View on GitHub
This document describes how Bun is used as a package and workspace manager for this monorepo. It is the companion to npm.md, which describes the npm release process.
Purpose
Bun is being adopted incrementally as part of a staged runtime migration. This
step (S1) introduces Bun only as the package/workspace manager: it is used
to install dependencies, resolve the 16 workspaces, and materialize the
node_modules tree. The runtime and build path are intentionally not
changed by S1 — they continue to run on Node and npm until later subissues
(S3–S7). npm must keep working during the transition so no consumer is broken.
bunfig.toml
The repository root contains a bunfig.toml file that
configures Bun's install behavior:
[install]
linker = "hoisted"
Why linker = "hoisted"
By default Bun uses an isolated dependency layout. npm, by contrast, hoists
shared dependencies to the top of node_modules. A great deal of tooling in
this repository (and in transitive dependencies) walks node_modules directly
and assumes a hoisted topology — for example scripts/tests/vitest.config.ts
aliases into the root node_modules. Setting linker = "hoisted" makes Bun use
the same hoisted node_modules topology class as npm (rather than Bun's
isolated linker) during S1. Bun's hoisting is not byte-for-byte identical to
npm's algorithm, but using the hoisted layout removes the large class of
"works under npm but not bun" (or vice-versa) surprises caused by an isolated
layout.
.bun-version
The file .bun-version pins the concrete Bun version to
1.3.14. This is the version that has been empirically verified to install all
workspaces and native binaries correctly from a clean checkout. For S1,
.bun-version is the authoritative local Bun pin for package-manager work.
Later subissues that switch the runtime or build to Bun should consume this same version rather than introducing a second, divergent pin, and should reconcile the CI/release pin (below) against it.
Known divergence, reconciliation deferred:
.github/workflows/release.ymlcurrently pins a different Bun version (bun-version: '1.3.3') viaoven-sh/setup-bun. This is a pre-existing pin that S1 does not touch: aligning the CI/release toolchain with.bun-versionis explicitly deferred to the CI/release subissues (S6/S7), which own the GitHub Actions workflows. Until then.bun-version(1.3.14) governs local installs whilerelease.yml(1.3.3) governs the release runner; the two are intentionally allowed to differ during the transition. Do not changerelease.ymlas part of S1.
trustedDependencies
Bun, by default, blocks lifecycle scripts (install, postinstall) for any
package that is not explicitly listed in the root package.json
trustedDependencies array. This is a security measure to prevent arbitrary
code execution from transitive dependencies during install.
Only packages whose lifecycle scripts produce required native binaries are
trusted. These are the 16 entries in trustedDependencies:
@lvce-editor/ripgrep— its postinstall fetches and places thergbinary used for project search. Declared in the root and inpackages/tools.@ast-grep/lang-*(13 packages) — each ships apostinstall.jsthat downloads/extracts the platform-specific parser prebuild (*.node/*.so) for that language. Declared acrosspackages/coreandpackages/tools.bun— its postinstall installs the pinned Bun runtime used by the Node launcher to relaunch the CLI internally.tree-sitter-bash— ships prebuilt native bindings underprebuilds/<platform>/. Declared inpackages/core.
Why other lifecycle-script packages are NOT trusted
esbuild— retired in S4 (the run path and distributable bundle now usebun build; see S4). When it was still a dependency it did not need trust: its platform binary was delivered by the separate@esbuild/<platform>package, which Bun installs directly without running a script.node-pty— not trusted because the runtime prefers@lydell/node-pty(seepackages/core/src/utils/getPty.ts), whose native binary is supplied by the prebuilt@lydell/node-pty-*platform packages.node-ptyis the legacy/fallback module, so its own build scripts are not required for a usable install. This is an intentional trade-off: if@lydell/node-ptywere ever unavailable on a platform, thenode-ptyfallback would not have a freshly built binary under Bun.keytar— not trusted because it is transitive release/VSCE tooling, not a package the CLI runtime depends on; its credential-store binary is not required for installing or running this repository.msw,vscode-ide-companionprepare — build/dev-only lifecycle scripts that do not produce a runtime-required artifact, so they do not need to run during install.
Native runtime modules that need no trust at all
A reader scanning trustedDependencies may be surprised that the repository's
two most prominent native modules are absent:
@ast-grep/napi— the native engine behind AST-aware code search.@napi-rs/keyring— the native OS credential store used by auth.
Both are used by the runtime and both ship native *.node binaries, yet
neither belongs in trustedDependencies because neither defines an install
or postinstall lifecycle script. Like esbuild, their platform binaries are
delivered by separate optional packages (@ast-grep/napi-<platform>,
@napi-rs/keyring-<platform>) that Bun installs directly. There is no script to
gate, so trusting them would be meaningless — and because the test below asserts
the trust list matches the allowlist exactly, adding them would actually
fail CI. This was verified empirically: after a clean bun install with neither
package trusted, the *.node binaries for both were present in node_modules.
This list is enforced by a behavioral test
(scripts/tests/bun-workspaces.test.ts)
in two complementary ways:
- It asserts
trustedDependenciesequals the intended allowlist exactly, so neither an unreviewed extra entry (over-trust / supply-chain risk) nor a dropped entry (a required native binary that silently fails to build) can slip in. - It asserts that every workspace-declared package in the known native-binary
families (
@lvce-editor/ripgrep,tree-sitter-bash, and the@ast-grep/lang-*family) is present in the trust list — so adding a new@ast-grep/lang-*package automatically requires it to be trusted.
The test does not attempt to discover arbitrary native dependencies of other names; introducing a native package outside those known families is a deliberate decision that must also update the allowlist (which the exact-match assertion forces).
Windows native-module smoke
The nightly workflow runs scripts/bun-native-modules-smoke.mjs in a dedicated
windows-latest job. The harness exercises @ast-grep/napi, constructs an
@napi-rs/keyring entry without credential I/O, parses Bash with
web-tree-sitter and tree-sitter-bash, and spawns a real ConPTY process with
@lydell/node-pty. Bun.Terminal remains POSIX-only and is explicitly skipped
on Windows.
A hosted Windows Server 2025 run on 2026-07-16 verified the complete smoke with
Bun 1.3.14, including streamed output and a real zero exit callback from the
@lydell/node-pty ConPTY process. The Windows runtime therefore keeps using
@lydell/node-pty; no JavaScript compile fallback is needed. The evidence is in
nightly run 29534151672.
Dual Lockfile Policy
During the migration, two lockfiles coexist side-by-side at the repository root:
bun.lock— produced and maintained bybun install.package-lock.json— produced and maintained bynpm install.
Both are committed. npm ignores bun.lock/bunfig.toml/.bun-version, and
Bun migrates from package-lock.json without modifying it. This lets either
tool install the project without churn, and keeps the cutover reversible. The
full cutover to a single lockfile (and dropping npm support) is deferred to a
later subissue.
Several repository scripts still read
package-lock.json(for example the lockfile/peer checks wired into the build pipeline). Do not removepackage-lock.jsonuntil those scripts are migrated — that work belongs to a later subissue, not S1.
Lockfile drift guard
Two committed lockfiles can silently diverge: a contributor who adds or removes
a dependency and regenerates only one of them would leave the other stale, so
npm install and bun install would build different trees. To catch this,
scripts/tests/bun-workspaces.test.ts
asserts structural parity between package.json and both committed
lockfiles. The checks run symmetrically against bun.lock and
package-lock.json so neither lockfile can drift unnoticed:
- Workspace membership — the set of workspace paths the lockfile records
(each keys members by path and the root as
"") equals the set the rootpackage.jsonworkspacesarray declares. - Root dependency graph — for the root workspace (
""), both the union of dependency names and each dependency's declared range (acrossdependencies,devDependencies,optionalDependencies, andpeerDependencies) inpackage.jsonequal what the lockfile mirrored. - Per-workspace dependency graph — the same name-set and declared-range
comparison for every non-root workspace, so a dependency (or a version-range
bump) applied to e.g.
packages/corewithout regenerating the lockfiles fails loudly.
The comparison covers dependency names and their declared ranges (the
^x.y.z specifiers written in package.json), but deliberately not the
resolved/pinned versions. The declared graph is authored input that both
lockfiles copy verbatim, so it must not diverge; resolved version selection is
each lockfile's own concern and the two can legitimately differ, because each
resolver independently picks a concrete version from the same range (the
TypeScript override section covers a case where that divergence was undesirable
and had to be actively pinned away). Validating the declared range — not just
the name — means a range bump applied to only one lockfile is caught, while
still allowing the resolvers to disagree on the concrete resolved version.
bun.lock is parsed as JSONC (Bun emits trailing commas) via the jsonc-parser
dependency; package-lock.json is strict JSON. Parse errors and a missing
lockfile are both surfaced as thrown failures, so a corrupted or absent lockfile
fails the suite loudly rather than passing vacuously. The workspace list driving
every axis comes from a single readDeclaredWorkspaceManifests() helper that
hard-fails if a declared workspace is missing its package.json (rather than
silently skipping it), so the guard cannot be quietly hollowed out.
Root Lifecycle Scripts (preinstall / postinstall)
The root package.json registers two lifecycle scripts that run on every
install of the repository root: scripts/preinstall.cjs
and scripts/postinstall.cjs. Unlike the
dependency lifecycle scripts gated by trustedDependencies above, a package
manager always runs the scripts of the package being installed, so these run
under Bun too. Both were written for npm and had to be made package-manager
aware for S1.
Lifecycle scripts execute under node, so process.versions.bun is not
set even when Bun drives the install. The reliable signal is the
npm_config_user_agent environment variable, which Bun sets to bun/<version> … and npm sets to npm/<version> …. Both scripts share a small
detectInstaller() helper that reads this variable and returns 'bun' or
'npm' (defaulting to 'npm' for any unknown manager so existing behavior is
preserved).
That helper lives in a single shared module,
scripts/detect-installer.cjs, which both
lifecycle scripts require(). It was extracted from a byte-identical copy that
previously lived in each script so the detection logic has exactly one
definition (and one set of tests). The function accepts an injectable env
parameter (defaulting to process.env) so its behavior can be unit-tested
without mutating the real process environment; its dedicated behavioral test is
scripts/tests/detect-installer.test.ts,
which pins the bun/-prefix contract (including that a stray bun substring
elsewhere in the user agent is not misclassified) and the npm default for
pnpm/Yarn/unknown managers.
Because detect-installer.cjs is a runtime dependency of the published
package's postinstall script, it must be included in the npm tarball. It is
listed in the root package.json files allowlist alongside
preinstall.cjs/postinstall.cjs; omitting it would make the published
package's postinstall fail at install time with MODULE_NOT_FOUND. This is
guarded by
scripts/tests/publish-integrity.test.ts,
which runs npm pack --dry-run, walks the transitive closure of static,
string-literal relative require() calls starting from the lifecycle entry
scripts, and asserts every locally-required .cjs file in that closure is
actually packed. The walk deliberately resolves only literal relative
specifiers (e.g. require('./detect-installer.cjs')); it does not follow
computed/dynamic requires or bare package specifiers, which matches how the
simple, dependency-free lifecycle scripts are written and keeps the guard from
silently passing on an unresolvable dynamic path.
-
postinstall.cjsnormally does two npm-specific things: it strips unsupported"peer": trueflags frompackage-lock.json, and — on a build-less GitHub-source checkout — it bootstraps a build by shelling out tonpm install --workspacesandnpm run build. Under Bun both must be skipped: Bun does not consumepackage-lock.json(so mutating it would be wrong), and the bootstrap shells out to npm, which would defeat thebun installthe user just ran. Under Bun the script instead runssymlinkBunWorkspaceCopies()(described just below) and then exits. Under npm the behavior is byte-for-byte unchanged.Bun hoisted-linker workspace symlinks
Bun's
hoistedlinker (seebunfig.toml) does not always install a local workspace dependency as a symlink to the real workspace directory the way npm does. When a version conflict forces a workspace package to be nested inside another workspace's ownnode_modules/@vybestack/, Bun materializes it as a static copy — a real directory snapshot of the workspace source tree taken at install time. Because the copy is taken before any build runs, it containssrc/but notdist/, yet the package'sexports/mainpoint atdist/. Tooling that resolves a transitive import through one of these nested copies — esbuild/vite bundling (which follows imports into compiled workspace output) and the TypeScript language server used by type-aware ESLint — then fails to resolve the entry point and degrades to errors/any.symlinkBunWorkspaceCopies()(inpostinstall.cjs, run only whendetectInstaller()returns'bun') restores npm's behavior: for every workspace's nestednode_modules/@vybestack/<pkg>entry that is a real directory (not already a symlink) and whose name matches a declared local workspace, it replaces the static copy with a relative symlink to the real workspace directory. That directory gainsdist/oncetscruns, so the links resolve correctly for both bundlers and the type checker. The function is self-contained (onlyfs/path), idempotent (an existing symlink is left untouched), and a no-op when no static copies exist. It is verified on a clean checkout on both macOS and Linux (the failure reproduced identically on both once staledist/artifacts from prior npm installs were purged — it is not a platform-specific bug). -
preinstall.cjscleans stale OS temp directories from previous runs. Under Bun it is a guarded no-op, both because the cleanup targets npm's install temp layout and to keep the Bun install path side-effect-free.
This behavior is locked down by a deterministic, fixture-based behavioral test
(scripts/tests/postinstall-manager-aware.test.ts).
It builds a throwaway clean-checkout fixture (the real postinstall.cjs, a
peer-flagged lockfile, a packages/ source dir, and no bundle) and shadows
npm on PATH with a stub, then asserts that a Bun invocation neither
shells out to npm nor mutates the lockfile, while an npm invocation still
bootstraps and still strips the peer flags. A second fixture suite seeds a
nested static workspace copy (mimicking Bun's hoisted linker) and asserts that
a Bun invocation replaces it with a symlink to the real workspace (and that
this is idempotent on re-run), while an npm invocation leaves the copy
untouched. (The test is skipped on Windows, where the shell stub is not
portable; test:scripts runs on macOS in CI.)
TypeScript Version Parity (overrides)
The root package.json overrides block pins typescript to 5.8.3:
"overrides": {
// ...
"typescript": "5.8.3"
}
This pin exists to keep Bun and npm resolving the same TypeScript version.
Most workspaces declare typescript: "^5.3.3"; packages/vscode-ide-companion
declares typescript: "^5.8.3". The pinned 5.8.3 satisfies every workspace
range. package-lock.json happens to resolve those ranges to 5.8.3, but Bun,
resolving them freshly, floats to the newest matching release (5.9.3 at time
of writing). That divergence is not cosmetic: TypeScript 5.9.x tightened the
type-aware @typescript-eslint/await-thenable analysis, so a Bun-installed tree
produced hundreds of for await...of lint errors that the npm-installed tree
did not.
Because package-lock.json already resolves typescript to 5.8.3, this
override is a no-op for npm (it does not change package-lock.json) while
it forces Bun to match the npm-locked version. This removes the known
Bun/npm TypeScript divergence that was observed during S1 validation, so the
type-aware lint results are identical under both package managers.
Maintenance trap — upgrade these together. This is an exact pin. When a later subissue advances the repository to a newer TypeScript, you must update all four in the same change: the workspace
typescriptranges, thisoverrides.typescriptpin,package-lock.json(vianpm install), andbun.lock(viabun install). If only some are updated, npm and Bun can silently diverge again. The behavioral test asserts the pin equals the npm-locked version and is compatible with every workspace range, so a partial update will fail CI.
Known Cosmetic Warning
Running bun install prints a warning:
warn: Bun currently does not support nested "overrides"
This comes from the single nested override in the root package.json
(jsonwebtoken → jws). It is a proven no-op: both npm and Bun resolve
jws@4.0.1 under jsonwebtoken regardless of this warning. It is safe to
ignore and is explicitly not fixed during S1 (removing the override could
change resolution and is out of scope).
CI: Bun Install Smoke
A dedicated CI job, bun_install_smoke in
.github/workflows/ci.yml, guards the core S1
acceptance criterion: that a clean checkout installs and links every workspace
under Bun. It pins the toolchain via .bun-version (bun-version-file), runs
bun install, then verifies with a Node-compatible JavaScript verifier
(scripts/verify-bun-workspace-links.mjs — the workflow invokes it with bun
to avoid relying on a runner-provided Node) that every declared workspace
package resolves to its in-repo directory (using realpathSync to prove the
node_modules/<name> entry is a link to the local workspace, not some other
resolution).
Why the check uses
realpathSync, not bare existence. The root package andpackages/cliare both named@vybestack/llxprt-code. A bareexistsSync(node_modules/@vybestack/llxprt-code)could therefore be satisfied by a registry copy rather than the localpackages/clilink, giving a false pass. The smoke job instead resolves the real path of every workspace entry and requires it to point inside the repository, so it cannot false-pass on a shadowed package. This strict check now applies to all 16 workspaces, includingpackages/cli, with no exemptions.This is only sound because S1 also removed a latent trap: the root
package.jsonpreviously listed its own published name ("@vybestack/llxprt-code": "^0.8.0") independencies. npm masked it (the local workspace always wins), but Bun honored the registry range and installed a stale published 0.8.x copy instead of linking the localpackages/cli, silently shadowing in-repo source and pulling in its entire transitive tree. Nothing imports the bare root name at runtime, so the self-dependency was purely harmful; it has been deleted, and thedoes not declare a self-dependency on the root package nameparity test guards against any automated version bump reintroducing it.
Why plain bun install and not --frozen-lockfile
CI runs a plain bun install, not bun install --frozen-lockfile. The
behavior below was observed under Bun 1.3.14 (the version pinned in
.bun-version); re-verify it after a Bun upgrade rather than assuming it still
holds. Two scenarios must be kept distinct:
- From-scratch regeneration (
rm bun.lock && bun install) is deterministic: repeated clean runs produce a byte-identicalbun.lock. The committedbun.lockis exactly this regenerate-from-scratch artifact. - Incremental install against the already-committed
bun.lockis not stable. Bun re-normalizes the existing lockfile on essentially every pass: a firstbun installrewrites a few hundred lines of transitive resolution, and a second consecutivebun installdrifts further still — i.e. it does not even converge against its own previous output, let alone the committed file.
That second point is why the lockfile is not guarded by a post-install diff.
Neither bun install --frozen-lockfile (perpetually red against the committed
file) nor a softer bun install && git diff --exit-code -- bun.lock gate is
usable, because both assume an incremental install is a fixed point — which it
is not here. The root cause is structural to this monorepo: the root package and
packages/cli share the name @vybestack/llxprt-code, combined with file:../
workspace links and a large overrides block. A plain bun install against the
committed lockfile still installs all 16 workspaces correctly; it is only the
re-normalized lockfile text that is unstable, so we regenerate-and-commit
bun.lock deliberately rather than diff-gating it in CI.
To reproduce the non-determinism locally (expect a non-empty diff on the second run despite no dependency change):
bun install && git checkout -- bun.lock # normalize working tree
bun install && git --no-pager diff --stat -- bun.lock # drift on pass 1
bun install && git --no-pager diff --stat -- bun.lock # further drift on pass 2
Note the scope of what replaces the frozen check here. The parity tests above
catch declared dependency-graph drift between package.json, bun.lock,
and package-lock.json — workspace membership plus each workspace's declared
dependency names and ranges. They deliberately do not compare the
resolved (transitive) versions each lockfile pins, because Bun's
re-normalization makes that comparison unstable for the structural reasons
above. So this guards against the high-value failure mode — a dependency added,
removed, or re-ranged in package.json without regenerating both lockfiles —
but it is not a byte-for-byte frozen-install equivalent for the fully resolved
tree.
Quick Start
Install all dependencies and link the workspaces with Bun:
bun install
npm still works during the transition:
npm install
Both commands produce a working, hoisted node_modules tree for all 16
workspaces.
Bun-backed Test Orchestration (issue #2463)
Why bun test does not work as a root test entry point
bun test invokes Bun's native test runner, which provides bun:test
APIs (describe, test, expect from Bun). The repository's tests are
written for Vitest and use Vitest-specific helper APIs that have no Bun
equivalent:
vi.stubEnv/vi.unstubAllEnvs— environment-variable stubbingvi.mocked— typed mock introspectionvi.setSystemTime— fake clock controlit.runIf— conditional test execution
Additionally, Bun's bun run <script> does not invoke npm lifecycle
hooks (pretest / posttest) the way npm run <script> does. The agents
package uses a pretest hook to run its API-surface guard
(scripts/check-agents-api-surface.mjs); running tests via bun run test
would silently skip it.
The test:bun entry point
The root package.json defines test:bun:
"test:bun": "bun scripts/test.ts"
This script is a Bun-backed orchestrator that mirrors npm run test --workspaces --if-present (plus test:scripts). It:
-
Discovers all 16 workspaces from the root
package.jsonworkspacesarray, reading each workspace'spackage.jsonto determine itstestandpretestscripts. -
Runs
pretestbeforetestfor each workspace that declares one (currently onlypackages/agents). This preserves the agents API-surface guard and any future pretest hooks. -
Runs each workspace's
testscript (typicallyvitest run) in the workspace directory withnode_modules/.binonPATH. Tests still run under Vitest, not Bun's native test runner, so all Vitest APIs and per-packagevitest.config.tsconfiguration remain fully available. -
Runs the script harness tests (
scripts/tests/) after workspace tests, matching the roottest:scriptsscript.
CLI flags
| Flag | Shorthand | Description |
|---|---|---|
--workspace <name> | -w | Run only the matching workspace |
--skip-scripts | Skip the script harness tests | |
--skip-pretest | Skip pretest lifecycle hooks | |
--continue-on-error | -c | Continue after failures instead of stopping |
The --workspace flag matches by directory name (core), relative path
(packages/core), or package name (@vybestack/llxprt-code-core).
Relationship to npm run test
Both paths run the same workspace Vitest suites and preserve the same
pretest guards. The npm path (npm run test) remains the primary CI
verification path; test:bun is the supported Bun-backed alternative. The
npm path is not removed until the Bun-backed path is proven equivalent
across all CI matrix legs.
Native Bun Test Runner Migration (issue #2475)
Overview
In addition to the Bun-backed Vitest orchestration (test:bun), each workspace
is being incrementally migrated to run tests natively under Bun's test runner
(bun test). Native Bun tests run faster (no Vitest overhead) and provide
better integration with Bun's module system.
Workspace test:bun scripts
Each migrated workspace defines a test:bun script in its package.json that
runs the native Bun test files in isolated processes via
scripts/run_bun_tests.ts:
"test:bun": "bun ../../scripts/run_bun_tests.ts --workspace a2a-server"
The isolation (one fresh Bun process per test file) preserves the same
module-level mock isolation that Vitest provides per file, because Bun's
mock.module is process-wide.
Vitest compatibility shim (augment-bun-vi.ts)
Bun's test runner injects its own partial Vitest API for import ... from 'vitest', but this built-in handler bypasses both mock.module and Bun
plugins. The shim at test-setup/augment-bun-vi.ts augments Bun's injected
vi object in-place with the missing Vitest-compatible methods:
vi.hoisted— hoisted mock factoryvi.mocked— typed mock introspectionvi.stubEnv/vi.unstubAllEnvs— environment-variable stubbingvi.stubGlobal/vi.unstubAllGlobals— global stubbingvi.importActual— real module loading via query-string bypassvi.waitFor— async wait helpervi.advanceTimersByTimeAsync/vi.runAllTimersAsync/vi.runOnlyPendingTimersAsync— async timer helpers. Bun provides only the sync variants (advanceTimersByTime,advanceTimersToNextTimer,runAllTimers,runOnlyPendingTimers). The elapsed-time helper advances between scheduled timer boundaries, preserves fractional advances across calls, and drains microtasks after each fired callback.runAllTimersAsyncperforms a bounded, interleaved timer/microtask drain so a timer scheduled after an awaited timer callback also runs.runOnlyPendingTimersAsyncuses Bun's pending-timer primitive once, preserving the initial last-timer boundary: a timer scheduled afterawaitruns only when its due time is at or before that boundary. These semantics preserve async timer scheduling without work proportional to elapsed milliseconds and enablewaitForto auto-advance fake timers under Bun (seesetWaitForSchedulerinstub-helpers.ts).vi.mock/vi.doMock— overridden to passimportOriginalto factories
The root bunfig.toml and migrated workspace configurations preload this
shim. Workspace paths are relative to that workspace, for example:
[test]
preload = ["../../test-setup/augment-bun-vi.ts"]
CLI workspace specifics
The CLI workspace has additional complexities:
-
Ink module redirect: Bun's
mock.module('ink')triggers export validation against the real Ink ESM build, which fails on re-exports likeRange/Selectionfromselection.js. The CLI'sbun-test-setup.tsuses aBun.pluginwithonResolveto redirect'ink'and'ink-testing-library'specifiers to TypeScript stub files before module resolution, preventing Bun from ever loading the real Ink module. -
Dependency injection seams: Some characterization tests previously used
vi.mockwithimportOriginalto replace individual exports (e.g.,writeToStderrfrom core). Under Bun,mock.moduleintercepts all imports of a specifier includingimportActualcalls, causing deadlocks. These tests now use typed dependency-injection seams (__setWriteToStderrForTesting,__setRenderForTesting) instead of module-level mocking.
Running native Bun tests
# All workspaces
bun scripts/run_bun_tests.ts
# Single workspace (exact manifest workspace name)
bun scripts/run_bun_tests.ts --workspace a2a-server
# Or through a migrated workspace's package script
npm run test:bun --workspace @vybestack/llxprt-code-a2a-server
run_bun_tests.ts does not support --exclude — every file run must be
explicitly listed in the manifest (scripts/bun-test-manifest.ts). Files
that are not Bun-compatible are simply absent from the manifest rather than
excluded at invocation time.
Only packages/a2a-server, packages/cli, and packages/providers define a
package-level test:bun script; each passes its exact manifest workspace name.
The native core and test-setup entries are run through the root runner
instead: bun scripts/run_bun_tests.ts --workspace core and
bun scripts/run_bun_tests.ts --workspace test-setup. The root package's own
test:bun command remains the separate Bun-backed Vitest orchestrator
(scripts/test.ts), not the native runner. Use bun scripts/run_bun_tests.ts
for all native manifest entries.
CI parity
The bun_native_test_parity CI job runs a representative sample of tests
from each migrated workspace under Bun's native test runner to verify
parity with Vitest results. The full Vitest suite remains the primary
verification path; native Bun runs are additive parity checks.