From f25f71b777e4fa0decdaf72df2951d8d975c8c85 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:43:41 +0000 Subject: [PATCH 1/4] test(harness): copy a fixture folder with its links instead of through fs.cpSync makeTreeSync, and so tempDir and createTestDir, copied a folder with fs.cpSync. That keeps an absolute link pointing into the folder it was copied from, and resolves a relative one against it, so a copy of an installed project was not independent of the original. copyTreeSync recreates each link instead: a relative target is kept, and an absolute target inside the source is pointed at the copy. On Windows a directory link with an absolute target is recreated as a junction, which needs no privilege, like the fallback bun install itself uses. The three existing callers copy fixture folders without links and are unaffected. This lets a test install a tree once and give every case a copy of it through createTestDir({ files: tree }). --- test/harness.ts | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/test/harness.ts b/test/harness.ts index b3167919ec84..c408f92ba1e9 100644 --- a/test/harness.ts +++ b/test/harness.ts @@ -13,7 +13,7 @@ import { ChildProcess, execSync, fork } from "child_process"; import { readdir, rm, writeFile } from "fs/promises"; import fs, { closeSync, openSync, rmSync } from "node:fs"; import os from "node:os"; -import { dirname, isAbsolute, join } from "path"; +import { dirname, isAbsolute, join, relative } from "path"; export const BREAKING_CHANGES_BUN_1_2 = false; @@ -429,18 +429,56 @@ export function makeTreeSyncFromDirectoryTree(base: string, tree: DirectoryTree) export function makeTreeSync(base: string, filesOrAbsolutePathToCopyFolderFrom: DirectoryTree | string) { if (typeof filesOrAbsolutePathToCopyFolderFrom === "string") { - fs.cpSync(filesOrAbsolutePathToCopyFolderFrom, base, { recursive: true }); + copyTreeSync(filesOrAbsolutePathToCopyFolderFrom, base); return; } return makeTreeSyncFromDirectoryTree(base, filesOrAbsolutePathToCopyFolderFrom); } +/** + * Copies the contents of the directory `source` into the existing directory `dest`, so that the copy is independent of + * the original. That is what makes an installed project (a `node_modules`, a workspace, a cache) usable as a fixture + * that is installed once and copied into each test's own directory. + * + * Links are recreated, not followed. A relative target is kept as it is. An absolute target that points into `source` + * (`bun install` writes those for workspace members and store entries on Windows, and for the cache's index everywhere) + * is pointed at the same entry of the copy. `fs.cpSync` would leave such a link pointing into `source`, and would + * resolve a relative one against `source` unless told otherwise. On Windows an absolute directory link is recreated as + * a junction, which needs no privilege, like the fallback `bun install` itself uses. + */ +export function copyTreeSync(source: string, dest: string) { + const root = fs.realpathSync.native(source); + const copy = (from: string, to: string) => { + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const entryFrom = join(from, entry.name); + const entryTo = join(to, entry.name); + if (entry.isSymbolicLink()) { + let target = fs.readlinkSync(entryFrom); + const kind = fs.statSync(entryFrom, { throwIfNoEntry: false })?.isDirectory() ? "dir" : "file"; + if (isAbsolute(target)) { + const inside = relative(root, target); + if (!inside.startsWith("..") && !isAbsolute(inside)) target = join(dest, inside); + fs.symlinkSync(target, entryTo, kind === "dir" ? "junction" : "file"); + } else { + fs.symlinkSync(target, entryTo, kind); + } + } else if (entry.isDirectory()) { + fs.mkdirSync(entryTo, { recursive: true }); + copy(entryFrom, entryTo); + } else { + fs.copyFileSync(entryFrom, entryTo); + } + } + }; + copy(source, dest); +} + /** * Recursively create files within a new temporary directory. * * @param basename prefix of the new temporary directory - * @param filesOrAbsolutePathToCopyFolderFrom Directory tree or absolute path to a folder to copy. If passing an object each key is a folder or file, and each value is the contents of the file. Use objects for directories. + * @param filesOrAbsolutePathToCopyFolderFrom Directory tree or absolute path to a folder to copy (see `copyTreeSync`). If passing an object each key is a folder or file, and each value is the contents of the file. Use objects for directories. * @returns an absolute path to the new temporary directory * * @example From ccae185974768e805dcde7e447e9118e1b5e8d18 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:44:23 +0000 Subject: [PATCH 2/4] test(install): install each starting tree of bun-update-transitive once Most cases of the file start from one of a few dozen trees, and each case installed its own: 303 of the 594 bun processes the file spawned were those installs. setup() and the recipes several cases start from (stale, staleSiblings, staleMemberEdges, staleShared, the patched trees and the rest) now build their tree once, in the first case that asks for it, and give every case a copy of it through createTestDir. The tree is never written to after it is built. Helper signatures and return shapes do not change. The file now spawns 410 processes. With a debug ASAN build, which runs 5 cases at a time, it takes about 24s instead of 27s to 28s. --- .../cli/install/bun-update-transitive.test.ts | 272 ++++++++++-------- 1 file changed, 157 insertions(+), 115 deletions(-) diff --git a/test/cli/install/bun-update-transitive.test.ts b/test/cli/install/bun-update-transitive.test.ts index 43d7ae599a80..9ca805ff3beb 100644 --- a/test/cli/install/bun-update-transitive.test.ts +++ b/test/cli/install/bun-update-transitive.test.ts @@ -49,17 +49,36 @@ async function install(dir: string, ...args: string[]) { const frozen = (dir: string, layout: Layout = {}) => install(dir, "--frozen-lockfile", ...linkerArgs(layout)); -async function setup(files: Record, layout: Layout = {}) { - const { packageDir } = await registry.createTestDir({ - bunfigOpts: { saveTextLockfile: layout.text ?? true, linker: layout.linker ?? "hoisted" }, - files: Object.fromEntries( - Object.entries(files).map(([path, json]) => [path, typeof json === "string" ? json : stringify(json)]), - ), - }); - await install(packageDir, ...linkerArgs(layout)); +const bunfigOpts = (layout: Layout) => ({ saveTextLockfile: layout.text ?? true, linker: layout.linker ?? "hoisted" }); + +// Most tests start from one of a few dozen installed trees, and each install is another `bun` process under ASAN. A tree +// is installed once, by the first test that asks for it, and is never written to again: every test gets its own copy of +// it from createTestDir (cache included, with a bunfig.toml naming the copy's own cache), exactly as if it had run the +// installs itself. +const templates = new Map>(); + +async function copyOf(key: unknown[], layout: Layout, build: () => Promise) { + const id = JSON.stringify([...key, bunfigOpts(layout)]); + let template = templates.get(id); + if (!template) templates.set(id, (template = build())); + const { packageDir } = await registry.createTestDir({ bunfigOpts: bunfigOpts(layout), files: await template }); return packageDir; } +// A copy of `files` installed with `layout`. +function setup(files: Record, layout: Layout = {}) { + return copyOf(["setup", files], layout, async () => { + const { packageDir } = await registry.createTestDir({ + bunfigOpts: bunfigOpts(layout), + files: Object.fromEntries( + Object.entries(files).map(([path, json]) => [path, typeof json === "string" ? json : stringify(json)]), + ), + }); + await install(packageDir, ...linkerArgs(layout)); + return packageDir; + }); +} + async function reinstall(dir: string, packageJson: Json, layout: Layout = {}, rel = "") { await write(join(dir, rel, "package.json"), stringify(packageJson)); expect(await install(dir, ...linkerArgs(layout))).toContain("Saved lockfile"); @@ -171,14 +190,17 @@ const noDepsPath = (linker: Linker = "hoisted") => // Dropping the root's exact no-deps@1.0.0 leaves one-range-dep's `^1.0.0` edge on 1.0.0, which `bun install` never moves. async function stale(layout: Layout = {}) { - const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" }) }, layout); const packageJson = pkgJson({ "one-range-dep": "1.0.0" }); - await reinstall(dir, packageJson, layout); const noDeps = noDepsPath(layout.linker); - if (layout.text ?? true) { - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - } - expect(await installedVersion(dir, ...noDeps)).toBe("1.0.0"); + const dir = await copyOf(["stale"], layout, async () => { + const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" }) }, layout); + await reinstall(dir, packageJson, layout); + if (layout.text ?? true) { + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + } + expect(await installedVersion(dir, ...noDeps)).toBe("1.0.0"); + return dir; + }); return { dir, packageJson, noDeps }; } @@ -211,11 +233,14 @@ function grouped(versions: Json, groups: Groups) { // Exact pins widened to ranges after the install: both entries stay locked below the newest version their range allows. async function staleSiblings(groups: Groups = {}) { - const dir = await setup({ "package.json": grouped({ "no-deps": "1.0.0", "a-dep": "1.0.1" }, groups) }); const packageJson = grouped({ "no-deps": "^1.0.0", "a-dep": "^1.0.1" }, groups); - await reinstall(dir, packageJson); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); + const dir = await copyOf(["staleSiblings", groups], {}, async () => { + const dir = await setup({ "package.json": grouped({ "no-deps": "1.0.0", "a-dep": "1.0.1" }, groups) }); + await reinstall(dir, packageJson); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); + return dir; + }); return { dir, packageJson, groups }; } @@ -574,31 +599,49 @@ index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e const PATCHED = { patchedDependencies: { "no-deps@1.0.0": "patches/no-deps@1.0.0.patch" } }; -// The `stale()` recipe with the transitive no-deps@1.0.0 patched; the kept row sits in the summary block and names the version the patch is holding back. -test.concurrent.each<[string, string[], string]>([ - ["bare", [], noChanges(2, 3)], - ["no-deps", ["no-deps"], noChanges(2, 3)], - ["bare --dry-run", ["--dry-run"], nothingToUpdate(3)], -])("a patched transitive dependency is left alone (%s)", async (_, args, countLine) => { - const dir = await setup({ - "package.json": pkgJson({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" }, PATCHED), - "patches/no-deps@1.0.0.patch": NO_DEPS_PATCH, +// `pinned` is installed with no-deps@1.0.0 patched, then re-installed as `widened`, which keeps the patched 1.0.0 locked. +async function stalePatched(pinned: Json, widened: Json) { + const packageJson = pkgJson(widened, PATCHED); + const dir = await copyOf(["stalePatched", pinned, widened], {}, async () => { + const dir = await setup({ "package.json": pkgJson(pinned, PATCHED), "patches/no-deps@1.0.0.patch": NO_DEPS_PATCH }); + await reinstall(dir, packageJson); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + expect(await file(join(dir, "node_modules", "no-deps", "patched.txt")).exists()).toBeTrue(); + return dir; }); - const packageJson = pkgJson({ "one-range-dep": "1.0.0" }, PATCHED); - await reinstall(dir, packageJson); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - const patched = file(join(dir, "node_modules", "no-deps", "patched.txt")); - expect(await patched.exists()).toBeTrue(); + return { dir, packageJson, patched: file(join(dir, "node_modules", "no-deps", "patched.txt")) }; +} + +// The `stale()` recipe with the transitive no-deps@1.0.0 patched. +const stalePatchedTransitive = () => + stalePatched({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" }, { "one-range-dep": "1.0.0" }); +const stalePatchedDirect = () => stalePatched({ "no-deps": "1.0.0" }, { "no-deps": "^1.0.0" }); + +// The kept row sits in the summary block and names the version the patch is holding back; nothing is saved or installed. +async function expectKeptPatched( + { dir, packageJson, patched }: Awaited>, + countLine: string, + ...args: string[] +) { const before = await lockText(dir); const { stdout, stderr, exitCode } = await run(dir, "update", ...args); expectSummary(stdout, keptPatched("no-deps", "1.0.0", "1.1.0"), "", countLine); expectCleanStderr(stderr); + expect(stderr).not.toContain("Saved lockfile"); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockText(dir)).toBe(before); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.0.0"); expect(await patched.exists()).toBeTrue(); await frozen(dir); expect(exitCode).toBe(0); +} + +test.concurrent.each<[string, string[], string]>([ + ["bare", [], noChanges(2, 3)], + ["no-deps", ["no-deps"], noChanges(2, 3)], + ["bare --dry-run", ["--dry-run"], nothingToUpdate(3)], +])("a patched transitive dependency is left alone (%s)", async (_, args, countLine) => { + await expectKeptPatched(await stalePatchedTransitive(), countLine, ...args); }); test.concurrent.each<[string, string[], string]>([ @@ -607,37 +650,14 @@ test.concurrent.each<[string, string[], string]>([ ["bare --dry-run", ["--dry-run"], nothingToUpdate(2)], ["no-deps --dry-run", ["no-deps", "--dry-run"], nothingToUpdate(2)], ])("a patched dependency declared in package.json is held within its range (%s)", async (_, args, countLine) => { - const dir = await setup({ - "package.json": pkgJson({ "no-deps": "1.0.0" }, PATCHED), - "patches/no-deps@1.0.0.patch": NO_DEPS_PATCH, - }); - const packageJson = pkgJson({ "no-deps": "^1.0.0" }, PATCHED); - await reinstall(dir, packageJson); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - const patched = file(join(dir, "node_modules", "no-deps", "patched.txt")); - expect(await patched.exists()).toBeTrue(); - const before = await lockText(dir); - const { stdout, stderr, exitCode } = await run(dir, "update", ...args); - expectSummary(stdout, keptPatched("no-deps", "1.0.0", "1.1.0"), "", countLine); - expectCleanStderr(stderr); - expect(await packageJsonOf(dir)).toStrictEqual(packageJson); - expect(await lockText(dir)).toBe(before); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - expect(await patched.exists()).toBeTrue(); - await frozen(dir); - expect(exitCode).toBe(0); + await expectKeptPatched(await stalePatchedDirect(), countLine, ...args); }); // --latest moves past the patch on purpose; the orphaned patchedDependencies entry is pointed out on stderr. test.concurrent( "`bun update --latest` moves a patched dependency and warns that its patch no longer applies", async () => { - const dir = await setup({ - "package.json": pkgJson({ "no-deps": "1.0.0" }, PATCHED), - "patches/no-deps@1.0.0.patch": NO_DEPS_PATCH, - }); - await reinstall(dir, pkgJson({ "no-deps": "^1.0.0" }, PATCHED)); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + const { dir } = await stalePatchedDirect(); const { stdout, stderr, exitCode } = await run(dir, "update", "no-deps", "--latest"); expectSummary(stdout, movedRow("no-deps", "1.0.0", "2.0.0"), "", installed(1)); expect(stderr).toContain("warn: patches/no-deps@1.0.0.patch no longer applies (no-deps is now 2.0.0)\n"); @@ -741,17 +761,20 @@ test.concurrent("without a lockfile `bun update` resolves everything fresh", asy // The `stale()` recipe applied inside pkg1; pkg2, when present, pins no-deps@1.0.0 too and is widened to `pkg2Range`. async function staleMemberTransitive(pkg2Range?: string) { - const dir = await setup({ - "package.json": ROOT, - "packages/pkg1/package.json": member("pkg1", { "one-range-dep": "1.0.0", "no-deps": "1.0.0" }), - ...(pkg2Range ? { "packages/pkg2/package.json": member("pkg2", { "no-deps": "1.0.0" }) } : {}), - }); - if (pkg2Range) { - await write(join(dir, "packages/pkg2/package.json"), stringify(member("pkg2", { "no-deps": pkg2Range }))); - } const pkg1 = member("pkg1", { "one-range-dep": "1.0.0" }); - await reinstall(dir, pkg1, {}, "packages/pkg1"); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + const dir = await copyOf(["staleMemberTransitive", pkg2Range], {}, async () => { + const dir = await setup({ + "package.json": ROOT, + "packages/pkg1/package.json": member("pkg1", { "one-range-dep": "1.0.0", "no-deps": "1.0.0" }), + ...(pkg2Range ? { "packages/pkg2/package.json": member("pkg2", { "no-deps": "1.0.0" }) } : {}), + }); + if (pkg2Range) { + await write(join(dir, "packages/pkg2/package.json"), stringify(member("pkg2", { "no-deps": pkg2Range }))); + } + await reinstall(dir, pkg1, {}, "packages/pkg1"); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + return dir; + }); return { dir, pkg1 }; } @@ -787,14 +810,17 @@ const MEMBER_FILES = ["", "packages/pkg1", "packages/pkg2"]; // The root's exact no-deps pin parks every member's transitive `^1.0.0` edge on 1.0.0; dropping it (and widening pkg1 to `pkg1After`) leaves everything where it was locked. async function staleMemberEdges(pkg1: Json, pkg2: Json, pkg1After: Json = pkg1) { - const dir = await setup({ - "package.json": { ...ROOT, dependencies: { "no-deps": "1.0.0" } }, - "packages/pkg1/package.json": member("pkg1", pkg1), - "packages/pkg2/package.json": member("pkg2", pkg2), + const dir = await copyOf(["staleMemberEdges", pkg1, pkg2, pkg1After], {}, async () => { + const dir = await setup({ + "package.json": { ...ROOT, dependencies: { "no-deps": "1.0.0" } }, + "packages/pkg1/package.json": member("pkg1", pkg1), + "packages/pkg2/package.json": member("pkg2", pkg2), + }); + await write(join(dir, "packages/pkg1/package.json"), stringify(member("pkg1", pkg1After))); + await reinstall(dir, ROOT); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + return dir; }); - await write(join(dir, "packages/pkg1/package.json"), stringify(member("pkg1", pkg1After))); - await reinstall(dir, ROOT); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); const texts = () => Promise.all(MEMBER_FILES.map(rel => packageJsonText(dir, rel))); return { dir, texts, textsBefore: await texts(), lockBefore: await lockText(dir) }; } @@ -1083,10 +1109,13 @@ async function expectNoop(dir: string, ...args: string[]) { const HOIST_DEPENDENTS = { "hoist-lockfile-1": "1.0.0", "hoist-lockfile-2": "1.0.0", "hoist-lockfile-3": "1.0.0" }; async function staleShared() { - const dir = await setup({ "package.json": pkgJson({ ...HOIST_DEPENDENTS, "hoist-lockfile-shared": "1.0.1" }) }); const packageJson = pkgJson(HOIST_DEPENDENTS); - await reinstall(dir, packageJson); - expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]); + const dir = await copyOf(["staleShared"], {}, async () => { + const dir = await setup({ "package.json": pkgJson({ ...HOIST_DEPENDENTS, "hoist-lockfile-shared": "1.0.1" }) }); + await reinstall(dir, packageJson); + expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]); + return dir; + }); return { dir, packageJson }; } @@ -1134,11 +1163,14 @@ test.concurrent.each([ // peer-deps-fixed@1.0.0 declares peer `no-deps: ^1.0.0`; the root's exact no-deps@1.0.0 provided it, and dropping that entry leaves the auto-installed peer parked on 1.0.0. async function staleAutoInstalledPeer() { - const dir = await setup({ "package.json": pkgJson({ "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" }) }); const packageJson = pkgJson({ "peer-deps-fixed": "1.0.0" }); - await reinstall(dir, packageJson); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - expect(await installedVersion(dir, "no-deps")).toBe("1.0.0"); + const dir = await copyOf(["staleAutoInstalledPeer"], {}, async () => { + const dir = await setup({ "package.json": pkgJson({ "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" }) }); + await reinstall(dir, packageJson); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.0.0"); + return dir; + }); return { dir, packageJson }; } @@ -1931,12 +1963,14 @@ test.concurrent("`bun update ` for a name only other workspaces depend on expect(exitCode).toBe(1); }); -async function staleScoped() { - const dir = await setup({ "package.json": pkgJson({ "no-deps": "1.0.0", "@types/no-deps": "^1.0.0" }) }); - await reinstall(dir, pkgJson({ "no-deps": "^1.0.0", "@types/no-deps": "^1.0.0" })); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - expect(await lockedVersions(dir, "@types/no-deps")).toStrictEqual(["1.0.0"]); - return dir; +function staleScoped() { + return copyOf(["staleScoped"], {}, async () => { + const dir = await setup({ "package.json": pkgJson({ "no-deps": "1.0.0", "@types/no-deps": "^1.0.0" }) }); + await reinstall(dir, pkgJson({ "no-deps": "^1.0.0", "@types/no-deps": "^1.0.0" })); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + expect(await lockedVersions(dir, "@types/no-deps")).toStrictEqual(["1.0.0"]); + return dir; + }); } test.concurrent("a scoped glob selects only the matching names", async () => { @@ -2109,12 +2143,14 @@ test.concurrent.each([ await expectOnlyADepMoved(await staleSiblings(DEV_A_DEP), "-D", arg); }); -async function staleAlias() { - const dir = await setup({ "package.json": pkgJson({ aliased: "npm:no-deps@1.0.0", "a-dep": "1.0.1" }) }); - await reinstall(dir, pkgJson({ aliased: "npm:no-deps@~1.0.0", "a-dep": "^1.0.1" })); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); - return dir; +function staleAlias() { + return copyOf(["staleAlias"], {}, async () => { + const dir = await setup({ "package.json": pkgJson({ aliased: "npm:no-deps@1.0.0", "a-dep": "1.0.1" }) }); + await reinstall(dir, pkgJson({ aliased: "npm:no-deps@~1.0.0", "a-dep": "^1.0.1" })); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); + return dir; + }); } test.concurrent("a pattern matches an aliased entry through its alias", async () => { @@ -2156,12 +2192,14 @@ const withPeer = (aDep: string, noDeps: string) => ({ peerDependencies: { "no-deps": noDeps }, }); -async function stalePeerEntry() { - const dir = await setup({ "package.json": withPeer("1.0.1", "1.0.0") }); - await reinstall(dir, withPeer("^1.0.1", "^1.0.0")); - expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); - return dir; +function stalePeerEntry() { + return copyOf(["stalePeerEntry"], {}, async () => { + const dir = await setup({ "package.json": withPeer("1.0.1", "1.0.0") }); + await reinstall(dir, withPeer("^1.0.1", "^1.0.0")); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); + expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); + return dir; + }); } // A root peerDependencies entry is never re-resolved by `bun update`; a pattern still counts it as a match, a group selector does not. @@ -2213,29 +2251,33 @@ const PKG2_GROUPS = (depWithTags: string, types: string) => ({ dependencies: { "dep-with-tags": depWithTags, "@types/no-deps": types }, }); +const lockedGroups = async (dir: string) => ({ + "no-deps": await lockedVersions(dir, "no-deps"), + "@types/no-deps": await lockedVersions(dir, "@types/no-deps"), + "a-dep": await lockedVersions(dir, "a-dep"), + "dep-with-tags": await lockedVersions(dir, "dep-with-tags"), +}); + async function staleMemberGroups() { - const dir = await setup({ - "package.json": ROOT, - "packages/pkg1/package.json": PKG1_GROUPS("1.0.0", "1.0.0", "1.0.1"), - "packages/pkg2/package.json": PKG2_GROUPS("1.0.0", "1.0.0"), - }); - await write(join(dir, "packages/pkg2/package.json"), stringify(PKG2_GROUPS("^1.0.0", "^1.0.0"))); - await reinstall(dir, PKG1_GROUPS("^1.0.0", "^1.0.0", "^1.0.1"), {}, "packages/pkg1"); - const locked = async () => ({ - "no-deps": await lockedVersions(dir, "no-deps"), - "@types/no-deps": await lockedVersions(dir, "@types/no-deps"), - "a-dep": await lockedVersions(dir, "a-dep"), - "dep-with-tags": await lockedVersions(dir, "dep-with-tags"), - }); const stale = { "no-deps": ["1.0.0"], "@types/no-deps": ["1.0.0"], "a-dep": ["1.0.1"], "dep-with-tags": ["1.0.0"], }; - expect(await locked()).toStrictEqual(stale); + const dir = await copyOf(["staleMemberGroups"], {}, async () => { + const dir = await setup({ + "package.json": ROOT, + "packages/pkg1/package.json": PKG1_GROUPS("1.0.0", "1.0.0", "1.0.1"), + "packages/pkg2/package.json": PKG2_GROUPS("1.0.0", "1.0.0"), + }); + await write(join(dir, "packages/pkg2/package.json"), stringify(PKG2_GROUPS("^1.0.0", "^1.0.0"))); + await reinstall(dir, PKG1_GROUPS("^1.0.0", "^1.0.0", "^1.0.1"), {}, "packages/pkg1"); + expect(await lockedGroups(dir)).toStrictEqual(stale); + return dir; + }); const texts = () => Promise.all(MEMBER_FILES.map(rel => packageJsonText(dir, rel))); - return { dir, locked, stale, texts, textsBefore: await texts() }; + return { dir, locked: () => lockedGroups(dir), stale, texts, textsBefore: await texts() }; } test.concurrent("`bun update --dev -r` rewrites every workspace's devDependencies and nothing else", async () => { From 33aea729796ad9b44c9127a756cd6ad154f51941 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:44:23 +0000 Subject: [PATCH 3/4] test(install): assert the summary of every bun update run in bun-update-transitive About 30 cases did not read the stdout of the bun update they ran. Each run now asserts the exact summary where one row moves, the row set plus the count line where two packages new to bun.lock move (a real run prints those in package id order, so the order is not pinned), the header alone on every error path, and the exact "Checked N installs across M packages" line on every no-op. Several cases also pin which version each dependent's edge resolves to and what is installed. The two cases that #38770 rewrites are left as they are, so that PR and the other open PRs on this file still merge on top of this one. --- .../cli/install/bun-update-transitive.test.ts | 290 +++++++++++------- 1 file changed, 184 insertions(+), 106 deletions(-) diff --git a/test/cli/install/bun-update-transitive.test.ts b/test/cli/install/bun-update-transitive.test.ts index 9ca805ff3beb..a12ee914feb6 100644 --- a/test/cli/install/bun-update-transitive.test.ts +++ b/test/cli/install/bun-update-transitive.test.ts @@ -122,6 +122,17 @@ function expectSummary(stdout: string, ...lines: string[]) { // Several rows in one real run: the rows are asserted as a list and the count line by itself. function expectRowsAnd(stdout: string, rows: string[], countLine: string) { expect(movedRows(stdout)).toStrictEqual(rows); + expectCountLine(stdout, countLine); +} + +// A real run prints the transitive rows in package id order, which is the order the packages were resolved in, so two +// packages that were both new to bun.lock are asserted as a set. (--dry-run sorts its plan.) +function expectRowSetAnd(stdout: string, rows: string[], countLine: string) { + expect(movedRows(stdout).sort()).toStrictEqual([...rows].sort()); + expectCountLine(stdout, countLine); +} + +function expectCountLine(stdout: string, countLine: string) { expect(normalize(stdout)).toEndWith(`\n\n${countLine}\n`); expect(stdout).not.toMatch(/^installed /m); } @@ -147,6 +158,10 @@ function expectDryRun(stdout: string, ...rows: string[]) { expectSummary(stdout, ...rows, "", wouldUpdate(rows.length)); } +// A command that fails before it has anything to report prints nothing but its header: no rows, no table, no count line. +const expectHeaderOnly = (stdout: string, command: string) => + expect(stdout).toMatch(new RegExp(`^bun ${command} v[^\\n]*\\n$`)); + const NOT_IN_LOCKFILE = (name: string, lockfile = "bun.lock") => `error: "${name}" is not in ${lockfile}\n bun add ${name}\n`; // `scope` is "the selected workspaces" once -r/--filter picked the scope, "this workspace" when the cwd did; one --filter hint per other workspace that reaches the name. @@ -181,6 +196,15 @@ async function lockedVersions(dir: string, name: string) { return [...new Set(versions)].sort(); } +// The version of `name` each dependent's edge resolves to in bun.lock: its nested row when it lost the hoisting race, else the hoisted one. +async function resolvedEdges(dir: string, name: string, ...dependents: string[]) { + const { packages } = await lock(dir); + const versionOf = (key: string) => (packages[key] as [string] | undefined)?.[0].slice(name.length + 1); + return Object.fromEntries( + dependents.map(dependent => [dependent, versionOf(`${dependent}/${name}`) ?? versionOf(name)]), + ); +} + async function installedVersion(dir: string, ...segments: string[]) { return (await file(join(dir, "node_modules", ...segments, "package.json")).json()).version; } @@ -260,11 +284,11 @@ async function expectRejected(dir: string, message: string, ...args: string[]) { const packageJsonBefore = await packageJsonText(dir); const lockBefore = await lockText(dir); const { stdout, stderr, exitCode } = await run(dir, "update", ...args); + expectHeaderOnly(stdout, "update"); expect(stderr).toContain(message); expect(await packageJsonText(dir)).toBe(packageJsonBefore); expect(await lockText(dir)).toBe(lockBefore); expect(exitCode).toBe(1); - return { stdout, stderr }; } async function expectNothingToUpdate(dir: string, line: string, ...args: string[]) { @@ -360,12 +384,13 @@ test.concurrent.each<[string, Layout]>([ expect(named.stderr).toContain(NOT_IN_LOCKFILE("does-not-exist", lockfile)); expect(named.stderr).not.toContain("note:"); expect(named.stderr).not.toContain("nothing to update"); - expect(named.stdout).not.toContain("installed"); + expectHeaderOnly(named.stdout, "update"); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await file(join(dir, lockfile)).bytes()).toStrictEqual(before); expect(named.exitCode).toBe(1); const pattern = await run(dir, "update", "zzz-*", ...linkerArgs(layout)); + expectHeaderOnly(pattern.stdout, "update"); expect(pattern.stderr).toContain(`error: no packages in ${lockfile} match "zzz-*"\n`); expect(pattern.stderr).not.toContain("note:"); expect(await file(join(dir, lockfile)).bytes()).toStrictEqual(before); @@ -382,8 +407,9 @@ async function withoutLockfile(dependencies: Json) { test.concurrent("without a lockfile, `bun update ` resolves and saves", async () => { const dir = await withoutLockfile({ "no-deps": "^1.0.0" }); - const { stderr, exitCode } = await run(dir, "update", "no-deps"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", "no-deps"); + expectSummary(stdout, "+ no-deps@1.1.0 (v2.0.0 available)", "", installed(1)); + expectCleanStderr(stderr); expect(stderr).toContain("Saved lockfile"); expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "^1.1.0" })); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); @@ -394,7 +420,8 @@ test.concurrent("without a lockfile, `bun update ` resolves and saves" test.concurrent("without a lockfile, `bun update ` is rejected and writes no lockfile", async () => { const dir = await withoutLockfile({ "no-deps": "^1.0.0" }); - const { stderr, exitCode } = await run(dir, "update", "a-dep"); + const { stdout, stderr, exitCode } = await run(dir, "update", "a-dep"); + expectHeaderOnly(stdout, "update"); expect(stderr).toContain(NOT_IN_LOCKFILE("a-dep")); expect(stderr).not.toContain("note:"); expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "^1.0.0" })); @@ -422,7 +449,8 @@ test.concurrent("`bun update --frozen-lockfile` refuses to move the transitive d const { dir, noDeps } = await stale(); const packageJsonBefore = await packageJsonText(dir); const lockBefore = await lockText(dir); - const { stderr, exitCode } = await run(dir, "update", "--frozen-lockfile"); + const { stdout, stderr, exitCode } = await run(dir, "update", "--frozen-lockfile"); + expectHeaderOnly(stdout, "update"); expect(stderr).toContain("error: lockfile had changes, but lockfile is frozen"); expect(await packageJsonText(dir)).toBe(packageJsonBefore); expect(await lockText(dir)).toBe(lockBefore); @@ -438,8 +466,9 @@ test.concurrent("a moved direct dependency is still followed after its row index join(dir, "package.json"), stringify(pkgJson({ "a-dep": "1.0.1", "no-deps": "^1.0.0", "peer-deps-fixed": "1.0.0" })), ); - const { stderr, exitCode } = await run(dir, "update"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectSummary(stdout, NO_DEPS_ROW_HINTED, "", "+ a-dep@1.0.1 (v1.0.10 available)", "", installed(2)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual( pkgJson({ "a-dep": "1.0.1", "no-deps": "^1.1.0", "peer-deps-fixed": "1.0.0" }), ); @@ -447,6 +476,7 @@ test.concurrent("a moved direct dependency is still followed after its row index expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); expect((await lock(dir)).packages["peer-deps-fixed/no-deps"]).toBeUndefined(); expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); + expect(await installedVersion(dir, "a-dep")).toBe("1.0.1"); await frozen(dir); expect(exitCode).toBe(0); }); @@ -512,8 +542,9 @@ test.concurrent( test.concurrent("a direct dependency's declared range is left alone when only its dependency moves", async () => { const { dir, packageJson } = await stale(); - const { stderr, exitCode } = await run(dir, "update"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update"); + expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(packageJson.dependencies); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); @@ -689,9 +720,9 @@ test.concurrent.each<[string, Json | undefined, string]>([ async (_, overrides, to) => { const { dir, packageJson } = await overriddenThenEdited(overrides); const { stdout, stderr, exitCode } = await run(dir, "update"); - expect(movedRows(stdout)).toStrictEqual([movedRow("no-deps", "1.0.0", to)]); + expectRowsAnd(stdout, [movedRow("no-deps", "1.0.0", to)], installed(1)); expect(stdout).not.toContain("+ no-deps@"); - expect(stderr).not.toContain("error:"); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect((await lock(dir)).overrides).toStrictEqual(overrides); expect(await lockedVersions(dir, "no-deps")).toStrictEqual([to]); @@ -708,8 +739,8 @@ test.concurrent( const packageJson = pkgJson({ "one-range-dep": "1.0.0" }, { overrides: { "no-deps": "1.0.1" } }); await write(join(dir, "package.json"), stringify(packageJson)); const { stdout, stderr, exitCode } = await run(dir, "update"); - expect(movedRows(stdout)).toStrictEqual([movedRow("no-deps", "1.0.0", "1.0.1")]); - expect(stderr).not.toContain("error:"); + expectRowsAnd(stdout, [movedRow("no-deps", "1.0.0", "1.0.1")], installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect((await lock(dir)).overrides).toStrictEqual({ "no-deps": "1.0.1" }); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]); @@ -781,9 +812,8 @@ async function staleMemberTransitive(pkg2Range?: string) { test.concurrent("in a workspace, `bun update` from the root moves a member's transitive dependency", async () => { const { dir, pkg1 } = await staleMemberTransitive(); const { stdout, stderr, exitCode } = await run(dir, "update"); - expectMoved(stdout, "no-deps", "1.0.0", "1.1.0"); - expect(stdout).not.toContain("+ no-deps@"); - expect(stderr).not.toContain("error:"); + expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(ROOT); expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); @@ -797,11 +827,13 @@ test.concurrent.each([ ["the member", "packages/pkg1"], ])("`bun update ` run from %s still moves a member's transitive dependency", async (_, cwd) => { const { dir, pkg1 } = await staleMemberTransitive(); - const { stderr, exitCode } = await runIn(dir, cwd, "update", "no-deps"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await runIn(dir, cwd, "update", "no-deps"); + expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(ROOT); expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); await frozen(dir); expect(exitCode).toBe(0); }); @@ -825,15 +857,6 @@ async function staleMemberEdges(pkg1: Json, pkg2: Json, pkg1After: Json = pkg1) return { dir, texts, textsBefore: await texts(), lockBefore: await lockText(dir) }; } -// The no-deps version each dependent's edge resolves to: its nested row when it lost the hoisting race, else the hoisted one. -async function noDepsEdges(dir: string, ...dependents: string[]) { - const { packages } = await lock(dir); - const versionOf = (key: string) => (packages[key] as [string] | undefined)?.[0].slice("no-deps@".length); - return Object.fromEntries( - dependents.map(dependent => [dependent, versionOf(`${dependent}/no-deps`) ?? versionOf("no-deps")]), - ); -} - // one-range-dep and one-range-dep-too both depend on no-deps ^1.0.0; pkg1 reaches no-deps only through the former, pkg2 only through the latter. const distinctEdges = () => staleMemberEdges({ "one-range-dep": "1.0.0" }, { "one-range-dep-too": "1.0.0" }); @@ -850,7 +873,7 @@ test.concurrent.each([ const { stdout, stderr, exitCode } = await runIn(dir, cwd, "update", ...args); expectRowsAnd(stdout, [NO_DEPS_ROW], installed(1)); expectCleanStderr(stderr); - expect(await noDepsEdges(dir, "one-range-dep", "one-range-dep-too")).toStrictEqual({ + expect(await resolvedEdges(dir, "no-deps", "one-range-dep", "one-range-dep-too")).toStrictEqual({ "one-range-dep": "1.1.0", "one-range-dep-too": "1.1.0", }); @@ -917,7 +940,8 @@ test.concurrent.each<[string, string, string[], string]>([ "in a workspace, `bun update ` %s naming a package only another workspace reaches is an error", async (_, cwd, args, message) => { const { dir, texts, textsBefore, lockBefore } = await disjointEdges(); - const { stderr, exitCode } = await runIn(dir, cwd, "update", ...args, "no-deps"); + const { stdout, stderr, exitCode } = await runIn(dir, cwd, "update", ...args, "no-deps"); + expectHeaderOnly(stdout, "update"); expect(stderr).toContain(message); expect(stderr).not.toContain("note:"); expect(await lockText(dir)).toBe(lockBefore); @@ -951,7 +975,7 @@ test.concurrent.each([ expect(stderr).toContain('error: No workspace packages matched the filter "zzz"\n'); expect(stderr).not.toContain("warn:"); expect(stderr).not.toContain("Saved lockfile"); - expect(stdout).not.toContain("Checked"); + expectHeaderOnly(stdout, "update"); expect(await lockText(dir)).toBe(lockBefore); expect(await texts()).toStrictEqual(textsBefore); expect(exitCode).toBe(1); @@ -964,10 +988,12 @@ test.concurrent.each([ "in a workspace, `bun update ` %s moves a package that workspace reaches transitively", async (_, cwd, args) => { const { dir, texts, textsBefore } = await disjointEdges(); - const { stderr, exitCode } = await runIn(dir, cwd, "update", ...args, "no-deps"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await runIn(dir, cwd, "update", ...args, "no-deps"); + expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1)); + expectCleanStderr(stderr); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.1.0"); expect(await texts()).toStrictEqual(textsBefore); await frozen(dir); expect(exitCode).toBe(0); @@ -983,8 +1009,10 @@ test.concurrent.each([ const dir = await setup({ "package.json": packageJson }); expect(await installedVersion(dir, name)).toBe(version); - const { stderr, exitCode } = await run(dir, "update", "--latest"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", "--latest"); + expectSummary(stdout, noChanges(1, 2)); + expectCleanStderr(stderr); + expect(stderr).not.toContain("Saved lockfile"); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockedVersions(dir, name)).toStrictEqual([version]); expect(await lockText(dir)).not.toContain('"latest"'); @@ -1004,8 +1032,10 @@ test.concurrent( const rootText = await packageJsonText(dir); const pkg1Text = await packageJsonText(dir, "packages/pkg1"); - const { stderr, exitCode } = await run(dir, "update", "--latest"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", "--latest"); + expectSummary(stdout, noChanges(3, 4)); + expectCleanStderr(stderr); + expect(stderr).not.toContain("Saved lockfile"); expect(await packageJsonText(dir)).toBe(rootText); expect(await packageJsonText(dir, "packages/pkg1")).toBe(pkg1Text); expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["2.0.1", "3.0.1"]); @@ -1035,8 +1065,8 @@ test.concurrent("`bun up --help` prints the update help", async () => { test.concurrent("`bun up` is `bun update`", async () => { const { dir, packageJson } = await stale(); const { stdout, stderr, exitCode } = await run(dir, "up"); - expectMoved(stdout, "no-deps", "1.0.0", "1.1.0"); - expect(stderr).not.toContain("error:"); + expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); expect(exitCode).toBe(0); @@ -1044,9 +1074,12 @@ test.concurrent("`bun up` is `bun update`", async () => { test.concurrent("`-L` is `--latest`", async () => { const dir = await setup({ "package.json": pkgJson({ "no-deps": "~1.0.0" }) }); - const { stderr, exitCode } = await run(dir, "update", "-L"); - expect(stderr).not.toContain("error:"); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]); + const { stdout, stderr, exitCode } = await run(dir, "update", "-L"); + expectSummary(stdout, movedRow("no-deps", "1.0.1", "2.0.0"), "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "~2.0.0" })); + expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); await frozen(dir); expect(exitCode).toBe(0); @@ -1103,6 +1136,7 @@ async function expectNoop(dir: string, ...args: string[]) { expect(await lockText(dir)).toBe(before); await frozen(dir); expect(exitCode).toBe(0); + return stdout; } // hoist-lockfile-{1,2,3} depend on hoist-lockfile-shared (1.0.1 / 1.0.2 / 2.0.1 / 2.0.2) as `*` / `^1.0.1` / `>=1.0.1`. @@ -1119,17 +1153,28 @@ async function staleShared() { return { dir, packageJson }; } +// One row per version the edges move onto: the `^1.0.1` edge goes to 1.0.2, the `*` and `>=1.0.1` edges to 2.0.2. test.concurrent.each([ ["bare", []], ["named", ["hoist-lockfile-shared"]], ["named with an ignored @version", ["hoist-lockfile-shared@1.0.1"]], ])("every dependent's range on a shared package is re-resolved on its own (%s)", async (_, args) => { const { dir, packageJson } = await staleShared(); - const { stderr, exitCode } = await run(dir, "update", ...args); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", ...args); + expectRowSetAnd( + stdout, + [movedRow("hoist-lockfile-shared", "1.0.1", "1.0.2"), movedRow("hoist-lockfile-shared", "1.0.1", "2.0.2")], + installed(2), + ); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(HOIST_DEPENDENTS); expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2", "2.0.2"]); + expect(await resolvedEdges(dir, "hoist-lockfile-shared", ...Object.keys(HOIST_DEPENDENTS))).toStrictEqual({ + "hoist-lockfile-1": "2.0.2", + "hoist-lockfile-2": "1.0.2", + "hoist-lockfile-3": "2.0.2", + }); await frozen(dir); expect(exitCode).toBe(0); }); @@ -1156,7 +1201,7 @@ test.concurrent.each([ ])("a peer edge keeps following the root's pinned provider instead of forking (%s)", async (_, args) => { const dir = await setup({ "package.json": pkgJson({ "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" }) }); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - await expectNoop(dir, ...args); + expectSummary(await expectNoop(dir, ...args), noChanges(2, 3)); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); expect(await installedVersion(dir, "no-deps")).toBe("1.0.0"); }); @@ -1174,15 +1219,15 @@ async function staleAutoInstalledPeer() { return { dir, packageJson }; } -test.concurrent.each<[string, string[], boolean]>([ - ["bare", [], true], - ["--latest peer-deps-fixed", ["--latest", "peer-deps-fixed"], true], - ["no-deps", ["no-deps"], false], -])("an auto-installed peer that nothing else depends on is re-resolved (%s)", async (_, args, planned) => { +test.concurrent.each([ + ["bare", []], + ["--latest peer-deps-fixed", ["--latest", "peer-deps-fixed"]], + ["no-deps", ["no-deps"]], +])("an auto-installed peer that nothing else depends on is re-resolved (%s)", async (_, args) => { const { dir, packageJson } = await staleAutoInstalledPeer(); const { stdout, stderr, exitCode } = await run(dir, "update", ...args); - if (planned) expectMoved(stdout, "no-deps", "1.0.0", "1.1.0"); - expect(stderr).not.toContain("error:"); + expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]); expect(await lockedVersions(dir, "peer-deps-fixed")).toStrictEqual(["1.0.0"]); @@ -1195,11 +1240,12 @@ test.concurrent.each<[string, string[], boolean]>([ test.concurrent("a package with only peer dependencies is a clean no-op", async () => { const dir = await setup({ "package.json": pkgJson({ "peer-deps": "1.0.0" }) }); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); - await expectNoop(dir); + expectSummary(await expectNoop(dir), noChanges(2, 3)); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); }); -// dep-loop-entry@1.0.0 and dep-loop-exit@1.0.0 pin each other; bundled-1@1.0.0 ships its own no-deps@1.0.0. +// dep-loop-entry@1.0.0 and dep-loop-exit@1.0.0 pin each other; bundled-1@1.0.0 ships its own no-deps@1.0.0, which is a +// row of bun.lock but not an install of its own, so the count line has one install fewer than packages besides the root. test.concurrent.each([ ["bare", []], ["no-deps", ["no-deps"]], @@ -1207,7 +1253,7 @@ test.concurrent.each([ ])("a dependency cycle and a bundled dependency are left alone (%s)", async (_, args) => { const dir = await setup({ "package.json": pkgJson({ "dep-loop-entry": "1.0.0", "bundled-1": "1.0.0" }) }); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - await expectNoop(dir, ...args); + expectSummary(await expectNoop(dir, ...args), noChanges(3, 5)); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); expect(await lockedVersions(dir, "dep-loop-entry")).toStrictEqual(["1.0.0"]); expect(await lockedVersions(dir, "dep-loop-exit")).toStrictEqual(["1.0.0"]); @@ -1227,8 +1273,9 @@ test.concurrent.each([ ); await write(join(dir, "bun.lock"), widened); const { stdout, stderr, exitCode } = await run(dir, "update", ...args); - expectNoMoves(stdout); - expect(stderr).not.toContain("error:"); + expectSummary(stdout, noChanges(1, 3)); + expectCleanStderr(stderr); + expect(stderr).not.toContain("Saved lockfile"); expect(await lockText(dir)).toBe(widened); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); expect(exitCode).toBe(0); @@ -1249,8 +1296,9 @@ async function staleMembers(pkg1Range: string, pkg2Range: string) { test.concurrent("in a workspace, `bun update` from one member also re-points a sibling's identical range", async () => { const { dir } = await staleMembers("~1.0.0", "~1.0.0"); - const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await runIn(dir, "packages/pkg1", "update"); + expectSummary(stdout, movedRow("no-deps", "1.0.0", "1.0.1", "2.0.0"), "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(ROOT); expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "~1.0.1" })); expect(await packageJsonOf(dir, "packages/pkg2")).toStrictEqual(member("pkg2", { "no-deps": "~1.0.0" })); @@ -1348,7 +1396,8 @@ test.concurrent("`bun update ` leaves the named package's own dependencies expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); const named = await run(dir, "update", "parent"); - expect(named.stderr).not.toContain("error:"); + expectSummary(named.stdout, movedRow("parent", "1.0.0", "1.1.0"), "", installed(1)); + expectCleanStderr(named.stderr); expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ parent: "^1.1.0" })); expect(await lockedVersions(dir, "parent")).toStrictEqual(["1.1.0"]); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]); @@ -1358,9 +1407,8 @@ test.concurrent("`bun update ` leaves the named package's own dependencies expect(named.exitCode).toBe(0); const bare = await run(dir, "update"); - expectMoved(bare.stdout, "leaf", "1.0.0", "1.1.0"); - expect(bare.stdout).not.toContain("+ leaf@"); - expect(bare.stderr).not.toContain("error:"); + expectSummary(bare.stdout, movedRow("leaf", "1.0.0", "1.1.0"), "", installed(1)); + expectCleanStderr(bare.stderr); expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ parent: "^1.1.0" })); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.1.0"]); expect(await installedVersion(dir, "leaf")).toBe("1.1.0"); @@ -1502,7 +1550,8 @@ test.concurrent("`bun update ` for a transitive-only name that the scanner const { stdout, stderr, exitCode } = await run(dir, "update", "leaf"); expect(stdout).toContain("scanned: leaf@1.0.1"); expect(stdout).not.toContain("FATAL:"); - expect(stderr).not.toContain("error:"); + expectRowsAnd(stdout, [movedRow("leaf", "1.0.0", "1.0.1")], installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.1"]); expect(await installedVersion(dir, "leaf")).toBe("1.0.1"); @@ -1536,8 +1585,8 @@ test.concurrent.each([ using server = await serveRegistry(TAGGED, tags); const { dir, packageJson } = await movedTag(server, tags, from, to); const { stdout, stderr, exitCode } = await run(dir, "update", ...args); - expectMoved(stdout, "leaf", from, to); - expect(stderr).not.toContain("error:"); + expectRowsAnd(stdout, [movedRow("leaf", from, to)], installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockedVersions(dir, "leaf")).toStrictEqual([to]); expect(await lockText(dir)).toContain('"leaf": "stable"'); @@ -1578,10 +1627,9 @@ test.concurrent( expect(await lockedVersions(dir, "tagged")).toStrictEqual(["1.0.0"]); const { stdout, stderr, exitCode } = await run(dir, "update", "--minimum-release-age", THREE_DAYS_SECONDS); - expectMoved(stdout, "leaf", "1.0.0", "1.1.0"); - expectMoved(stdout, "tagged", "1.0.0", "1.1.0"); + expectRowSetAnd(stdout, [movedRow("leaf", "1.0.0", "1.1.0"), movedRow("tagged", "1.0.0", "1.1.0")], installed(2)); expect(stdout).not.toContain("1.2.0"); - expect(stderr).not.toContain("error:"); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.1.0"]); expect(await lockedVersions(dir, "tagged")).toStrictEqual(["1.1.0"]); @@ -1616,9 +1664,8 @@ test.concurrent( expect(await lockedVersions(dir, "leaf")).toStrictEqual(["2.0.1"]); const { stdout, stderr, exitCode } = await run(dir, "update"); - expectMoved(stdout, "leaf", "2.0.1", "3.0.0"); - expect(stdout).not.toContain("3.0.1"); - expect(stderr).not.toContain("error:"); + expectSummary(stdout, movedRow("leaf", "2.0.1", "3.0.0"), "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.0"]); expect(await installedVersion(dir, "leaf")).toBe("3.0.0"); @@ -1635,7 +1682,7 @@ test.concurrent( const packageJson = pkgJson({ parent: "^1.0.0" }); const dir = await setupServed(server, "update-at-latest-", packageJson); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.0"]); - await expectNoop(dir); + expectSummary(await expectNoop(dir), noChanges(2, 3)); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.0"]); expect(await lock(dir)).toStrictEqual(await freshInstallLock(server, "update-at-latest-fresh-", packageJson)); }, @@ -1651,7 +1698,7 @@ test.concurrent("a transitive range edge locked ahead of `latest` is not downgra packageJson, ); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.1"]); - await expectNoop(dir); + expectSummary(await expectNoop(dir), noChanges(2, 3)); expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.1"]); expect(await installedVersion(dir, "leaf")).toBe("3.0.1"); expect( @@ -1697,8 +1744,8 @@ test.concurrent.each<[string, string, string[], string, string]>([ expect(await lockedVersions(dir, "pre")).toStrictEqual([from]); const { stdout, stderr, exitCode } = await run(dir, "update"); - expectMoved(stdout, "pre", from, to); - expect(stderr).not.toContain("error:"); + expectSummary(stdout, movedRow("pre", from, to), "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(packageJson); expect(await lockedVersions(dir, "pre")).toStrictEqual([to]); expect((await lock(dir)).packages.pre[0]).toBe(`pre@${to}`); @@ -1822,10 +1869,6 @@ async function staleDirectLeaf(server: Bun.Server, groups: Groups = {}) { return dir; } -// Nothing but the header: no table, and no "up to date" either. -const expectHeaderOnly = (stdout: string, command: string) => - expect(stdout).toMatch(new RegExp(`^bun ${command} v[^\\n]*\\n$`)); - test.concurrent.each<[number, string[]]>([ [502, []], [404, []], @@ -1894,12 +1937,14 @@ test.concurrent( test.concurrent("`bun update ` from a member leaves a sibling's own entry alone but lets it follow", async () => { const { dir, pkg2Text } = await staleMembers("~1.0.0", "^1.0.0"); - const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps"); + expectSummary(stdout, movedRow("no-deps", "1.0.0", "1.0.1", "2.0.0"), "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "~1.0.1" })); expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text); expect((await lock(dir)).workspaces["packages/pkg2"].dependencies).toStrictEqual({ "no-deps": "^1.0.0" }); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]); + expect(await installedVersion(dir, "no-deps")).toBe("1.0.1"); await frozen(dir); expect(exitCode).toBe(0); }); @@ -1931,15 +1976,20 @@ test.concurrent("`bun update ` from the root does not re-resolve a member' const pkg1Text = await packageJsonText(dir, "packages/pkg1"); const fromRoot = await run(dir, "update", "no-deps"); - expect(fromRoot.stderr).not.toContain("error:"); + expectSummary(fromRoot.stdout, noChanges(3, 4)); + expectCleanStderr(fromRoot.stderr); + expect(fromRoot.stderr).not.toContain("Saved lockfile"); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "2.0.0"]); expect(await packageJsonText(dir)).toBe(rootText); expect(await packageJsonText(dir, "packages/pkg1")).toBe(pkg1Text); expect(fromRoot.exitCode).toBe(0); const fromMember = await runIn(dir, "packages/pkg1", "update", "no-deps"); - expect(fromMember.stderr).not.toContain("error:"); + expectSummary(fromMember.stdout, NO_DEPS_ROW_HINTED, "", installed(1)); + expectCleanStderr(fromMember.stderr); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0", "2.0.0"]); + expect(await installedVersion(dir, "no-deps")).toBe("2.0.0"); + expect(await installedVersion(dir, "pkg1", "node_modules", "no-deps")).toBe("1.1.0"); expect(await packageJsonText(dir)).toBe(rootText); expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "^1.1.0" })); await frozen(dir); @@ -1955,7 +2005,8 @@ test.concurrent("`bun update ` for a name only other workspaces depend on expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); const rootText = await packageJsonText(dir); const lockBefore = await lockText(dir); - const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps"); + const { stdout, stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps"); + expectHeaderOnly(stdout, "update"); expect(stderr).toContain(NOT_A_DEPENDENCY_HERE("no-deps", "root")); expect(stderr).not.toContain("note:"); expect(await lockText(dir)).toBe(lockBefore); @@ -2155,22 +2206,26 @@ function staleAlias() { test.concurrent("a pattern matches an aliased entry through its alias", async () => { const dir = await staleAlias(); - const { stderr, exitCode } = await run(dir, "update", "alias*"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", "alias*"); + expectSummary(stdout, movedRow("aliased", "1.0.0", "1.0.1", "2.0.0"), "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ aliased: "npm:no-deps@~1.0.1", "a-dep": "^1.0.1" })); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); + expect(await installedVersion(dir, "aliased")).toBe("1.0.1"); await frozen(dir); expect(exitCode).toBe(0); }); test.concurrent("a negated pattern excludes an aliased entry through its alias", async () => { const dir = await staleAlias(); - const { stderr, exitCode } = await run(dir, "update", "!aliased"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", "!aliased"); + expectSummary(stdout, A_DEP_ROW, "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ aliased: "npm:no-deps@~1.0.0", "a-dep": "^1.0.10" })); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]); + expect(await installedVersion(dir, "aliased")).toBe("1.0.0"); await frozen(dir); expect(exitCode).toBe(0); }); @@ -2179,7 +2234,8 @@ test.concurrent("a pattern with an unparsable lockfile is an error", async () => const dir = await setup({ "package.json": pkgJson({ "no-deps": "^1.0.0" }) }); await write(join(dir, "bun.lock"), "this is not a lockfile\n"); const packageJsonBefore = await packageJsonText(dir); - const { stderr, exitCode } = await run(dir, "update", "no-*"); + const { stdout, stderr, exitCode } = await run(dir, "update", "no-*"); + expectHeaderOnly(stdout, "update"); expect(stderr).toContain("error: failed to parse lockfile:"); expect(await packageJsonText(dir)).toBe(packageJsonBefore); expect(await lockText(dir)).toBe("this is not a lockfile\n"); @@ -2206,8 +2262,9 @@ function stalePeerEntry() { test.concurrent("a pattern matches a peerDependencies entry", async () => { const dir = await stalePeerEntry(); const packageJsonBefore = await packageJsonText(dir); - const { stderr, exitCode } = await run(dir, "update", "no-*"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", "no-*"); + expectSummary(stdout, noChanges(2, 3)); + expectCleanStderr(stderr); expect(await packageJsonText(dir)).toBe(packageJsonBefore); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]); @@ -2222,8 +2279,9 @@ test.concurrent("`bun update --prod` does not match a peerDependencies entry", a test.concurrent("`bun update --prod` leaves a stale peerDependencies entry alone", async () => { const dir = await stalePeerEntry(); - const { stderr, exitCode } = await run(dir, "update", "--prod"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", "--prod"); + expectSummary(stdout, A_DEP_ROW, "", installed(1)); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(withPeer("^1.0.10", "^1.0.0")); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]); @@ -2308,7 +2366,7 @@ test.concurrent("`bun update '@types/*' --filter pkg1 --latest` rewrites only pk const { dir, locked, stale, texts, textsBefore } = await staleMemberGroups(); const [rootBefore, , pkg2Before] = textsBefore; const { stdout, stderr, exitCode } = await run(dir, "update", "@types/*", "--filter", "pkg1", "--latest"); - expectMoved(stdout, "@types/no-deps", "1.0.0", "2.0.0"); + expectSummary(stdout, movedRow("@types/no-deps", "1.0.0", "2.0.0"), "", installed(1)); expectCleanStderr(stderr); expect(await texts()).toStrictEqual([rootBefore, stringify(PKG1_GROUPS("^1.0.0", "^2.0.0", "^1.0.1")), pkg2Before]); expect(await locked()).toStrictEqual({ ...stale, "@types/no-deps": ["1.0.0", "2.0.0"] }); @@ -2365,8 +2423,12 @@ test.concurrent.each([ test.concurrent("`bun update --filter ` records a widened range without re-resolving it", async () => { const { dir, rootText, pkg2Text } = await widenedPkg1(); - const { stderr, exitCode } = await run(dir, "update", "--filter", "pkg2", "no-deps"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await run(dir, "update", "--filter", "pkg2", "no-deps"); + // As with `bun install --filter`, only the selected workspace's installs are checked (pkg2 and its no-deps); the + // package count is still all of bun.lock. + expectSummary(stdout, noChanges(2, 4)); + expectCleanStderr(stderr); + expect(stderr).toContain("Saved lockfile"); expect(await packageJsonText(dir)).toBe(rootText); expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "^1.0.0" })); expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text); @@ -2382,7 +2444,8 @@ test.concurrent( async () => { const { dir, rootText, pkg2Text } = await widenedPkg1(); const lockBefore = await lockText(dir); - const { stderr, exitCode } = await run(dir, "update", "no-deps"); + const { stdout, stderr, exitCode } = await run(dir, "update", "no-deps"); + expectHeaderOnly(stdout, "update"); expect(stderr).toContain(NOT_A_DEPENDENCY_HERE("no-deps", "pkg1", "pkg2")); expect(stderr).not.toContain("note:"); expect(await packageJsonText(dir)).toBe(rootText); @@ -2411,19 +2474,26 @@ async function runInteractive(dir: string, keys: string, ...args: string[]) { test.concurrent("`bun update -i --dev` only offers and updates devDependencies", async () => { const { dir } = await staleSiblings(DEV_A_DEP); - const { stderr, exitCode } = await runInteractive(dir, "a\r", "--dev"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await runInteractive(dir, "a\r", "--dev"); + expectPicked(stdout, installed(1), A_DEP_ROW); + expect(stdout).not.toContain("no-deps"); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(grouped({ "no-deps": "^1.0.0", "a-dep": "^1.0.10" }, DEV_A_DEP)); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]); + expect(await installedVersion(dir, "a-dep")).toBe("1.0.10"); await frozen(dir); expect(exitCode).toBe(0); }); -// `-i --dry-run` answers with the same rows and count line as `bun update --dry-run`; the picker itself is drawn above them. -function expectInteractiveDryRun(stdout: string, ...rows: string[]) { +// A confirmed picker answers with the same rows and count line as the plain command would; the picker itself is drawn above them. +function expectPicked(stdout: string, countLine: string, ...rows: string[]) { expect(movedRows(stdout)).toStrictEqual(rows); - expect(normalize(stdout)).toContain(`\n${wouldUpdate(rows.length)}\n`); + expect(normalize(stdout)).toContain(`\n${countLine}\n`); +} + +function expectInteractiveDryRun(stdout: string, ...rows: string[]) { + expectPicked(stdout, wouldUpdate(rows.length), ...rows); expect(stdout).not.toContain("Would update"); } @@ -2479,12 +2549,14 @@ async function staleSiblingsWithTransitive() { test.concurrent("`bun update -i` installs only the selected entries, as `bun update `", async () => { const dir = await staleSiblingsWithTransitive(); - const { stderr, exitCode } = await runInteractive(dir, " \r"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await runInteractive(dir, " \r"); + expectPicked(stdout, installed(1), A_DEP_ROW); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual( pkgJson({ "no-deps": "^1.0.0", "a-dep": "^1.0.10", "one-range-dep": "1.0.0" }), ); expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]); + expect(await installedVersion(dir, "a-dep")).toBe("1.0.10"); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); expect(await installedVersion(dir, "no-deps")).toBe("1.0.0"); await frozen(dir); @@ -2497,8 +2569,14 @@ test.concurrent("`bun update -i --latest` honours an entry toggled back to its i await reinstall(dir, pkgJson({ "dep-with-tags": "^1.0.0", "no-deps": "^1.0.0" })); expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["1.0.0"]); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]); - const { stderr, exitCode } = await runInteractive(dir, "lj \r", "--latest"); - expect(stderr).not.toContain("error:"); + const { stdout, stderr, exitCode } = await runInteractive(dir, "lj \r", "--latest"); + expectPicked( + stdout, + installed(2), + movedRow("dep-with-tags", "1.0.0", "1.0.1"), + movedRow("no-deps", "1.0.0", "2.0.0"), + ); + expectCleanStderr(stderr); expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "dep-with-tags": "^1.0.1", "no-deps": "^2.0.0" })); expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["1.0.1"]); expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]); From bcf2e17fd1d17699ce6c7e943fe70efc5dfcb188 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:25:12 +0000 Subject: [PATCH 4/4] ci: retrigger