diff --git a/test/cli/install/bun-prune.test.ts b/test/cli/install/bun-prune.test.ts index 47483f97d9f9..0e8bf770bdb0 100644 --- a/test/cli/install/bun-prune.test.ts +++ b/test/cli/install/bun-prune.test.ts @@ -11,26 +11,66 @@ import { openSync, readdirSync, readFileSync, + readlinkSync, + realpathSync, renameSync, rmSync, + statSync, symlinkSync, writeFileSync, } from "node:fs"; -import { basename, join } from "node:path"; +import { basename, isAbsolute, join, relative } from "node:path"; import { pathToFileURL } from "node:url"; const registry = new VerdaccioRegistry(); +// Every registry package some test here installs. beforeAll installs them once into `sharedCache`; from then on every +// `bun install` in this file is served from that cache and never talks to the registry, which under ASAN runs on the +// build under test and is by far the slowest part of this file. The last test checks that the cache is still exactly +// this set: a package fetched during the run is one that concurrent tests could race each other to write. +const registryPackages = [ + "@scoped/has-bin-entry@1.0.0", + "a-dep@1.0.1", + "a-dep@1.0.2", + "bundled-transitive@1.0.0", + "left-pad@1.0.0", + "no-deps@1.0.0", + "no-deps@1.0.1", + "no-deps@1.1.0", + "no-deps@2.0.0", + "no-deps-bins@1.0.0", + "no-deps-build-metadata@1.0.0", + "one-dep@1.0.0", + "one-fixed-dep@1.0.0", + "one-fixed-dep-bins@1.0.0", + "optional-peer-deps@1.0.0", + "peer-deps-fixed@1.0.0", + "test-postinstall-skip-native@1.0.0", + "uses-what-bin@1.0.0", + "what-bin@1.0.0", +]; +let sharedCache: string; +let sharedCacheEntries: string[]; +// Projects whose installs write to the cache (git and tarball dependencies, the global store) get a cache of their own. +const ownCache = new Set(); + beforeAll(async () => { await registry.start(); + sharedCache = String(tempDir("bun-prune-cache", {})); + const dependencies = Object.fromEntries(registryPackages.map((pkg, i) => [`dep${i}`, `npm:${pkg}`])); + await install(await project({}, { "package.json": JSON.stringify({ name: "warm-up", dependencies }) })); + sharedCacheEntries = readdirSync(sharedCache).toSorted(); }); afterAll(() => { registry.stop(); }); -// CI exports BUN_INSTALL_CACHE_DIR, which overrides the harness bunfig's per-test `cache`; concurrent cases sharing one cache race on Windows. -const installEnv = (dir: string) => ({ ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") }); +// CI exports BUN_INSTALL_CACHE_DIR, which would override the bunfig; this file decides the cache per project instead. +const installEnv = (dir: string) => ({ + ...bunEnv, + BUN_INSTALL_CACHE_DIR: ownCache.has(dir) ? join(dir, ".bun-cache") : sharedCache, +}); const WARN = (expected: string, kept: string) => `warn: ${expected} is not the version bun.lock expects; keeping ${kept}`; @@ -48,6 +88,7 @@ const CAN_BE_REMOVED = (n: number, checked: number) => `${plural(n, "package")} // The copy-pasteable line `--dry-run` prints last: the invocation with `--dry-run` taken out. const APPLY_HINT = (...flags: string[]) => [" bun prune", ...flags].join(" "); const DURATION = /\) \[\d+(\.\d+)?m?s\]$/m; +type Linker = "hoisted" | "isolated"; const linkers: Linker[] = ["hoisted", "isolated"]; async function prune(where: string | { dir: string; cwd: string }, ...args: string[]) { @@ -101,35 +142,87 @@ function lock(dir: string) { return file(join(dir, "bun.lock")).text(); } -function isSymlink(path: string) { - return lstatSync(path).isSymbolicLink(); +type Listing = Record; + +// Everything prune can touch, by folder: the root node_modules, each workspace's, the nested ones, the isolated store +// and its hidden hoist folder. Scope dirs and .bin are flattened into their folder's entries, so a scope dir or .bin +// left behind empty lists as a bare entry, and an emptied folder lists as []. Links are marked, dangling ones as such. +// On Windows a bin is a `.exe` + `.bunx` shim pair instead of a link: a complete pair lists as `.bin/` +// like the link does elsewhere, a shim without its partner lists under its file name. +function tree(dir: string): Listing { + const listing: Listing = {}; + const folders = ["node_modules"]; + if (existsSync(join(dir, "packages"))) { + folders.push(...readdirSync(join(dir, "packages")).map(workspace => `packages/${workspace}/node_modules`)); + } + for (const folder of folders) { + if (lstatSync(join(dir, folder), { throwIfNoEntry: false })?.isDirectory()) { + listFolder(dir, folder, listing); + } + } + for (const entries of Object.values(listing)) { + entries.sort(); + } + return listing; } -// On Windows a bin is a `.exe` + `.bunx` shim pair instead of a symlink. -function binFiles(nm: string, name: string) { - const bin = join(nm, ".bin", name); - return isWindows ? [`${bin}.exe`, `${bin}.bunx`] : [bin]; +function listFolder(dir: string, folder: string, listing: Listing, scope = "") { + const entries = (listing[folder] ??= []); + for (const entry of readdirSync(join(dir, folder, scope), { withFileTypes: true })) { + const name = scope + entry.name; + const path = join(dir, folder, name); + if (entry.isSymbolicLink()) { + entries.push(`${name} ${existsSync(path) ? "(link)" : "(dangling link)"}`); + } else if (!entry.isDirectory()) { + entries.push(name); + } else if (scope === "" && name === ".bin") { + const files = readdirSync(path); + const bins = new Set(files.map(bin => (isWindows ? shimPairName(files, bin) : bin))); + entries.push(...(bins.size === 0 ? [".bin"] : [...bins].map(bin => `.bin/${bin}`))); + } else if (scope === "" && name === ".bun") { + const store = (listing[`${folder}/.bun`] = []); + for (const child of readdirSync(path, { withFileTypes: true })) { + if (child.name === "node_modules") { + listFolder(dir, `${folder}/.bun/node_modules`, listing); + } else { + store.push(child.isSymbolicLink() ? `${child.name} (link)` : child.name); + } + } + } else if (scope === "" && name.startsWith("@")) { + const before = entries.length; + listFolder(dir, folder, listing, `${name}/`); + if (entries.length === before) { + entries.push(name); + } + } else { + entries.push(name); + if (existsSync(join(path, "node_modules"))) { + listFolder(dir, `${folder}/${name}/node_modules`, listing); + } + } + } } -function expectBinInstalled(nm: string, name: string) { - for (const path of binFiles(nm, name)) { - expect(existsSync(path)).toBeTrue(); - } +function shimPairName(files: string[], file: string) { + const name = file.replace(/\.(exe|bunx)$/, ""); + return files.includes(`${name}.exe`) && files.includes(`${name}.bunx`) ? name : file; } -function expectBinRemoved(nm: string, name: string) { - for (const path of binFiles(nm, name)) { - expect(() => lstatSync(path)).toThrow(); - } +function expectOk({ stderr, exitCode }: { stderr: string; exitCode: number }) { + expect(stderr).toBe(""); + expect(exitCode).toBe(0); } -type BunfigOpts = NonNullable[0]>["bunfigOpts"]; +type BunfigOpts = NonNullable[1]>; +type Files = Record; -async function setup(pkgJson: Record, bunfigOpts?: BunfigOpts) { - const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts }); - await write(packageJson, JSON.stringify(pkgJson)); - await runBunInstall(installEnv(packageDir), packageDir); - return packageDir; +async function project(bunfigOpts: BunfigOpts, files: Files, { own = false } = {}) { + const dir = String(tempDir("bun-prune", files)); + await registry.writeBunfig(dir, bunfigOpts); + if (own) { + ownCache.add(dir); + } + return dir; } async function install(dir: string, ...args: string[]) { @@ -146,31 +239,80 @@ async function install(dir: string, ...args: string[]) { return stdout; } -type Linker = "hoisted" | "isolated"; +const templates = new Map>(); + +// Each distinct starting tree is installed once; every test that starts from it gets its own copy. A copy is a few +// syscalls where an install is another bun process, and about a third of the setups in this file repeat a tree that +// some other test starts from as well. +async function installed(bunfig: BunfigOpts, files: Files) { + const key = JSON.stringify([bunfig, files]); + let template = templates.get(key); + if (!template) { + template = project(bunfig, files).then(async dir => { + await install(dir); + return dir; + }); + templates.set(key, template); + } + const dir = String(tempDir("bun-prune", {})); + copyTree(await template, dir); + return dir; +} -async function setupWithLinker(linker: Linker, pkgJson: Record, bunfigOpts?: BunfigOpts) { - const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker, ...bunfigOpts } }); - await write(packageJson, JSON.stringify(pkgJson)); - await install(packageDir, "--linker", linker); - return packageDir; +// bun links with relative targets, which a verbatim copy keeps pointing inside the copy. When Windows denies symlinks +// bun falls back to junctions, whose targets are absolute: those are re-pointed at the same place inside the copy. +// Both `template` and the resolved target are real paths, so they agree on the spelling of the temp dir. +function copyTree(template: string, copy: string) { + const copyDir = (from: string, to: string) => { + for (const entry of readdirSync(from, { withFileTypes: true })) { + const source = join(from, entry.name); + const target = join(to, entry.name); + if (entry.isSymbolicLink()) { + const link = readlinkSync(source); + if (isAbsolute(link)) { + symlinkSync(join(copy, relative(template, realpathSync.native(source))), target, "junction"); + } else { + symlinkSync(link, target, statSync(source, { throwIfNoEntry: false })?.isDirectory() ? "dir" : "file"); + } + } else if (entry.isDirectory()) { + mkdirSync(target); + copyDir(source, target); + } else { + copyFileSync(source, target); + } + } + }; + copyDir(template, copy); +} + +function setup(pkgJson: Record, bunfig: BunfigOpts = {}) { + return installed(bunfig, { "package.json": JSON.stringify(pkgJson) }); +} + +function setupWithLinker(linker: Linker, pkgJson: Record, bunfig: BunfigOpts = {}) { + return installed({ linker, ...bunfig }, { "package.json": JSON.stringify(pkgJson) }); } type Workspaces = { root?: Record; packages: Record> }; -function writeWorkspaces(dir: string, packageJson: string, { root, packages }: Workspaces) { - return Promise.all([ - write(packageJson, JSON.stringify({ name: "root", workspaces: ["packages/*"], ...root })), - ...Object.entries(packages).map(([folder, pkg]) => - write(join(dir, "packages", folder, "package.json"), JSON.stringify({ name: folder, version: "1.0.0", ...pkg })), +function workspaceFiles({ root, packages }: Workspaces): Files { + return { + "package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"], ...root }), + ...Object.fromEntries( + Object.entries(packages).map(([folder, pkg]) => [ + `packages/${folder}/package.json`, + JSON.stringify({ name: folder, version: "1.0.0", ...pkg }), + ]), ), - ]); + }; +} + +function writeWorkspaces(dir: string, workspaces: Workspaces) { + return Promise.all(Object.entries(workspaceFiles(workspaces)).map(([path, text]) => write(join(dir, path), text))); } -async function setupWorkspaces(linker: Linker, workspaces: Workspaces) { - const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker } }); - await writeWorkspaces(packageDir, packageJson, workspaces); - await install(packageDir, "--linker", linker); - return packageDir; +function setupWorkspaces(linker: Linker, workspaces: Workspaces) { + return installed({ linker }, workspaceFiles(workspaces)); } function expectRefused({ stdout, stderr, exitCode }: Awaited>) { @@ -193,7 +335,7 @@ function linkOutside(dir: string, rel: string, contents: Record const link = join(dir, rel); mkdirSync(join(link, ".."), { recursive: true }); symlinkSync(outside, link, "junction"); - expect(isSymlink(link)).toBeTrue(); + expect(lstatSync(link).isSymbolicLink()).toBeTrue(); return outside; } @@ -256,25 +398,35 @@ function copyTarball(dir: string, name: string, version: string) { return `file:./${file}`; } -const storeEntries = (dir: string) => - readdirSync(join(dir, "node_modules", ".bun")) - .filter(name => name !== "node_modules") - .toSorted(); +const storeEntries = (dir: string) => tree(dir)["node_modules/.bun"]; + +// Where the isolated store's hidden hoist link for `name` points; only the store entry named in it is of interest. +const hiddenHoistTarget = (dir: string, name: string) => + readlinkSync(join(dir, "node_modules", ".bun", "node_modules", name)); test.concurrent("removes extraneous packages, keeps everything the lockfile installs", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0", "@scoped/has-bin-entry": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - const planted = [ - plant(dir, "node_modules/junk"), - plant(dir, "node_modules/@scoped/junk"), - plant(dir, "node_modules/@other/thing"), - ]; - writeFileSync(join(nm, "README.txt"), ""); + plant(dir, "node_modules/junk"); + plant(dir, "node_modules/@scoped/junk"); + plant(dir, "node_modules/@other/thing"); + writeFileSync(join(dir, "node_modules", "README.txt"), ""); plant(dir, "node_modules/.cache/x"); const lockBefore = await lock(dir); + expect(tree(dir)).toEqual({ + "node_modules": [ + ".bin/has-bin-entry", + ".cache", + "@other/thing", + "@scoped/has-bin-entry", + "@scoped/junk", + "README.txt", + "junk", + "no-deps", + ], + }); const first = await prune(dir); expect(out(first.stdout)).toMatchInlineSnapshot(` @@ -286,41 +438,41 @@ test.concurrent("removes extraneous packages, keeps everything the lockfile inst 3 packages removed (checked 5)" `); expect(first.stdout).toMatch(/\(checked 5\) \[\d+(\.\d+)?m?s\]\n?$/); - expect(first.exitCode).toBe(0); - - for (const path of planted) { - expect(existsSync(path)).toBeFalse(); - } - expect(existsSync(join(nm, "@other"))).toBeFalse(); - expect(existsSync(join(nm, "no-deps"))).toBeTrue(); - expect(existsSync(join(nm, "@scoped", "has-bin-entry"))).toBeTrue(); - expect(existsSync(join(nm, "README.txt"))).toBeTrue(); - expect(existsSync(join(nm, ".cache", "x"))).toBeTrue(); - expectBinInstalled(nm, "has-bin-entry"); + expectOk(first); + // The emptied @other scope dir goes with its package; files and dot entries are not packages. + const pruned = { "node_modules": [".bin/has-bin-entry", ".cache", "@scoped/has-bin-entry", "README.txt", "no-deps"] }; + expect(tree(dir)).toEqual(pruned); expect(await lock(dir)).toBe(lockBefore); const second = await prune(dir); - expect(out(second.stdout)).toEndWith(NOTHING(2, 1)); + expect(lines(second.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 1)]); expect(second.stdout).toMatch(/\(nothing to prune\) \[\d+(\.\d+)?m?s\]\n?$/); - expect(second.exitCode).toBe(0); + expectOk(second); + expect(tree(dir)).toEqual(pruned); }); test.concurrent("prunes nested node_modules folders the tree installs into", async () => { const dir = await setup({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0" } }); const nested = join(dir, "node_modules", "one-dep", "node_modules", "no-deps"); expect(await file(join(nested, "package.json")).json()).toMatchObject({ version: "1.0.1" }); - const junk = plant(dir, "node_modules/one-dep/node_modules/junk"); + plant(dir, "node_modules/one-dep/node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps", "one-dep"], + "node_modules/one-dep/node_modules": ["junk", "no-deps"], + }); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk (node_modules/one-dep/node_modules) 1 package removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(join(nested, "package.json"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps", "one-dep"], + "node_modules/one-dep/node_modules": ["no-deps"], + }); }); test.concurrent.each([["--production"], ["--prod"], ["--omit=dev"]])( @@ -331,14 +483,22 @@ test.concurrent.each([["--production"], ["--prod"], ["--omit=dev"]])( dependencies: { "no-deps": "1.0.0", "@scoped/has-bin-entry": "1.0.0" }, devDependencies: { "one-fixed-dep-bins": "1.0.0", "what-bin": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - expect(existsSync(join(nm, "no-deps-bins"))).toBeTrue(); - expectBinInstalled(nm, "what-bin"); - expectBinInstalled(nm, "has-bin-entry"); + // no-deps-bins' tarball lacks its bin file and bun install skips bin links whose target is missing. + expect(tree(dir)).toEqual({ + "node_modules": [ + ".bin/has-bin-entry", + ".bin/what-bin", + "@scoped/has-bin-entry", + "no-deps", + "no-deps-bins", + "one-fixed-dep-bins", + "what-bin", + ], + }); const lockBefore = await lock(dir); - const { stdout, exitCode } = await prune(dir, ...flags); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, ...flags); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - no-deps-bins@1.0.0 @@ -346,18 +506,13 @@ test.concurrent.each([["--production"], ["--prod"], ["--omit=dev"]])( - what-bin@1.0.0 3 packages removed (checked 5)" `); - expect(exitCode).toBe(0); - - expect(existsSync(join(nm, "no-deps"))).toBeTrue(); - expect(existsSync(join(nm, "@scoped", "has-bin-entry"))).toBeTrue(); - expect(existsSync(join(nm, "no-deps-bins"))).toBeFalse(); - expect(existsSync(join(nm, "one-fixed-dep-bins"))).toBeFalse(); - expect(existsSync(join(nm, "what-bin"))).toBeFalse(); + expectOk(result); // pnpm#2326: bins of removed packages are cleaned up, on Windows too (shim files instead of links). - expectBinRemoved(nm, "what-bin"); - expectBinInstalled(nm, "has-bin-entry"); + const pruned = { "node_modules": [".bin/has-bin-entry", "@scoped/has-bin-entry", "no-deps"] }; + expect(tree(dir)).toEqual(pruned); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(pruned); expect(await lock(dir)).toBe(lockBefore); }, ); @@ -369,6 +524,9 @@ test.concurrent("--production keeps a package that prod and dev both need", asyn devDependencies: { "one-fixed-dep": "1.0.0" }, }; const [dir, plainDir] = await Promise.all([setup(pkg), setup(pkg)]); + // one-fixed-dep needs no-deps@1.0.0 too, so the root copy serves both and nothing is nested. + const installed = { "node_modules": ["no-deps", "one-fixed-dep"] }; + expect(tree(dir)).toEqual(installed); const production = await prune(dir, "--production"); expect(out(production.stdout)).toMatchInlineSnapshot(` @@ -377,9 +535,8 @@ test.concurrent("--production keeps a package that prod and dev both need", asyn - one-fixed-dep@1.0.0 1 package removed (checked 2)" `); - expect(production.exitCode).toBe(0); - expect(existsSync(join(dir, "node_modules", "no-deps"))).toBeTrue(); - expect(existsSync(join(dir, "node_modules", "one-fixed-dep"))).toBeFalse(); + expectOk(production); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); const plain = await prune(plainDir); expect(out(plain.stdout)).toMatchInlineSnapshot(` @@ -387,13 +544,14 @@ test.concurrent("--production keeps a package that prod and dev both need", asyn Done! Checked 2 packages across 1 folder (nothing to prune)" `); - expect(plain.exitCode).toBe(0); - expect(existsSync(join(plainDir, "node_modules", "one-fixed-dep"))).toBeTrue(); + expectOk(plain); + expect(tree(plainDir)).toEqual(installed); }); test.concurrent("--dry-run prints without deleting; --silent deletes without printing", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); + const planted = { "node_modules": ["junk", "no-deps"] }; const dryRun = await prune(dir, "--dry-run"); expect(out(dryRun.stdout)).toMatchInlineSnapshot(` @@ -404,9 +562,8 @@ test.concurrent("--dry-run prints without deleting; --silent deletes without pri bun prune" `); expect(dryRun.stdout).toMatch(DURATION); - expect(dryRun.stderr).toBe(""); - expect(dryRun.exitCode).toBe(0); - expect(existsSync(junk)).toBeTrue(); + expectOk(dryRun); + expect(tree(dir)).toEqual(planted); const withFlags = await prune(dir, "--dry-run", "--linker", "hoisted", "--dry-run"); expect(lines(withFlags.stdout)).toStrictEqual([ @@ -416,84 +573,81 @@ test.concurrent("--dry-run prints without deleting; --silent deletes without pri CAN_BE_REMOVED(1, 2), APPLY_HINT("--linker", "hoisted"), ]); - expect(withFlags.exitCode).toBe(0); - expect(existsSync(junk)).toBeTrue(); + expectOk(withFlags); + expect(tree(dir)).toEqual(planted); const silentDryRun = await prune(dir, "--dry-run", "--silent"); expect(silentDryRun.stdout).toBe(""); - expect(silentDryRun.stderr).toBe(""); - expect(silentDryRun.exitCode).toBe(0); - expect(existsSync(junk)).toBeTrue(); + expectOk(silentDryRun); + expect(tree(dir)).toEqual(planted); const silent = await prune(dir, "--silent"); expect(silent.stdout).toBe(""); - expect(silent.stderr).toBe(""); - expect(silent.exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); + expectOk(silent); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); const clean = await prune(dir, "--dry-run"); expect(lines(clean.stdout)).toStrictEqual([BANNER, "", NOTHING(1, 1)]); - expect(clean.exitCode).toBe(0); + expectOk(clean); }); test.concurrent("nothing to prune when node_modules is missing or clean", async () => { - const { packageDir, packageJson } = await registry.createTestDir(); - await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } })); - await install(packageDir, "--lockfile-only"); - const nm = join(packageDir, "node_modules"); - rmSync(nm, { recursive: true, force: true }); - expect(existsSync(join(packageDir, "bun.lock"))).toBeTrue(); - - const missing = await prune(packageDir); + const pkgJson = { name: "foo", dependencies: { "no-deps": "1.0.0" } }; + const [lockOnlyDir, cleanDir] = await Promise.all([ + project({ linker: "hoisted" }, { "package.json": JSON.stringify(pkgJson) }), + setup(pkgJson), + ]); + await install(lockOnlyDir, "--lockfile-only"); + expect(existsSync(join(lockOnlyDir, "bun.lock"))).toBeTrue(); + expect(tree(lockOnlyDir)).toEqual({}); + + const missing = await prune(lockOnlyDir); expect(out(missing.stdout)).toMatchInlineSnapshot(` "bun prune () Done! No node_modules folder (nothing to prune)" `); - expect(missing.exitCode).toBe(0); - expect(existsSync(nm)).toBeFalse(); + expectOk(missing); + expect(tree(lockOnlyDir)).toEqual({}); - const cleanDir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); const clean = await prune(cleanDir, "--production"); expect(out(clean.stdout)).toMatchInlineSnapshot(` "bun prune () Done! Checked 1 package across 1 folder (nothing to prune)" `); - expect(clean.exitCode).toBe(0); - expect(existsSync(join(cleanDir, "node_modules", "no-deps"))).toBeTrue(); + expectOk(clean); + expect(tree(cleanDir)).toEqual({ "node_modules": ["no-deps"] }); }); test.concurrent("never follows symlinks out of node_modules", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const nm = join(dir, "node_modules"); const outside = join(dir, "outside"); mkdirSync(outside); writeFileSync(join(outside, "keep.txt"), "keep"); - const link = join(nm, "linked-junk"); - symlinkSync(outside, link, "junction"); - expect(isSymlink(link)).toBeTrue(); + symlinkSync(outside, join(dir, "node_modules", "linked-junk"), "junction"); + expect(tree(dir)).toEqual({ "node_modules": ["linked-junk (link)", "no-deps"] }); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - linked-junk 1 package removed (checked 2)" `); - expect(exitCode).toBe(0); - expect(() => lstatSync(link)).toThrow(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); expect(existsSync(join(outside, "keep.txt"))).toBeTrue(); - expect(existsSync(join(nm, "no-deps"))).toBeTrue(); }); test.concurrent("refuses to run without a lockfile", async () => { - const [{ packageDir: noLockDir, packageJson }, installedDir] = await Promise.all([ - registry.createTestDir(), - setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }), + const pkgJson = { name: "foo", dependencies: { "no-deps": "1.0.0" } }; + const [noLockDir, installedDir] = await Promise.all([ + project({ linker: "hoisted" }, { "package.json": JSON.stringify(pkgJson) }), + setup(pkgJson), ]); - await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } })); - const junk = plant(noLockDir, "node_modules/junk"); + plant(noLockDir, "node_modules/junk"); + const planted = { "node_modules": ["junk"] }; const noLock = await prune(noLockDir); expect(normalizeBunSnapshot(noLock.stderr)).toMatchInlineSnapshot(` @@ -502,13 +656,13 @@ test.concurrent("refuses to run without a lockfile", async () => { `); expect(out(noLock.stdout)).toBe(BANNER); expect(noLock.exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); + expect(tree(noLockDir)).toEqual(planted); const silentNoLock = await prune(noLockDir, "--silent"); expect(silentNoLock.stdout).toBe(""); expect(silentNoLock.stderr).toBe(""); expect(silentNoLock.exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); + expect(tree(noLockDir)).toEqual(planted); // Usage errors are the one class --silent does not suppress. for (const args of [["foo"], ["foo", "--silent"]]) { @@ -520,72 +674,57 @@ test.concurrent("refuses to run without a lockfile", async () => { expect(positional.stdout).toBe(""); expect(positional.exitCode).toBe(1); } - expect(existsSync(join(installedDir, "node_modules", "no-deps"))).toBeTrue(); + expect(tree(installedDir)).toEqual({ "node_modules": ["no-deps"] }); const help = await prune(noLockDir, "--help"); - expect(help.stdout).toContain("bun prune"); - expect(help.stdout).toContain("--production"); - expect(help.stdout).toContain("--linker"); - expect(help.stdout).toContain("--filter"); - expect(help.exitCode).toBe(0); - expect(existsSync(junk)).toBeTrue(); + expect(out(help.stdout)).toStartWith("Usage: bun prune [flags]"); + expectOk(help); + expect(tree(noLockDir)).toEqual(planted); }); // pnpm#9796: hoisted keeps the root's workspace links under --production because they are root->workspace prod edges; the isolated per-importer dev link case is below. test.concurrent("workspaces: prunes workspace folders, keeps workspace links, runs from the root", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir(); - await Promise.all([ - write( - packageJson, - JSON.stringify({ name: "root", workspaces: ["packages/*"], dependencies: { "no-deps": "2.0.0" } }), - ), - write( - join(dir, "packages", "a", "package.json"), - JSON.stringify({ - name: "a", - version: "1.0.0", - dependencies: { "no-deps": "1.0.0" }, - devDependencies: { "a-dep": "1.0.1" }, - }), - ), - ]); - await runBunInstall(installEnv(dir), dir); - const nm = join(dir, "node_modules"); - const workspaceNoDeps = join(dir, "packages", "a", "node_modules", "no-deps"); - expect(isSymlink(join(nm, "a"))).toBeTrue(); - expect(existsSync(workspaceNoDeps)).toBeTrue(); - expect(existsSync(join(nm, "a-dep"))).toBeTrue(); - const junk = plant(dir, "packages/a/node_modules/junk"); - - const { stdout, exitCode } = await prune(join(dir, "packages", "a"), "--production"); - expect(out(stdout)).toMatchInlineSnapshot(` + const dir = await setupWorkspaces("hoisted", { + root: { dependencies: { "no-deps": "2.0.0" } }, + packages: { a: { dependencies: { "no-deps": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } } }, + }); + plant(dir, "packages/a/node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["a (link)", "a-dep", "no-deps"], + "packages/a/node_modules": ["junk", "no-deps"], + }); + + const result = await prune({ dir, cwd: join(dir, "packages", "a") }, "--production"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - a-dep@1.0.1 - junk (node_modules/a/node_modules) 2 packages removed (checked 5)" `); - expect(exitCode).toBe(0); - - expect(isSymlink(join(nm, "a"))).toBeTrue(); - expect(existsSync(workspaceNoDeps)).toBeTrue(); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(join(nm, "a-dep"))).toBeFalse(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": ["a (link)", "no-deps"], + "packages/a/node_modules": ["no-deps"], + }); }); test.concurrent("keeps dependencies bundled inside a package", async () => { const dir = await setup({ name: "foo", dependencies: { "bundled-transitive": "1.0.0" } }); - const bundled = join(dir, "node_modules", "bundled-transitive", "node_modules", "no-deps", "package.json"); - expect(existsSync(bundled)).toBeTrue(); + const installed = { + "node_modules": ["bundled-transitive", "no-deps", "one-dep"], + "node_modules/bundled-transitive/node_modules": ["no-deps"], + }; + expect(tree(dir)).toEqual(installed); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () Done! Checked 3 packages across 1 folder (nothing to prune)" `); - expect(exitCode).toBe(0); - expect(existsSync(bundled)).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual(installed); }); // pnpm#881: --production also removes dev-only entries from the store. @@ -594,25 +733,27 @@ test.concurrent("isolated linker: removes unused store entries and their links", { name: "foo", dependencies: { "no-deps": "1.0.0" }, devDependencies: { "one-dep": "1.0.0" } }, { linker: "isolated" }, ); - const nm = join(dir, "node_modules"); - const store = join(nm, ".bun"); - expect(existsSync(join(store, "one-dep@1.0.0"))).toBeTrue(); - expect(existsSync(join(store, "no-deps@1.0.1"))).toBeTrue(); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); - expect(isSymlink(join(nm, "one-dep"))).toBeTrue(); - plant(dir, "node_modules/.bun/junk@1.0.0/node_modules/junk"); - const peerVariant = plant(dir, "node_modules/.bun/no-deps@1.0.0+0123456789abcdef/node_modules/no-deps"); - const junkReal = plant(dir, "node_modules/junk-real"); - const hiddenHoist = join(store, "node_modules"); - mkdirSync(hiddenHoist, { recursive: true }); + plant(dir, "node_modules/.bun/no-deps@1.0.0+0123456789abcdef/node_modules/no-deps"); + plant(dir, "node_modules/junk-real"); const zzz = plant(dir, "node_modules/.bun/zzz@1.0.0/node_modules/zzz"); - symlinkSync(zzz, join(hiddenHoist, "zzz"), "junction"); - expect(isSymlink(join(hiddenHoist, "zzz"))).toBeTrue(); + symlinkSync(zzz, join(dir, "node_modules", ".bun", "node_modules", "zzz"), "junction"); + expect(tree(dir)).toEqual({ + "node_modules": ["junk-real", "no-deps (link)", "one-dep (link)"], + "node_modules/.bun": [ + "junk@1.0.0", + "no-deps@1.0.0", + "no-deps@1.0.0+0123456789abcdef", + "no-deps@1.0.1", + "one-dep@1.0.0", + "zzz@1.0.0", + ], + "node_modules/.bun/node_modules": ["no-deps (link)", "one-dep (link)", "zzz (link)"], + }); const lockBefore = await lock(dir); - const { stdout, exitCode } = await prune(dir, "--production"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk@1.0.0 @@ -623,22 +764,18 @@ test.concurrent("isolated linker: removes unused store entries and their links", - zzz@1.0.0 6 packages removed (checked 9)" `); - expect(exitCode).toBe(0); - - expect(existsSync(join(store, "junk@1.0.0"))).toBeFalse(); - expect(existsSync(join(store, "zzz@1.0.0"))).toBeFalse(); - expect(existsSync(join(store, "no-deps@1.0.1"))).toBeFalse(); - expect(existsSync(join(store, "one-dep@1.0.0"))).toBeFalse(); - expect(existsSync(junkReal)).toBeFalse(); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); - expect(existsSync(peerVariant)).toBeFalse(); - expect(() => lstatSync(join(nm, "one-dep"))).toThrow(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - expect(existsSync(hiddenHoist)).toBeTrue(); - expect(() => lstatSync(join(hiddenHoist, "zzz"))).toThrow(); + expectOk(result); + // The hidden hoist links of removed entries go too; the store's hidden node_modules itself stays. + const pruned = { + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual(pruned); expect(await lock(dir)).toBe(lockBefore); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(pruned); }); test.concurrent("isolated linker: --verbose does not print the store build timings", async () => { @@ -650,17 +787,20 @@ test.concurrent("isolated linker: --verbose does not print the store build timin // `--production` makes prune build the store twice (once with every // dependency type, once with the production set); `bun install --verbose` // prints a timing line per store build, prune must print none. - const { stdout, stderr, exitCode } = await prune(dir, "--production", "--verbose"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production", "--verbose"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - no-deps@1.0.1 - one-dep@1.0.0 2 packages removed (checked 5)" `); - expect(stderr).not.toContain("Resolved peers"); - expect(stderr).not.toContain("Created store"); - expect(exitCode).toBe(0); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }); }); test.concurrent("isolated linker: prune removes the peer-hash variants a peer bump left behind", async () => { @@ -668,10 +808,15 @@ test.concurrent("isolated linker: prune removes the peer-hash variants a peer bu name: "foo", dependencies: { "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" }, }); - const store = join(dir, "node_modules", ".bun"); const peerEntries = () => storeEntries(dir).filter(entry => entry.startsWith("peer-deps-fixed@")); const [before] = peerEntries(); expect(before).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/); + const linked = ["no-deps (link)", "peer-deps-fixed (link)"]; + expect(tree(dir)).toEqual({ + "node_modules": linked, + "node_modules/.bun": ["no-deps@1.0.0", before], + "node_modules/.bun/node_modules": linked, + }); await write( join(dir, "package.json"), @@ -682,44 +827,63 @@ test.concurrent("isolated linker: prune removes the peer-hash variants a peer bu expect(variants).toHaveLength(2); expect(variants).toContain(before); const after = variants.find(entry => entry !== before)!; - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); - expect(existsSync(join(store, "no-deps@1.0.1"))).toBeTrue(); + // bun install relinks but leaves the entries the old peer set produced in the store. + expect(tree(dir)).toEqual({ + "node_modules": linked, + "node_modules/.bun": ["no-deps@1.0.0", "no-deps@1.0.1", ...variants], + "node_modules/.bun/node_modules": linked, + }); - const { stdout, exitCode } = await prune(dir, "--linker", "isolated"); - expect(out(stdout).split("\n")).toStrictEqual([ - "bun prune ()", - "", - "- no-deps@1.0.0", - `- ${before}`, - "2 packages removed (checked 6)", - ]); - expect(exitCode).toBe(0); - expect(peerEntries()).toStrictEqual([after]); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeFalse(); - expect(existsSync(join(store, "no-deps@1.0.1"))).toBeTrue(); + const result = await prune(dir, "--linker", "isolated"); + expect(lines(result.stdout)).toStrictEqual([BANNER, "", "- no-deps@1.0.0", `- ${before}`, REMOVED(2, 6)]); + expectOk(result); + const pruned = { + "node_modules": linked, + "node_modules/.bun": ["no-deps@1.0.1", after], + "node_modules/.bun/node_modules": linked, + }; + expect(tree(dir)).toEqual(pruned); expect(await install(dir, "--linker", "isolated")).toContain("no changes"); + expect(tree(dir)).toEqual(pruned); }); test.concurrent("isolated linker + global store: unlinks the store link, never deletes the shared entry", async () => { - const dir = await setup( - { name: "foo", devDependencies: { "one-dep": "1.0.0" } }, + const dir = await project( { linker: "isolated", globalStore: true }, + { "package.json": JSON.stringify({ name: "foo", devDependencies: { "one-dep": "1.0.0" } }) }, + { own: true }, ); - const storeEntry = join(dir, "node_modules", ".bun", "one-dep@1.0.0"); - expect(isSymlink(storeEntry)).toBeTrue(); + await install(dir); + // With the global store, each store entry is a link into the cache's shared entry for that package. + expect(tree(dir)).toEqual({ + "node_modules": ["one-dep (link)"], + "node_modules/.bun": ["no-deps@1.0.1 (link)", "one-dep@1.0.0 (link)"], + "node_modules/.bun/node_modules": ["no-deps (link)", "one-dep (link)"], + }); const linksDir = join(installEnv(dir).BUN_INSTALL_CACHE_DIR, "links"); - const globalEntry = readdirSync(linksDir).find(name => name.startsWith("one-dep@1.0.0-")); - expect(globalEntry).toBeDefined(); - const globalPkgJson = join(linksDir, globalEntry!, "node_modules", "one-dep", "package.json"); - expect(existsSync(globalPkgJson)).toBeTrue(); - - const { stdout, exitCode } = await prune(dir, "--production"); - expect(out(stdout)).toContain("- one-dep@1.0.0"); - expect(out(stdout)).toEndWith("2 packages removed (checked 3)"); - expect(exitCode).toBe(0); + const globalEntries = readdirSync(linksDir).toSorted(); + expect(globalEntries).toEqual([ + expect.stringMatching(/^no-deps@1\.0\.1-/), + expect.stringMatching(/^one-dep@1\.0\.0-/), + ]); + const globalPkgJson = join(linksDir, globalEntries[1], "node_modules", "one-dep", "package.json"); + expect(await file(globalPkgJson).json()).toMatchObject({ name: "one-dep", version: "1.0.0" }); - expect(() => lstatSync(storeEntry)).toThrow(); - expect(lstatSync(join(linksDir, globalEntry!)).isDirectory()).toBeTrue(); + const result = await prune(dir, "--production"); + expect(out(result.stdout)).toMatchInlineSnapshot(` + "bun prune () + + - no-deps@1.0.1 + - one-dep@1.0.0 + 2 packages removed (checked 3)" + `); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": [], + "node_modules/.bun": [], + "node_modules/.bun/node_modules": [], + }); + expect(readdirSync(linksDir).toSorted()).toEqual(globalEntries); expect(await file(globalPkgJson).json()).toMatchObject({ name: "one-dep", version: "1.0.0" }); }); @@ -729,25 +893,28 @@ test.concurrent("isolated linker: bins of removed packages are removed, live one dependencies: { "@scoped/has-bin-entry": "1.0.0" }, devDependencies: { "what-bin": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - expectBinInstalled(nm, "what-bin"); - expectBinInstalled(nm, "has-bin-entry"); - - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout)).toEndWith("- what-bin@1.0.0\n1 package removed (checked 4)"); - expect(exitCode).toBe(0); + expect(tree(dir)).toEqual({ + "node_modules": [".bin/has-bin-entry", ".bin/what-bin", "@scoped/has-bin-entry (link)", "what-bin (link)"], + "node_modules/.bun": ["@scoped+has-bin-entry@1.0.0", "what-bin@1.0.0"], + "node_modules/.bun/node_modules": ["@scoped/has-bin-entry (link)", "what-bin (link)"], + }); - expectBinRemoved(nm, "what-bin"); - expectBinInstalled(nm, "has-bin-entry"); + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(lines(result.stdout)).toStrictEqual([BANNER, "", "- what-bin@1.0.0", REMOVED(1, 4)]); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": [".bin/has-bin-entry", "@scoped/has-bin-entry (link)"], + "node_modules/.bun": ["@scoped+has-bin-entry@1.0.0"], + "node_modules/.bun/node_modules": ["@scoped/has-bin-entry (link)"], + }); }); test.concurrent("hoisted: dot entries and files are never touched even when the lockfile is empty", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir(); - await Promise.all([ - write(packageJson, JSON.stringify({ name: "empty" })), - write( - join(dir, "bun.lock"), - `{ + const dir = await project( + { linker: "hoisted" }, + { + "package.json": JSON.stringify({ name: "empty" }), + "bun.lock": `{ "lockfileVersion": 1, "workspaces": { "": { @@ -757,29 +924,26 @@ test.concurrent("hoisted: dot entries and files are never touched even when the "packages": {} } `, - ), - ]); - const nm = join(dir, "node_modules"); - mkdirSync(nm); - const junk = plant(dir, "node_modules/junk"); - const emptyStore = plant(dir, "node_modules/.bun/node_modules/whatever"); - const cache = plant(dir, "node_modules/.cache/whatever@1.0.0"); - const integrity = join(nm, ".yarn-integrity"); - writeFileSync(integrity, ""); + "node_modules/.yarn-integrity": "", + }, + ); + plant(dir, "node_modules/junk"); + plant(dir, "node_modules/.bun/node_modules/whatever"); + plant(dir, "node_modules/.cache/whatever@1.0.0"); - const { stdout, stderr, exitCode } = await prune(dir); - expect(stderr).toBe(""); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk 1 package removed (checked 1)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(emptyStore)).toBeTrue(); - expect(existsSync(cache)).toBeTrue(); - expect(existsSync(integrity)).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": [".cache", ".yarn-integrity"], + "node_modules/.bun": [], + "node_modules/.bun/node_modules": ["whatever"], + }); }); // A hoisted prune over a tree whose store still holds entries would report the store as checked without looking at it. @@ -787,8 +951,12 @@ test.concurrent( "hoisted: refuses up front when node_modules/.bun holds store entries, even with nothing to remove", async () => { const dir = await setupWithLinker("isolated", { name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const nm = join(dir, "node_modules"); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.0"]); + const installed = { + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual(installed); for (const flags of [ ["--linker", "hoisted"], @@ -807,12 +975,12 @@ test.concurrent( expect(silent.stdout).toBe(""); expect(silent.stderr).toBe(""); expect(silent.exitCode).toBe(1); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.0"]); - expect(isSymlink(join(nm, "no-deps"))).toBeTrue(); + expect(tree(dir)).toEqual(installed); const same = await prune(dir, "--linker", "isolated"); expect(lines(same.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 2)]); - expect(same.exitCode).toBe(0); + expectOk(same); + expect(tree(dir)).toEqual(installed); }, ); @@ -820,46 +988,48 @@ test.concurrent( "hoisted: a nested tree owned by a package that was replaced with a symlink is not walked", async () => { const dir = await setup({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0" } }); - const nm = join(dir, "node_modules"); - expect(existsSync(join(nm, "one-dep", "node_modules", "no-deps"))).toBeTrue(); - rmSync(join(nm, "one-dep"), { recursive: true }); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps", "one-dep"], + "node_modules/one-dep/node_modules": ["no-deps"], + }); + rmSync(join(dir, "node_modules", "one-dep"), { recursive: true }); const outside = linkOutside(dir, "node_modules/one-dep", { "package.json": JSON.stringify({ name: "one-dep", version: "1.0.0" }), }); plant(outside, "node_modules/no-deps"); - const keepMe = plant(outside, "node_modules/keep-me"); + plant(outside, "node_modules/keep-me"); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps", "one-dep (link)"] }); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () Done! Checked 2 packages across 1 folder (nothing to prune)" `); - expect(exitCode).toBe(0); - expect(existsSync(keepMe)).toBeTrue(); - expect(isSymlink(join(nm, "one-dep"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps", "one-dep (link)"] }); + expect(readdirSync(join(outside, "node_modules")).toSorted()).toEqual(["keep-me", "no-deps"]); }, ); test.concurrent("hoisted: a symlinked scope dir is unlinked, not followed", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const nm = join(dir, "node_modules"); const outside = linkOutside(dir, "node_modules/@fake"); - const inner = plant(outside, "thing"); + plant(outside, "thing"); plant(dir, "node_modules/@real/junk"); + expect(tree(dir)).toEqual({ "node_modules": ["@fake (link)", "@real/junk", "no-deps"] }); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - @fake - @real/junk 2 packages removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(() => lstatSync(join(nm, "@fake"))).toThrow(); - expect(existsSync(inner)).toBeTrue(); - expect(existsSync(join(nm, "@real"))).toBeFalse(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); + expect(readdirSync(outside)).toEqual(["thing"]); }); test.concurrent( @@ -869,19 +1039,19 @@ test.concurrent( root: { dependencies: { "no-deps": "2.0.0" } }, packages: { a: { dependencies: { "no-deps": "1.0.0" } } }, }); - const link = join(dir, "node_modules", "a"); - const workspaceNoDeps = join(dir, "packages", "a", "node_modules", "no-deps", "package.json"); - expect(isSymlink(link)).toBeTrue(); - expect(existsSync(workspaceNoDeps)).toBeTrue(); - const junk = plant(dir, "packages/a/node_modules/junk"); + plant(dir, "packages/a/node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["a (link)", "no-deps"], + "packages/a/node_modules": ["junk", "no-deps"], + }); // `bun link a` run from another checkout leaves node_modules/a pointing at that checkout, which has a node_modules of its own. - rmSync(link); + rmSync(join(dir, "node_modules", "a")); const other = linkOutside(dir, "node_modules/a", { "package.json": JSON.stringify({ name: "a", version: "1.0.0" }), }); - const victim = plant(other, "node_modules/victim"); - const otherNoDeps = plant(other, "node_modules/no-deps"); + plant(other, "node_modules/victim"); + plant(other, "node_modules/no-deps"); const dryRun = await prune(dir, "--dry-run", "--linker", "hoisted"); expect(out(dryRun.stdout)).toMatchInlineSnapshot(` @@ -891,52 +1061,53 @@ test.concurrent( 1 package can be removed (checked 4) bun prune --linker hoisted" `); - expect(dryRun.exitCode).toBe(0); + expectOk(dryRun); // bun.lock records workspace folders relative to the root; prune chdirs there first, so running it from inside a workspace resolves them the same way. - const { stdout, exitCode } = await prune({ dir, cwd: join(dir, "packages", "a") }, "--linker", "hoisted"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune({ dir, cwd: join(dir, "packages", "a") }, "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk (node_modules/a/node_modules) 1 package removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(workspaceNoDeps)).toBeTrue(); - expect(existsSync(join(victim, "package.json"))).toBeTrue(); - expect(existsSync(join(otherNoDeps, "package.json"))).toBeTrue(); - expect(isSymlink(link)).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": ["a (link)", "no-deps"], + "packages/a/node_modules": ["no-deps"], + }); + expect(readdirSync(join(other, "node_modules")).toSorted()).toEqual(["no-deps", "victim"]); }, ); test.concurrent.skipIf(isWindows)("a symlinked .bin directory is never cleaned through", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const nm = join(dir, "node_modules"); - expect(existsSync(join(nm, ".bin"))).toBeFalse(); const outsideBins = linkOutside(dir, "node_modules/.bin"); - const dangling = join(outsideBins, "dangling"); - symlinkSync("./does-not-exist", dangling); - const junk = plant(dir, "node_modules/junk"); + symlinkSync("./does-not-exist", join(outsideBins, "dangling")); + plant(dir, "node_modules/junk"); + expect(tree(dir)).toEqual({ "node_modules": [".bin (link)", "junk", "no-deps"] }); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk 1 package removed (checked 2)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(isSymlink(dangling)).toBeTrue(); - expect(isSymlink(join(nm, ".bin"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": [".bin (link)", "no-deps"] }); + expect(readdirSync(outsideBins)).toEqual(["dangling"]); }); test.concurrent("isolated: extraneous symlinks are removed even when the store is clean", async () => { const dir = await setupWithLinker("isolated", { name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const nm = join(dir, "node_modules"); const outside = linkOutside(dir, "node_modules/ext", { "keep.txt": "keep" }); const scopedOutside = linkOutside(dir, "node_modules/@ext/thing", { "keep.txt": "keep" }); + expect(tree(dir)).toEqual({ + "node_modules": ["@ext/thing (link)", "ext (link)", "no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }); const first = await prune(dir, "--linker", "isolated"); expect(out(first.stdout)).toMatchInlineSnapshot(` @@ -946,16 +1117,20 @@ test.concurrent("isolated: extraneous symlinks are removed even when the store i - ext 2 packages removed (checked 4)" `); - expect(first.exitCode).toBe(0); - expect(() => lstatSync(join(nm, "ext"))).toThrow(); - expect(existsSync(join(nm, "@ext"))).toBeFalse(); - expect(existsSync(join(outside, "keep.txt"))).toBeTrue(); - expect(existsSync(join(scopedOutside, "keep.txt"))).toBeTrue(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); + expectOk(first); + const pruned = { + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual(pruned); + expect(readdirSync(outside)).toEqual(["keep.txt"]); + expect(readdirSync(scopedOutside)).toEqual(["keep.txt"]); const second = await prune(dir, "--linker", "isolated"); - expect(out(second.stdout)).toEndWith(NOTHING(2, 2)); - expect(second.exitCode).toBe(0); + expect(lines(second.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 2)]); + expectOk(second); + expect(tree(dir)).toEqual(pruned); }); test.concurrent( @@ -965,28 +1140,28 @@ test.concurrent( name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0", "@scoped/has-bin-entry": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - expect(existsSync(join(nm, "one-dep", "node_modules", "no-deps"))).toBeTrue(); await write(join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "no-deps": "2.0.0" } })); await install(dir, "--lockfile-only"); - expect(existsSync(join(nm, "one-dep"))).toBeTrue(); - expect(existsSync(join(nm, "@scoped", "has-bin-entry"))).toBeTrue(); + expect(tree(dir)).toEqual({ + "node_modules": [".bin/has-bin-entry", "@scoped/has-bin-entry", "no-deps", "one-dep"], + "node_modules/one-dep/node_modules": ["no-deps"], + }); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - @scoped/has-bin-entry@1.0.0 - one-dep@1.0.0 2 packages removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(join(nm, "one-dep"))).toBeFalse(); - expect(existsSync(join(nm, "@scoped"))).toBeFalse(); - expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); - expectBinRemoved(nm, "has-bin-entry"); + expectOk(result); + // The bin link goes with its package; the .bin folder itself is left behind, empty. + expect(tree(dir)).toEqual({ "node_modules": [".bin", "no-deps"] }); + expect(await file(join(dir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); const { out: installOut } = await runBunInstall(installEnv(dir), dir, { savesLockfile: false }); expect(installOut).toContain("no changes"); + expect(tree(dir)).toEqual({ "node_modules": [".bin", "no-deps"] }); }, ); @@ -996,19 +1171,17 @@ test.concurrent("hoisted: removing only a scoped package also removes its bin li dependencies: { "no-deps": "1.0.0" }, devDependencies: { "@scoped/has-bin-entry": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - expectBinInstalled(nm, "has-bin-entry"); + expect(tree(dir)).toEqual({ "node_modules": [".bin/has-bin-entry", "@scoped/has-bin-entry", "no-deps"] }); - const { stdout, exitCode } = await prune(dir, "--production"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - @scoped/has-bin-entry@1.0.0 1 package removed (checked 2)" `); - expect(exitCode).toBe(0); - expect(existsSync(join(nm, "@scoped"))).toBeFalse(); - expectBinRemoved(nm, "has-bin-entry"); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": [".bin", "no-deps"] }); }); test.concurrent( @@ -1018,15 +1191,16 @@ test.concurrent( name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0", "@scoped/has-bin-entry": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - const store = join(nm, ".bun"); await write(join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "no-deps": "2.0.0" } })); await install(dir, "--lockfile-only", "--linker", "isolated"); - expect(isSymlink(join(nm, "one-dep"))).toBeTrue(); - expect(isSymlink(join(nm, "@scoped", "has-bin-entry"))).toBeTrue(); + expect(tree(dir)).toEqual({ + "node_modules": [".bin/has-bin-entry", "@scoped/has-bin-entry (link)", "no-deps (link)", "one-dep (link)"], + "node_modules/.bun": ["@scoped+has-bin-entry@1.0.0", "no-deps@1.0.1", "no-deps@2.0.0", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": ["@scoped/has-bin-entry (link)", "no-deps (link)", "one-dep (link)"], + }); - const { stdout, exitCode } = await prune(dir, "--linker", "isolated"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--linker", "isolated"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - @scoped/has-bin-entry@1.0.0 @@ -1034,44 +1208,39 @@ test.concurrent( - one-dep@1.0.0 3 packages removed (checked 7)" `); - expect(exitCode).toBe(0); - expect(() => lstatSync(join(nm, "one-dep"))).toThrow(); - expect(existsSync(join(nm, "@scoped"))).toBeFalse(); - expect(existsSync(join(store, "no-deps@2.0.0"))).toBeTrue(); - expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); - expectBinRemoved(nm, "has-bin-entry"); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": [".bin", "no-deps (link)"], + "node_modules/.bun": ["no-deps@2.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }); + expect(await file(join(dir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); }, ); test.concurrent("hoisted: --production empties a workspace folder that only held nested devDependencies", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } }); - await Promise.all([ - write( - packageJson, - JSON.stringify({ name: "root", workspaces: ["packages/*"], dependencies: { "no-deps": "2.0.0" } }), - ), - write( - join(dir, "packages", "a", "package.json"), - JSON.stringify({ name: "a", version: "1.0.0", devDependencies: { "no-deps": "1.0.0" } }), - ), - ]); - await install(dir, "--linker", "hoisted"); - const nm = join(dir, "node_modules"); - const nested = join(dir, "packages", "a", "node_modules", "no-deps"); - expect(await file(join(nested, "package.json")).json()).toMatchObject({ version: "1.0.0" }); + const dir = await setupWorkspaces("hoisted", { + root: { dependencies: { "no-deps": "2.0.0" } }, + packages: { a: { devDependencies: { "no-deps": "1.0.0" } } }, + }); + expect(tree(dir)).toEqual({ + "node_modules": ["a (link)", "no-deps"], + "packages/a/node_modules": ["no-deps"], + }); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "hoisted"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production", "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - no-deps@1.0.0 (packages/a/node_modules) 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(nested)).toBeFalse(); - expect(isSymlink(join(nm, "a"))).toBeTrue(); - expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); + expectOk(result); + const pruned = { "node_modules": ["a (link)", "no-deps"], "packages/a/node_modules": [] }; + expect(tree(dir)).toEqual(pruned); + expect(await file(join(dir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(pruned); }); const appLinksTool = { @@ -1084,20 +1253,24 @@ const appLinksTool = { tool: {}, }, }; +// The isolated linker links each workspace's dependencies into that workspace; the root folder only gets the root's own. +const appLinksToolInstalled = { + "node_modules": [], + "node_modules/.bun": ["a-dep@1.0.1", "no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["a-dep (link)", "no-deps (link)"], + "packages/app/node_modules": ["a-dep (link)", "lib (link)", "no-deps (link)", "tool (link)"], +}; test.concurrent( "isolated + workspaces: --production removes a workspace's registry devDependency and its dev-only workspace link, keeps prod links", async () => { const dir = await setupWorkspaces("isolated", appLinksTool); - const app = join(dir, "packages", "app"); - const appNm = join(app, "node_modules"); - expect(existsSync(join(dir, "node_modules", ".bun"))).toBeTrue(); - expect(isSymlink(join(appNm, "a-dep"))).toBeTrue(); - expect(isSymlink(join(appNm, "tool"))).toBeTrue(); + expect(tree(dir)).toEqual(appLinksToolInstalled); const plain = await prune(dir, "--linker", "isolated"); - expect(out(plain.stdout)).toEndWith(NOTHING(6, 3)); - expect(plain.exitCode).toBe(0); + expect(lines(plain.stdout)).toStrictEqual([BANNER, "", NOTHING(6, 3)]); + expectOk(plain); + expect(tree(dir)).toEqual(appLinksToolInstalled); const dryRun = await prune(dir, "--production", "--dry-run", "--linker", "isolated"); expect(out(dryRun.stdout)).toMatchInlineSnapshot(` @@ -1108,48 +1281,59 @@ test.concurrent( 2 packages can be removed (checked 6) bun prune --production --linker isolated" `); - expect(dryRun.exitCode).toBe(0); - expect(isSymlink(join(appNm, "tool"))).toBeTrue(); + expectOk(dryRun); + expect(tree(dir)).toEqual(appLinksToolInstalled); - const { stdout, exitCode } = await prune({ dir, cwd: app }, "--production", "--linker", "isolated"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune({ dir, cwd: join(dir, "packages", "app") }, "--production", "--linker", "isolated"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - a-dep@1.0.1 - tool@1.0.0 (packages/app/node_modules) 2 packages removed (checked 6)" `); - expect(exitCode).toBe(0); - expect(() => lstatSync(join(appNm, "a-dep"))).toThrow(); - expect(() => lstatSync(join(appNm, "tool"))).toThrow(); - expect(existsSync(join(appNm, "no-deps", "package.json"))).toBeTrue(); - expect(existsSync(join(appNm, "lib", "package.json"))).toBeTrue(); + expectOk(result); + const production = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/app/node_modules": ["lib (link)", "no-deps (link)"], + }; + expect(tree(dir)).toEqual(production); expect(existsSync(join(dir, "packages", "tool", "package.json"))).toBeTrue(); const again = await prune(dir, "--production", "--linker", "isolated"); - expect(out(again.stdout)).toEndWith(NOTHING(3, 3)); - expect(again.exitCode).toBe(0); + expect(lines(again.stdout)).toStrictEqual([BANNER, "", NOTHING(3, 3)]); + expectOk(again); + expect(tree(dir)).toEqual(production); await expectProductionInstallIsNoop(dir); - expect(() => lstatSync(join(appNm, "tool"))).toThrow(); + expect(tree(dir)).toEqual(production); }, ); test.concurrent("isolated: a real directory named like a workspace is never deleted", async () => { const dir = await setupWorkspaces("isolated", appLinksTool); - const appNm = join(dir, "packages", "app", "node_modules"); - rmSync(join(appNm, "tool")); - const planted = plant(dir, "packages/app/node_modules/tool"); + rmSync(join(dir, "packages", "app", "node_modules", "tool")); + plant(dir, "packages/app/node_modules/tool"); + expect(tree(dir)).toEqual({ + ...appLinksToolInstalled, + "packages/app/node_modules": ["a-dep (link)", "lib (link)", "no-deps (link)", "tool"], + }); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - a-dep@1.0.1 1 package removed (checked 6)" `); - expect(exitCode).toBe(0); - expect(existsSync(join(planted, "package.json"))).toBeTrue(); - expect(existsSync(join(appNm, "lib", "package.json"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/app/node_modules": ["lib (link)", "no-deps (link)", "tool"], + }); }); test.concurrent( @@ -1172,10 +1356,19 @@ test.concurrent( ), setupWorkspaces("isolated", scoped({ devDependencies: { "@scope/tool": "workspace:*" } })), ]); - const mixedScope = join(mixedDir, "packages", "app", "node_modules", "@scope"); - const devOnlyScope = join(devOnlyDir, "packages", "app", "node_modules", "@scope"); - expect(isSymlink(join(mixedScope, "tool"))).toBeTrue(); - expect(isSymlink(join(devOnlyScope, "tool"))).toBeTrue(); + const shared = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }; + expect(tree(mixedDir)).toEqual({ + ...shared, + "packages/app/node_modules": ["@scope/lib (link)", "@scope/tool (link)", "no-deps (link)"], + }); + expect(tree(devOnlyDir)).toEqual({ + ...shared, + "packages/app/node_modules": ["@scope/tool (link)", "no-deps (link)"], + }); const mixed = await prune(mixedDir, "--production", "--linker", "isolated"); expect(out(mixed.stdout)).toMatchInlineSnapshot(` @@ -1184,11 +1377,12 @@ test.concurrent( - @scope/tool@1.0.0 (packages/app/node_modules) 1 package removed (checked 4)" `); - expect(mixed.exitCode).toBe(0); - expect(() => lstatSync(join(mixedScope, "tool"))).toThrow(); - expect(existsSync(join(mixedScope, "lib", "package.json"))).toBeTrue(); + expectOk(mixed); + const mixedPruned = { ...shared, "packages/app/node_modules": ["@scope/lib (link)", "no-deps (link)"] }; + expect(tree(mixedDir)).toEqual(mixedPruned); expect(existsSync(join(mixedDir, "packages", "tool", "package.json"))).toBeTrue(); await expectProductionInstallIsNoop(mixedDir); + expect(tree(mixedDir)).toEqual(mixedPruned); const devOnly = await prune(devOnlyDir, "--production", "--linker", "isolated"); expect(out(devOnly.stdout)).toMatchInlineSnapshot(` @@ -1197,11 +1391,13 @@ test.concurrent( - @scope/tool@1.0.0 (packages/app/node_modules) 1 package removed (checked 3)" `); - expect(devOnly.exitCode).toBe(0); - expect(existsSync(devOnlyScope)).toBeFalse(); - expect(existsSync(join(devOnlyDir, "packages", "app", "node_modules", "no-deps", "package.json"))).toBeTrue(); + expectOk(devOnly); + // An emptied @scope dir would list as a bare "@scope" entry. + const devOnlyPruned = { ...shared, "packages/app/node_modules": ["no-deps (link)"] }; + expect(tree(devOnlyDir)).toEqual(devOnlyPruned); expect(existsSync(join(devOnlyDir, "packages", "tool", "package.json"))).toBeTrue(); await expectProductionInstallIsNoop(devOnlyDir); + expect(tree(devOnlyDir)).toEqual(devOnlyPruned); }, ); @@ -1215,24 +1411,26 @@ test.concurrent( tool: {}, }, }); - const appTool = join(dir, "packages", "app", "node_modules", "tool"); - const bTool = join(dir, "packages", "b", "node_modules", "tool"); - expect(isSymlink(appTool)).toBeTrue(); - expect(isSymlink(bTool)).toBeTrue(); + const shared = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/b/node_modules": ["no-deps (link)", "tool (link)"], + }; + expect(tree(dir)).toEqual({ ...shared, "packages/app/node_modules": ["tool (link)"] }); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - tool@1.0.0 (packages/app/node_modules) 1 package removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(() => lstatSync(appTool)).toThrow(); - expect(await file(join(bTool, "package.json")).json()).toMatchObject({ name: "tool" }); + expectOk(result); + const pruned = { ...shared, "packages/app/node_modules": [] }; + expect(tree(dir)).toEqual(pruned); await expectProductionInstallIsNoop(dir); - expect(() => lstatSync(appTool)).toThrow(); - expect(isSymlink(bTool)).toBeTrue(); + expect(tree(dir)).toEqual(pruned); }, ); @@ -1246,68 +1444,85 @@ test.concurrent.each(linkers)( tool: { dependencies: { "no-deps": "1.0.0" } }, }, }); - const appTool = join(dir, "packages", "app", "node_modules", "tool"); - const rootTool = join(dir, "node_modules", "tool"); - expect(isSymlink(rootTool)).toBeTrue(); - const toolNoDeps = + // hoisted: the root's tool link doubles as app's, and tool's no-deps is hoisted. isolated: every importer links its own. + const isolated = { + "node_modules": ["tool (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/tool/node_modules": ["no-deps (link)"], + }; + const installed = linker === "hoisted" - ? join(dir, "node_modules", "no-deps", "package.json") - : join(dir, "packages", "tool", "node_modules", "no-deps", "package.json"); - expect(existsSync(toolNoDeps)).toBeTrue(); + ? { "node_modules": ["app (link)", "no-deps", "tool (link)"] } + : { ...isolated, "packages/app/node_modules": ["tool (link)"] }; + expect(tree(dir)).toEqual(installed); const first = await prune(dir, "--production", "--linker", linker); - if (linker === "hoisted") { - expect(out(first.stdout)).toEndWith(NOTHING(3, 1)); - expect(isSymlink(rootTool)).toBeTrue(); - } else { - expect(out(first.stdout)).toEndWith(`- tool@1.0.0 (packages/app/node_modules)\n${REMOVED(1, 4)}`); - expect(() => lstatSync(appTool)).toThrow(); - } - expect(first.exitCode).toBe(0); - expect(existsSync(toolNoDeps)).toBeTrue(); + expect(lines(first.stdout)).toStrictEqual( + linker === "hoisted" + ? [BANNER, "", NOTHING(3, 1)] + : [BANNER, "", "- tool@1.0.0 (packages/app/node_modules)", REMOVED(1, 4)], + ); + expectOk(first); + const pruned = linker === "hoisted" ? installed : { ...isolated, "packages/app/node_modules": [] }; + expect(tree(dir)).toEqual(pruned); expect(existsSync(join(dir, "packages", "tool", "package.json"))).toBeTrue(); const second = await prune(dir, "--production", "--linker", linker); - expect(out(second.stdout)).toEndWith(linker === "hoisted" ? NOTHING(3, 1) : NOTHING(3, 4)); - expect(second.exitCode).toBe(0); + expect(lines(second.stdout)).toStrictEqual([BANNER, "", linker === "hoisted" ? NOTHING(3, 1) : NOTHING(3, 4)]); + expectOk(second); + expect(tree(dir)).toEqual(pruned); await expectProductionInstallIsNoop(dir); - expect(existsSync(toolNoDeps)).toBeTrue(); + expect(tree(dir)).toEqual(pruned); }, ); test.concurrent.each(linkers)("%s: refuses when package.json changed since bun.lock was written", async linker => { const dir = await setupWithLinker(linker, { name: "foo", dependencies: { "no-deps": "1.0.0", "a-dep": "1.0.1" } }); - const junk = plant(dir, "node_modules/junk"); - const aDep = join(dir, "node_modules", "a-dep"); + plant(dir, "node_modules/junk"); await write(join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } })); const lockBefore = await lock(dir); + const planted = + linker === "hoisted" + ? { "node_modules": ["a-dep", "junk", "no-deps"] } + : { + "node_modules": ["a-dep (link)", "junk", "no-deps (link)"], + "node_modules/.bun": ["a-dep@1.0.1", "no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["a-dep (link)", "no-deps (link)"], + }; expectRefused(await prune(dir, "--linker", linker)); expectRefused(await prune(dir, "--dry-run", "--linker", linker)); - expect(existsSync(junk)).toBeTrue(); - expect(existsSync(join(aDep, "package.json"))).toBeTrue(); + expect(tree(dir)).toEqual(planted); const silent = await prune(dir, "--silent", "--linker", linker); expect(silent.stdout).toBe(""); expect(silent.stderr).toBe(""); expect(silent.exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); + expect(tree(dir)).toEqual(planted); expect(await lock(dir)).toBe(lockBefore); await install(dir, "--lockfile-only", "--linker", linker); - const { stdout, stderr, exitCode } = await prune(dir, "--linker", linker); + expect(tree(dir)).toEqual(planted); + const result = await prune(dir, "--linker", linker); // The rows read the same under both linkers; only the store changes what was checked. - expect(lines(stdout)).toStrictEqual([ + expect(lines(result.stdout)).toStrictEqual([ BANNER, "", "- a-dep@1.0.1", "- junk", REMOVED(2, linker === "hoisted" ? 3 : 5), ]); - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(() => lstatSync(aDep)).toThrow(); + expectOk(result); + expect(tree(dir)).toEqual( + linker === "hoisted" + ? { "node_modules": ["no-deps"] } + : { + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }, + ); }); test.concurrent.each([ @@ -1316,12 +1531,12 @@ test.concurrent.each([ ["an override is added", { dependencies: { "no-deps": "1.0.0" }, overrides: { "no-deps": "1.0.0" } }], ] as const)("refuses when %s", async (_, edited) => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); const lockBefore = await lock(dir); await write(join(dir, "package.json"), JSON.stringify({ name: "foo", ...edited })); expectRefused(await prune(dir)); - expect(existsSync(junk)).toBeTrue(); + expect(tree(dir)).toEqual({ "node_modules": ["junk", "no-deps"] }); expect(await lock(dir)).toBe(lockBefore); }); @@ -1331,12 +1546,12 @@ test.concurrent("refuses when a catalog entry changed", async () => { packages: { a: { dependencies: { "no-deps": "catalog:" } } }, }); const dir = await setupWorkspaces("hoisted", catalogRoot("1.0.0")); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); const lockBefore = await lock(dir); - await writeWorkspaces(dir, join(dir, "package.json"), catalogRoot("1.0.1")); + await writeWorkspaces(dir, catalogRoot("1.0.1")); expectRefused(await prune(dir, "--linker", "hoisted")); - expect(existsSync(junk)).toBeTrue(); + expect(tree(dir)).toEqual({ "node_modules": ["a (link)", "junk", "no-deps"] }); expect(await lock(dir)).toBe(lockBefore); }); @@ -1348,34 +1563,44 @@ test.concurrent("refuses when a workspace's package.json changed, from any cwd a b: { dependencies: { "a-dep": "1.0.1" } }, }, }); - const junk = plant(dir, "packages/a/node_modules/junk"); - const aDep = join(dir, "node_modules", "a-dep"); - expect(existsSync(aDep)).toBeTrue(); + plant(dir, "packages/a/node_modules/junk"); + const planted = { + "node_modules": ["a (link)", "a-dep", "b (link)", "no-deps"], + "packages/a/node_modules": ["junk", "no-deps"], + }; + expect(tree(dir)).toEqual(planted); await write(join(dir, "packages", "b", "package.json"), JSON.stringify({ name: "b", version: "1.0.0" })); expectRefused(await prune(dir, "--linker", "hoisted")); expectRefused(await prune({ dir, cwd: join(dir, "packages", "a") }, "--linker", "hoisted")); expectRefused(await prune(dir, "--filter", "a", "--linker", "hoisted")); - expect(existsSync(junk)).toBeTrue(); - expect(existsSync(aDep)).toBeTrue(); + expect(tree(dir)).toEqual(planted); }); test.concurrent.each(linkers)("%s: a workspace lifecycle script is not out of sync", async linker => { const dir = await setupWorkspaces(linker, { packages: { a: { dependencies: { "no-deps": "1.0.0" }, scripts: { postinstall: "echo ok" } } }, }); - const junk = plant(dir, "node_modules/junk"); - - const { stdout, stderr, exitCode } = await prune(dir, "--linker", linker); - expect(stderr).not.toContain(OUT_OF_SYNC); - expect(out(stdout)).toMatchInlineSnapshot(` + plant(dir, "node_modules/junk"); + const installed = + linker === "hoisted" + ? { "node_modules": ["a (link)", "no-deps"] } + : { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/a/node_modules": ["no-deps (link)"], + }; + + const result = await prune(dir, "--linker", linker); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); + expectOk(result); + expect(tree(dir)).toEqual(installed); }); test.concurrent("trustedDependencies stripped from bun.lock is not out of sync", async () => { @@ -1385,18 +1610,17 @@ test.concurrent("trustedDependencies stripped from bun.lock is not out of sync", const stripped = before.replace(/\n "trustedDependencies": \[\n(?: [^\n]*\n)* \],/, ""); expect(stripped).not.toContain('"trustedDependencies"'); await write(join(dir, "bun.lock"), stripped); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); - const { stdout, stderr, exitCode } = await prune(dir); - expect(stderr).not.toContain(OUT_OF_SYNC); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk 1 package removed (checked 2)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); }); const prunedCheckout = (app: Record) => ({ @@ -1408,97 +1632,100 @@ const prunedCheckout = (app: Record) => ({ test.concurrent("prunes a checkout whose bun.lock lists a workspace that is no longer on disk", async () => { const dir = await setupWorkspaces("hoisted", prunedCheckout({})); - const nm = join(dir, "node_modules"); rmSync(join(dir, "packages", "other"), { recursive: true }); - expect(isSymlink(join(nm, "other"))).toBeTrue(); - expect(existsSync(join(nm, "left-pad", "package.json"))).toBeTrue(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["app (link)", "junk", "left-pad", "no-deps", "other (dangling link)"], + }); const { lines: merged, exitCode } = await pruneMerged(dir, "--linker", "hoisted"); expect(merged).toStrictEqual([BANNER, "", PRUNED_NOTE, "- junk", "- left-pad@1.0.0", "- other", REMOVED(3, 5)]); expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(join(nm, "left-pad"))).toBeFalse(); - expect(() => lstatSync(join(nm, "other"))).toThrow(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - expect(isSymlink(join(nm, "app"))).toBeTrue(); + const pruned = { "node_modules": ["app (link)", "no-deps"] }; + expect(tree(dir)).toEqual(pruned); await install(dir, "--frozen-lockfile", "--linker", "hoisted"); - expect(existsSync(join(nm, "left-pad"))).toBeFalse(); - expect(() => lstatSync(join(nm, "other"))).toThrow(); + expect(tree(dir)).toEqual(pruned); const second = await prune(dir, "--linker", "hoisted"); - expect(out(second.stdout)).toEndWith(NOTHING(2, 1)); + expect(lines(second.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 1)]); + expect(normalizeBunSnapshot(second.stderr)).toBe(PRUNED_NOTE); expect(second.exitCode).toBe(0); + expect(tree(dir)).toEqual(pruned); }); test.concurrent("isolated: a workspace missing from disk no longer keeps its store entries", async () => { const dir = await setupWorkspaces("isolated", prunedCheckout({})); - const store = join(dir, "node_modules", ".bun"); - const appNoDeps = join(dir, "packages", "app", "node_modules", "no-deps", "package.json"); rmSync(join(dir, "packages", "other"), { recursive: true }); - expect(existsSync(join(store, "left-pad@1.0.0"))).toBeTrue(); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["junk"], + "node_modules/.bun": ["left-pad@1.0.0", "no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["left-pad (link)", "no-deps (link)"], + "packages/app/node_modules": ["no-deps (link)"], + }); - const { stdout, stderr, exitCode } = await prune(dir, "--linker", "isolated"); - expect(stderr).toContain(PRUNED_NOTE); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--linker", "isolated"); + expect(normalizeBunSnapshot(result.stderr)).toBe(PRUNED_NOTE); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk - left-pad@1.0.0 2 packages removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(join(store, "left-pad@1.0.0"))).toBeFalse(); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); - expect(existsSync(appNoDeps)).toBeTrue(); + expect(result.exitCode).toBe(0); + const pruned = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/app/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual(pruned); await install(dir, "--frozen-lockfile", "--linker", "isolated"); - expect(existsSync(join(store, "left-pad@1.0.0"))).toBeFalse(); - expect(existsSync(appNoDeps)).toBeTrue(); + expect(tree(dir)).toEqual(pruned); const second = await prune(dir, "--linker", "isolated"); - expect(out(second.stdout)).toEndWith(NOTHING(2, 3)); + expect(lines(second.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 3)]); + expect(normalizeBunSnapshot(second.stderr)).toBe(PRUNED_NOTE); expect(second.exitCode).toBe(0); + expect(tree(dir)).toEqual(pruned); }); test.concurrent("hoisted: --filter on a pruned checkout does not protect the missing workspace", async () => { const dir = await setupWorkspaces("hoisted", prunedCheckout({})); - const nm = join(dir, "node_modules"); rmSync(join(dir, "packages", "other"), { recursive: true }); + expect(tree(dir)).toEqual({ "node_modules": ["app (link)", "left-pad", "no-deps", "other (dangling link)"] }); - const { stdout, stderr, exitCode } = await prune(dir, "--filter", "app", "--linker", "hoisted"); - expect(stderr).toContain(PRUNED_NOTE); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--filter", "app", "--linker", "hoisted"); + expect(normalizeBunSnapshot(result.stderr)).toBe(PRUNED_NOTE); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - left-pad@1.0.0 - other 2 packages removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(existsSync(join(nm, "left-pad"))).toBeFalse(); - expect(() => lstatSync(join(nm, "other"))).toThrow(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - expect(isSymlink(join(nm, "app"))).toBeTrue(); + expect(result.exitCode).toBe(0); + expect(tree(dir)).toEqual({ "node_modules": ["app (link)", "no-deps"] }); }); test.concurrent("a survivor depending on a missing workspace makes prune fail like install does", async () => { const dir = await setupWorkspaces("hoisted", prunedCheckout({ other: "workspace:*" })); rmSync(join(dir, "packages", "other"), { recursive: true }); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); + const planted = { "node_modules": ["app (link)", "junk", "left-pad", "no-deps", "other (dangling link)"] }; + expect(tree(dir)).toEqual(planted); const { stdout, stderr, exitCode } = await prune(dir, "--linker", "hoisted"); - expect(stderr).toContain( - 'workspace "app" depends on workspace "other" (packages/other), which is listed in bun.lock but not on disk', - ); - expect(out(stdout)).not.toMatch(/^- /m); + expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(` + "error: workspace "app" depends on workspace "other" (packages/other), which is listed in bun.lock but not on disk + note: a pruned checkout must keep every workspace that its remaining workspaces depend on" + `); + expect(out(stdout)).toBe(BANNER); expect(exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); + expect(tree(dir)).toEqual(planted); }); test.concurrent( @@ -1510,27 +1737,25 @@ test.concurrent( shared: {}, }, }); - const nm = join(dir, "node_modules"); - expect(isSymlink(join(nm, "app"))).toBeTrue(); - expect(isSymlink(join(nm, "shared"))).toBeTrue(); - expect(isSymlink(join(nm, "shared-alias"))).toBeTrue(); + expect(tree(dir)).toEqual({ "node_modules": ["app (link)", "shared (link)", "shared-alias (link)"] }); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "hoisted"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production", "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - shared-alias@1.0.0 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(isSymlink(join(nm, "app"))).toBeTrue(); - expect(isSymlink(join(nm, "shared"))).toBeTrue(); - expect(() => lstatSync(join(nm, "shared-alias"))).toThrow(); + expectOk(result); + const pruned = { "node_modules": ["app (link)", "shared (link)"] }; + expect(tree(dir)).toEqual(pruned); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(pruned); const second = await prune(dir, "--production", "--linker", "hoisted"); - expect(out(second.stdout)).toEndWith(NOTHING(2, 1)); - expect(second.exitCode).toBe(0); + expect(lines(second.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 1)]); + expectOk(second); + expect(tree(dir)).toEqual(pruned); }, ); @@ -1544,15 +1769,16 @@ test.concurrent( b: { dependencies: { "no-deps": "1.0.0" }, devDependencies: { "one-fixed-dep": "1.0.0" } }, }, }); - const nm = join(dir, "node_modules"); - const aJunk = plant(dir, "packages/a/node_modules/junk"); - const bJunk = plant(dir, "packages/b/node_modules/junk"); - const stillInstalled = () => { - expect(isSymlink(join(nm, "a"))).toBeTrue(); - expect(isSymlink(join(nm, "b"))).toBeTrue(); - expect(existsSync(join(dir, "packages", "a", "node_modules", "no-deps", "package.json"))).toBeTrue(); - expect(existsSync(join(dir, "packages", "b", "node_modules", "no-deps", "package.json"))).toBeTrue(); - }; + plant(dir, "packages/a/node_modules/junk"); + plant(dir, "packages/b/node_modules/junk"); + // one-fixed-dep wants no-deps@1.0.0 while the root holds 2.0.0, so it carries a nested copy. + const nested = { "node_modules/one-fixed-dep/node_modules": ["no-deps"] }; + expect(tree(dir)).toEqual({ + "node_modules": ["a (link)", "a-dep", "b (link)", "left-pad", "no-deps", "one-fixed-dep"], + ...nested, + "packages/a/node_modules": ["junk", "no-deps"], + "packages/b/node_modules": ["junk", "no-deps"], + }); const onlyA = await prune(dir, "--production", "--filter", "a", "--linker", "hoisted"); expect(out(onlyA.stdout)).toMatchInlineSnapshot(` @@ -1562,13 +1788,14 @@ test.concurrent( - junk (node_modules/a/node_modules) 2 packages removed (checked 8)" `); - expect(onlyA.exitCode).toBe(0); - expect(existsSync(aJunk)).toBeFalse(); - expect(existsSync(bJunk)).toBeTrue(); - expect(existsSync(join(nm, "left-pad"))).toBeTrue(); - expect(existsSync(join(nm, "one-fixed-dep"))).toBeTrue(); - expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); - stillInstalled(); + expectOk(onlyA); + expect(tree(dir)).toEqual({ + "node_modules": ["a (link)", "b (link)", "left-pad", "no-deps", "one-fixed-dep"], + ...nested, + "packages/a/node_modules": ["no-deps"], + "packages/b/node_modules": ["junk", "no-deps"], + }); + expect(await file(join(dir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); const onlyRoot = await prune(dir, "--production", "--filter", "root", "--linker", "hoisted"); expect(out(onlyRoot.stdout)).toMatchInlineSnapshot(` @@ -1577,9 +1804,13 @@ test.concurrent( - left-pad@1.0.0 1 package removed (checked 5)" `); - expect(onlyRoot.exitCode).toBe(0); - expect(existsSync(bJunk)).toBeTrue(); - expect(existsSync(join(nm, "one-fixed-dep"))).toBeTrue(); + expectOk(onlyRoot); + expect(tree(dir)).toEqual({ + "node_modules": ["a (link)", "b (link)", "no-deps", "one-fixed-dep"], + ...nested, + "packages/a/node_modules": ["no-deps"], + "packages/b/node_modules": ["junk", "no-deps"], + }); const everything = await prune(dir, "--production", "--linker", "hoisted"); expect(out(everything.stdout)).toMatchInlineSnapshot(` @@ -1589,10 +1820,15 @@ test.concurrent( - one-fixed-dep@1.0.0 2 packages removed (checked 7)" `); - expect(everything.exitCode).toBe(0); - expect(existsSync(bJunk)).toBeFalse(); - stillInstalled(); + expectOk(everything); + const production = { + "node_modules": ["a (link)", "b (link)", "no-deps"], + "packages/a/node_modules": ["no-deps"], + "packages/b/node_modules": ["no-deps"], + }; + expect(tree(dir)).toEqual(production); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(production); }, ); @@ -1605,9 +1841,13 @@ test.concurrent( b: { devDependencies: { "a-dep": "1.0.1", "left-pad": "1.0.0" } }, }, }); - const store = join(dir, "node_modules", ".bun"); - const aNm = join(dir, "packages", "a", "node_modules"); - const bNm = join(dir, "packages", "b", "node_modules"); + expect(tree(dir)).toEqual({ + "node_modules": [], + "node_modules/.bun": ["a-dep@1.0.1", "left-pad@1.0.0", "no-deps@1.0.0", "one-fixed-dep@1.0.0"], + "node_modules/.bun/node_modules": ["a-dep (link)", "left-pad (link)", "no-deps (link)", "one-fixed-dep (link)"], + "packages/a/node_modules": ["a-dep (link)", "no-deps (link)", "one-fixed-dep (link)"], + "packages/b/node_modules": ["a-dep (link)", "left-pad (link)"], + }); const onlyA = await prune(dir, "--production", "--filter", "a", "--linker", "isolated"); expect(out(onlyA.stdout)).toMatchInlineSnapshot(` @@ -1617,15 +1857,15 @@ test.concurrent( - one-fixed-dep@1.0.0 2 packages removed (checked 7)" `); - expect(onlyA.exitCode).toBe(0); - expect(existsSync(join(store, "a-dep@1.0.1"))).toBeTrue(); - expect(existsSync(join(store, "left-pad@1.0.0"))).toBeTrue(); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); - expect(existsSync(join(bNm, "a-dep", "package.json"))).toBeTrue(); - expect(existsSync(join(bNm, "left-pad", "package.json"))).toBeTrue(); - expect(() => lstatSync(join(aNm, "a-dep"))).toThrow(); - expect(() => lstatSync(join(aNm, "one-fixed-dep"))).toThrow(); - expect(existsSync(join(aNm, "no-deps", "package.json"))).toBeTrue(); + expectOk(onlyA); + // a-dep's store entry stays because b, which was not selected, still links to it. + expect(tree(dir)).toEqual({ + "node_modules": [], + "node_modules/.bun": ["a-dep@1.0.1", "left-pad@1.0.0", "no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["a-dep (link)", "left-pad (link)", "no-deps (link)"], + "packages/a/node_modules": ["no-deps (link)"], + "packages/b/node_modules": ["a-dep (link)", "left-pad (link)"], + }); const everything = await prune(dir, "--production", "--linker", "isolated"); expect(out(everything.stdout)).toMatchInlineSnapshot(` @@ -1635,11 +1875,17 @@ test.concurrent( - left-pad@1.0.0 2 packages removed (checked 6)" `); - expect(everything.exitCode).toBe(0); - expect(() => lstatSync(join(bNm, "a-dep"))).toThrow(); - expect(() => lstatSync(join(bNm, "left-pad"))).toThrow(); - expect(existsSync(join(aNm, "no-deps", "package.json"))).toBeTrue(); + expectOk(everything); + const production = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/a/node_modules": ["no-deps (link)"], + "packages/b/node_modules": [], + }; + expect(tree(dir)).toEqual(production); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(production); }, ); @@ -1648,9 +1894,17 @@ test.concurrent( async () => { const pkg = { dependencies: { "no-deps": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } }; const dir = await setupWorkspaces("isolated", { packages: { selected: pkg, unselected: pkg } }); - const storeEntry = join(dir, "node_modules", ".bun", "a-dep@1.0.1"); - const selectedADep = join(dir, "packages", "selected", "node_modules", "a-dep"); - const unselectedADep = join(dir, "packages", "unselected", "node_modules", "a-dep"); + const bothLinked = ["a-dep (link)", "no-deps (link)"]; + const fullStore = { + "node_modules": [], + "node_modules/.bun": ["a-dep@1.0.1", "no-deps@1.0.0"], + "node_modules/.bun/node_modules": bothLinked, + }; + expect(tree(dir)).toEqual({ + ...fullStore, + "packages/selected/node_modules": bothLinked, + "packages/unselected/node_modules": bothLinked, + }); const first = await prune(dir, "--production", "--filter", "selected", "--linker", "isolated"); expect(out(first.stdout)).toMatchInlineSnapshot(` @@ -1659,10 +1913,12 @@ test.concurrent( - a-dep@1.0.1 (packages/selected/node_modules) 1 package removed (checked 4)" `); - expect(first.exitCode).toBe(0); - expect(() => lstatSync(selectedADep)).toThrow(); - expect(existsSync(join(unselectedADep, "package.json"))).toBeTrue(); - expect(existsSync(storeEntry)).toBeTrue(); + expectOk(first); + expect(tree(dir)).toEqual({ + ...fullStore, + "packages/selected/node_modules": ["no-deps (link)"], + "packages/unselected/node_modules": bothLinked, + }); const second = await prune(dir, "--production", "--filter", "unselected", "--linker", "isolated"); expect(out(second.stdout)).toMatchInlineSnapshot(` @@ -1671,9 +1927,14 @@ test.concurrent( - a-dep@1.0.1 (packages/unselected/node_modules) 1 package removed (checked 4)" `); - expect(second.exitCode).toBe(0); - expect(() => lstatSync(unselectedADep)).toThrow(); - expect(existsSync(storeEntry)).toBeTrue(); + expectOk(second); + // Nothing links to a-dep any more, but a workspace outside the filter counts as wanting all of its bun.lock + // dependencies, dev ones included, so each filtered run keeps the store entry for the other workspace. + expect(tree(dir)).toEqual({ + ...fullStore, + "packages/selected/node_modules": ["no-deps (link)"], + "packages/unselected/node_modules": ["no-deps (link)"], + }); const everything = await prune(dir, "--production", "--linker", "isolated"); expect(out(everything.stdout)).toMatchInlineSnapshot(` @@ -1682,12 +1943,17 @@ test.concurrent( - a-dep@1.0.1 1 package removed (checked 4)" `); - expect(everything.exitCode).toBe(0); - expect(existsSync(storeEntry)).toBeFalse(); - for (const ws of ["selected", "unselected"]) { - expect(existsSync(join(dir, "packages", ws, "node_modules", "no-deps", "package.json"))).toBeTrue(); - } + expectOk(everything); + const production = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/selected/node_modules": ["no-deps (link)"], + "packages/unselected/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual(production); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(production); }, ); @@ -1701,10 +1967,30 @@ test.concurrent.each(linkers)( lib: { dependencies: { "no-deps": "1.0.0" } }, }, }); - const rootJunk = plant(dir, "node_modules/root-junk"); - const appJunk = plant(dir, "packages/app/node_modules/app-junk"); - const libJunk = plant(dir, "packages/lib/node_modules/lib-junk"); - const storeJunk = linker === "isolated" ? plant(dir, "node_modules/.bun/junk@1.0.0/node_modules/junk") : null; + plant(dir, "node_modules/root-junk"); + plant(dir, "packages/app/node_modules/app-junk"); + plant(dir, "packages/lib/node_modules/lib-junk"); + if (linker === "isolated") { + plant(dir, "node_modules/.bun/junk@1.0.0/node_modules/junk"); + } + // The root holds no-deps@2.0.0, so each workspace gets no-deps@1.0.0 in its own folder under both linkers. + const noDeps = linker === "hoisted" ? "no-deps" : "no-deps (link)"; + const expected = (junk: { root?: true; store?: true; app?: true; lib?: true }) => ({ + "node_modules": [ + ...(linker === "hoisted" ? ["app (link)", "lib (link)"] : []), + noDeps, + ...(junk.root ? ["root-junk"] : []), + ], + ...(linker === "isolated" + ? { + "node_modules/.bun": [...(junk.store ? ["junk@1.0.0"] : []), "no-deps@1.0.0", "no-deps@2.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + } + : {}), + "packages/app/node_modules": junk.app ? ["app-junk", noDeps] : [noDeps], + "packages/lib/node_modules": junk.lib ? ["lib-junk", noDeps] : [noDeps], + }); + expect(tree(dir)).toEqual(expected({ root: true, store: true, app: true, lib: true })); const shown = (ws: string, name: string) => linker === "hoisted" ? `- ${name} (node_modules/${ws}/node_modules)` : `- ${name} (packages/${ws}/node_modules)`; // Rows are listed by package name, wherever the entry lives. @@ -1722,34 +2008,24 @@ test.concurrent.each(linkers)( CAN_BE_REMOVED(2, sharedAndApp), APPLY_HINT("--filter", "app", "--linker", linker), ]); - expect(dryRun.stderr).toBe(""); - expect(dryRun.exitCode).toBe(0); - expect(existsSync(appJunk)).toBeTrue(); - expect(existsSync(rootJunk)).toBeTrue(); + expectOk(dryRun); + expect(tree(dir)).toEqual(expected({ root: true, store: true, app: true, lib: true })); const byPath = await prune(dir, "--filter", "./packages/app", "--linker", linker); expect(lines(byPath.stdout)).toStrictEqual(removed(appRows, sharedAndApp)); - expect(byPath.exitCode).toBe(0); - expect(existsSync(appJunk)).toBeFalse(); - expect(existsSync(libJunk)).toBeTrue(); - expect(existsSync(rootJunk)).toBe(linker === "isolated"); - if (storeJunk) { - expect(existsSync(storeJunk)).toBeFalse(); - } + expectOk(byPath); + expect(tree(dir)).toEqual(linker === "hoisted" ? expected({ lib: true }) : expected({ root: true, lib: true })); const byGlob = await prune({ dir, cwd: join(dir, "packages", "app") }, "--filter", "li*", "--linker", linker); expect(lines(byGlob.stdout)).toStrictEqual(removed([shown("lib", "lib-junk")], linker === "hoisted" ? 5 : 4)); - expect(byGlob.exitCode).toBe(0); - expect(existsSync(libJunk)).toBeFalse(); + expectOk(byGlob); + expect(tree(dir)).toEqual(linker === "hoisted" ? expected({}) : expected({ root: true })); if (linker === "isolated") { const root = await prune(dir, "--filter", "./", "--linker", linker); expect(lines(root.stdout)).toStrictEqual(removed(["- root-junk"], 4)); - expect(root.exitCode).toBe(0); - } - expect(existsSync(rootJunk)).toBeFalse(); - for (const ws of ["app", "lib"]) { - expect(existsSync(join(dir, "packages", ws, "node_modules", "no-deps", "package.json"))).toBeTrue(); + expectOk(root); + expect(tree(dir)).toEqual(expected({})); } }, ); @@ -1759,7 +2035,11 @@ test.concurrent("hoisted: --filter with no match is an error; path filters resol root: { dependencies: { "no-deps": "2.0.0" } }, packages: { a: { dependencies: { "no-deps": "1.0.0" } } }, }); - const junk = plant(dir, "packages/a/node_modules/junk"); + plant(dir, "packages/a/node_modules/junk"); + const planted = { + "node_modules": ["a (link)", "no-deps"], + "packages/a/node_modules": ["junk", "no-deps"], + }; const listing = (...flags: string[]) => [ BANNER, "", @@ -1784,7 +2064,7 @@ test.concurrent("hoisted: --filter with no match is an error; path filters resol expect(silentNoMatch.stdout).toBe(""); expect(silentNoMatch.stderr).toBe(""); expect(silentNoMatch.exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); + expect(tree(dir)).toEqual(planted); // A typo next to a real name is not fatal, but it is reported before the verdict. const someMatch = await pruneMerged(dir, "--filter", "a", "--filter", "nope", "--dry-run", "--linker", "hoisted"); @@ -1797,13 +2077,12 @@ test.concurrent("hoisted: --filter with no match is an error; path filters resol APPLY_HINT("--filter", "a", "--filter", "nope", "--linker", "hoisted"), ]); expect(someMatch.exitCode).toBe(0); - expect(existsSync(junk)).toBeTrue(); + expect(tree(dir)).toEqual(planted); const fromRoot = await prune(dir, "--filter", "./packages/a", "--dry-run", "--linker", "hoisted"); - expect(fromRoot.stderr).toBe(""); expect(lines(fromRoot.stdout)).toStrictEqual(listing("--filter", "./packages/a")); - expect(fromRoot.exitCode).toBe(0); - expect(existsSync(junk)).toBeTrue(); + expectOk(fromRoot); + expect(tree(dir)).toEqual(planted); const fromInside = await prune( { dir, cwd: join(dir, "packages", "a") }, @@ -1813,21 +2092,19 @@ test.concurrent("hoisted: --filter with no match is an error; path filters resol "--linker", "hoisted", ); - expect(fromInside.stderr).toBe(""); expect(lines(fromInside.stdout)).toStrictEqual(listing("--filter", ".")); - expect(fromInside.exitCode).toBe(0); - expect(existsSync(junk)).toBeTrue(); + expectOk(fromInside); + expect(tree(dir)).toEqual(planted); - const { stdout, stderr, exitCode } = await prune(dir, "--filter", "a", "--linker", "hoisted"); - expect(stderr).toBe(""); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--filter", "a", "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk (node_modules/a/node_modules) 1 package removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); + expectOk(result); + expect(tree(dir)).toEqual({ ...planted, "packages/a/node_modules": ["no-deps"] }); }); test.concurrent.each(linkers)( @@ -1836,11 +2113,21 @@ test.concurrent.each(linkers)( const dir = await setupWorkspaces(linker, { packages: { app: { dependencies: { "no-deps": "1.0.0" } }, lib: {} }, }); - const junk = plant(dir, "packages/app/node_modules/junk"); + plant(dir, "packages/app/node_modules/junk"); + const pruned = + linker === "hoisted" + ? { "node_modules": ["app (link)", "lib (link)", "no-deps"], "packages/app/node_modules": [] } + : { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/app/node_modules": ["no-deps (link)"], + }; const { stdout, stderr, exitCode } = await prune(dir, "--filter", "app", "--filter", "nope", "--linker", linker); expect(normalizeBunSnapshot(stderr)).toBe('warn: No workspace packages matched the filter "nope"'); - // bun.lock installs nothing there, so the folder is shown by its own path, not through the node_modules/app link. + // Under hoisted, bun.lock installs nothing into app's folder, so it is shown by its own path rather than through + // the node_modules/app link. expect(lines(stdout)).toStrictEqual([ BANNER, "", @@ -1848,12 +2135,12 @@ test.concurrent.each(linkers)( REMOVED(1, linker === "hoisted" ? 4 : 3), ]); expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); + expect(tree(dir)).toEqual(pruned); const silent = await prune(dir, "--filter", "app", "--filter", "nope", "--silent", "--linker", linker); expect(silent.stdout).toBe(""); - expect(silent.stderr).toBe(""); - expect(silent.exitCode).toBe(0); + expectOk(silent); + expect(tree(dir)).toEqual(pruned); }, ); @@ -1868,19 +2155,28 @@ test.concurrent.each(["hoisted", "isolated"] as Linker[])( }; const [prodDir, omitDir] = await Promise.all([setupWithLinker(linker, pkg), setupWithLinker(linker, pkg)]); const removed = (row: string) => [BANNER, "", row, REMOVED(1, linker === "hoisted" ? 3 : 6)]; + // one-fixed-dep wants the no-deps@1.0.0 the root has, so the tree is flat under both linkers. + const versions = { "a-dep": "1.0.1", "no-deps": "1.0.0", "one-fixed-dep": "1.0.0" }; + const installedWith = (...names: (keyof typeof versions)[]) => + linker === "hoisted" + ? { "node_modules": names } + : { + "node_modules": names.map(name => `${name} (link)`), + "node_modules/.bun": names.map(name => `${name}@${versions[name]}`), + "node_modules/.bun/node_modules": names.map(name => `${name} (link)`), + }; + expect(tree(prodDir)).toEqual(installedWith("a-dep", "no-deps", "one-fixed-dep")); + expect(tree(omitDir)).toEqual(installedWith("a-dep", "no-deps", "one-fixed-dep")); const production = await prune(prodDir, "--production", "--linker", linker); expect(lines(production.stdout)).toStrictEqual(removed("- one-fixed-dep@1.0.0")); - expect(production.exitCode).toBe(0); - expect(existsSync(join(prodDir, "node_modules", "a-dep", "package.json"))).toBeTrue(); - expect(existsSync(join(prodDir, "node_modules", "no-deps", "package.json"))).toBeTrue(); + expectOk(production); + expect(tree(prodDir)).toEqual(installedWith("a-dep", "no-deps")); const omit = await prune(omitDir, "--omit=optional", "--linker", linker); expect(lines(omit.stdout)).toStrictEqual(removed("- a-dep@1.0.1")); - expect(omit.exitCode).toBe(0); - expect(() => lstatSync(join(omitDir, "node_modules", "a-dep"))).toThrow(); - expect(existsSync(join(omitDir, "node_modules", "no-deps", "package.json"))).toBeTrue(); - expect(existsSync(join(omitDir, "node_modules", "one-fixed-dep", "package.json"))).toBeTrue(); + expectOk(omit); + expect(tree(omitDir)).toEqual(installedWith("no-deps", "one-fixed-dep")); }, ); @@ -1891,14 +2187,13 @@ test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])( name: "foo", dependencies: { "no-deps": "1.0.0", "test-postinstall-skip-native": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - const native = join(nm, "test-postinstall-skip-native"); - expect(existsSync(native)).toBeTrue(); + const installed = { "node_modules": ["no-deps", "test-postinstall-skip-native"] }; + expect(tree(dir)).toEqual(installed); const host = await prune(dir); - expect(out(host.stdout)).toEndWith(NOTHING(2, 1)); - expect(host.exitCode).toBe(0); - expect(existsSync(native)).toBeTrue(); + expect(lines(host.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 1)]); + expectOk(host); + expect(tree(dir)).toEqual(installed); const other = await prune(dir, flag); expect(out(other.stdout)).toMatchInlineSnapshot(` @@ -1907,10 +2202,10 @@ test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])( - test-postinstall-skip-native@1.0.0 1 package removed (checked 2)" `); - expect(other.exitCode).toBe(0); - expect(existsSync(native)).toBeFalse(); - expect(existsSync(join(nm, "no-deps"))).toBeTrue(); + expectOk(other); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); expect(await install(dir, flag)).toContain("no changes"); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); }, ); @@ -1921,27 +2216,29 @@ test.concurrent.each(["hoisted", "isolated"] as Linker[])( name: "foo", dependencies: { "my-alias": "npm:no-deps@1.0.0", "one-dep": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - expect(await file(join(nm, "my-alias", "package.json")).json()).toMatchObject({ - name: "no-deps", - version: "1.0.0", - }); - const junk = plant(dir, "node_modules/junk"); + const aliasPkgJson = file(join(dir, "node_modules", "my-alias", "package.json")); + expect(await aliasPkgJson.json()).toMatchObject({ name: "no-deps", version: "1.0.0" }); + plant(dir, "node_modules/junk"); + // one-dep pulls in no-deps@1.0.1 under its real name; the alias resolves to no-deps@1.0.0 next to it. + const isolated = { + "node_modules/.bun": ["no-deps@1.0.0", "no-deps@1.0.1", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)", "one-dep (link)"], + }; + expect(tree(dir)).toEqual( + linker === "hoisted" + ? { "node_modules": ["junk", "my-alias", "no-deps", "one-dep"] } + : { ...isolated, "node_modules": ["junk", "my-alias (link)", "one-dep (link)"] }, + ); - const { stdout, exitCode } = await prune(dir, "--linker", linker); - expect(out(stdout)).toBe( - `bun prune ()\n\n- junk\n1 package removed (checked ${linker === "hoisted" ? 4 : 6})`, + const result = await prune(dir, "--linker", linker); + expect(lines(result.stdout)).toStrictEqual([BANNER, "", "- junk", REMOVED(1, linker === "hoisted" ? 4 : 6)]); + expectOk(result); + expect(tree(dir)).toEqual( + linker === "hoisted" + ? { "node_modules": ["my-alias", "no-deps", "one-dep"] } + : { ...isolated, "node_modules": ["my-alias (link)", "one-dep (link)"] }, ); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(await file(join(nm, "my-alias", "package.json")).json()).toMatchObject({ - name: "no-deps", - version: "1.0.0", - }); - if (linker === "isolated") { - expect(existsSync(join(nm, ".bun", "no-deps@1.0.0"))).toBeTrue(); - expect(existsSync(join(nm, ".bun", "no-deps@1.0.1"))).toBeTrue(); - } + expect(await aliasPkgJson.json()).toMatchObject({ name: "no-deps", version: "1.0.0" }); }, ); @@ -1951,14 +2248,20 @@ test.concurrent("isolated + publicHoistPattern: hoisted links follow their store { name: "foo", dependencies: { "no-deps": "1.0.0" }, devDependencies: { "one-dep": "1.0.0" } }, { publicHoistPattern: ["no-deps"], hoistPattern: ["one-dep"] }, ); - const nm = join(dir, "node_modules"); - const store = join(nm, ".bun"); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - expect(isSymlink(join(store, "node_modules", "one-dep"))).toBeTrue(); + // hoistPattern narrows the hidden hoist folder to one-dep; the root's own no-deps link doubles as the public hoist. + const installed = { + "node_modules": ["no-deps (link)", "one-dep (link)"], + "node_modules/.bun": ["no-deps@1.0.0", "no-deps@1.0.1", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": ["one-dep (link)"], + }; + expect(tree(dir)).toEqual(installed); + const rootNoDeps = file(join(dir, "node_modules", "no-deps", "package.json")); + expect(await rootNoDeps.json()).toMatchObject({ version: "1.0.0" }); const clean = await prune(dir, "--linker", "isolated"); - expect(out(clean.stdout)).toEndWith(NOTHING(5, 2)); - expect(clean.exitCode).toBe(0); + expect(lines(clean.stdout)).toStrictEqual([BANNER, "", NOTHING(5, 2)]); + expectOk(clean); + expect(tree(dir)).toEqual(installed); const production = await prune(dir, "--production", "--linker", "isolated"); expect(out(production.stdout)).toMatchInlineSnapshot(` @@ -1968,20 +2271,22 @@ test.concurrent("isolated + publicHoistPattern: hoisted links follow their store - one-dep@1.0.0 2 packages removed (checked 5)" `); - expect(production.exitCode).toBe(0); - expect(() => lstatSync(join(store, "node_modules", "one-dep"))).toThrow(); - expect(() => lstatSync(join(nm, "one-dep"))).toThrow(); - expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.0" }); + expectOk(production); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": [], + }); + expect(await rootNoDeps.json()).toMatchObject({ version: "1.0.0" }); }); test.concurrent.skipIf(isWindows || process.getuid?.() === 0)( "a failed deletion is reported, the rest is removed, exit code 1", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const nm = join(dir, "node_modules"); - const junkA = plant(dir, "node_modules/junk-a"); + plant(dir, "node_modules/junk-a"); const inner = plant(dir, "node_modules/junk-b/inner"); - const junkB = join(nm, "junk-b"); + const junkB = join(dir, "node_modules", "junk-b"); chmodSync(junkB, 0o555); const failure = /^error: failed to remove node_modules\/junk-b: E[A-Z]+ \(.+\)$/; try { @@ -1995,7 +2300,7 @@ test.concurrent.skipIf(isWindows || process.getuid?.() === 0)( "1 package removed, 1 failed (checked 3)", ]); expect(exitCode).toBe(1); - expect(existsSync(junkA)).toBeFalse(); + expect(tree(dir)).toEqual({ "node_modules": ["junk-b", "no-deps"] }); expect(existsSync(inner)).toBeTrue(); // --silent still reports what could not be removed; the exit code alone would hide which entry it was. @@ -2003,20 +2308,21 @@ test.concurrent.skipIf(isWindows || process.getuid?.() === 0)( expect(silent.stdout).toBe(""); expect(normalizeBunSnapshot(silent.stderr)).toMatch(failure); expect(silent.exitCode).toBe(1); + expect(tree(dir)).toEqual({ "node_modules": ["junk-b", "no-deps"] }); expect(existsSync(inner)).toBeTrue(); } finally { chmodSync(junkB, 0o755); } - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk-b 1 package removed (checked 2)" `); - expect(exitCode).toBe(0); - expect(existsSync(junkB)).toBeFalse(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); }, ); @@ -2035,7 +2341,7 @@ test.concurrent("never runs the project's lifecycle scripts", async () => { const ran = join(dir, "ran.txt"); expect(existsSync(ran)).toBeTrue(); rmSync(ran); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); const plain = await prune(dir); expect(out(plain.stdout)).toMatchInlineSnapshot(` @@ -2044,8 +2350,8 @@ test.concurrent("never runs the project's lifecycle scripts", async () => { - junk 1 package removed (checked 3)" `); - expect(plain.exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); + expectOk(plain); + expect(tree(dir)).toEqual({ "node_modules": ["a-dep", "no-deps"] }); expect(existsSync(ran)).toBeFalse(); const production = await prune(dir, "--production"); @@ -2055,7 +2361,8 @@ test.concurrent("never runs the project's lifecycle scripts", async () => { - a-dep@1.0.1 1 package removed (checked 2)" `); - expect(production.exitCode).toBe(0); + expectOk(production); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); expect(existsSync(ran)).toBeFalse(); }); @@ -2064,27 +2371,37 @@ test.concurrent.each(["hoisted", "isolated"] as Linker[])( async linker => { const pkg = { name: "foo", dependencies: { "peer-deps-fixed": "1.0.0" } }; const [dir, plainDir] = await Promise.all([setupWithLinker(linker, pkg), setupWithLinker(linker, pkg)]); - const nm = join(dir, "node_modules"); - const installedNoDeps = () => - linker === "hoisted" - ? existsSync(join(nm, "no-deps")) - : readdirSync(join(nm, ".bun")).some(name => name.startsWith("no-deps@")); - expect(existsSync(join(nm, "peer-deps-fixed", "package.json"))).toBeTrue(); - expect(installedNoDeps()).toBeTrue(); + // The peer is auto-installed as the newest no-deps 1.x. Under isolated its consumer's entry carries a peer hash. + const expected = (project: string, withPeer: boolean) => { + if (linker === "hoisted") { + return { "node_modules": withPeer ? ["no-deps", "peer-deps-fixed"] : ["peer-deps-fixed"] }; + } + const consumer = storeEntries(project).find(entry => entry.startsWith("peer-deps-fixed@"))!; + expect(consumer).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/); + return { + "node_modules": ["peer-deps-fixed (link)"], + "node_modules/.bun": withPeer ? ["no-deps@1.1.0", consumer] : [consumer], + "node_modules/.bun/node_modules": withPeer + ? ["no-deps (link)", "peer-deps-fixed (link)"] + : ["peer-deps-fixed (link)"], + }; + }; + expect(tree(dir)).toEqual(expected(dir, true)); + expect(tree(plainDir)).toEqual(expected(plainDir, true)); const omit = await prune(dir, "--omit=peer", "--linker", linker); expect(lines(omit.stdout)).toStrictEqual([BANNER, "", "- no-deps@1.1.0", REMOVED(1, linker === "hoisted" ? 2 : 3)]); - expect(omit.exitCode).toBe(0); - expect(installedNoDeps()).toBeFalse(); - expect(() => lstatSync(join(nm, "no-deps"))).toThrow(); - expect(existsSync(join(nm, "peer-deps-fixed", "package.json"))).toBeTrue(); + expectOk(omit); + expect(tree(dir)).toEqual(expected(dir, false)); if (linker === "hoisted") { expect(await install(dir, "--omit=peer")).toContain("no changes"); + expect(tree(dir)).toEqual(expected(dir, false)); } const plain = await prune(plainDir, "--linker", linker); - expect(out(plain.stdout)).toEndWith(linker === "hoisted" ? NOTHING(2, 1) : NOTHING(3, 2)); - expect(plain.exitCode).toBe(0); + expect(lines(plain.stdout)).toStrictEqual([BANNER, "", linker === "hoisted" ? NOTHING(2, 1) : NOTHING(3, 2)]); + expectOk(plain); + expect(tree(plainDir)).toEqual(expected(plainDir, true)); }, ); @@ -2097,36 +2414,35 @@ test.concurrent.each(["peer-deps-fixed", "optional-peer-deps"])( dependencies: { [consumer]: "1.0.0" }, devDependencies: { "no-deps": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - const store = join(nm, ".bun"); const [entry, ...rest] = storeEntries(dir).filter(name => name.startsWith(`${consumer}@`)); expect(rest).toStrictEqual([]); expect(entry).toMatch(new RegExp(`^${consumer}@1\\.0\\.0\\+[0-9a-f]{16}$`)); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.0", entry]); - expect(isSymlink(join(nm, "no-deps"))).toBeTrue(); - const peerLink = join(store, entry, "node_modules", "no-deps", "package.json"); + const store = { + "node_modules/.bun": ["no-deps@1.0.0", entry], + "node_modules/.bun/node_modules": ["no-deps (link)", `${consumer} (link)`], + }; + expect(tree(dir)).toEqual({ ...store, "node_modules": ["no-deps (link)", `${consumer} (link)`] }); + const peerLink = join(dir, "node_modules", ".bun", entry, "node_modules", "no-deps", "package.json"); expect(existsSync(peerLink)).toBeTrue(); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout).split("\n")).toStrictEqual([ - "bun prune ()", - "", - "- no-deps@1.0.0", - "1 package removed (checked 4)", - ]); - expect(exitCode).toBe(0); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.0", entry]); - expect(() => lstatSync(join(nm, "no-deps"))).toThrow(); + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(lines(result.stdout)).toStrictEqual([BANNER, "", "- no-deps@1.0.0", REMOVED(1, 4)]); + expectOk(result); + const production = { ...store, "node_modules": [`${consumer} (link)`] }; + expect(tree(dir)).toEqual(production); expect(existsSync(peerLink)).toBeTrue(); - expect(await file(join(nm, consumer, "package.json")).json()).toMatchObject({ name: consumer, version: "1.0.0" }); + expect(await file(join(dir, "node_modules", consumer, "package.json")).json()).toMatchObject({ + name: consumer, + version: "1.0.0", + }); await expectProductionInstallIsNoop(dir); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.0", entry]); - expect(() => lstatSync(join(nm, "no-deps"))).toThrow(); + expect(tree(dir)).toEqual(production); const again = await prune(dir, "--production", "--linker", "isolated"); - expect(out(again.stdout)).toEndWith(NOTHING(3, 2)); - expect(again.exitCode).toBe(0); + expect(lines(again.stdout)).toStrictEqual([BANNER, "", NOTHING(3, 2)]); + expectOk(again); + expect(tree(dir)).toEqual(production); }, ); @@ -2134,59 +2450,85 @@ test.concurrent.each(["peer-deps-fixed", "optional-peer-deps"])( test.concurrent( "isolated: --production keeps the entries `bun install --production` itself created, then only drops dev-only ones after a full install", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); - await write( - packageJson, - JSON.stringify({ - name: "foo", - dependencies: { "peer-deps-fixed": "1.0.0", "one-dep": "1.0.0" }, - devDependencies: { "no-deps": "2.0.0", "a-dep": "1.0.1" }, - }), + const dir = await project( + { linker: "isolated" }, + { + "package.json": JSON.stringify({ + name: "foo", + dependencies: { "peer-deps-fixed": "1.0.0", "one-dep": "1.0.0" }, + devDependencies: { "no-deps": "2.0.0", "a-dep": "1.0.1" }, + }), + }, ); await install(dir, "--lockfile-only", "--linker", "isolated"); await install(dir, "--production", "--linker", "isolated"); - const nm = join(dir, "node_modules"); - const store = join(nm, ".bun"); + const store = join(dir, "node_modules", ".bun"); + // Every file and link inside the store, to show the entries are left exactly as bun install made them. const storeTree = () => readdirSync(store, { recursive: true }).map(String).toSorted(); const consumerEntries = () => storeEntries(dir).filter(entry => entry.startsWith("peer-deps-fixed@")); const [productionEntry, ...rest] = consumerEntries(); expect(rest).toStrictEqual([]); expect(productionEntry).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.1", "one-dep@1.0.0", productionEntry]); - expect(() => lstatSync(join(nm, "no-deps"))).toThrow(); - const productionTree = storeTree(); + // Without the dev no-deps@2.0.0, the peer resolves to the no-deps@1.0.1 one-dep brings in. + const production = { + "node_modules": ["one-dep (link)", "peer-deps-fixed (link)"], + "node_modules/.bun": ["no-deps@1.0.1", "one-dep@1.0.0", productionEntry], + "node_modules/.bun/node_modules": ["no-deps (link)", "one-dep (link)", "peer-deps-fixed (link)"], + }; + expect(tree(dir)).toEqual(production); + const productionStore = storeTree(); const noop = await prune(dir, "--production", "--linker", "isolated"); - expect(out(noop.stdout)).toEndWith(NOTHING(5, 2)); - expect(noop.exitCode).toBe(0); - expect(storeTree()).toStrictEqual(productionTree); + expect(lines(noop.stdout)).toStrictEqual([BANNER, "", NOTHING(5, 2)]); + expectOk(noop); + expect(tree(dir)).toEqual(production); + expect(storeTree()).toStrictEqual(productionStore); await expectProductionInstallIsNoop(dir); - expect(storeTree()).toStrictEqual(productionTree); + expect(tree(dir)).toEqual(production); + expect(storeTree()).toStrictEqual(productionStore); await install(dir, "--linker", "isolated"); const [fullEntry, ...others] = consumerEntries().filter(entry => entry !== productionEntry); expect(others).toStrictEqual([]); expect(fullEntry).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/); - expect(storeEntries(dir)).toStrictEqual( - ["a-dep@1.0.1", "no-deps@1.0.1", "no-deps@2.0.0", "one-dep@1.0.0", productionEntry, fullEntry].toSorted(), - ); - expect(isSymlink(join(nm, "no-deps"))).toBeTrue(); - expect(isSymlink(join(nm, "a-dep"))).toBeTrue(); - - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout).split("\n")).toStrictEqual([ - "bun prune ()", - "", - "- a-dep@1.0.1", - "- no-deps@2.0.0", - "2 packages removed (checked 10)", - ]); - expect(exitCode).toBe(0); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.1", "one-dep@1.0.0", productionEntry, fullEntry].toSorted()); - expect(() => lstatSync(join(nm, "no-deps"))).toThrow(); - expect(() => lstatSync(join(nm, "a-dep"))).toThrow(); - expect(await file(join(nm, "peer-deps-fixed", "package.json")).json()).toMatchObject({ version: "1.0.0" }); + const hoisted = ["a-dep (link)", "no-deps (link)", "one-dep (link)", "peer-deps-fixed (link)"]; + expect(tree(dir)).toEqual({ + "node_modules": hoisted, + "node_modules/.bun": [ + "a-dep@1.0.1", + "no-deps@1.0.1", + "no-deps@2.0.0", + "one-dep@1.0.0", + ...[productionEntry, fullEntry].toSorted(), + ], + "node_modules/.bun/node_modules": hoisted, + }); + // The full install does not always move the hidden hoist's no-deps link from the 1.0.1 the production install + // put there to the new 2.0.0. prune removes the link along with 2.0.0 and otherwise leaves it; it never dangles. + const hoistHeldByDev = hiddenHoistTarget(dir, "no-deps").includes("no-deps@2.0.0"); + + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(lines(result.stdout)).toStrictEqual([BANNER, "", "- a-dep@1.0.1", "- no-deps@2.0.0", REMOVED(2, 10)]); + expectOk(result); + const afterFullInstall = { + "node_modules": ["one-dep (link)", "peer-deps-fixed (link)"], + "node_modules/.bun": ["no-deps@1.0.1", "one-dep@1.0.0", ...[productionEntry, fullEntry].toSorted()], + "node_modules/.bun/node_modules": [ + ...(hoistHeldByDev ? [] : ["no-deps (link)"]), + "one-dep (link)", + "peer-deps-fixed (link)", + ], + }; + expect(tree(dir)).toEqual(afterFullInstall); + expect(await file(join(dir, "node_modules", "peer-deps-fixed", "package.json")).json()).toMatchObject({ + version: "1.0.0", + }); + // The no-op install still refreshes the hidden hoist links, so both cases end up with a link to 1.0.1. await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual({ + ...afterFullInstall, + "node_modules/.bun/node_modules": ["no-deps (link)", "one-dep (link)", "peer-deps-fixed (link)"], + }); }, ); @@ -2197,64 +2539,75 @@ test.concurrent("isolated: --production removes the stale peer-hash variant and devDependencies: { "a-dep": "1.0.1" }, }); const dir = await setupWithLinker("isolated", deps("1.0.0")); - const store = join(dir, "node_modules", ".bun"); const peerEntries = () => storeEntries(dir).filter(entry => entry.startsWith("peer-deps-fixed@")); const [before] = peerEntries(); expect(before).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/); + const linked = ["a-dep (link)", "no-deps (link)", "peer-deps-fixed (link)"]; + expect(tree(dir)).toEqual({ + "node_modules": linked, + "node_modules/.bun": ["a-dep@1.0.1", "no-deps@1.0.0", before], + "node_modules/.bun/node_modules": linked, + }); await write(join(dir, "package.json"), JSON.stringify(deps("1.0.1"))); await install(dir, "--linker", "isolated"); const variants = peerEntries(); expect(variants).toHaveLength(2); const after = variants.find(entry => entry !== before)!; - expect(storeEntries(dir)).toStrictEqual(["a-dep@1.0.1", "no-deps@1.0.0", "no-deps@1.0.1", ...variants].toSorted()); + expect(tree(dir)).toEqual({ + "node_modules": linked, + "node_modules/.bun": ["a-dep@1.0.1", "no-deps@1.0.0", "no-deps@1.0.1", ...variants], + "node_modules/.bun/node_modules": linked, + }); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout).split("\n")).toStrictEqual([ - "bun prune ()", + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(lines(result.stdout)).toStrictEqual([ + BANNER, "", "- a-dep@1.0.1", "- no-deps@1.0.0", `- ${before}`, - "3 packages removed (checked 8)", + REMOVED(3, 8), ]); - expect(exitCode).toBe(0); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.1", after]); - expect(await file(join(store, "no-deps@1.0.1", "node_modules", "no-deps", "package.json")).json()).toMatchObject({ - version: "1.0.1", - }); + expectOk(result); + const production = { + "node_modules": ["no-deps (link)", "peer-deps-fixed (link)"], + "node_modules/.bun": ["no-deps@1.0.1", after], + "node_modules/.bun/node_modules": ["no-deps (link)", "peer-deps-fixed (link)"], + }; + expect(tree(dir)).toEqual(production); + expect(await file(join(dir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.1" }); await expectProductionInstallIsNoop(dir); - expect(storeEntries(dir)).toStrictEqual(["no-deps@1.0.1", after]); + expect(tree(dir)).toEqual(production); }); test.concurrent("keeps dependencies bundled inside a file: dependency", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir(); - await Promise.all([ - write(packageJson, JSON.stringify({ name: "foo", dependencies: { local: "file:./local" } })), - write( - join(dir, "local", "package.json"), - JSON.stringify({ name: "local", version: "1.0.0", bundleDependencies: ["inner"] }), - ), - write( - join(dir, "local", "node_modules", "inner", "package.json"), - JSON.stringify({ name: "inner", version: "1.0.0" }), - ), - ]); - await runBunInstall(installEnv(dir), dir); - const inner = join(dir, "node_modules", "local", "node_modules", "inner", "package.json"); - expect(existsSync(inner)).toBeTrue(); - const junk = plant(dir, "node_modules/junk"); + const dir = await installed( + { linker: "hoisted" }, + { + "package.json": JSON.stringify({ name: "foo", dependencies: { local: "file:./local" } }), + "local/package.json": JSON.stringify({ name: "local", version: "1.0.0", bundleDependencies: ["inner"] }), + "local/node_modules/inner/package.json": JSON.stringify({ name: "inner", version: "1.0.0" }), + }, + ); + plant(dir, "node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["junk", "local"], + "node_modules/local/node_modules": ["inner"], + }); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk 1 package removed (checked 2)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(inner)).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": ["local"], + "node_modules/local/node_modules": ["inner"], + }); }); // pnpm#13676 @@ -2262,18 +2615,22 @@ test.concurrent( "hoisted: a nested copy is kept while the root copy is still the old version and removed once bun install replaced it", async () => { const dir = await setup({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0" } }); - const nm = join(dir, "node_modules"); - const nested = join(nm, "one-dep", "node_modules", "no-deps"); - const rootPkgJson = join(nm, "no-deps", "package.json"); - expect(await file(join(nested, "package.json")).json()).toMatchObject({ version: "1.0.1" }); + const rootPkgJson = file(join(dir, "node_modules", "no-deps", "package.json")); + const nestedPkgJson = file(join(dir, "node_modules", "one-dep", "node_modules", "no-deps", "package.json")); + expect(await nestedPkgJson.json()).toMatchObject({ version: "1.0.1" }); + const withNestedCopy = { + "node_modules": ["no-deps", "one-dep"], + "node_modules/one-dep/node_modules": ["no-deps"], + }; + expect(tree(dir)).toEqual(withNestedCopy); await write( join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "1.0.1" } }), ); await install(dir, "--lockfile-only"); - expect(existsSync(nested)).toBeTrue(); - expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); + expect(tree(dir)).toEqual(withNestedCopy); + expect(await rootPkgJson.json()).toMatchObject({ version: "2.0.0" }); // The reason the copy was kept comes before the verdict, as it would on a terminal. const stale = await pruneMerged(dir); @@ -2285,25 +2642,24 @@ test.concurrent( NOTHING(3, 2), ]); expect(stale.exitCode).toBe(0); - expect(existsSync(join(nested, "package.json"))).toBeTrue(); - expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); + expect(tree(dir)).toEqual(withNestedCopy); + expect(await rootPkgJson.json()).toMatchObject({ version: "2.0.0" }); + // bun install replaces the root copy but does not clean up the nested one it made redundant. await install(dir); - expect(await file(rootPkgJson).json()).toMatchObject({ version: "1.0.1" }); - expect(existsSync(nested)).toBeTrue(); + expect(tree(dir)).toEqual(withNestedCopy); + expect(await rootPkgJson.json()).toMatchObject({ version: "1.0.1" }); - const { stdout, stderr, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - no-deps@1.0.1 (node_modules/one-dep/node_modules) 1 package removed (checked 3)" `); - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - expect(existsSync(nested)).toBeFalse(); - expect(existsSync(join(nm, "one-dep", "package.json"))).toBeTrue(); - expect(existsSync(rootPkgJson)).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps", "one-dep"], "node_modules/one-dep/node_modules": [] }); + expect(await rootPkgJson.json()).toMatchObject({ version: "1.0.1" }); }, ); @@ -2312,32 +2668,40 @@ test.concurrent( async () => { const pkg = { name: "foo", dependencies: { "one-fixed-dep": "1.0.0" }, devDependencies: { "no-deps": "2.0.0" } }; const [dir, silentDir] = await Promise.all([setup(pkg), setup(pkg)]); - const rootPkgJson = join(dir, "node_modules", "no-deps", "package.json"); - const nestedPkgJson = join(dir, "node_modules", "one-fixed-dep", "node_modules", "no-deps", "package.json"); - expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); - expect(await file(nestedPkgJson).json()).toMatchObject({ version: "1.0.0" }); + const rootPkgJson = file(join(dir, "node_modules", "no-deps", "package.json")); + const nestedPkgJson = file(join(dir, "node_modules", "one-fixed-dep", "node_modules", "no-deps", "package.json")); + expect(await rootPkgJson.json()).toMatchObject({ version: "2.0.0" }); + expect(await nestedPkgJson.json()).toMatchObject({ version: "1.0.0" }); + const installed = { + "node_modules": ["no-deps", "one-fixed-dep"], + "node_modules/one-fixed-dep/node_modules": ["no-deps"], + }; + expect(tree(dir)).toEqual(installed); + expect(tree(silentDir)).toEqual(installed); - const { stdout, stderr, exitCode } = await prune(dir, "--production"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () Done! Checked 3 packages across 2 folders (nothing to prune)" `); - expect(out(stderr)).toBe( + expect(out(result.stderr)).toBe( `${WARN("node_modules/no-deps", "node_modules/one-fixed-dep/node_modules/no-deps")}\n${NOTE}`, ); - expect(exitCode).toBe(0); - expect(await file(nestedPkgJson).json()).toMatchObject({ version: "1.0.0" }); - expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); + expect(result.exitCode).toBe(0); + expect(tree(dir)).toEqual(installed); + expect(await nestedPkgJson.json()).toMatchObject({ version: "1.0.0" }); + expect(await rootPkgJson.json()).toMatchObject({ version: "2.0.0" }); + // The install the note asks for puts 1.0.0 at the root and leaves the nested copy behind: only then is it extraneous. await runBunInstall(installEnv(dir), dir, { production: true }); + expect(tree(dir)).toEqual(installed); + expect(await rootPkgJson.json()).toMatchObject({ version: "1.0.0" }); + expect(await nestedPkgJson.json()).toMatchObject({ version: "1.0.0" }); const silent = await prune(silentDir, "--production", "--silent"); expect(silent.stdout).toBe(""); - expect(silent.stderr).toBe(""); - expect(silent.exitCode).toBe(0); - expect( - existsSync(join(silentDir, "node_modules", "one-fixed-dep", "node_modules", "no-deps", "package.json")), - ).toBeTrue(); + expectOk(silent); + expect(tree(silentDir)).toEqual(installed); }, ); @@ -2348,18 +2712,22 @@ test.concurrent( name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0", "a-dep": "1.0.2" }, }); - const nm = join(dir, "node_modules"); - const nestedNoDeps = join(nm, "one-dep", "node_modules", "no-deps"); - expect(await file(join(nestedNoDeps, "package.json")).json()).toMatchObject({ version: "1.0.1" }); - expect(await file(join(nm, "a-dep", "package.json")).json()).toMatchObject({ version: "1.0.2" }); + const nestedNoDeps = file(join(dir, "node_modules", "one-dep", "node_modules", "no-deps", "package.json")); + const rootADep = file(join(dir, "node_modules", "a-dep", "package.json")); + expect(await nestedNoDeps.json()).toMatchObject({ version: "1.0.1" }); + expect(await rootADep.json()).toMatchObject({ version: "1.0.2" }); await write( join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0", "a-dep": "1.0.1" } }), ); await install(dir, "--lockfile-only"); - const shadowed = plant(dir, "node_modules/one-dep/node_modules/a-dep"); - const junk = plant(dir, "node_modules/one-dep/node_modules/junk"); + plant(dir, "node_modules/one-dep/node_modules/a-dep"); + plant(dir, "node_modules/one-dep/node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["a-dep", "no-deps", "one-dep"], + "node_modules/one-dep/node_modules": ["a-dep", "junk", "no-deps"], + }); const { lines: merged, exitCode } = await pruneMerged(dir); expect(merged).toStrictEqual([ @@ -2371,45 +2739,40 @@ test.concurrent( REMOVED(1, 6), ]); expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(shadowed)).toBeTrue(); - expect(await file(join(nestedNoDeps, "package.json")).json()).toMatchObject({ version: "1.0.1" }); - expect(await file(join(nm, "a-dep", "package.json")).json()).toMatchObject({ version: "1.0.2" }); + expect(tree(dir)).toEqual({ + "node_modules": ["a-dep", "no-deps", "one-dep"], + "node_modules/one-dep/node_modules": ["a-dep", "no-deps"], + }); + expect(await nestedNoDeps.json()).toMatchObject({ version: "1.0.1" }); + expect(await rootADep.json()).toMatchObject({ version: "1.0.2" }); }, ); test.concurrent( "hoisted + workspaces: --production keeps a workspace's copy while the root still holds the dev version", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } }); - await Promise.all([ - write( - packageJson, - JSON.stringify({ name: "root", workspaces: ["packages/*"], devDependencies: { "no-deps": "2.0.0" } }), - ), - write( - join(dir, "packages", "a", "package.json"), - JSON.stringify({ name: "a", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }), - ), - ]); - await install(dir, "--linker", "hoisted"); - const nm = join(dir, "node_modules"); - const rootPkgJson = join(nm, "no-deps", "package.json"); - const workspacePkgJson = join(dir, "packages", "a", "node_modules", "no-deps", "package.json"); - expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); - expect(await file(workspacePkgJson).json()).toMatchObject({ version: "1.0.0" }); - - const { stdout, stderr, exitCode } = await prune(dir, "--production", "--linker", "hoisted"); - expect(out(stdout)).toMatchInlineSnapshot(` + const dir = await setupWorkspaces("hoisted", { + root: { devDependencies: { "no-deps": "2.0.0" } }, + packages: { a: { dependencies: { "no-deps": "1.0.0" } } }, + }); + const rootPkgJson = file(join(dir, "node_modules", "no-deps", "package.json")); + const workspacePkgJson = file(join(dir, "packages", "a", "node_modules", "no-deps", "package.json")); + expect(await rootPkgJson.json()).toMatchObject({ version: "2.0.0" }); + expect(await workspacePkgJson.json()).toMatchObject({ version: "1.0.0" }); + const installed = { "node_modules": ["a (link)", "no-deps"], "packages/a/node_modules": ["no-deps"] }; + expect(tree(dir)).toEqual(installed); + + const result = await prune(dir, "--production", "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () Done! Checked 3 packages across 2 folders (nothing to prune)" `); - expect(out(stderr)).toBe(`${WARN("node_modules/no-deps", "packages/a/node_modules/no-deps")}\n${NOTE}`); - expect(exitCode).toBe(0); - expect(await file(workspacePkgJson).json()).toMatchObject({ version: "1.0.0" }); - expect(isSymlink(join(nm, "a"))).toBeTrue(); - expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" }); + expect(out(result.stderr)).toBe(`${WARN("node_modules/no-deps", "packages/a/node_modules/no-deps")}\n${NOTE}`); + expect(result.exitCode).toBe(0); + expect(tree(dir)).toEqual(installed); + expect(await workspacePkgJson.json()).toMatchObject({ version: "1.0.0" }); + expect(await rootPkgJson.json()).toMatchObject({ version: "2.0.0" }); }, ); @@ -2422,11 +2785,12 @@ test.concurrent( dependencies: { "uses-what-bin": "1.0.0" }, devDependencies: { "what-bin": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - const store = join(nm, ".bun"); - expect(isSymlink(join(nm, "what-bin"))).toBeTrue(); - expect(existsSync(join(store, "what-bin@1.0.0"))).toBeTrue(); - expectBinInstalled(nm, "what-bin"); + const store = { + "node_modules/.bun": ["uses-what-bin@1.0.0", "what-bin@1.0.0"], + "node_modules/.bun/node_modules": ["uses-what-bin (link)", "what-bin (link)"], + }; + const installed = { ...store, "node_modules": [".bin/what-bin", "uses-what-bin (link)", "what-bin (link)"] }; + expect(tree(dir)).toEqual(installed); const dryRun = await prune(dir, "--production", "--dry-run", "--linker", "isolated"); expect(out(dryRun.stdout)).toMatchInlineSnapshot(` @@ -2436,28 +2800,29 @@ test.concurrent( 1 package can be removed (checked 4) bun prune --production --linker isolated" `); - expect(dryRun.exitCode).toBe(0); - expect(isSymlink(join(nm, "what-bin"))).toBeTrue(); - expectBinInstalled(nm, "what-bin"); + expectOk(dryRun); + expect(tree(dir)).toEqual(installed); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - what-bin@1.0.0 1 package removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(() => lstatSync(join(nm, "what-bin"))).toThrow(); - expect(existsSync(join(store, "what-bin@1.0.0"))).toBeTrue(); - expect(existsSync(join(store, "uses-what-bin@1.0.0", "node_modules", "what-bin", "package.json"))).toBeTrue(); - expect(existsSync(join(nm, "uses-what-bin", "package.json"))).toBeTrue(); - expectBinRemoved(nm, "what-bin"); + expectOk(result); + // The root link and its bin go; the store keeps what-bin for uses-what-bin, so its hidden hoist link stays too. + const production = { ...store, "node_modules": [".bin", "uses-what-bin (link)"] }; + expect(tree(dir)).toEqual(production); + const usesWhatBin = join(dir, "node_modules", ".bun", "uses-what-bin@1.0.0", "node_modules"); + expect(existsSync(join(usesWhatBin, "what-bin", "package.json"))).toBeTrue(); const again = await prune(dir, "--production", "--linker", "isolated"); - expect(out(again.stdout)).toEndWith(NOTHING(3, 2)); - expect(again.exitCode).toBe(0); + expect(lines(again.stdout)).toStrictEqual([BANNER, "", NOTHING(3, 2)]); + expectOk(again); + expect(tree(dir)).toEqual(production); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(production); }, ); @@ -2467,66 +2832,75 @@ test.concurrent("hoisted: nested node_modules of packages without a tree node ar name: "foo", dependencies: { "no-deps": "1.0.0", "@scoped/has-bin-entry": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - const junk = plant(dir, "node_modules/no-deps/node_modules/junk"); - const scopedJunk = plant(dir, "node_modules/@scoped/has-bin-entry/node_modules/@other/thing"); - writeFileSync(join(nm, "no-deps", "node_modules", "keep.txt"), ""); + plant(dir, "node_modules/no-deps/node_modules/junk"); + plant(dir, "node_modules/@scoped/has-bin-entry/node_modules/@other/thing"); + writeFileSync(join(dir, "node_modules", "no-deps", "node_modules", "keep.txt"), ""); + expect(tree(dir)).toEqual({ + "node_modules": [".bin/has-bin-entry", "@scoped/has-bin-entry", "no-deps"], + "node_modules/@scoped/has-bin-entry/node_modules": ["@other/thing"], + "node_modules/no-deps/node_modules": ["junk", "keep.txt"], + }); - const { stdout, exitCode } = await prune(dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - @other/thing (node_modules/@scoped/has-bin-entry/node_modules) - junk (node_modules/no-deps/node_modules) 2 packages removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(scopedJunk)).toBeFalse(); - expect(existsSync(join(nm, "@scoped", "has-bin-entry", "node_modules", "@other"))).toBeFalse(); - expect(existsSync(join(nm, "no-deps", "node_modules", "keep.txt"))).toBeTrue(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - expect(existsSync(join(nm, "@scoped", "has-bin-entry", "package.json"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": [".bin/has-bin-entry", "@scoped/has-bin-entry", "no-deps"], + "node_modules/@scoped/has-bin-entry/node_modules": [], + "node_modules/no-deps/node_modules": ["keep.txt"], + }); }); // pnpm#8307 test.concurrent("refuses to prune a hoisted install with the isolated linker", async () => { const dir = await setupWithLinker("hoisted", { name: "foo", dependencies: { "one-dep": "1.0.0" } }); - const nm = join(dir, "node_modules"); - const hoisted = join(nm, "no-deps", "package.json"); - expect(existsSync(hoisted)).toBeTrue(); - expect(existsSync(join(nm, ".bun"))).toBeFalse(); + const installed = { "node_modules": ["no-deps", "one-dep"] }; + expect(tree(dir)).toEqual(installed); for (const flags of [ ["--linker", "isolated"], ["--linker", "isolated", "--dry-run"], ]) { const { stdout, stderr, exitCode } = await prune(dir, ...flags); - expect(out(stdout)).toMatchInlineSnapshot(`"bun prune ()"`); - expect(stderr).toContain("node_modules was installed with the hoisted linker"); - expect(stderr).toContain("bun prune --linker hoisted"); + expect(out(stdout)).toBe(BANNER); + expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(` + "error: node_modules was installed with the hoisted linker, but bun prune would use the isolated linker + note: run 'bun prune --linker hoisted' to prune it as-is, or 'bun install' to reinstall with the isolated linker" + `); expect(exitCode).toBe(1); - expect(existsSync(hoisted)).toBeTrue(); + expect(tree(dir)).toEqual(installed); } const same = await prune(dir, "--linker", "hoisted"); - expect(out(same.stdout)).toEndWith(NOTHING(2, 1)); - expect(same.exitCode).toBe(0); + expect(lines(same.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 1)]); + expectOk(same); + expect(tree(dir)).toEqual(installed); }); // pnpm#8307 test.concurrent("refuses to prune an isolated install with the hoisted linker", async () => { const dir = await setupWithLinker("isolated", { name: "foo", devDependencies: { "one-dep": "1.0.0" } }); - const nm = join(dir, "node_modules"); - expect(isSymlink(join(nm, "one-dep"))).toBeTrue(); + const installed = { + "node_modules": ["one-dep (link)"], + "node_modules/.bun": ["no-deps@1.0.1", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)", "one-dep (link)"], + }; + expect(tree(dir)).toEqual(installed); const mismatch = await prune(dir, "--production", "--linker", "hoisted"); - expect(out(mismatch.stdout)).toMatchInlineSnapshot(`"bun prune ()"`); - expect(mismatch.stderr).toContain("node_modules was installed with the isolated linker"); - expect(mismatch.stderr).toContain("bun prune --linker isolated"); + expect(out(mismatch.stdout)).toBe(BANNER); + expect(normalizeBunSnapshot(mismatch.stderr)).toMatchInlineSnapshot(` + "error: node_modules was installed with the isolated linker, but bun prune would use the hoisted linker + note: run 'bun prune --linker isolated' to prune it as-is, or 'bun install' to reinstall with the hoisted linker" + `); expect(mismatch.exitCode).toBe(1); - expect(isSymlink(join(nm, "one-dep"))).toBeTrue(); - expect(existsSync(join(nm, "one-dep", "package.json"))).toBeTrue(); + expect(tree(dir)).toEqual(installed); const same = await prune(dir, "--production", "--linker", "isolated"); expect(out(same.stdout)).toMatchInlineSnapshot(` @@ -2536,8 +2910,12 @@ test.concurrent("refuses to prune an isolated install with the hoisted linker", - one-dep@1.0.0 2 packages removed (checked 3)" `); - expect(same.exitCode).toBe(0); - expect(() => lstatSync(join(nm, "one-dep"))).toThrow(); + expectOk(same); + expect(tree(dir)).toEqual({ + "node_modules": [], + "node_modules/.bun": [], + "node_modules/.bun/node_modules": [], + }); }); // pnpm#5960 @@ -2549,19 +2927,45 @@ test.concurrent.each(["hoisted", "isolated"] as Linker[])( dependencies: { aliased: "npm:no-deps@1.0.0" }, devDependencies: { "no-deps": "2.0.0" }, }); - const nm = join(dir, "node_modules"); - expect(await file(join(nm, "aliased", "package.json")).json()).toMatchObject({ version: "1.0.0" }); - expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); + const aliasPkgJson = file(join(dir, "node_modules", "aliased", "package.json")); + expect(await aliasPkgJson.json()).toMatchObject({ version: "1.0.0" }); + expect(await file(join(dir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); + expect(tree(dir)).toEqual( + linker === "hoisted" + ? { "node_modules": ["aliased", "no-deps"] } + : { + "node_modules": ["aliased (link)", "no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0", "no-deps@2.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }, + ); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", linker); - expect(lines(stdout)).toStrictEqual([BANNER, "", "- no-deps@2.0.0", REMOVED(1, linker === "hoisted" ? 2 : 4)]); - expect(exitCode).toBe(0); - expect(() => lstatSync(join(nm, "no-deps"))).toThrow(); - expect(await file(join(nm, "aliased", "package.json")).json()).toMatchObject({ version: "1.0.0" }); - if (linker === "isolated") { - expect(existsSync(join(nm, ".bun", "no-deps@1.0.0"))).toBeTrue(); - } + // Both versions are direct dependencies, and which one bun install gives the hidden hoist link to varies from + // install to install. prune removes the link along with 2.0.0 and otherwise leaves it; it never dangles. + const hoistHeldByDev = linker === "isolated" && hiddenHoistTarget(dir, "no-deps").includes("no-deps@2.0.0"); + + const result = await prune(dir, "--production", "--linker", linker); + expect(lines(result.stdout)).toStrictEqual([ + BANNER, + "", + "- no-deps@2.0.0", + REMOVED(1, linker === "hoisted" ? 2 : 4), + ]); + expectOk(result); + const production = { + "node_modules": ["aliased (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual( + linker === "hoisted" + ? { "node_modules": ["aliased"] } + : { ...production, "node_modules/.bun/node_modules": hoistHeldByDev ? [] : ["no-deps (link)"] }, + ); + expect(await aliasPkgJson.json()).toMatchObject({ version: "1.0.0" }); + // The no-op install still refreshes the hidden hoist links, so both cases end up with a link to 1.0.0. await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(linker === "hoisted" ? { "node_modules": ["aliased"] } : production); }, ); @@ -2569,20 +2973,27 @@ test.concurrent.each(["hoisted", "isolated"] as Linker[])( test.concurrent.each(["hoisted", "isolated"] as Linker[])( "%s: the dangling link of a renamed workspace is removed, the renamed workspace is not", async linker => { - const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker } }); const appJson = (lib: string) => JSON.stringify({ name: "app", version: "1.0.0", dependencies: { [lib]: "workspace:*" } }); const libJson = (name: string) => JSON.stringify({ name, version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }); - await Promise.all([ - write(packageJson, JSON.stringify({ name: "root", workspaces: ["packages/*"] })), - write(join(dir, "packages", "app", "package.json"), appJson("a")), - write(join(dir, "packages", "a", "package.json"), libJson("a")), - ]); - await install(dir, "--linker", linker); + const dir = await setupWorkspaces(linker, { + packages: { app: { dependencies: { a: "workspace:*" } }, a: { dependencies: { "no-deps": "1.0.0" } } }, + }); // The hoisted linker links every workspace into the root folder; the isolated linker links it where it is depended on. - const linkFolder = linker === "hoisted" ? "node_modules" : "packages/app/node_modules"; - const staleLink = join(dir, linkFolder, "a"); - expect(isSymlink(staleLink)).toBeTrue(); + const isolatedStore = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual( + linker === "hoisted" + ? { "node_modules": ["a (link)", "app (link)", "no-deps"] } + : { + ...isolatedStore, + "packages/a/node_modules": ["no-deps (link)"], + "packages/app/node_modules": ["a (link)"], + }, + ); renameSync(join(dir, "packages", "a"), join(dir, "packages", "b")); await Promise.all([ @@ -2590,64 +3001,73 @@ test.concurrent.each(["hoisted", "isolated"] as Linker[])( write(join(dir, "packages", "app", "package.json"), appJson("b")), ]); await install(dir, "--lockfile-only", "--linker", linker); - expect(isSymlink(staleLink)).toBeTrue(); - expect(existsSync(staleLink)).toBeFalse(); + // b's own links are relative, so they survive the rename; only the links to the old name dangle. + const renamed = + linker === "hoisted" + ? { "node_modules": ["app (link)", "no-deps"] } + : { ...isolatedStore, "packages/b/node_modules": ["no-deps (link)"] }; + expect(tree(dir)).toEqual( + linker === "hoisted" + ? { "node_modules": ["a (dangling link)", "app (link)", "no-deps"] } + : { ...renamed, "packages/app/node_modules": ["a (dangling link)"] }, + ); - const { stdout, exitCode } = await prune(dir, "--linker", linker); + const result = await prune(dir, "--linker", linker); // A dangling link has no package.json to read a version from; only a non-root folder is named. - const row = linker === "hoisted" ? "- a" : `- a (${linkFolder})`; - expect(lines(stdout)).toStrictEqual([BANNER, "", row, REMOVED(1, 3)]); - expect(exitCode).toBe(0); - expect(() => lstatSync(staleLink)).toThrow(); - expect(existsSync(join(dir, "packages", "b", "package.json"))).toBeTrue(); - const noDeps = - linker === "hoisted" - ? join(dir, "node_modules", "no-deps") - : join(dir, "packages", "b", "node_modules", "no-deps"); - expect(existsSync(join(noDeps, "package.json"))).toBeTrue(); + const row = linker === "hoisted" ? "- a" : "- a (packages/app/node_modules)"; + expect(lines(result.stdout)).toStrictEqual([BANNER, "", row, REMOVED(1, 3)]); + expectOk(result); + expect(tree(dir)).toEqual(linker === "hoisted" ? renamed : { ...renamed, "packages/app/node_modules": [] }); + expect(await file(join(dir, "packages", "b", "package.json")).json()).toMatchObject({ name: "b" }); }, ); test.concurrent( "isolated: store entries of file:, tarball and git dependencies are kept under their non-npm keys", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); - const gitPkg = await gitDependency(dir, "git-pkg"); - await Promise.all([ - write( - packageJson, - JSON.stringify({ - name: "foo", - dependencies: { - "no-deps": "1.0.0", - "local": "file:./local", - "left-pad": copyTarball(dir, "left-pad", "1.0.0"), - "git-pkg": gitPkg, - }, - }), - ), - write(join(dir, "local", "package.json"), JSON.stringify({ name: "local", version: "1.0.0" })), - ]); - await install(dir, "--linker", "isolated"); - const nm = join(dir, "node_modules"); - const installed = storeEntries(dir); - expect(installed).toStrictEqual([ + const dir = await project( + { linker: "isolated" }, + { "local/package.json": JSON.stringify({ name: "local", version: "1.0.0" }) }, + { own: true }, + ); + await write( + join(dir, "package.json"), + JSON.stringify({ + name: "foo", + dependencies: { + "no-deps": "1.0.0", + "local": "file:./local", + "left-pad": copyTarball(dir, "left-pad", "1.0.0"), + "git-pkg": await gitDependency(dir, "git-pkg"), + }, + }), + ); + await install(dir); + const store = storeEntries(dir); + expect(store).toStrictEqual([ expect.stringMatching(/^git-pkg@git\+/), expect.stringMatching(/^left-pad@/), "local@file+local", "no-deps@1.0.0", ]); - expect(installed[1]).not.toBe("left-pad@1.0.0"); - for (const name of ["no-deps", "local", "left-pad", "git-pkg"]) { - expect(isSymlink(join(nm, name))).toBeTrue(); - } + expect(store[1]).not.toBe("left-pad@1.0.0"); + // bun install gives the file: folder dependency no hidden hoist link. + const installed = { + "node_modules": ["git-pkg (link)", "left-pad (link)", "local (link)", "no-deps (link)"], + "node_modules/.bun": store, + "node_modules/.bun/node_modules": ["git-pkg (link)", "left-pad (link)", "no-deps (link)"], + }; + expect(tree(dir)).toEqual(installed); plant(dir, "node_modules/.bun/junk@1.0.0/node_modules/junk"); plant(dir, "node_modules/.bun/local@file+elsewhere/node_modules/local"); plant(dir, "node_modules/.bun/git-pkg@1.0.0/node_modules/git-pkg"); + expect(tree(dir)).toEqual({ + ...installed, + "node_modules/.bun": [...store, "git-pkg@1.0.0", "junk@1.0.0", "local@file+elsewhere"].toSorted(), + }); - const { stdout, stderr, exitCode } = await prune(dir, "--linker", "isolated"); - expect(stderr).not.toContain("warn:"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--linker", "isolated"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - git-pkg@1.0.0 @@ -2655,120 +3075,125 @@ test.concurrent( - local@file+elsewhere 3 packages removed (checked 11)" `); - expect(exitCode).toBe(0); - expect(storeEntries(dir)).toStrictEqual(installed); - for (const name of ["no-deps", "local", "left-pad", "git-pkg"]) { - expect(existsSync(join(nm, name, "package.json"))).toBeTrue(); - } + expectOk(result); + expect(tree(dir)).toEqual(installed); const production = await prune(dir, "--production", "--linker", "isolated"); - expect(out(production.stdout)).toEndWith(NOTHING(8, 2)); - expect(production.exitCode).toBe(0); - expect(storeEntries(dir)).toStrictEqual(installed); + expect(lines(production.stdout)).toStrictEqual([BANNER, "", NOTHING(8, 2)]); + expectOk(production); + expect(tree(dir)).toEqual(installed); }, ); +const workspaceWithDevDep: Workspaces = { + packages: { a: { dependencies: { "one-dep": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } } }, +}; + test.concurrent( "workspaces: without --linker, a bun.lock with configVersion 1 is pruned with the isolated linker", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({}); - await writeWorkspaces(dir, packageJson, { - packages: { a: { dependencies: { "one-dep": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } } }, - }); - await install(dir); + const dir = await installed({}, workspaceFiles(workspaceWithDevDep)); expect(await lock(dir)).toContain('"configVersion": 1,'); - const store = join(dir, "node_modules", ".bun"); - expect(storeEntries(dir)).toStrictEqual(["a-dep@1.0.1", "no-deps@1.0.1", "one-dep@1.0.0"]); - const junk = plant(dir, "node_modules/.bun/junk@1.0.0/node_modules/junk"); + plant(dir, "node_modules/.bun/junk@1.0.0/node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": [], + "node_modules/.bun": ["a-dep@1.0.1", "junk@1.0.0", "no-deps@1.0.1", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": ["a-dep (link)", "no-deps (link)", "one-dep (link)"], + "packages/a/node_modules": ["a-dep (link)", "one-dep (link)"], + }); - const { stdout, stderr, exitCode } = await prune(dir, "--production"); - expect(stderr).not.toContain("linker"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - a-dep@1.0.1 - junk@1.0.0 2 packages removed (checked 6)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(join(store, "a-dep@1.0.1"))).toBeFalse(); - expect(existsSync(join(store, "one-dep@1.0.0"))).toBeTrue(); - expect(() => lstatSync(join(dir, "packages", "a", "node_modules", "a-dep"))).toThrow(); - expect(existsSync(join(dir, "packages", "a", "node_modules", "one-dep", "package.json"))).toBeTrue(); + expectOk(result); + const production = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.1", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)", "one-dep (link)"], + "packages/a/node_modules": ["one-dep (link)"], + }; + expect(tree(dir)).toEqual(production); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(production); }, ); test.concurrent( "workspaces: without --linker, a bun.lock without configVersion is pruned with the hoisted linker", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({}); - await writeWorkspaces(dir, packageJson, { - packages: { a: { dependencies: { "one-dep": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } } }, - }); + const dir = await project({}, workspaceFiles(workspaceWithDevDep)); await install(dir, "--linker", "hoisted"); const before = await lock(dir); expect(before).toContain('"configVersion": 1,'); await write(join(dir, "bun.lock"), before.replace(' "configVersion": 1,\n', "")); - const nm = join(dir, "node_modules"); - expect(existsSync(join(nm, ".bun"))).toBeFalse(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); plant(dir, "node_modules/.bun/junk@1.0.0/node_modules/junk"); + const planted = { + "node_modules": ["a (link)", "a-dep", "junk", "no-deps", "one-dep"], + "node_modules/.bun": ["junk@1.0.0"], + }; + expect(tree(dir)).toEqual(planted); // The refusal names the linker that was picked. const withStore = await prune(dir, "--production"); - expect(withStore.stderr).toContain("but bun prune would use the hoisted linker"); + expect(out(withStore.stdout)).toBe(BANNER); + expect(normalizeBunSnapshot(withStore.stderr)).toMatchInlineSnapshot(` + "error: node_modules was installed with the isolated linker, but bun prune would use the hoisted linker + note: run 'bun prune --linker isolated' to prune it as-is, or 'bun install' to reinstall with the hoisted linker" + `); expect(withStore.exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); - rmSync(join(nm, ".bun"), { recursive: true }); + expect(tree(dir)).toEqual(planted); + rmSync(join(dir, "node_modules", ".bun"), { recursive: true }); - const { stdout, stderr, exitCode } = await prune(dir, "--production"); - expect(stderr).toBe(""); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - a-dep@1.0.1 - junk 2 packages removed (checked 5)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(join(nm, "a-dep"))).toBeFalse(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - expect(existsSync(join(nm, "one-dep", "package.json"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["a (link)", "no-deps", "one-dep"] }); }, ); test.concurrent("without --linker, a project without workspaces is pruned with the hoisted linker", async () => { const dir = await setup({ name: "foo", dependencies: { "one-dep": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } }); expect(await lock(dir)).toContain('"configVersion": 1,'); - const nm = join(dir, "node_modules"); - expect(existsSync(join(nm, ".bun"))).toBeFalse(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); plant(dir, "node_modules/.bun/junk@1.0.0/node_modules/junk"); + const planted = { + "node_modules": ["a-dep", "junk", "no-deps", "one-dep"], + "node_modules/.bun": ["junk@1.0.0"], + }; + expect(tree(dir)).toEqual(planted); const withStore = await prune(dir, "--production"); - expect(withStore.stderr).toContain("but bun prune would use the hoisted linker"); + expect(out(withStore.stdout)).toBe(BANNER); + expect(normalizeBunSnapshot(withStore.stderr)).toMatchInlineSnapshot(` + "error: node_modules was installed with the isolated linker, but bun prune would use the hoisted linker + note: run 'bun prune --linker isolated' to prune it as-is, or 'bun install' to reinstall with the hoisted linker" + `); expect(withStore.exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); - rmSync(join(nm, ".bun"), { recursive: true }); + expect(tree(dir)).toEqual(planted); + rmSync(join(dir, "node_modules", ".bun"), { recursive: true }); - const { stdout, stderr, exitCode } = await prune(dir, "--production"); - expect(stderr).toBe(""); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - a-dep@1.0.1 - junk 2 packages removed (checked 4)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(join(nm, "a-dep"))).toBeFalse(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps", "one-dep"] }); }); test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])( @@ -2778,15 +3203,18 @@ test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])( name: "foo", dependencies: { "no-deps": "1.0.0", "test-postinstall-skip-native": "1.0.0" }, }); - const nm = join(dir, "node_modules"); - const store = join(nm, ".bun"); - expect(isSymlink(join(nm, "test-postinstall-skip-native"))).toBeTrue(); - expect(existsSync(join(store, "test-postinstall-skip-native@1.0.0"))).toBeTrue(); + const linked = ["no-deps (link)", "test-postinstall-skip-native (link)"]; + const installed = { + "node_modules": linked, + "node_modules/.bun": ["no-deps@1.0.0", "test-postinstall-skip-native@1.0.0"], + "node_modules/.bun/node_modules": linked, + }; + expect(tree(dir)).toEqual(installed); const host = await prune(dir, "--linker", "isolated"); - expect(out(host.stdout)).toEndWith(NOTHING(4, 2)); - expect(host.exitCode).toBe(0); - expect(existsSync(join(store, "test-postinstall-skip-native@1.0.0"))).toBeTrue(); + expect(lines(host.stdout)).toStrictEqual([BANNER, "", NOTHING(4, 2)]); + expectOk(host); + expect(tree(dir)).toEqual(installed); const other = await prune(dir, flag, "--linker", "isolated"); expect(out(other.stdout)).toMatchInlineSnapshot(` @@ -2795,19 +3223,26 @@ test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])( - test-postinstall-skip-native@1.0.0 1 package removed (checked 4)" `); - expect(other.exitCode).toBe(0); - expect(existsSync(join(store, "test-postinstall-skip-native@1.0.0"))).toBeFalse(); - expect(() => lstatSync(join(nm, "test-postinstall-skip-native"))).toThrow(); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); - expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue(); + expectOk(other); + const pruned = { + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual(pruned); expect(await install(dir, flag, "--linker", "isolated")).toContain("no changes"); + expect(tree(dir)).toEqual(pruned); }, ); test.concurrent("isolated: keeps dependencies bundled inside a package, with and without --production", async () => { const dir = await setupWithLinker("isolated", { name: "foo", dependencies: { "bundled-transitive": "1.0.0" } }); - const installed = storeEntries(dir); - expect(installed).toStrictEqual(["bundled-transitive@1.0.0", "no-deps@1.0.1", "one-dep@1.0.0"]); + const installed = { + "node_modules": ["bundled-transitive (link)"], + "node_modules/.bun": ["bundled-transitive@1.0.0", "no-deps@1.0.1", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": ["bundled-transitive (link)", "no-deps (link)", "one-dep (link)"], + }; + expect(tree(dir)).toEqual(installed); const bundled = join( dir, "node_modules", @@ -2822,100 +3257,117 @@ test.concurrent("isolated: keeps dependencies bundled inside a package, with and expect(existsSync(bundled)).toBeTrue(); for (const flags of [[], ["--production"]]) { - const { stdout, exitCode } = await prune(dir, ...flags, "--linker", "isolated"); - expect(out(stdout)).toEndWith(NOTHING(4, 2)); - expect(exitCode).toBe(0); - expect(storeEntries(dir)).toStrictEqual(installed); + const result = await prune(dir, ...flags, "--linker", "isolated"); + expect(lines(result.stdout)).toStrictEqual([BANNER, "", NOTHING(4, 2)]); + expectOk(result); + expect(tree(dir)).toEqual(installed); expect(existsSync(bundled)).toBeTrue(); } await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(installed); }); test.concurrent("hoisted: the nested tree of a package with bundled dependencies is not walked", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "2.0.0", "bundled-transitive": "1.0.0" } }); const nm = join(dir, "node_modules"); - const bundledNm = join(nm, "bundled-transitive", "node_modules"); - expect(await file(join(bundledNm, "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.0" }); + expect(await file(join(nm, "bundled-transitive", "node_modules", "no-deps", "package.json")).json()).toMatchObject({ + version: "1.0.0", + }); expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); expect(await file(join(nm, "one-dep", "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.1", }); - const junk = plant(dir, "node_modules/bundled-transitive/node_modules/junk"); - const oneDepJunk = plant(dir, "node_modules/one-dep/node_modules/junk"); + plant(dir, "node_modules/bundled-transitive/node_modules/junk"); + plant(dir, "node_modules/one-dep/node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["bundled-transitive", "no-deps", "one-dep"], + "node_modules/bundled-transitive/node_modules": ["junk", "no-deps"], + "node_modules/one-dep/node_modules": ["junk", "no-deps"], + }); - const { stdout, stderr, exitCode } = await prune(dir); - expect(stderr).not.toContain("warn:"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk (node_modules/one-dep/node_modules) 1 package removed (checked 5)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeTrue(); - expect(existsSync(oneDepJunk)).toBeFalse(); - expect(existsSync(join(bundledNm, "no-deps", "package.json"))).toBeTrue(); + expectOk(result); + // The bundled folder is the package's own contents, so even junk planted inside it is left alone. + expect(tree(dir)).toEqual({ + "node_modules": ["bundled-transitive", "no-deps", "one-dep"], + "node_modules/bundled-transitive/node_modules": ["junk", "no-deps"], + "node_modules/one-dep/node_modules": ["no-deps"], + }); }); test.concurrent( "hoisted: a nested copy of a git dependency is removed only once the root copy's .bun-tag matches bun.lock", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } }); + const dir = await project({ linker: "hoisted" }, {}, { own: true }); const gitPkg = await gitDependency(dir, "git-pkg"); - await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0", "git-pkg": gitPkg } })); - await install(dir, "--linker", "hoisted"); - const nm = join(dir, "node_modules"); - const rootTag = join(nm, "git-pkg", ".bun-tag"); + await write( + join(dir, "package.json"), + JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0", "git-pkg": gitPkg } }), + ); + await install(dir); + const rootTag = join(dir, "node_modules", "git-pkg", ".bun-tag"); const tag = await file(rootTag).text(); expect(tag).toMatch(/^[0-9a-f]{40}$/); expect(await lock(dir)).toContain(tag); - const nested = plant(dir, "node_modules/no-deps/node_modules/git-pkg"); + plant(dir, "node_modules/no-deps/node_modules/git-pkg"); + const withNestedCopy = { + "node_modules": ["git-pkg", "no-deps"], + "node_modules/no-deps/node_modules": ["git-pkg"], + }; + expect(tree(dir)).toEqual(withNestedCopy); writeFileSync(rootTag, "0000000000000000000000000000000000000000"); const stale = await prune(dir, "--linker", "hoisted"); - expect(out(stale.stdout)).toEndWith(NOTHING(3, 2)); + expect(lines(stale.stdout)).toStrictEqual([BANNER, "", NOTHING(3, 2)]); expect(out(stale.stderr)).toBe( `${WARN("node_modules/git-pkg", "node_modules/no-deps/node_modules/git-pkg")}\n${NOTE}`, ); expect(stale.exitCode).toBe(0); - expect(existsSync(nested)).toBeTrue(); + expect(tree(dir)).toEqual(withNestedCopy); writeFileSync(rootTag, tag); - const { stdout, stderr, exitCode } = await prune(dir, "--linker", "hoisted"); - expect(stderr).not.toContain("warn:"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - git-pkg (node_modules/no-deps/node_modules) 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(nested)).toBeFalse(); - expect(existsSync(join(nm, "git-pkg", "package.json"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["git-pkg", "no-deps"], "node_modules/no-deps/node_modules": [] }); }, ); test.concurrent( "hoisted: a nested copy of a local tarball dependency is removed once the root copy is installed", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } }); + const dir = await project({ linker: "hoisted" }, {}, { own: true }); await write( - packageJson, + join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0", "left-pad": copyTarball(dir, "left-pad", "1.0.0") }, }), ); - await install(dir, "--linker", "hoisted"); - const nm = join(dir, "node_modules"); - const rootPkgJson = join(nm, "left-pad", "package.json"); - expect(await file(rootPkgJson).json()).toMatchObject({ version: "1.0.0" }); - const nested = plant(dir, "node_modules/no-deps/node_modules/left-pad"); - const junk = plant(dir, "node_modules/no-deps/node_modules/junk"); + await install(dir); + const rootLeftPad = join(dir, "node_modules", "left-pad"); + expect(await file(join(rootLeftPad, "package.json")).json()).toMatchObject({ version: "1.0.0" }); + plant(dir, "node_modules/no-deps/node_modules/left-pad"); + plant(dir, "node_modules/no-deps/node_modules/junk"); + expect(tree(dir)).toEqual({ + "node_modules": ["left-pad", "no-deps"], + "node_modules/no-deps/node_modules": ["junk", "left-pad"], + }); const kept = "node_modules/no-deps/node_modules/left-pad"; - renameSync(join(nm, "left-pad"), join(dir, "left-pad.moved")); + renameSync(rootLeftPad, join(dir, "left-pad.moved")); const missing = await prune(dir, "--linker", "hoisted"); expect(lines(missing.stdout)).toStrictEqual([ BANNER, @@ -2925,70 +3377,80 @@ test.concurrent( ]); expect(out(missing.stderr)).toBe(`${MISSING_WARN("node_modules/left-pad", kept)}\n${NOTE}`); expect(missing.exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(nested)).toBeTrue(); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"], "node_modules/no-deps/node_modules": ["left-pad"] }); - renameSync(join(dir, "left-pad.moved"), join(nm, "left-pad")); - renameSync(rootPkgJson, join(nm, "left-pad", "package.json.bak")); + renameSync(join(dir, "left-pad.moved"), rootLeftPad); + renameSync(join(rootLeftPad, "package.json"), join(rootLeftPad, "package.json.bak")); const mismatch = await prune(dir, "--linker", "hoisted"); expect(lines(mismatch.stdout)).toStrictEqual([BANNER, "", NOTHING(3, 2)]); expect(out(mismatch.stderr)).toBe(`${WARN("node_modules/left-pad", kept)}\n${NOTE}`); expect(mismatch.exitCode).toBe(0); - expect(existsSync(nested)).toBeTrue(); + expect(tree(dir)).toEqual({ + "node_modules": ["left-pad", "no-deps"], + "node_modules/no-deps/node_modules": ["left-pad"], + }); - renameSync(join(nm, "left-pad", "package.json.bak"), rootPkgJson); - const { stdout, stderr, exitCode } = await prune(dir, "--linker", "hoisted"); - expect(stderr).toBe(""); - expect(out(stdout)).toMatchInlineSnapshot(` + renameSync(join(rootLeftPad, "package.json.bak"), join(rootLeftPad, "package.json")); + const result = await prune(dir, "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - left-pad (node_modules/no-deps/node_modules) 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(nested)).toBeFalse(); - expect(existsSync(rootPkgJson)).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["left-pad", "no-deps"], "node_modules/no-deps/node_modules": [] }); + expect(await file(join(rootLeftPad, "package.json")).json()).toMatchObject({ version: "1.0.0" }); }, ); test.concurrent("hoisted: a nested copy of a link: dependency is removed when the root entry is the link", async () => { - const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } }); - await Promise.all([ - write(join(dir, "linked", "package.json"), JSON.stringify({ name: "linked", version: "1.0.0" })), - write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0", linked: "link:linked" } })), - ]); + const dir = await project( + { linker: "hoisted" }, + { + "linked/package.json": JSON.stringify({ name: "linked", version: "1.0.0" }), + "package.json": JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0", linked: "link:linked" } }), + }, + ); const env = globalEnv(dir, join(dir, ".global")); const link = await run(env, join(dir, "linked"), "link"); expect(link.stderr).not.toContain("error:"); expect(link.stdout).toContain('Success! Registered "linked"'); expect(link.exitCode).toBe(0); - const installed = await run(env, dir, "install", "--linker", "hoisted"); + const installed = await run(env, dir, "install"); expect(installed.stderr).not.toContain("error:"); expect(installed.exitCode).toBe(0); - const nm = join(dir, "node_modules"); - expect(isSymlink(join(nm, "linked"))).toBeTrue(); - const nested = plant(dir, "node_modules/no-deps/node_modules/linked"); + plant(dir, "node_modules/no-deps/node_modules/linked"); + expect(tree(dir)).toEqual({ + "node_modules": ["linked (link)", "no-deps"], + "node_modules/no-deps/node_modules": ["linked"], + }); - const { stdout, stderr, exitCode } = await prune(dir, "--linker", "hoisted"); - expect(stderr).not.toContain("warn:"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - linked (node_modules/no-deps/node_modules) 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(nested)).toBeFalse(); - expect(isSymlink(join(nm, "linked"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": ["linked (link)", "no-deps"], + "node_modules/no-deps/node_modules": [], + }); expect(await file(join(dir, "linked", "package.json")).json()).toStrictEqual({ name: "linked", version: "1.0.0" }); }); test.concurrent("hoisted: a nested copy left behind by an override to a tarball is removed", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "2.0.0", "one-dep": "1.0.0" } }); - const nm = join(dir, "node_modules"); - const nested = join(nm, "one-dep", "node_modules", "no-deps"); - expect(await file(join(nested, "package.json")).json()).toMatchObject({ version: "1.0.1" }); + const nestedPkgJson = file(join(dir, "node_modules", "one-dep", "node_modules", "no-deps", "package.json")); + expect(await nestedPkgJson.json()).toMatchObject({ version: "1.0.1" }); expect(await lock(dir)).toContain('"one-dep/no-deps"'); + const withNestedCopy = { + "node_modules": ["no-deps", "one-dep"], + "node_modules/one-dep/node_modules": ["no-deps"], + }; + expect(tree(dir)).toEqual(withNestedCopy); await write( join(dir, "package.json"), @@ -2998,22 +3460,23 @@ test.concurrent("hoisted: a nested copy left behind by an override to a tarball overrides: { "no-deps": copyTarball(dir, "no-deps", "2.0.0") }, }), ); + // Installing the tarball writes it to the cache, so this project leaves the shared one from here on. + ownCache.add(dir); await install(dir); expect(await lock(dir)).not.toContain('"one-dep/no-deps"'); expect(await lock(dir)).toContain("no-deps-2.0.0.tgz"); - expect(existsSync(join(nested, "package.json"))).toBeTrue(); + expect(tree(dir)).toEqual(withNestedCopy); - const { stdout, stderr, exitCode } = await prune(dir); - expect(stderr).not.toContain("warn:"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - no-deps@1.0.1 (node_modules/one-dep/node_modules) 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(nested)).toBeFalse(); - expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ name: "no-deps" }); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps", "one-dep"], "node_modules/one-dep/node_modules": [] }); + expect(await file(join(dir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" }); }); // https://github.com/oven-sh/bun/issues/13563 @@ -3021,24 +3484,28 @@ test.concurrent( "hoisted: build metadata in the installed package.json version does not block removing a nested copy", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0", "no-deps-build-metadata": "1.0.0" } }); - const nm = join(dir, "node_modules"); - expect(await file(join(nm, "no-deps-build-metadata", "package.json")).json()).toMatchObject({ + expect(await file(join(dir, "node_modules", "no-deps-build-metadata", "package.json")).json()).toMatchObject({ version: "1.0.0+123", }); expect(await lock(dir)).toContain('"no-deps-build-metadata@1.0.0"'); - const nested = plant(dir, "node_modules/no-deps/node_modules/no-deps-build-metadata"); + plant(dir, "node_modules/no-deps/node_modules/no-deps-build-metadata"); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps", "no-deps-build-metadata"], + "node_modules/no-deps/node_modules": ["no-deps-build-metadata"], + }); - const { stdout, stderr, exitCode } = await prune(dir); - expect(stderr).not.toContain("warn:"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - no-deps-build-metadata (node_modules/no-deps/node_modules) 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(nested)).toBeFalse(); - expect(existsSync(join(nm, "no-deps-build-metadata", "package.json"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps", "no-deps-build-metadata"], + "node_modules/no-deps/node_modules": [], + }); }, ); @@ -3050,15 +3517,20 @@ test.concurrent( { name: "foo", dependencies: { "a-dep": "1.0.1" }, devDependencies: { "one-dep": "1.0.0" } }, { publicHoistPattern: ["no-deps"] }, ); - const nm = join(dir, "node_modules"); - const store = join(nm, ".bun"); - expect(isSymlink(join(nm, "no-deps"))).toBeTrue(); - expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.1" }); + expect(await file(join(dir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.1" }); + // no-deps is only one-dep's dependency; publicHoistPattern gives it a root link next to the direct dependencies. + const linked = ["a-dep (link)", "no-deps (link)", "one-dep (link)"]; + const installed = { + "node_modules": linked, + "node_modules/.bun": ["a-dep@1.0.1", "no-deps@1.0.1", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": linked, + }; + expect(tree(dir)).toEqual(installed); const plain = await prune(dir, "--linker", "isolated"); - expect(out(plain.stdout)).toEndWith(NOTHING(6, 2)); - expect(plain.exitCode).toBe(0); - expect(isSymlink(join(nm, "no-deps"))).toBeTrue(); + expect(lines(plain.stdout)).toStrictEqual([BANNER, "", NOTHING(6, 2)]); + expectOk(plain); + expect(tree(dir)).toEqual(installed); const production = await prune(dir, "--production", "--linker", "isolated"); expect(out(production.stdout)).toMatchInlineSnapshot(` @@ -3068,12 +3540,15 @@ test.concurrent( - one-dep@1.0.0 2 packages removed (checked 6)" `); - expect(production.exitCode).toBe(0); - expect(existsSync(join(store, "no-deps@1.0.1"))).toBeFalse(); - expect(() => lstatSync(join(nm, "no-deps"))).toThrow(); - expect(() => lstatSync(join(nm, "one-dep"))).toThrow(); - expect(existsSync(join(nm, "a-dep", "package.json"))).toBeTrue(); + expectOk(production); + const pruned = { + "node_modules": ["a-dep (link)"], + "node_modules/.bun": ["a-dep@1.0.1"], + "node_modules/.bun/node_modules": ["a-dep (link)"], + }; + expect(tree(dir)).toEqual(pruned); await expectProductionInstallIsNoop(dir); + expect(tree(dir)).toEqual(pruned); }, ); @@ -3086,19 +3561,31 @@ test.concurrent( devDependencies: { "one-dep": "1.0.0" }, }); const store = join(dir, "node_modules", ".bun"); - const hiddenHoist = join(store, "node_modules"); - const variant = plant(dir, "node_modules/.bun/one-dep@1.0.0+0123456789abcdef/node_modules/one-dep"); + const scopeDir = join(store, "node_modules", "@scope"); + plant(dir, "node_modules/.bun/one-dep@1.0.0+0123456789abcdef/node_modules/one-dep"); const scopedEntry = plant(dir, "node_modules/.bun/@scope+zzz@1.0.0/node_modules/@scope/zzz"); - const scopedLink = join(hiddenHoist, "@scope", "zzz"); - mkdirSync(join(hiddenHoist, "@scope"), { recursive: true }); - symlinkSync(scopedEntry, scopedLink, "junction"); - const liveLink = join(hiddenHoist, "@scope", "no-deps"); - symlinkSync(join(store, "no-deps@1.0.0", "node_modules", "no-deps"), liveLink, "junction"); - expect(isSymlink(scopedLink)).toBeTrue(); - expect(existsSync(join(liveLink, "package.json"))).toBeTrue(); - - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout)).toMatchInlineSnapshot(` + mkdirSync(scopeDir); + symlinkSync(scopedEntry, join(scopeDir, "zzz"), "junction"); + symlinkSync(join(store, "no-deps@1.0.0", "node_modules", "no-deps"), join(scopeDir, "no-deps"), "junction"); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps (link)", "one-dep (link)"], + "node_modules/.bun": [ + "@scope+zzz@1.0.0", + "no-deps@1.0.0", + "no-deps@1.0.1", + "one-dep@1.0.0", + "one-dep@1.0.0+0123456789abcdef", + ], + "node_modules/.bun/node_modules": [ + "@scope/no-deps (link)", + "@scope/zzz (link)", + "no-deps (link)", + "one-dep (link)", + ], + }); + + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - @scope/zzz@1.0.0 @@ -3107,13 +3594,13 @@ test.concurrent( - one-dep@1.0.0+0123456789abcdef 4 packages removed (checked 7)" `); - expect(exitCode).toBe(0); - expect(existsSync(variant)).toBeFalse(); - expect(existsSync(join(store, "one-dep@1.0.0+0123456789abcdef"))).toBeFalse(); - expect(existsSync(join(store, "@scope+zzz@1.0.0"))).toBeFalse(); - expect(() => lstatSync(scopedLink)).toThrow(); - expect(existsSync(join(liveLink, "package.json"))).toBeTrue(); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); + expectOk(result); + // The scoped link into a kept entry stays, so its scope dir does too. + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["@scope/no-deps (link)", "no-deps (link)"], + }); }, ); @@ -3123,14 +3610,18 @@ test.concurrent("isolated: an emptied scope dir of dangling hidden-hoist links i dependencies: { "no-deps": "1.0.0" }, devDependencies: { "one-dep": "1.0.0" }, }); - const store = join(dir, "node_modules", ".bun"); - const scopeDir = join(store, "node_modules", "@scope"); + const scopeDir = join(dir, "node_modules", ".bun", "node_modules", "@scope"); const scopedEntry = plant(dir, "node_modules/.bun/@scope+zzz@1.0.0/node_modules/@scope/zzz"); - mkdirSync(scopeDir, { recursive: true }); + mkdirSync(scopeDir); symlinkSync(scopedEntry, join(scopeDir, "zzz"), "junction"); + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps (link)", "one-dep (link)"], + "node_modules/.bun": ["@scope+zzz@1.0.0", "no-deps@1.0.0", "no-deps@1.0.1", "one-dep@1.0.0"], + "node_modules/.bun/node_modules": ["@scope/zzz (link)", "no-deps (link)", "one-dep (link)"], + }); - const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--production", "--linker", "isolated"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - @scope/zzz@1.0.0 @@ -3138,32 +3629,43 @@ test.concurrent("isolated: an emptied scope dir of dangling hidden-hoist links i - one-dep@1.0.0 3 packages removed (checked 6)" `); - expect(exitCode).toBe(0); - expect(existsSync(scopeDir)).toBeFalse(); - expect(existsSync(join(store, "node_modules"))).toBeTrue(); + expectOk(result); + // A @scope dir left behind would list as a bare "@scope" entry of the hidden hoist folder. + expect(tree(dir)).toEqual({ + "node_modules": ["no-deps (link)"], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + }); }); test.concurrent("isolated: --filter on a pruned checkout does not protect the missing workspace", async () => { const dir = await setupWorkspaces("isolated", prunedCheckout({})); - const store = join(dir, "node_modules", ".bun"); rmSync(join(dir, "packages", "other"), { recursive: true }); - expect(existsSync(join(store, "left-pad@1.0.0"))).toBeTrue(); + expect(tree(dir)).toEqual({ + "node_modules": [], + "node_modules/.bun": ["left-pad@1.0.0", "no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["left-pad (link)", "no-deps (link)"], + "packages/app/node_modules": ["no-deps (link)"], + }); - const { stdout, stderr, exitCode } = await prune(dir, "--filter", "app", "--linker", "isolated"); - expect(stderr).toContain(PRUNED_NOTE); - expect(stderr).not.toContain(OUT_OF_SYNC); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "--filter", "app", "--linker", "isolated"); + expect(normalizeBunSnapshot(result.stderr)).toBe(PRUNED_NOTE); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - left-pad@1.0.0 1 package removed (checked 3)" `); - expect(exitCode).toBe(0); - expect(existsSync(join(store, "left-pad@1.0.0"))).toBeFalse(); - expect(existsSync(join(store, "no-deps@1.0.0"))).toBeTrue(); - expect(existsSync(join(dir, "packages", "app", "node_modules", "no-deps", "package.json"))).toBeTrue(); + expect(result.exitCode).toBe(0); + const pruned = { + "node_modules": [], + "node_modules/.bun": ["no-deps@1.0.0"], + "node_modules/.bun/node_modules": ["no-deps (link)"], + "packages/app/node_modules": ["no-deps (link)"], + }; + expect(tree(dir)).toEqual(pruned); await install(dir, "--frozen-lockfile", "--linker", "isolated"); - expect(existsSync(join(store, "left-pad@1.0.0"))).toBeFalse(); + expect(tree(dir)).toEqual(pruned); }); test.concurrent.each(linkers)( @@ -3182,51 +3684,67 @@ test.concurrent.each(linkers)( expect(trimmed).toContain('"a-dep": "1.0.1",'); await write(join(dir, "bun.lock"), trimmed); rmSync(join(dir, "packages", "other"), { recursive: true }); - const leftPad = + const isolatedApp = { "node_modules": [], "packages/app/node_modules": ["a-dep (link)"] }; + expect(tree(dir)).toEqual( linker === "hoisted" - ? join(dir, "node_modules", "left-pad") - : join(dir, "node_modules", ".bun", "left-pad@1.0.0"); - expect(existsSync(leftPad)).toBeTrue(); + ? { "node_modules": ["a-dep", "app (link)", "left-pad", "other (dangling link)"] } + : { + ...isolatedApp, + "node_modules/.bun": ["a-dep@1.0.1", "left-pad@1.0.0"], + "node_modules/.bun/node_modules": ["a-dep (link)", "left-pad (link)"], + }, + ); - const { stdout, stderr, exitCode } = await prune(dir, "--linker", linker); - expect(normalizeBunSnapshot(stderr)).toBe( + const result = await prune(dir, "--linker", linker); + expect(normalizeBunSnapshot(result.stderr)).toBe( `${PRUNED_NOTE}\nnote: skipped 1 catalog entry not in bun.lock (unused by the workspaces on disk): "left-pad"`, ); - expect(lines(stdout)).toStrictEqual( + expect(lines(result.stdout)).toStrictEqual( linker === "hoisted" ? [BANNER, "", "- left-pad@1.0.0", "- other", REMOVED(2, 4)] : [BANNER, "", "- left-pad@1.0.0", REMOVED(1, 3)], ); - expect(exitCode).toBe(0); - expect(existsSync(leftPad)).toBeFalse(); + expect(result.exitCode).toBe(0); + const pruned = + linker === "hoisted" + ? { "node_modules": ["a-dep", "app (link)"] } + : { + ...isolatedApp, + "node_modules/.bun": ["a-dep@1.0.1"], + "node_modules/.bun/node_modules": ["a-dep (link)"], + }; + expect(tree(dir)).toEqual(pruned); expect(await lock(dir)).toBe(trimmed); await install(dir, "--frozen-lockfile", "--linker", linker); expect(await lock(dir)).toBe(trimmed); + expect(tree(dir)).toEqual(pruned); }, ); test.concurrent("a lockfile that fails to parse is an error and nothing is deleted", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); await write(join(dir, "bun.lock"), "{ this is not a lockfile"); + const planted = { "node_modules": ["junk", "no-deps"] }; const { stdout, stderr, exitCode } = await prune(dir); - expect(stderr).toContain("error: failed to load lockfile: "); - expect(out(stdout)).toMatchInlineSnapshot(`"bun prune ()"`); + // The parser's own diagnostic comes first; prune adds the verdict. + expect(normalizeBunSnapshot(stderr)).toEndWith("\nerror: failed to load lockfile: ParserError"); + expect(out(stdout)).toBe(BANNER); expect(exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); + expect(tree(dir)).toEqual(planted); const silent = await prune(dir, "--silent"); expect(silent.stdout).toBe(""); expect(silent.stderr).toBe(""); expect(silent.exitCode).toBe(1); - expect(existsSync(junk)).toBeTrue(); + expect(tree(dir)).toEqual(planted); }); test.concurrent("missing package.json is an error; --cwd prunes another directory", async () => { const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }); using empty = tempDir("prune-empty", {}); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); const missing = await prune(String(empty)); expect(normalizeBunSnapshot(missing.stderr)).toBe("error: missing package.json, nothing to prune"); @@ -3238,17 +3756,18 @@ test.concurrent("missing package.json is an error; --cwd prunes another director expect(silent.stdout).toBe(""); expect(silent.stderr).toBe(""); expect(silent.exitCode).toBe(1); + expect(tree(dir)).toEqual({ "node_modules": ["junk", "no-deps"] }); - const { stdout, exitCode } = await prune({ dir, cwd: String(empty) }, "--cwd", dir); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune({ dir, cwd: String(empty) }, "--cwd", dir); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk 1 package removed (checked 2)" `); - expect(exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); - expect(existsSync(join(dir, "node_modules", "no-deps", "package.json"))).toBeTrue(); + expectOk(result); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); + expect(tree(String(empty))).toEqual({}); }); test.concurrent("a node_modules that is a file is an error", async () => { @@ -3258,9 +3777,10 @@ test.concurrent("a node_modules that is a file is an error", async () => { writeFileSync(nm, "not a directory"); const { stdout, stderr, exitCode } = await prune(dir); - expect(stderr).toContain("failed to open node_modules"); - expect(out(stdout)).toMatchInlineSnapshot(`"bun prune ()"`); + expect(normalizeBunSnapshot(stderr)).toMatch(/^E[A-Z]+: .*failed to open node_modules/); + expect(out(stdout)).toBe(BANNER); expect(exitCode).toBe(1); + expect(await file(nm).text()).toBe("not a directory"); const silent = await prune(dir, "--silent"); expect(silent.stdout).toBe(""); @@ -3277,22 +3797,21 @@ test.concurrent( dependencies: { "no-deps": "1.0.0" }, scripts: { prune: "echo SCRIPT_RAN" }, }); - const junk = plant(dir, "node_modules/junk"); + plant(dir, "node_modules/junk"); const pruned = await bun(dir, "prune"); - expect(pruned.stdout).not.toContain("SCRIPT_RAN"); expect(out(pruned.stdout)).toMatchInlineSnapshot(` "bun prune () - junk 1 package removed (checked 2)" `); - expect(pruned.exitCode).toBe(0); - expect(existsSync(junk)).toBeFalse(); + expectOk(pruned); + expect(tree(dir)).toEqual({ "node_modules": ["no-deps"] }); const script = await bun(dir, "run", "prune"); - expect(script.stdout).toContain("SCRIPT_RAN"); - expect(script.stdout).not.toContain("Checked"); + expect(normalizeBunSnapshot(script.stdout)).toMatchInlineSnapshot(`"SCRIPT_RAN"`); + expect(normalizeBunSnapshot(script.stderr)).toMatchInlineSnapshot(`"$ echo SCRIPT_RAN"`); expect(script.exitCode).toBe(0); }, ); @@ -3304,11 +3823,12 @@ test.concurrent("--no-optional is not --omit=optional", async () => { dependencies: { "no-deps": "1.0.0" }, optionalDependencies: { "a-dep": "1.0.1" }, }); + const installed = { "node_modules": ["a-dep", "no-deps"] }; const noOptional = await prune(dir, "--no-optional", "--dry-run"); - expect(noOptional.stderr).toBe(""); - expect(out(noOptional.stdout)).toEndWith(NOTHING(2, 1)); - expect(noOptional.exitCode).toBe(0); + expect(lines(noOptional.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 1)]); + expectOk(noOptional); + expect(tree(dir)).toEqual(installed); const omit = await prune(dir, "--omit=optional", "--dry-run"); expect(out(omit.stdout)).toMatchInlineSnapshot(` @@ -3318,8 +3838,8 @@ test.concurrent("--no-optional is not --omit=optional", async () => { 1 package can be removed (checked 2) bun prune --omit=optional" `); - expect(omit.exitCode).toBe(0); - expect(existsSync(join(dir, "node_modules", "a-dep", "package.json"))).toBeTrue(); + expectOk(omit); + expect(tree(dir)).toEqual(installed); }); test.concurrent("--help lists every flag; -F is --filter, -p is --production", async () => { @@ -3327,11 +3847,15 @@ test.concurrent("--help lists every flag; -F is --filter, -p is --production", a root: { dependencies: { "no-deps": "2.0.0" } }, packages: { a: { dependencies: { "no-deps": "1.0.0" } }, b: { dependencies: { "no-deps": "1.0.0" } } }, }); - const aJunk = plant(dir, "packages/a/node_modules/junk"); - const bJunk = plant(dir, "packages/b/node_modules/junk"); + plant(dir, "packages/a/node_modules/junk"); + plant(dir, "packages/b/node_modules/junk"); + const planted = { + "node_modules": ["a (link)", "b (link)", "no-deps"], + "packages/a/node_modules": ["junk", "no-deps"], + "packages/b/node_modules": ["junk", "no-deps"], + }; const help = await prune(dir, "--help"); - expect(help.stderr).toBe(""); expect(out(help.stdout)).toMatchInlineSnapshot(` "Usage: bun prune [flags] @@ -3364,7 +3888,8 @@ test.concurrent("--help lists every flag; -F is --filter, -p is --production", a Full documentation is available at https://bun.com/docs/pm/cli/prune." `); - expect(help.exitCode).toBe(0); + expectOk(help); + expect(tree(dir)).toEqual(planted); const longFlag = await prune(dir, "--filter", "a", "--dry-run", "--linker", "hoisted"); const shortFlag = await prune(dir, "-F", "a", "--dry-run", "--linker", "hoisted"); @@ -3376,22 +3901,23 @@ test.concurrent("--help lists every flag; -F is --filter, -p is --production", a CAN_BE_REMOVED(1, 5), APPLY_HINT("--filter", "a", "--linker", "hoisted"), ]); + expectOk(longFlag); expect(lines(shortFlag.stdout)).toStrictEqual([ ...lines(longFlag.stdout).slice(0, -1), APPLY_HINT("-F", "a", "--linker", "hoisted"), ]); - expect(shortFlag.exitCode).toBe(0); + expectOk(shortFlag); + expect(tree(dir)).toEqual(planted); - const { stdout, exitCode } = await prune(dir, "-F", "b", "-p", "--linker", "hoisted"); - expect(out(stdout)).toMatchInlineSnapshot(` + const result = await prune(dir, "-F", "b", "-p", "--linker", "hoisted"); + expect(out(result.stdout)).toMatchInlineSnapshot(` "bun prune () - junk (node_modules/b/node_modules) 1 package removed (checked 5)" `); - expect(exitCode).toBe(0); - expect(existsSync(aJunk)).toBeTrue(); - expect(existsSync(bJunk)).toBeFalse(); + expectOk(result); + expect(tree(dir)).toEqual({ ...planted, "packages/b/node_modules": ["no-deps"] }); }); test.concurrent( @@ -3404,7 +3930,6 @@ test.concurrent( const root = String(dir); const bunInstall = join(root, ".global"); const globalDir = join(bunInstall, "install", "global"); - const globalNm = join(globalDir, "node_modules"); const env = globalEnv(root, bunInstall); const link = await run(env, join(root, "lib"), "link"); @@ -3416,29 +3941,37 @@ test.concurrent( expect(add.stderr).not.toContain("error:"); expect(add.exitCode).toBe(0); expect(existsSync(join(globalDir, "bun.lock"))).toBeTrue(); - expect(readdirSync(globalNm).sort()).toStrictEqual(["gpkg", "lib"]); - expect(existsSync(join(globalNm, "lib", "package.json"))).toBeTrue(); - + // The link registration is the one entry a bun.lock-driven prune of this folder would remove. + const globalFolder = { "node_modules": ["gpkg", "lib (link)"] }; + expect(tree(globalDir)).toEqual(globalFolder); + + const rejected = [ + "error: --global cannot be used with bun prune", + "note: the global folder is also the 'bun link' registry, and bun.lock does not list linked packages", + ].join("\n"); for (const args of [ ["prune", "-g"], ["prune", "--dry-run", "--global"], ["prune", "-g", "--silent"], ]) { const { stdout, stderr, exitCode } = await run(env, root, ...args); - expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(` - "error: --global cannot be used with bun prune - note: the global folder is also the 'bun link' registry, and bun.lock does not list linked packages" - `); + expect(normalizeBunSnapshot(stderr)).toBe(rejected); expect(stdout).toBe(""); expect(exitCode).toBe(1); } - expect(readdirSync(globalNm).sort()).toStrictEqual(["gpkg", "lib"]); - expect(existsSync(join(globalNm, "lib", "package.json"))).toBeTrue(); + expect(tree(globalDir)).toEqual(globalFolder); const fresh = join(root, ".fresh"); - const { stderr, exitCode } = await run(globalEnv(root, fresh), root, "prune", "-g"); - expect(stderr).toContain("error: --global cannot be used with bun prune"); + const { stdout, stderr, exitCode } = await run(globalEnv(root, fresh), root, "prune", "-g"); + expect(normalizeBunSnapshot(stderr)).toBe(rejected); + expect(stdout).toBe(""); expect(exitCode).toBe(1); expect(existsSync(fresh)).toBeFalse(); }, ); + +// Not concurrent, so it runs once every test above has finished. A new entry here was fetched by some test instead of +// in beforeAll: add it to `registryPackages`, or give the project its own cache if it is not a registry package. +test("no test wrote to the shared cache", () => { + expect(readdirSync(sharedCache).toSorted()).toEqual(sharedCacheEntries); +});