test: replace verdaccio and the mock npm servers with an in-process spec-compliant registry
Replaces verdaccio and the two hand-rolled mock registries the install tests run against with test/npm-registry/, an in-process npm registry built on Bun.serve({ routes }).
The case for this is structural, not a stopwatch win. Verdaccio is a real registry forked as a child process: a boot per test file, a shared mutable on-disk package store that forces every test in a file to run serially and every publish test to rm -rf after itself, and enough startup flakiness that config-version.test.ts spent up to 30 s polling whether it had come up and silently skipped two of its three tests when it hadn't. An in-process registry starts in a socket bind, never touches disk, and every test can have its own, so tests in a file can run concurrently and nothing has state to clean up. Measured on CI (debian 13 x64 release, expected-durations.json vs build 70555), the 11 ex-verdaccio files I captured run in 8.9 s instead of 19.2 s; across the whole test/cli/install/ directory the sum goes from 147.8 s to 141.4 s because the long-pole files were never verdaccio-bound.
The library
test/npm-registry/README.md is the tour. The short version: describe a package's package.json in code and the registry packs the tarball, hashes it, derives the packument, and answers real content negotiation.
await using registry = await new NpmRegistry().start();
registry.define("say-hi", {
"2.0.0": {
bin: { "say-hi": "cli.js" },
dependencies: { "left-pad": "^1" },
tarball: { "cli.js": "#!/usr/bin/env node\nconsole.log('hi');\n" },
},
});
What it implements, because the tests needed it:
- Full and abbreviated ("corgi") packuments with real
Acceptnegotiation, including the derivedhasInstallScript. - The manifest normalization
npm publishapplies (bincanonicalization, the implicitnode-gyp rebuildinstall script for abinding.gyp). - A deterministic
.tgzwriter whose tar payload is byte-for-bytenpm pack's (node-tarportable: trueheader encoding, npm-packlist's entry sort, gzip OS byte normalized to 255).tar.test.tsassertsgunzip(buildTarball(src))equalsgunzip(npm pack src)against five committed golden tarballs (plain, bin at 0755,binding.gyp, scoped, and a 117-byte path that crosses ustar'sname/prefixsplit), and pins a known-answer sha512 so a zlib or compression-default change trips there instead of as an opaque integrity diff elsewhere. - npm's real tarball URL shape (
@scope/name/-/name-1.0.0.tgz), and scoped packuments at both/@scope%2fnameand/@scope/name. ETag/If-None-Match/ 304, plusVary: Accept.Cache-Controlis sent when configured, but bun does not read that header; its warm-manifest gate is an on-disk cache age.- Auth:
PUT /-/user/org.couchdb.user:<n>, bearer and basicAuthorization,/-/whoami, per-package-glob access rules. - Publish end to end: strict
Content-Type: application/json(the verdaccio gate the two comments inpublish_command.rscite), attachment decoding, integrity verification of the uploaded bytes, a 403 on republish, deprecate. A failed publish is transactional. - npm's 2FA web-authentication flow: the real OTP 401 challenge (
www-authenticate: OTP, the exact error string clients match,authUrl/doneUrl) and thedoneUrlpoll that hands a one-time password back. This replaces a third hand-rolled mock that lived insidebun-publish.test.ts. POST /-/npm/v1/security/advisories/bulkforbun audit, with requestContent-Encoding: gzipdecoded (whichbun auditalways sends).- Request recording (
registry.paths,registry.requests[n].headers) and interception (registry.simulateFailures({ timesPerUrl: 4, status: 500 })), replacing theurls: string[]out-parameter and theexpect()calls the old mocks made inside their server handlers.
The class is decoupled from the test harness (it imports only Bun and node builtins); test/harness.ts exports TestRegistry, a thin subclass that preloads the shared fixture tree and owns the bun-specific conveniences (writing a bunfig.toml, the per-test project directory).
The fixtures
test/cli/install/registry/packages/ held 210 packages as verdaccio storage: a hand-written packument JSON each (some hundreds of lines of _attachments, _rev, and integrity strings) plus 287 prebuilt .tgz files. Adding one meant running tar and sha512sum yourself; create-native-binlink-packages.ts was 155 lines of that boilerplate for two trivial packages.
- All 210 packument JSONs are deleted. The registry derives the packument. I diffed the derived document field by field against every hand-written one before deleting them; the only semantic deltas were things verdaccio had applied at publish time that the registry now applies too (the
bin/gyp normalization above) plus four packages whosedist-tagsare not derivable, which get a three-line_registry.json. (That diff compared packument fields, includingdist.integritywhich at that point still came from hashing the original npm bytes; it is not a claim thatbuildTarballreproduces the deleted tarballs. Thenpm packgolden vectors are what establish that separately.) - 254 of the 287
.tgzfiles are deleted, exploded into plain source directories (pkg/1.0.0/package.json) the registry packs on demand. The 33 that remain are the ones whose exact bytes are pinned by an artifact that cannot be regenerated (checked-inpnpm-lock.yaml/package-lock.json/ yarn migration fixtures, an integrity inlined in a test, a committed snapshot) plus one whose deliberately non-standard internal layout is the thing its test exercises. Each.tgzI kept has a reason. - New fixtures never need a
.tgzor a packument. A.gitattributesrule marks the tree-textso no platform's EOL conversion can change a packed tarball's bytes, and a single trailing!/test/cli/install/registry/packages/**in.gitignorekeeps the whole tree out of the global ignore rules.
Validation
- The library has its own suite (
test/npm-registry/*.test.ts): the golden vectors, the pinned sha512, and the registry's HTTP contract. - All 16 test files that used
VerdaccioRegistryare migrated and green against a debug build: ~320 tests, zero failures attributable to the registry. (The handful of local reds are environmental and reproducible with no registry configured at all: tests that clone a real GitHub repo through this container's TLS-intercepting proxy, and acpuTime < 750msassertion for which a barebun installhere measures 961 ms.)
Three things it surfaced:
- Nothing had ever tested bun against a real abbreviated packument. bun asks every registry for
application/vnd.npm.install-v1+json; registry.npmjs.org honors that (noscripts, a derivedhasInstallScript, a strict field subset) but verdaccio ignores theAcceptheader and serves the full document. Every install test now runs against the document shape the real registry sends. All of them pass, which is reassuring rather than alarming, but it was an accident before. bun pm whoamiwith an invalid token had only ever been tested against verdaccio's non-standard answer (a 200 with an empty body). registry.npmjs.org sends a 401. The registry now 401s, the test asserts bun's 401 output, and a second test reproduces the 200-with-no-username shape withregistry.intercept(), so both of bun's code paths for it are now covered deliberately.- Three
bun publishtests republished the samename@versionwithin one file and only passed because anrm -rfinto verdaccio's on-disk store unpublished it in between. A registry correctly rejects the second publish. Those three now callregistry.remove(), which is what thatrmreally was; the other eighteenrms againstregistry.packagesPathwere dead.
The other half: dummy.registry.ts
All 17 files that used dummy.registry.ts or simple-dummy-registry.ts are migrated too, and both modules are deleted, along with 56 of the 63 flat test/cli/install/*.tgz files they served (the seven that remain are each read by a test of bun's tarball-file dependency protocol, "pkg": "./x.tgz", which has nothing to do with a registry). Each of those tests now gets a fresh in-process registry in its beforeEach and declares its packages in code, so the files that used to share one mutable module-level handler can run their tests concurrently.
Two details from that migration worth a reviewer's attention:
- The old mock's "fail each URL N times before succeeding" counter actually failed N+1 times (a set-then-check ordering quirk), and the retry test's request-count assertions encode that.
registry.simulateFailures({ timesPerUrl })fails exactly N, so the argument changed by one and the twelve recorded requests stayed twelve. - The old mock served every tarball at
<root>/<name>-<v>.tgz; a spec-compliant registry serves<root>/<name>/-/<name>-<v>.tgz. Every URL assertion changed shape, which is also the entire diff of the two regenerated security-scanner snapshot files (1,856 lines thatsort | uniq -creduces to four substitutions).
The matrix
test/cli/install/registry-resolver-matrix.test.ts runs five resolver scenarios that have historically broken in exactly one configuration (a transitive peer chain, an optional peer reached from two aliases of one package, a workspace catalog, platform-filtered optional dependencies, and a pair of npm: aliases that point at each other) across {hoisted, isolated} × {cold, warm-on-disk}. The second axis varies BUN_INSTALL_CACHE_DIR (same dir for both passes vs a fresh one for pass 2), which is bun's actual warm-manifest gate; the test asserts the axis is real (a warm pass 2 issues strictly fewer packument requests than a cold one). Every cell of a scenario must produce a byte-identical bun.lock, so resolution can never silently depend on the linker or on whether a manifest came from the network or the warm cache.
Rebase onto 332f7444f9
The branch is squashed and rebased onto main at 332f7444f9. Ten test/cli/install/ files had changed on both sides; here is what each resolution did:
- #33622 ("make some tests faster") reordered
bun-install-lifecycle-scripts.test.tsand rewrotebun-workspaces.test.tsaround a semaphore-gatedsetupTest(). Resolution: takemain's version of both files whole, then re-apply only this PR'sverdacciotoregistry/VerdaccioRegistrytoTestRegistrysubstitution. Jarred's reordering and concurrency machinery are kept exactly. - Nine new tests landed on
mainwritten against the infrastructure this PR deletes; they are migrated in place with no change in what they assert:bun-install-retry.test.ts: the three redirect-retry tests from #33242.setHandler(h)becomesregistry.intercept(h); the handlers, the recorded-URL assertions, and thebar-0.0.2.tgzbytes are unchanged.bun-install.test.ts: the four manifest-tarball-URL-validation tests from #33072.setContextHandler(ctx, h)becomesctx.registry.intercept(h). (#33159's two transitive-file:tests do not use a registry and auto-merged.)bun-lockb.test.tsandisolated-install.test.ts: the new binary-lockfile-flag test from #33072 and the eight peer-stability/global-store/nested-alias tests from #32182, #33072 and #33646. All already usedregistry.createTestDir(), whichTestRegistryimplements, so they auto-merged and pass unmodified.
bun-install-tarball-integrity.test.ts: #33245's extra<r>assertions and #33072's three multi-hashdist.integritytests auto-merged (the newdescribebrings its ownBun.serveand does not touch the registry); the resolution is a one-linegzipSyncimport my side had removed.bun-install-security-provider.test.ts: tookmain's in-memory large-payload path from #33622 (no more temp dir of 3000.tgzfiles) on top of this PR's per-test registry.bun-install-registry.test.ts(100 to 10 iterations),bun-lock.test.ts(await using file),.gitattributes,.gitignore,test/tsconfig.json: auto-merged, both sides kept.simple-dummy-registry.ts: deletion kept;NpmRegistryalready has[Symbol.asyncDispose].
All nine migrated tests and the two reorganized files pass against a debug build after the rebase. The only local failures are the usual environmental ones the PR body already names (tests that clone a real GitHub/Bitbucket/GitLab repo, and node -p/node-gyp tests that empty PATH and rely on the host's node), which are unchanged by this PR and pass in CI.
Related
- #44055 has the same goal with a narrower scope: it replaces verdaccio only, and it keeps the fixtures of main as they are. Only one of the two PRs can land as it is. The comparison is in https://github.com/oven-sh/bun/pull/33115#issuecomment-5849444725.
no test proof · iteration 39 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-add.test.ts test/cli/install/bun-install-lifecycle-scripts.test.ts test/cli/install/bun-install-native-binlink.test.ts test/cli/install/bun-install-registry.test.ts test/cli/install/bun-install.test.ts test/cli/install/bun-link.test.ts test/cli/install/bun-lock.test.ts te…