test(install): share the starting trees between bun-update-transitive cases and assert every update summary
Problem
bun-update-transitive.test.tstakes 19s on the x64 ASAN lane (build 101560). Under ASANbun testruns 5 cases at a time (context.rs:506), so the file takes its total per-case work divided by 5.- Its 174 cases spawned 594
bunprocesses, 303 of them installs of a starting tree, and most cases start from the same few dozen trees. The harness can not share one:makeTreeSyncusesfs.cpSync, which leaves the linksbun installwrites pointing into the original. - About 30 cases did not read the stdout of the
bun updateunder test.
Fix
- Commit 1,
test/harness.ts:copyTreeSyncreplacesfs.cpSync. A relative link is kept, an absolute link into the source is pointed at the copy. SocreateTestDir({ files: tree })is an independent copy of an installed tree, cache included. - Commit 2:
setup()and the recipes build each tree once and give every case such a copy. Helper signatures do not change. A case writes only to its copy. A tree is never written to after its promise resolves. - Commit 3: every
bun updaterun asserts its stdout (list in the notes). - Processes: 594 to 410. Interleaved pairs on a loaded host, Linux debug ASAN build: main 27.3s, 35.4s, 28.7s against 24.4s, 29.8s, 25.1s. Windows x64 debug build: 18.0s against 14.7s. 174 pass in every run. Every open PR on this file merges cleanly on top.
Background
- A recipe such as
stale()installs a tree, then re-installs it with a widened package.json, which leaves a transitive row forbun updateto move. bun installwrites absolute links for workspace members and store entries on Windows, and for the cache's index everywhere. A Windows directory link is recreated as a junction, which needs no privilege, as bun's own fallback does.- A real run prints transitive rows in package id order (
print_transitive_updates).--dry-runsorts. SoexpectRowSetAndcompares two such rows as a set.
Notes
Self-review. A review of the first version of this PR (a copier local to this file, which also left the cache behind) made two points that this version takes up. The copier is a harness matter: tempDir(prefix, folder) and createTestDir({ files: folder }) already exist for copying a folder, their fs.cpSync is what made them unfit for an installed tree, and #39742 was about to add a second, different copier to bun-dedupe.test.ts for the same reason. So commit 1 fixes the harness and commit 2 is its first consumer. And the assertion commit conflicted with #38770 in the two cases that PR rewrites ("a sibling whose range rejects the picked version" and "several names in one command"), so those two are left as they are on main. Afterwards git merge-tree merges #38770, #38901, #38919, #39531 and #39742 cleanly onto this branch, both onto commit 2 and onto the whole PR. #39165 conflicts with main itself in mordant-baseline.toml and pack_command.rs, not with this branch. The review also suggested removing expectMoved, expectNoMoves and expectNoChangesLine and giving expectNoop a required count line. Not done: #38770 and #38901 add cases that call them with the current signatures, so that would break those PRs for no gain in the cases this PR touches (expectNoop returns its stdout instead, and the callers here pin the line).
copyTreeSync. The three existing callers of the folder form (migration/migrate.test.ts:728, migration/pnpm-migration.test.ts:185, migration/pnpm-lock-v9.test.ts:35) copy npm-arborist and pnpm fixture folders that hold regular files and directories only (checked with find), and all three pass with the new copier (125 pass and 1 todo, 3 pass and 11 todo as on main, 81 pass; pnpm-lock-v9 also on Windows). copyFileSync keeps the mode, as cpSync did. relative() is taken against realpathSync.native(source) because bun writes resolved targets. A link is recreated with the kind of its target (statSync on the source link), "file" if it dangles. On Windows a probe showed the workspace link of a hoisted tree is absolute and the store links of an isolated tree are relative on a runner with the symlink privilege, and with BUN_FEATURE_FLAG_FORCE_WINDOWS_JUNCTIONS=1 every link is an absolute junction that readdirSync reports as a link. In both modes the copy's links point into the copy and bun install --frozen-lockfile in the copy reports no changes for both linkers, and the whole file passes in both modes (174 pass, twice in normal mode and once with junctions forced, on this version).
Cost of a copy. A stale() tree is 23 entries, 12 of them cache. With the debug runner a copy takes 8.8ms (copyTreeSync) or 12.6ms (createTestDir), with a release runner 0.8ms or 1.6ms, so the 158 copies of a run cost about 0.25s with a release runner and about 1.5s more than the first version of this PR (which skipped the cache: 22.5s to 22.8s against 26.6s to 28.5s on a quieter host) with the debug one. The cache is copied anyway because that is what each case had before: its own installs had just warmed it. The index entries of the cache are absolute links into the tree's own cache and are relocated like the rest. fs.promises.cp and fs.cpSync were measured too: cpSync walks in JS on Linux and took 27ms per 20-entry tree with the debug runner against 7ms for the hand written walk, and the async form was slower end to end.
Timing method. Same machine, same debug ASAN build (bun bd, isASANEnabled() is true), 12 CPUs of quota, reading the Ran 174 tests line. The numbers in the description come from git checkout main -- test/harness.ts test/cli/install/bun-update-transitive.test.ts, a run, git checkout HEAD -- ..., a run, three times, with the host load average between 31 and 43, which is why the pairs differ so much from each other. The fixed cost of the file (verdaccio start plus runner start) is about 3.9s locally and is untouched. Windows: main 17.98s and 17.97s, this version 14.71s and 14.85s (not ASAN, so 20 cases at a time).
CI. Build 101601 (the first version) reported the file at 20.98s on the x64 ASAN lane against 19.20s for main in build 101560. The runner executes changed files first, so the file ran third in its job, during the job's service warm-up: coordinator: redis_unified ready lands in the middle of its output and its first cases were reported done after 8.7s, against 3.4s in build 101560 where it ran eighth. From there on main completed its cases in about 15s (11.5 per second) and the branch in about 12.5s (about 13.5 per second). The same applies to every build of this PR: in builds 101601, 102381 and 102393 the file ran third in its job, its first cases were reported done after 8.7s, 14.6s and 13.7s, and it finished 12.3s, 8.9s and 10.7s after that (20.98s, 23.50s and 24.41s in total, 174 pass each time), against main's 3.4s and then 15.8s in build 101560.
Count line semantics (install_with_manager.rs:1170): installs is the number of node_modules entries found in place, packages is the number of bun.lock rows. So a bundled dependency is a package but not an install (noChanges(3, 5), noChanges(1, 3)), a workspace link is an install (noChanges(3, 4)), and --filter pkg2 checks pkg2's installs only (noChanges(2, 4), the same line bun install --filter pkg2 prints on that tree).
Assertion changes (commit 3).
- Exact summary (
expectSummary) added to: a direct dependency's range left alone,bun up,-L(also pins the 1.0.1 start and the 2.0.0 end in bun.lock), the threestaleMemberTransitivecases, the twodisjointEdgesmove cases, from one member re-points a sibling, from a member lets a sibling follow, both runs of "from the root does not re-resolve a member's own entry" (plus where each no-deps copy is installed), the named and bare runs of "leaves the named package's own dependencies", abovelatest, the three prerelease cases, both alias cases (the row is printed under the alias, and the installed alias is checked), both peerDependencies cases,@types/*with--filter,--filter <other>(and that it saves the lockfile), the row index shift case (also pins+ a-dep@1.0.1 (v1.0.10 available)), without a lockfile (+ no-deps@1.1.0 (v2.0.0 available)), and the auto-installed peer cases. The named peer case prints the same row as the bare one, so itsplannedflag is gone. - Exact no-op lines added to the two
--latesthold-back cases, the bundled edge cases, and, throughexpectNoopreturning stdout, the peer edge, peer-only, dependency cycle and twoABOVE_LATESTcases. - Row list plus count line (
expectRowsAnd) replaces the row-only or contains checks in the two override cases, the moved dist-tag cases and the accepted scanner case. expectRowSetAndin the shared package cases (plusresolvedEdgespinning which dependent got 1.0.2 and which 2.0.2) and the--minimum-release-agecase.expectHeaderOnly(moved up from the outdated section) on the twelve update error paths,expectRejectedincluded.- The three confirmed interactive runs check their rows and count line through
expectPicked, whichexpectInteractiveDryRunnow uses too. expectKeptPatched(commit 2) folds the two identical patched bodies and adds that the lockfile is not reported as saved and that the patched 1.0.0 is still the one installed.noDepsEdgesbecameresolvedEdges(dir, name, ...)so that the shared package cases can use it.expect()calls went from 2601 to 2197: the checks inside a recipe run once per shared tree instead of once per case, andinstall()'s two checks run 184 fewer times.
Not changed. The 35 cases that serve a registry from memory bake their server's port into bun.lock, so their trees are not shared. frozen() still runs after every update. No case was removed, skipped or reordered. The verdaccio start under the ASAN binary in CI is a fixed cost of every registry test file and is outside this PR.
[stamp-90s] gate passed · iteration 2 · 2 files touched
passes on PR (with fix)
Test-only change.
Debug/ASAN (expected pass):
$ bun bd test 'test/cli/install/bun-update-transitive.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/cli/install/bun-update-transitive.test.ts
bun test v1.4.0 (4199361ed)
test/cli/install/bun-update-transitive.test.ts:
(pass) `bun update` moves a transitive dependency within its dependent's range (text lockfile) [973.93ms]
(pass) `bun update --latest` still moves transitive dependencies only within their ranges [926.05ms]
(pass) `bun update` moves a transitive dependency within its dependent's range (isolated linker) [982.15ms]
(pass) `bun update` moves a transitive dependency within its dependent's range (binary lockfile) [986.19ms]
(pass) `bun update no-deps` reaches a package that is only a transitive dependency [1076.75ms]
(pass) `bun update` with a pattern reaches a package that is only a transitive dependency [388.84ms]
(pass) `bun update` with a bare `*` reaches a package that is only a transitive dependency [353.41ms]
(pass) `bun update` with a negated name reaches a package that is only a transitive dependency [397.00ms]
(pass) `bun update` with a pattern alongside a direct name reaches a package that is only a transitive dependency [374.20ms]
(pass) `bun update --latest no-deps` reaches a package that is only a transitive dependency [551.95ms]
(pass) `bun update <name>` naming a package with nothing newer is the same no-op as a bare rerun [235.00ms]
(pass) without a lockfile, `bun update <undeclared>` is rejected and writes no lockfile [170.67ms]
(pass) `bun update <name>` and a pattern that match nothing in bun.lock name that file [325.57ms]
(pass) `bun update <name>` and a pattern that match nothing in bun.lockb name that file [334.56ms]
(pass) without a lockfile, `bun update <declared>` resolves and saves [412.49ms]
(pass) `bun update --frozen-lockfile` refuses to move the transitive dependency [265.16ms]
(pass) `bun update --lockfile-only` moves the transitive dependency in bun.lock only [420.89ms]
(pass) `bun update --dry-run` (bare) prints the plan as su
... (truncated)
Exit: 0
diff hotspot
test/cli/install/bun-update-transitive.test.ts | 562 +++++++++++++++----------
test/harness.ts | 44 +-
2 files changed, 382 insertions(+), 224 deletions(-)
gate history · 3 passed · 0 rejected · iteration 2
evidence per changed file
file reads edits tests
test/cli/install/bun-update-transitive.test.ts 54 96 0
test/harness.ts 5 3 0