Files
bun-src/test/cli/install/frozen-lockfile-pruned.test.ts
2026-08-27 21:09:14 +00:00

1571 lines
68 KiB
TypeScript

import { file, write } from "bun";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { exists, lstat } from "fs/promises";
import { VerdaccioRegistry, bunEnv, bunExe, isWindows, normalizeBunSnapshot } from "harness";
import { dirname, join } from "path";
type Linker = "hoisted" | "isolated";
type PackageJson = Record<string, unknown>;
type Tree = { root: PackageJson; packages: Record<string, PackageJson>; files?: Record<string, string> };
const registry = new VerdaccioRegistry();
beforeAll(async () => {
await registry.start();
});
afterAll(() => {
registry.stop();
});
const rootPackageJson: PackageJson = { name: "mono", workspaces: ["packages/*"], trustedDependencies: ["a-dep"] };
const appPackageJson: PackageJson = {
name: "app",
version: "1.0.0",
bin: { "app-cli": "cli.js" },
dependencies: { shared: "workspace:*", "a-dep": "1.0.1" },
};
const sharedPackageJson: PackageJson = {
name: "shared",
version: "1.0.0",
peerDependencies: { "no-deps": "*" },
peerDependenciesMeta: { "no-deps": { optional: true } },
};
const otherPackageJson: PackageJson = {
name: "other",
version: "1.0.0",
dependencies: { shared: "workspace:*", "no-deps": "1.0.0", "left-pad": "1.0.0" },
};
async function writeTree(dir: string, tree: Tree, workspaces: string[] = Object.keys(tree.packages)) {
await Promise.all([
write(join(dir, "package.json"), JSON.stringify(tree.root)),
...workspaces.map(path => write(join(dir, path, "package.json"), JSON.stringify(tree.packages[path]))),
...Object.entries(tree.files ?? {})
.filter(([path]) => {
const owner = Object.keys(tree.packages).find(ws => path.startsWith(ws + "/"));
return owner === undefined || workspaces.includes(owner);
})
.map(([path, contents]) => write(join(dir, path), contents)),
]);
}
const survivors = ["packages/app", "packages/shared"];
const monorepo: Tree = {
root: rootPackageJson,
packages: {
"packages/app": appPackageJson,
"packages/shared": sharedPackageJson,
"packages/other": otherPackageJson,
},
files: { "packages/app/cli.js": "" },
};
// turbo prune rewrites the root workspaces list to the survivors and copies everything else, trustedDependencies included.
const turboOutput: Tree = { ...monorepo, root: { ...rootPackageJson, workspaces: survivors } };
const explicitMonorepo: Tree = {
...monorepo,
root: { ...rootPackageJson, workspaces: ["packages/app", "packages/shared", "packages/other"] },
};
const withApp = (extra: PackageJson): Tree => ({
...monorepo,
packages: { ...monorepo.packages, "packages/app": { ...appPackageJson, ...extra } },
});
const survivorTree = withApp({ dependencies: { ...(appPackageJson.dependencies as object), other: "workspace:*" } });
const rootSurvivorTree: Tree = { ...monorepo, root: { ...rootPackageJson, dependencies: { other: "workspace:*" } } };
const aDepPatch = `diff --git a/patched.txt b/patched.txt
new file mode 100644
index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e728b8dad
--- /dev/null
+++ b/patched.txt
@@ -0,0 +1 @@
+hello world
`;
const aDepPatchedDependencies = { "[email protected]": "patches/[email protected]" };
const patchedMonorepo: Tree = {
...monorepo,
root: { ...rootPackageJson, patchedDependencies: aDepPatchedDependencies },
files: { ...monorepo.files, "patches/[email protected]": aDepPatch },
};
const patchedLockLine = '"[email protected]": "patches/[email protected]"';
const changedSectionNote = (section: "overrides" | "the catalog") =>
`note: ${section} in package.json changed since bun.lock was saved`;
const survivorError = (dependent: string, ws = "other") =>
`workspace "${dependent}" depends on workspace "${ws}" (packages/${ws}), which is listed in bun.lock but not on disk`;
const rootSurvivorError =
'the root package depends on workspace "other" (packages/other), which is listed in bun.lock but not on disk';
const survivorNote = "note: a pruned checkout must keep every workspace that its remaining workspaces depend on";
const catalogTree: Tree = {
root: { name: "mono", workspaces: { packages: ["packages/*"], catalog: { "a-dep": "1.0.1", "left-pad": "1.0.0" } } },
packages: {
"packages/app": { name: "app", version: "1.0.0", dependencies: { "a-dep": "catalog:" } },
"packages/other": { name: "other", version: "1.0.0", dependencies: { "left-pad": "catalog:" } },
},
};
const namedCatalogTree: Tree = {
root: {
name: "mono",
workspaces: { packages: ["packages/*"], catalogs: { build: { "a-dep": "1.0.1", "left-pad": "1.0.0" } } },
},
packages: {
"packages/app": { name: "app", version: "1.0.0", dependencies: { "a-dep": "catalog:build" } },
"packages/other": { name: "other", version: "1.0.0", dependencies: { "left-pad": "catalog:build" } },
},
};
// Scoped to the top-level catalog/catalogs block so importer rows, package rows and overrides are out of reach.
function trimCatalogLine(lock: string, name: string, spec: string): string {
const line = new RegExp(`^ +"${name}": "${spec}",\\n`, "m");
let trimmed = lock;
let removed = 0;
for (const header of ['\n "catalog": {\n', '\n "catalogs": {\n']) {
const start = lock.indexOf(header);
if (start === -1) continue;
const end = lock.indexOf("\n },", start);
expect(end).toBeGreaterThan(start);
// Keep the closing line's leading newline so the block's last entry still ends in "\n".
const block = lock.slice(start, end + 1);
const matches = block.match(new RegExp(line.source, "gm")) ?? [];
removed += matches.length;
trimmed = trimmed.replace(block, block.replace(line, ""));
}
expect(removed).toBe(1);
expect(trimmed).not.toBe(lock);
return trimmed;
}
const singleCatalogEntryTree: Tree = {
root: { name: "mono", workspaces: { packages: ["packages/*"], catalog: { "a-dep": "1.0.1" } } },
packages: { "packages/app": { name: "app", version: "1.0.0", dependencies: { "a-dep": "catalog:" } } },
};
const defaultCatalogBlock = '\n "catalog": {\n "a-dep": "1.0.1",\n },\n';
// A fresh checkout of the whole tree whose bun.lock also lists the one catalog entry under `"catalogs": {"default": ...}`.
async function doubleListedScenario() {
const { full } = await fullInstall("hoisted", singleCatalogEntryTree);
expect(full).toContain(defaultCatalogBlock);
expect(full).not.toContain('"catalogs"');
const doubled = full.replace(
defaultCatalogBlock,
`${defaultCatalogBlock} "catalogs": {\n "default": {\n "a-dep": "1.0.1",\n },\n },\n`,
);
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await writeTree(packageDir, singleCatalogEntryTree);
await write(join(packageDir, "bun.lock"), doubled);
return { packageDir, doubled };
}
const binFiles = (dir: string, name: string) =>
isWindows
? [join(dir, "node_modules", ".bin", `${name}.exe`), join(dir, "node_modules", ".bin", `${name}.bunx`)]
: [join(dir, "node_modules", ".bin", name)];
async function spawnBun(dir: string, args: string[], cwd = dir, env: Record<string, string> = {}) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
cwd,
// CI exports BUN_INSTALL_CACHE_DIR, which overrides the bunfig's per-test `cache`; concurrent cases sharing one cache race on Windows.
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache"), ...env },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
const raw = (dir: string, linker: Linker, args: string[], cwd = dir) =>
spawnBun(dir, [...args, "--linker", linker], cwd);
const quoted = (names: string[]) => names.map(name => `"${name}"`).join(", ");
const prunedNote = 'note: skipped 1 workspace listed in bun.lock but not on disk: "other"';
const catalogNote = (...names: string[]) =>
`note: skipped ${names.length} catalog ${names.length === 1 ? "entry" : "entries"} not in bun.lock (unused by the workspaces on disk): ${quoted(names)}`;
async function run(dir: string, linker: Linker, args: string[], expectedExitCode: number, cwd = dir) {
const { stdout, stderr, exitCode } = await raw(dir, linker, args, cwd);
if (expectedExitCode === 0) {
expect(stderr).not.toContain("error:");
} else {
expect(stderr).toContain("lockfile had changes, but lockfile is frozen");
}
expect(exitCode).toBe(expectedExitCode);
return { stdout, stderr, exitCode };
}
const install = (dir: string, linker: Linker) => run(dir, linker, ["install"], 0);
const frozen = (dir: string, linker: Linker, expectedExitCode: number, cmd = ["install", "--frozen-lockfile"]) =>
run(dir, linker, cmd, expectedExitCode);
async function fullInstall(linker: Linker, tree: Tree) {
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker } });
await writeTree(packageDir, tree);
const { stderr } = await install(packageDir, linker);
expect(stderr).toContain("Saved lockfile");
return { fullDir: packageDir, full: await file(join(packageDir, "bun.lock")).text() };
}
// A full install of `tree`, then a second checkout with only `keep` on disk and the full bun.lock copied verbatim.
async function verbatimScenario(linker: Linker, tree: Tree, keep: string[]) {
const { fullDir, full } = await fullInstall(linker, tree);
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker } });
await writeTree(packageDir, tree, keep);
await write(join(packageDir, "bun.lock"), full);
return { packageDir, fullDir, full };
}
const fullLockfiles = new Map<Linker, Promise<string>>();
function fullLockfile(linker: Linker): Promise<string> {
let lock = fullLockfiles.get(linker);
if (!lock) {
lock = fullInstall(linker, monorepo).then(({ full }) => {
expect(full).toContain('"no-deps"');
expect(full).toContain('"left-pad"');
expect(full).toContain('"packages/other"');
expect(full).toContain('"trustedDependencies"');
expect(full).toContain('"app-cli"');
return full;
});
fullLockfiles.set(linker, lock);
}
return lock;
}
const trustedDependenciesSection = /\n "trustedDependencies": \[\n(?: [^\n]*\n)* \],/;
const workspaceBinField = /,\n "bin": \{\n(?: [^\n]*\n)* \}/;
// The three edits turbo prune makes to Bun's own bun.lock: workspace + exclusive packages removed, trustedDependencies emitted empty (i.e. omitted), workspace bin dropped.
function stripTurboFields(lock: string): string {
expect(lock).toContain('"trustedDependencies"');
expect(lock).toContain('"app-cli"');
const pruned = lock
.replace(/ "packages\/other": \{\n(?: .*\n)* \},\n/, "")
.replace(/\n "(?:other|left-pad)": \[[^\n]*\],\n\n?/g, "\n")
.replace(trustedDependenciesSection, "")
.replace(workspaceBinField, "");
expect(pruned).not.toContain('"trustedDependencies"');
expect(pruned).not.toContain('"bin"');
expect(pruned).toContain('"packages/app"');
expect(pruned).not.toContain('"other"');
expect(pruned).not.toContain('"left-pad"');
return pruned;
}
function turboPrune(lock: string): string {
const pruned = stripTurboFields(lock);
expect(pruned).toContain('"no-deps": ["[email protected]"');
return pruned;
}
async function prunedTree(linker: Linker) {
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker } });
await writeTree(packageDir, turboOutput, survivors);
const pruned = turboPrune(await fullLockfile(linker));
await write(join(packageDir, "bun.lock"), pruned);
return { packageDir, pruned };
}
async function verbatimTree(linker: Linker) {
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker } });
await writeTree(packageDir, monorepo, survivors);
const full = await fullLockfile(linker);
await write(join(packageDir, "bun.lock"), full);
return { packageDir, full };
}
const lockText = (dir: string) => file(join(dir, "bun.lock")).text();
async function editApp(dir: string, edit: (app: any) => void) {
const appJson = join(dir, "packages", "app", "package.json");
const app = await file(appJson).json();
edit(app);
await write(appJson, JSON.stringify(app));
}
function installedPath(dir: string, linker: Linker, name: string, version: string) {
return linker === "hoisted"
? join(dir, "node_modules", name, "package.json")
: join(dir, "node_modules", ".bun", `${name}@${version}`, "node_modules", name, "package.json");
}
describe.each(["hoisted", "isolated"] as Linker[])("linker: %s", linker => {
// Also pins that the optional-peer-bound no-deps is installed even though only the pruned workspace needed it (pnpm#6264).
test.concurrent(
"turbo output: peer-held package survives --frozen-lockfile although turbo dropped trustedDependencies and the workspace bin from bun.lock",
async () => {
const { packageDir, pruned } = await prunedTree(linker);
await frozen(packageDir, linker, 0);
expect(await lockText(packageDir)).toBe(pruned);
const noDeps =
linker === "hoisted"
? join(packageDir, "node_modules", "no-deps", "package.json")
: join(packageDir, "packages", "shared", "node_modules", "no-deps", "package.json");
expect(await file(noDeps).json()).toStrictEqual({ name: "no-deps", version: "1.0.0" });
},
);
test.concurrent(
"turbo output: plain bun install writes only trustedDependencies back and keeps the peer-held package",
async () => {
const { packageDir } = await prunedTree(linker);
const { stderr } = await install(packageDir, linker);
expect(stderr).toContain("Saved lockfile");
const lock = await lockText(packageDir);
expect(lock).toContain('"trustedDependencies": [\n "a-dep",\n ],');
expect(lock).toContain('"no-deps": ["[email protected]"');
expect(lock).not.toContain('"other"');
expect(lock).not.toContain('"left-pad"');
const second = await frozen(packageDir, linker, 0);
expect(second.stderr).not.toContain("Saved lockfile");
expect(await lockText(packageDir)).toBe(lock);
},
);
test.concurrent("verbatim full lockfile with a workspace folder missing passes --frozen-lockfile", async () => {
const { packageDir, full } = await verbatimTree(linker);
await frozen(packageDir, linker, 0);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
if (linker === "hoisted") {
expect((await lstat(join(packageDir, "node_modules", "app"))).isSymbolicLink()).toBeTrue();
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toMatchObject({
name: "a-dep",
version: "1.0.1",
});
} else {
expect(await exists(join(packageDir, "node_modules", ".bun", "[email protected]"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", ".bun", "[email protected]"))).toBeTrue();
expect(
await file(join(packageDir, "packages", "app", "node_modules", "a-dep", "package.json")).json(),
).toMatchObject({
name: "a-dep",
version: "1.0.1",
});
}
});
test.concurrent("second --frozen-lockfile install on a pruned tree is a no-op", async () => {
const { packageDir, pruned } = await prunedTree(linker);
await frozen(packageDir, linker, 0);
const { stderr } = await frozen(packageDir, linker, 0);
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(packageDir)).toBe(pruned);
const noDeps =
linker === "hoisted"
? join(packageDir, "node_modules", "no-deps", "package.json")
: join(packageDir, "packages", "shared", "node_modules", "no-deps", "package.json");
expect(await file(noDeps).json()).toStrictEqual({ name: "no-deps", version: "1.0.0" });
});
test.concurrent("a real package.json change in a pruned tree still fails --frozen-lockfile", async () => {
const { packageDir, pruned } = await prunedTree(linker);
await editApp(packageDir, app => (app.dependencies["no-deps"] = "2.0.0"));
await frozen(packageDir, linker, 1);
expect(await lockText(packageDir)).toBe(pruned);
});
test.concurrent("a missing workspace is still removed from the lockfile by a non-frozen install", async () => {
const { packageDir } = await verbatimTree(linker);
const { stderr } = await install(packageDir, linker);
expect(stderr).toContain("Saved lockfile");
const lock = await lockText(packageDir);
expect(lock).not.toContain('"packages/other"');
expect(lock).not.toContain('"left-pad"');
expect(await exists(join(packageDir, "packages", "other"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "left-pad", "1.0.0"))).toBeFalse();
});
test.concurrent("scoped workspace whose folder name differs from its package name is pruned", async () => {
const tree: Tree = {
root: rootPackageJson,
packages: {
"packages/app": appPackageJson,
"packages/shared": sharedPackageJson,
"packages/other-dir": { name: "@mono/other", version: "1.0.0", dependencies: { "left-pad": "1.0.0" } },
},
};
const { packageDir, full } = await verbatimScenario(linker, tree, survivors);
expect(full).toContain('"packages/other-dir"');
await frozen(packageDir, linker, 0);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "@mono"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "left-pad", "1.0.0"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "a-dep", "1.0.1"))).toBeTrue();
});
test.concurrent("several workspaces pruned at once", async () => {
const tree: Tree = {
root: rootPackageJson,
packages: {
...monorepo.packages,
"packages/other2": { name: "other2", version: "1.0.0", dependencies: { "is-number": "1.0.0" } },
},
};
const { packageDir, full } = await verbatimScenario(linker, tree, survivors);
expect(full).toContain('"packages/other2"');
await frozen(packageDir, linker, 0);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "other2"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "left-pad", "1.0.0"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "is-number", "1.0.0"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "a-dep", "1.0.1"))).toBeTrue();
await editApp(packageDir, app => (app.dependencies["is-number"] = "2.0.0"));
await frozen(packageDir, linker, 1);
expect(await lockText(packageDir)).toBe(full);
});
test.concurrent("bun ci behaves the same on the verbatim full lockfile", async () => {
const { packageDir, full } = await verbatimTree(linker);
await frozen(packageDir, linker, 0, ["ci"]);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "left-pad", "1.0.0"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "a-dep", "1.0.1"))).toBeTrue();
});
// pnpm#11364: explicit workspaces list plus a hand-copied full bun.lock; turbo instead rewrites the list (see turboOutput).
test.concurrent("explicitly listed workspace whose folder is missing passes --frozen-lockfile", async () => {
const { packageDir, full } = await verbatimScenario(linker, explicitMonorepo, survivors);
const { stderr } = await frozen(packageDir, linker, 0);
expect(stderr).not.toContain("Workspace not found");
expect(stderr).toContain(prunedNote);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "left-pad", "1.0.0"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "a-dep", "1.0.1"))).toBeTrue();
});
test.concurrent("bun ci with an explicitly listed workspace whose folder is missing", async () => {
const { packageDir, full } = await verbatimScenario(linker, explicitMonorepo, survivors);
const { stderr } = await frozen(packageDir, linker, 0, ["ci"]);
expect(stderr).not.toContain("Workspace not found");
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "a-dep", "1.0.1"))).toBeTrue();
});
// pnpm#7823: the relaxation is not silent, so a stale bun.lock is greppable in CI logs.
test.concurrent("--frozen-lockfile names how many workspaces it skipped", async () => {
const { packageDir } = await verbatimTree(linker);
const { stderr } = await frozen(packageDir, linker, 0);
expect(stderr).toBe(`${prunedNote}\n`);
});
// pnpm#6094: `--lockfile-only` must not write under `--frozen-lockfile`; the extra newline is a byte-level canary.
test.concurrent("--frozen-lockfile --lockfile-only leaves the pruned bun.lock byte-identical", async () => {
const { packageDir, pruned } = await prunedTree(linker);
const canary = pruned + "\n";
await write(join(packageDir, "bun.lock"), canary);
const { stdout, stderr } = await frozen(packageDir, linker, 0, ["install", "--frozen-lockfile", "--lockfile-only"]);
expect(stdout + stderr).not.toContain("Saved");
expect(await lockText(packageDir)).toBe(canary);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
// Guard that passes on main too: the dropped trustedDependencies/bin alone are not a frozen failure.
test.concurrent("turbo output without a peer-held package passes --frozen-lockfile too", async () => {
const tree: Tree = {
root: rootPackageJson,
packages: {
"packages/app": appPackageJson,
"packages/shared": { name: "shared", version: "1.0.0" },
"packages/other": otherPackageJson,
},
files: monorepo.files,
};
const { full } = await fullInstall(linker, tree);
const pruned = stripTurboFields(full).replace(/\n "no-deps": \[[^\n]*\],\n\n?/, "\n");
expect(pruned).not.toContain('"no-deps"');
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker } });
await writeTree(packageDir, { ...tree, root: { ...rootPackageJson, workspaces: survivors } }, survivors);
await write(join(packageDir, "bun.lock"), pruned);
await frozen(packageDir, linker, 0);
expect(await lockText(packageDir)).toBe(pruned);
});
// Like trustedDependencies above, patchedDependencies is not part of the frozen comparison (docs/pm/cli/install.mdx).
test.concurrent("patchedDependencies added after bun.lock was written still passes --frozen-lockfile", async () => {
const { packageDir, full } = await verbatimScenario(linker, monorepo, survivors);
expect(full).not.toContain('"patchedDependencies"');
await writeTree(packageDir, patchedMonorepo, survivors);
const { stderr } = await frozen(packageDir, linker, 0);
expect(stderr).not.toContain("patchedDependencies");
expect(await lockText(packageDir)).toBe(full);
const aDep = installedPath(packageDir, linker, "a-dep", "1.0.1");
expect(await file(aDep).json()).toMatchObject({ name: "a-dep", version: "1.0.1" });
expect(await file(join(dirname(aDep), "patched.txt")).text()).toBe("hello world\n");
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
const { stderr: plain } = await install(packageDir, linker);
expect(plain).toContain("Saved lockfile");
expect(await lockText(packageDir)).toContain(patchedLockLine);
});
test.concurrent("patchedDependencies removed after bun.lock was written still passes --frozen-lockfile", async () => {
const { fullDir, full } = await fullInstall(linker, patchedMonorepo);
expect(full).toContain(patchedLockLine);
expect(await exists(join(dirname(installedPath(fullDir, linker, "a-dep", "1.0.1")), "patched.txt"))).toBeTrue();
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker } });
await writeTree(packageDir, monorepo, survivors);
await write(join(packageDir, "bun.lock"), full);
const { stderr } = await frozen(packageDir, linker, 0);
expect(stderr).not.toContain("patchedDependencies");
expect(await lockText(packageDir)).toBe(full);
const aDep = installedPath(packageDir, linker, "a-dep", "1.0.1");
expect(await file(aDep).json()).toMatchObject({ name: "a-dep", version: "1.0.1" });
expect(await exists(join(dirname(aDep), "patched.txt"))).toBeFalse();
});
test.concurrent("overrides added after bun.lock was written still fail --frozen-lockfile", async () => {
const { packageDir, full } = await verbatimScenario(linker, monorepo, survivors);
await write(
join(packageDir, "package.json"),
JSON.stringify({ ...rootPackageJson, overrides: { "a-dep": "1.0.1" } }),
);
const { stderr } = await frozen(packageDir, linker, 1);
expect(stderr).toContain(changedSectionNote("overrides"));
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
test.concurrent("overrides trimmed from a pruned bun.lock still fail --frozen-lockfile", async () => {
const tree: Tree = { ...monorepo, root: { ...rootPackageJson, overrides: { "a-dep": "1.0.1" } } };
const { packageDir, full } = await verbatimScenario(linker, tree, survivors);
const trimmed = full.replace(/\n "overrides": \{\n(?: [^\n]*\n)* \},/, "");
expect(full).toContain('"overrides"');
expect(trimmed).not.toContain('"overrides"');
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr } = await frozen(packageDir, linker, 1);
expect(stderr).toContain(changedSectionNote("overrides"));
expect(await lockText(packageDir)).toBe(trimmed);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
test.concurrent("a catalog entry changed after bun.lock was written still fails --frozen-lockfile", async () => {
const { packageDir, full } = await verbatimScenario(linker, catalogTree, ["packages/app"]);
await write(
join(packageDir, "package.json"),
JSON.stringify({
...catalogTree.root,
workspaces: { packages: ["packages/*"], catalog: { "a-dep": "1.0.2", "left-pad": "1.0.0" } },
}),
);
const { stderr } = await frozen(packageDir, linker, 1);
expect(stderr).toContain(changedSectionNote("the catalog"));
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
test.concurrent("a survivor depending on a pruned workspace fails with the workspace error", async () => {
const { packageDir, full } = await verbatimScenario(linker, survivorTree, survivors);
const { stderr, exitCode } = await raw(packageDir, linker, ["install", "--frozen-lockfile"]);
expect(stderr).toContain(survivorError("app"));
expect(stderr).toContain(survivorNote);
expect(stderr).not.toContain('Workspace dependency "other" not found');
expect(stderr).not.toContain("failed to resolve");
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
expect(exitCode).toBe(1);
});
test.concurrent(
"a plain install reports a survivor depending on a missing workspace with the same error",
async () => {
const { packageDir, full } = await verbatimScenario(linker, survivorTree, survivors);
const { stderr, exitCode } = await raw(packageDir, linker, ["install"]);
expect(stderr).toContain(survivorError("app"));
expect(stderr).toContain(survivorNote);
expect(stderr).not.toContain('Workspace dependency "other" not found');
expect(stderr).not.toContain("failed to resolve");
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
expect(exitCode).toBe(1);
},
);
test.concurrent("a catalog entry only the pruned workspace used may be missing from bun.lock", async () => {
const { packageDir, full } = await verbatimScenario(linker, catalogTree, ["packages/app"]);
const trimmed = trimCatalogLine(full, "left-pad", "1.0.0");
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr } = await frozen(packageDir, linker, 0);
expect(stderr).toBe(`${prunedNote}\n${catalogNote("left-pad")}\n`);
expect(await lockText(packageDir)).toBe(trimmed);
expect(await exists(installedPath(packageDir, linker, "a-dep", "1.0.1"))).toBeTrue();
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "left-pad", "1.0.0"))).toBeFalse();
});
// docs/pm/cli/install.mdx: the skipped workspaces stay in bun.lock, so lockfile-driven commands still see them.
test.concurrent("bun pm ls --all still lists the skipped workspace and its exclusive dependency", async () => {
const { packageDir, full } = await verbatimTree(linker);
await frozen(packageDir, linker, 0);
const { stdout, stderr, exitCode } = await spawnBun(packageDir, ["pm", "ls", "--all"]);
expect(stderr).toBe("");
expect(normalizeBunSnapshot(stdout, packageDir)).toMatchInlineSnapshot(`
"<dir> node_modules
├── [email protected]
├── app@workspace:packages/app
├── [email protected]
├── [email protected]
├── other@workspace:packages/other
└── shared@workspace:packages/shared"
`);
expect(await lockText(packageDir)).toBe(full);
expect(exitCode).toBe(0);
});
test.concurrent("bun audit still submits the skipped workspace's exclusive dependency", async () => {
const { packageDir, full } = await verbatimTree(linker);
await frozen(packageDir, linker, 0);
const bodies: unknown[] = [];
await using auditServer = Bun.serve({
port: 0,
async fetch(req) {
bodies.push(JSON.parse(Buffer.from(Bun.gunzipSync(await req.arrayBuffer())).toString()));
return Response.json({});
},
});
const { stdout, stderr, exitCode } = await spawnBun(packageDir, ["audit"], packageDir, {
NPM_CONFIG_REGISTRY: auditServer.url.href,
});
expect(bodies).toStrictEqual([{ "a-dep": ["1.0.1"], "left-pad": ["1.0.0"], "no-deps": ["1.0.0"] }]);
expect(normalizeBunSnapshot(stdout)).toMatchInlineSnapshot(`
"bun audit <version> (<revision>)
No vulnerabilities found (checked 3 packages)"
`);
expect(stderr).toBe("");
expect(await lockText(packageDir)).toBe(full);
expect(exitCode).toBe(0);
});
test.concurrent("verbatim lockfile still passes when a surviving workspace has a lifecycle hook", async () => {
const tree = withApp({ scripts: { postinstall: "echo ok" } });
const { packageDir, full } = await verbatimScenario(linker, tree, survivors);
await frozen(packageDir, linker, 0);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "left-pad", "1.0.0"))).toBeFalse();
expect(await exists(installedPath(packageDir, linker, "a-dep", "1.0.1"))).toBeTrue();
});
});
describe("hoisted", () => {
test.concurrent("the root package depending on a pruned workspace is reported", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", rootSurvivorTree, survivors);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile"]);
expect(stderr).toContain(rootSurvivorError);
expect(stderr).toContain(survivorNote);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(exitCode).toBe(1);
});
test.concurrent("a plain install reports the root package depending on a missing workspace", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", rootSurvivorTree, survivors);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install"]);
expect(stderr).toContain(rootSurvivorError);
expect(stderr).toContain(survivorNote);
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(packageDir)).toBe(full);
expect(exitCode).toBe(1);
});
// Fences a from-side implementation: the edge to the missing workspace lives only in bun.lock now, so this must install.
test.concurrent("a plain install succeeds once the survivor no longer lists the missing workspace", async () => {
const { packageDir } = await verbatimScenario("hoisted", survivorTree, survivors);
await editApp(packageDir, app => {
delete app.dependencies.other;
});
const { stderr } = await install(packageDir, "hoisted");
expect(stderr).toContain("Saved lockfile");
expect(stderr).not.toContain("depends on workspace");
expect(await lockText(packageDir)).not.toContain('"packages/other"');
});
test.concurrent("--production still reports a devDependencies edge to a pruned workspace", async () => {
const tree = withApp({ devDependencies: { other: "workspace:*" } });
const { packageDir, full } = await verbatimScenario("hoisted", tree, survivors);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile", "--production"]);
expect(stderr).toContain(survivorError("app"));
expect(await lockText(packageDir)).toBe(full);
expect(exitCode).toBe(1);
});
test.concurrent.each(["optionalDependencies", "peerDependencies"])(
"a %s edge to a pruned workspace is reported too",
async group => {
const tree = withApp({ [group]: { other: "workspace:*" } });
const { packageDir, full } = await verbatimScenario("hoisted", tree, survivors);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile"]);
expect(stderr).toContain(survivorError("app"));
expect(await lockText(packageDir)).toBe(full);
expect(exitCode).toBe(1);
},
);
test.concurrent("every offending survivor is listed", async () => {
const tree: Tree = {
...survivorTree,
packages: {
...survivorTree.packages,
"packages/shared": { ...sharedPackageJson, dependencies: { other: "workspace:*" } },
},
};
const { packageDir, full } = await verbatimScenario("hoisted", tree, survivors);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile"]);
expect(stderr).toContain(survivorError("app"));
expect(stderr).toContain(survivorError("shared"));
expect(stderr.split(survivorNote)).toHaveLength(2);
expect(await lockText(packageDir)).toBe(full);
expect(exitCode).toBe(1);
});
test.concurrent("--silent suppresses the survivor error but still fails", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", survivorTree, survivors);
const { stdout, stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile", "--silent"]);
expect(stderr).not.toContain("depends on workspace");
expect(stderr).not.toContain("error:");
expect(stdout).toBe("");
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
expect(exitCode).toBe(1);
});
test.concurrent("named catalog group: unreferenced entry may be missing", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", namedCatalogTree, ["packages/app"]);
const trimmed = trimCatalogLine(full, "left-pad", "1.0.0");
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr } = await frozen(packageDir, "hoisted", 0);
expect(stderr).toContain(catalogNote("left-pad"));
expect(await lockText(packageDir)).toBe(trimmed);
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
});
test.concurrent("catalog note is pluralized", async () => {
const tree: Tree = {
root: {
name: "mono",
workspaces: {
packages: ["packages/*"],
catalog: { "a-dep": "1.0.1", "left-pad": "1.0.0", "no-deps": "1.0.0" },
},
},
packages: {
...catalogTree.packages,
"packages/other": {
name: "other",
version: "1.0.0",
dependencies: { "left-pad": "catalog:", "no-deps": "catalog:" },
},
},
};
const { packageDir, full } = await verbatimScenario("hoisted", tree, ["packages/app"]);
const trimmed = trimCatalogLine(trimCatalogLine(full, "left-pad", "1.0.0"), "no-deps", "1.0.0");
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr } = await frozen(packageDir, "hoisted", 0);
expect(stderr).toContain(catalogNote("left-pad", "no-deps"));
expect(await lockText(packageDir)).toBe(trimmed);
expect(await exists(join(packageDir, "node_modules", "a-dep", "package.json"))).toBeTrue();
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBeFalse();
});
test.concurrent("--silent suppresses the catalog note", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", catalogTree, ["packages/app"]);
const trimmed = trimCatalogLine(full, "left-pad", "1.0.0");
await write(join(packageDir, "bun.lock"), trimmed);
const { stdout, stderr } = await frozen(packageDir, "hoisted", 0, ["install", "--frozen-lockfile", "--silent"]);
expect(stdout + stderr).toBe("");
expect(await lockText(packageDir)).toBe(trimmed);
});
test.concurrent("bun ci behaves the same on turbo output", async () => {
const { packageDir, pruned } = await prunedTree("hoisted");
await frozen(packageDir, "hoisted", 0, ["ci"]);
expect(await lockText(packageDir)).toBe(pruned);
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toStrictEqual({
name: "no-deps",
version: "1.0.0",
});
});
// pnpm#11364: not on disk and not in bun.lock is a broken workspaces list, not a pruned checkout.
test.concurrent(
"explicitly listed workspace missing from disk and from a turbo-pruned bun.lock fails --frozen-lockfile",
async () => {
const { full } = await fullInstall("hoisted", explicitMonorepo);
const pruned = turboPrune(full);
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await writeTree(packageDir, explicitMonorepo, survivors);
await write(join(packageDir, "bun.lock"), pruned);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile"]);
expect(stderr).toContain('Workspace not found "packages/other"');
expect(stderr).not.toContain("not on disk");
expect(await lockText(packageDir)).toBe(pruned);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
expect(exitCode).toBe(1);
},
);
test.concurrent(
"explicitly listed workspace missing: --frozen-lockfile from inside a surviving workspace",
async () => {
const { packageDir, full } = await verbatimScenario("hoisted", explicitMonorepo, survivors);
await run(packageDir, "hoisted", ["install", "--frozen-lockfile"], 0, join(packageDir, "packages", "app"));
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "packages", "app", "bun.lock"))).toBeFalse();
expect((await lstat(join(packageDir, "node_modules", "app"))).isSymbolicLink()).toBeTrue();
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
},
);
test.concurrent("explicitly listed workspace missing from disk still errors on a non-frozen install", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", explicitMonorepo, survivors);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install"]);
expect(stderr).toContain('Workspace not found "packages/other"');
expect(exitCode).toBe(1);
expect(await lockText(packageDir)).toBe(full);
});
test.concurrent.each(["./packages/other", "packages/other/"])(
"explicitly listed workspace spelled %s still matches bun.lock's packages/other and is skipped",
async spelling => {
const tree: Tree = { ...monorepo, root: { ...rootPackageJson, workspaces: [...survivors, spelling] } };
const { packageDir, full } = await verbatimScenario("hoisted", tree, survivors);
expect(full).toContain('"packages/other"');
expect(full).not.toContain(`"${spelling}"`);
const { stderr } = await frozen(packageDir, "hoisted", 0);
expect(stderr).not.toContain("Workspace not found");
expect(stderr).toContain(prunedNote);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
},
);
test.concurrent("--verbose prints the same skipped-workspace note and no per-workspace lines", async () => {
const { packageDir } = await verbatimTree("hoisted");
const { stderr } = await frozen(packageDir, "hoisted", 0, ["install", "--frozen-lockfile", "--verbose"]);
expect(stderr.split(prunedNote)).toHaveLength(2);
expect(stderr).not.toContain("skipping workspace");
});
test.concurrent("--silent suppresses the skipped-workspace note", async () => {
const { packageDir } = await verbatimTree("hoisted");
const { stdout, stderr } = await frozen(packageDir, "hoisted", 0, ["install", "--frozen-lockfile", "--silent"]);
expect(stdout + stderr).toBe("");
expect(await exists(join(packageDir, "node_modules", "app"))).toBeTrue();
});
test.concurrent("no skipped-workspace note when nothing was skipped", async () => {
const { packageDir } = await prunedTree("hoisted");
const { stderr } = await frozen(packageDir, "hoisted", 0);
expect(stderr).not.toContain("not on disk");
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
});
test.concurrent("skipped-workspace note is pluralized", async () => {
const tree: Tree = {
root: rootPackageJson,
packages: {
...monorepo.packages,
"packages/other2": { name: "other2", version: "1.0.0" },
},
};
const { packageDir } = await verbatimScenario("hoisted", tree, survivors);
const { stderr } = await frozen(packageDir, "hoisted", 0);
expect(stderr).toBe('note: skipped 2 workspaces listed in bun.lock but not on disk: "other", "other2"\n');
});
// The one sanctioned frozen write: the bun.lockb -> bun.lock migration recipe from the docs.
test.concurrent(
"--save-text-lockfile --frozen-lockfile --lockfile-only still migrates a pruned bun.lockb",
async () => {
const tree: Tree = {
root: rootPackageJson,
packages: {
"packages/app": { name: "app", version: "1.0.0", dependencies: { "a-dep": "1.0.1" } },
"packages/other": { name: "other", version: "1.0.0", dependencies: { "left-pad": "1.0.0" } },
},
};
const bunfigOpts = { linker: "hoisted", saveTextLockfile: false } as const;
const { packageDir: fullDir } = await registry.createTestDir({ bunfigOpts });
await writeTree(fullDir, tree);
await install(fullDir, "hoisted");
const lockb = await file(join(fullDir, "bun.lockb")).bytes();
const { packageDir } = await registry.createTestDir({ bunfigOpts });
await writeTree(packageDir, tree, ["packages/app"]);
await write(join(packageDir, "bun.lockb"), lockb);
await frozen(packageDir, "hoisted", 0, [
"install",
"--save-text-lockfile",
"--frozen-lockfile",
"--lockfile-only",
]);
expect(await exists(join(packageDir, "bun.lockb"))).toBeFalse();
const lock = await lockText(packageDir);
expect(lock).toContain('"packages/other"');
expect(lock).toContain('"left-pad"');
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
},
);
// pnpm#8795: a catalog change is caught by the frozen check before `--lockfile-only` gets a chance to write.
test.concurrent("--frozen-lockfile --lockfile-only still fails on a catalog change in a pruned tree", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", catalogTree, ["packages/app"]);
await write(
join(packageDir, "package.json"),
JSON.stringify({
name: "mono",
workspaces: { packages: ["packages/*"], catalog: { "a-dep": "1.0.2", "left-pad": "1.0.0" } },
}),
);
await frozen(packageDir, "hoisted", 1, ["install", "--frozen-lockfile", "--lockfile-only"]);
expect(await lockText(packageDir)).toBe(full);
});
// pnpm#4861: `--frozen-lockfile --dry-run` is the install-free lockfile check, and it understands pruned trees.
test.concurrent("--frozen-lockfile --dry-run checks a pruned tree without installing", async () => {
const { packageDir, full } = await verbatimTree("hoisted");
await frozen(packageDir, "hoisted", 0, ["install", "--frozen-lockfile", "--dry-run"]);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
await editApp(packageDir, app => (app.dependencies["is-number"] = "1.0.0"));
await frozen(packageDir, "hoisted", 1, ["install", "--frozen-lockfile", "--dry-run"]);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
// pnpm#6312 / pnpm#9741: bun.lock's shape does not depend on the linker or on --production/--omit.
test.concurrent("pruned bun.lock written with the hoisted linker passes frozen under other settings", async () => {
const pruned = turboPrune(await fullLockfile("hoisted"));
const { packageDir: isolatedDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
await writeTree(isolatedDir, turboOutput, survivors);
await write(join(isolatedDir, "bun.lock"), pruned);
await frozen(isolatedDir, "isolated", 0);
expect(await lockText(isolatedDir)).toBe(pruned);
expect(await exists(installedPath(isolatedDir, "isolated", "a-dep", "1.0.1"))).toBeTrue();
const { packageDir: productionDir } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await writeTree(productionDir, turboOutput, survivors);
await write(join(productionDir, "bun.lock"), pruned);
await frozen(productionDir, "hoisted", 0, ["install", "--frozen-lockfile", "--production", "--omit=optional"]);
expect(await lockText(productionDir)).toBe(pruned);
expect(await exists(installedPath(productionDir, "hoisted", "a-dep", "1.0.1"))).toBeTrue();
});
// pnpm#5794: a pruned bun.lock a plain install would rewrite also fails frozen (the two checks are the same comparison).
test.concurrent(
"a hand-edited specifier in the pruned bun.lock fails frozen and is rewritten by a plain install",
async () => {
const { packageDir, pruned } = await prunedTree("hoisted");
const edited = pruned.replace('"a-dep": "1.0.1"', '"a-dep": "1.0.0"');
expect(edited).not.toBe(pruned);
await write(join(packageDir, "bun.lock"), edited);
await frozen(packageDir, "hoisted", 1);
expect(await lockText(packageDir)).toBe(edited);
const { stderr } = await install(packageDir, "hoisted");
expect(stderr).toContain("Saved lockfile");
expect(await lockText(packageDir)).toContain('"a-dep": "1.0.1"');
},
);
test.concurrent("--frozen-lockfile run from inside a surviving workspace", async () => {
const { packageDir, full } = await verbatimTree("hoisted");
await run(packageDir, "hoisted", ["install", "--frozen-lockfile"], 0, join(packageDir, "packages", "app"));
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "packages", "app", "bun.lock"))).toBeFalse();
expect((await lstat(join(packageDir, "node_modules", "app"))).isSymbolicLink()).toBeTrue();
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
});
test.concurrent("workspace folder without a package.json is treated as pruned", async () => {
const { packageDir, full } = await verbatimTree("hoisted");
await write(join(packageDir, "packages", "other", "dist", "index.js"), "");
await frozen(packageDir, "hoisted", 0);
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await exists(join(packageDir, "packages", "other", "node_modules"))).toBeFalse();
});
test.concurrent("pruned workspace's lifecycle scripts and bins are neither run nor linked", async () => {
const tree: Tree = {
root: rootPackageJson,
packages: {
"packages/app": { ...appPackageJson, bin: { "app-cli": "cli.js" } },
"packages/shared": sharedPackageJson,
"packages/other": {
...otherPackageJson,
bin: { "other-cli": "cli.js" },
scripts: { postinstall: "echo other-postinstall > ../../other-postinstall.txt" },
},
},
files: { "packages/app/cli.js": "", "packages/other/cli.js": "" },
};
const { packageDir, fullDir } = await verbatimScenario("hoisted", tree, survivors);
expect(await exists(join(fullDir, "other-postinstall.txt"))).toBeTrue();
for (const bin of binFiles(fullDir, "other-cli")) expect(await exists(bin)).toBeTrue();
const { stdout, stderr } = await frozen(packageDir, "hoisted", 0);
expect(stdout + stderr).not.toContain("other-postinstall");
expect(stdout + stderr).not.toContain("ENOENT");
expect(await exists(join(packageDir, "other-postinstall.txt"))).toBeFalse();
for (const bin of binFiles(packageDir, "other-cli")) expect(await exists(bin)).toBeFalse();
for (const bin of binFiles(packageDir, "app-cli")) expect(await exists(bin)).toBeTrue();
});
test.concurrent("--production composes with the pruned-workspace filter", async () => {
const tree: Tree = {
root: rootPackageJson,
packages: {
"packages/app": {
name: "app",
version: "1.0.0",
dependencies: { shared: "workspace:*" },
devDependencies: { "a-dep": "1.0.1" },
},
"packages/shared": sharedPackageJson,
"packages/other": { name: "other", version: "1.0.0", devDependencies: { "left-pad": "1.0.0" } },
},
};
const { packageDir, full } = await verbatimScenario("hoisted", tree, survivors);
await frozen(packageDir, "hoisted", 0, ["install", "--frozen-lockfile", "--production"]);
expect(await lockText(packageDir)).toBe(full);
expect((await lstat(join(packageDir, "node_modules", "shared"))).isSymbolicLink()).toBeTrue();
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "a-dep"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
});
test.concurrent("non-frozen prune writes the same bun.lock a fresh install of the survivors would", async () => {
const { packageDir } = await verbatimTree("hoisted");
const { full: expected } = await fullInstall("hoisted", {
root: rootPackageJson,
packages: { "packages/app": appPackageJson, "packages/shared": sharedPackageJson },
});
const { stderr } = await install(packageDir, "hoisted");
expect(stderr).toContain("Saved lockfile");
expect(await lockText(packageDir)).toBe(expected);
});
// No `workspace:` edges here: bun.lockb round-trips those as a diff on its own, independent of pruning.
test.concurrent("verbatim bun.lockb with a workspace folder missing passes --frozen-lockfile", async () => {
const tree: Tree = {
root: rootPackageJson,
packages: {
"packages/app": { name: "app", version: "1.0.0", dependencies: { "a-dep": "1.0.1" } },
"packages/other": { name: "other", version: "1.0.0", dependencies: { "left-pad": "1.0.0" } },
},
};
const bunfigOpts = { linker: "hoisted", saveTextLockfile: false } as const;
const { packageDir: fullDir } = await registry.createTestDir({ bunfigOpts });
await writeTree(fullDir, tree);
await install(fullDir, "hoisted");
const lockb = await file(join(fullDir, "bun.lockb")).bytes();
const { packageDir } = await registry.createTestDir({ bunfigOpts });
await writeTree(packageDir, tree, ["packages/app"]);
await write(join(packageDir, "bun.lockb"), lockb);
await frozen(packageDir, "hoisted", 0);
expect(await file(join(packageDir, "bun.lockb")).bytes()).toStrictEqual(lockb);
expect(await exists(join(packageDir, "bun.lock"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "other"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
});
test.concurrent("pruned tree using catalogs passes --frozen-lockfile when the catalog is left intact", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", catalogTree, ["packages/app"]);
expect(full).toContain('"left-pad": "1.0.0"');
const { stderr } = await frozen(packageDir, "hoisted", 0);
expect(stderr).not.toContain("catalog entr");
expect(await lockText(packageDir)).toBe(full);
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
});
// The guards below pass on main too; they pin the boundaries of the pruned-workspace relaxation.
test.concurrent("a pruned workspace does not mask a workspace added on disk", async () => {
const { packageDir, full } = await verbatimTree("hoisted");
await write(join(packageDir, "packages", "extra", "package.json"), JSON.stringify({ name: "extra" }));
await frozen(packageDir, "hoisted", 1);
expect(await lockText(packageDir)).toBe(full);
});
test.concurrent("a pruned workspace does not mask a dependency added to the root package.json", async () => {
const { packageDir, full } = await verbatimTree("hoisted");
await write(
join(packageDir, "package.json"),
JSON.stringify({ ...rootPackageJson, dependencies: { "is-number": "1.0.0" } }),
);
await frozen(packageDir, "hoisted", 1);
expect(await lockText(packageDir)).toBe(full);
});
test.concurrent(
"a dependency-free workspace on disk but missing from bun.lock still fails --frozen-lockfile",
async () => {
const { packageDir, pruned } = await prunedTree("hoisted");
await write(
join(packageDir, "packages", "newpkg", "package.json"),
JSON.stringify({ name: "newpkg", version: "1.0.0" }),
);
await write(
join(packageDir, "package.json"),
JSON.stringify({ ...rootPackageJson, workspaces: [...survivors, "packages/newpkg"] }),
);
await frozen(packageDir, "hoisted", 1);
expect(await lockText(packageDir)).toBe(pruned);
},
);
test.concurrent("a package.json change still drops the optional-peer-only entry on a normal install", async () => {
const { packageDir } = await prunedTree("hoisted");
await editApp(packageDir, app => delete app.dependencies["a-dep"]);
const { stderr } = await install(packageDir, "hoisted");
expect(stderr).toContain("Saved lockfile");
const lock = await lockText(packageDir);
expect(lock).not.toContain('"no-deps": ["no-deps@');
expect(lock).not.toContain('"a-dep"');
});
test.concurrent("a workspace that is on disk but no longer globbed is not treated as pruned", async () => {
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await writeTree(packageDir, monorepo);
const full = await fullLockfile("hoisted");
await write(join(packageDir, "bun.lock"), full);
await write(
join(packageDir, "package.json"),
JSON.stringify({ name: "mono", workspaces: ["packages/app", "packages/shared"] }),
);
await frozen(packageDir, "hoisted", 1);
expect(await lockText(packageDir)).toBe(full);
});
test.concurrent(
"single-package project: trustedDependencies stripped from bun.lock does not drop a peer-held package under --frozen-lockfile",
async () => {
const single: Tree = {
root: {
name: "single",
dependencies: { "optional-peer-deps": "1.0.0", "no-deps": "1.0.0" },
trustedDependencies: ["optional-peer-deps"],
},
packages: {},
};
const { full } = await fullInstall("hoisted", single);
expect(full).toContain('"trustedDependencies"');
// The 8-space row is the root's declared dependency; the package entry stays, held only by optional-peer-deps' peer slot.
const pruned = full.replace(/\n "no-deps": "1\.0\.0",/, "").replace(trustedDependenciesSection, "");
expect(pruned).not.toBe(full);
expect(pruned).toContain('"no-deps": ["[email protected]"');
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await write(
join(packageDir, "package.json"),
JSON.stringify({
name: "single",
dependencies: { "optional-peer-deps": "1.0.0" },
trustedDependencies: ["optional-peer-deps"],
}),
);
await write(join(packageDir, "bun.lock"), pruned);
await frozen(packageDir, "hoisted", 0);
expect(await lockText(packageDir)).toBe(pruned);
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toStrictEqual({
name: "no-deps",
version: "1.0.0",
});
},
);
// Fences of the catalog-subset relaxation: only entries no surviving importer or override references may be missing.
test.concurrent("a catalog entry a surviving workspace references must stay in bun.lock", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", catalogTree, ["packages/app"]);
const trimmed = trimCatalogLine(full, "a-dep", "1.0.1");
await write(join(packageDir, "bun.lock"), trimmed);
await frozen(packageDir, "hoisted", 1);
expect(await lockText(packageDir)).toBe(trimmed);
});
test.concurrent("a catalog entry an override references must stay in bun.lock", async () => {
const tree: Tree = {
root: {
name: "mono",
workspaces: { packages: ["packages/*"], catalog: { "no-deps": "1.0.0" } },
overrides: { "no-deps": "catalog:" },
},
packages: {
"packages/app": { name: "app", version: "1.0.0", dependencies: { "one-dep": "1.0.0" } },
"packages/other": { name: "other", version: "1.0.0" },
},
};
const { packageDir, full } = await verbatimScenario("hoisted", tree, ["packages/app"]);
const trimmed = trimCatalogLine(full, "no-deps", "1.0.0");
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile"]);
expect(stderr).toContain("error:");
expect(await lockText(packageDir)).toBe(trimmed);
expect(exitCode).toBe(1);
});
test.concurrent("a catalog entry the root package.json references must stay in bun.lock", async () => {
const tree: Tree = { ...catalogTree, root: { ...catalogTree.root, dependencies: { "left-pad": "catalog:" } } };
const { packageDir, full } = await verbatimScenario("hoisted", tree, ["packages/app"]);
const trimmed = trimCatalogLine(full, "left-pad", "1.0.0");
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr } = await frozen(packageDir, "hoisted", 1);
expect(stderr).toContain(changedSectionNote("the catalog"));
expect(stderr).not.toContain("catalog entr");
expect(await lockText(packageDir)).toBe(trimmed);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
test.concurrent("a catalog entry a scoped override references must stay in bun.lock", async () => {
const tree: Tree = {
root: {
name: "mono",
workspaces: { packages: ["packages/*"], catalog: { "no-deps": "1.0.0" } },
overrides: { "one-dep>no-deps": "catalog:" },
},
packages: {
"packages/app": { name: "app", version: "1.0.0", dependencies: { "one-dep": "1.0.0" } },
"packages/other": { name: "other", version: "1.0.0" },
},
};
const { packageDir, full } = await verbatimScenario("hoisted", tree, ["packages/app"]);
expect(full).toContain('"one-dep": {\n "no-deps": "catalog:",\n },');
expect(full).toContain('"no-deps": ["[email protected]"');
const trimmed = trimCatalogLine(full, "no-deps", "1.0.0");
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr } = await frozen(packageDir, "hoisted", 1);
expect(stderr).toContain(changedSectionNote("the catalog"));
expect(stderr).not.toContain("catalog entr");
expect(await lockText(packageDir)).toBe(trimmed);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
test.concurrent(
"a whole named catalog group only the pruned workspace used may be missing from bun.lock",
async () => {
const tree: Tree = {
root: {
name: "mono",
workspaces: {
packages: ["packages/*"],
catalog: { "a-dep": "1.0.1" },
catalogs: { build: { "left-pad": "1.0.0", "no-deps": "1.0.0" } },
},
},
packages: {
"packages/app": { name: "app", version: "1.0.0", dependencies: { "a-dep": "catalog:" } },
"packages/other": {
name: "other",
version: "1.0.0",
dependencies: { "left-pad": "catalog:build", "no-deps": "catalog:build" },
},
},
};
const { packageDir, full } = await verbatimScenario("hoisted", tree, ["packages/app"]);
const trimmed = full.replace(/\n "catalogs": \{\n(?: [^\n]*\n)* \},/, "");
expect(full).toContain('"catalogs"');
expect(trimmed).not.toContain('"catalogs"');
expect(trimmed).toContain('"catalog": {\n "a-dep": "1.0.1",\n },');
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr } = await frozen(packageDir, "hoisted", 0);
expect(stderr).toContain(prunedNote);
expect(stderr).toContain(catalogNote("left-pad", "no-deps"));
expect(await lockText(packageDir)).toBe(trimmed);
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBeFalse();
},
);
test.concurrent("a whole named catalog group a surviving workspace uses must stay in bun.lock", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", namedCatalogTree, ["packages/app"]);
const trimmed = full.replace(/\n "catalogs": \{\n(?: [^\n]*\n)* \},/, "");
expect(full).toContain('"catalogs"');
expect(trimmed).not.toContain('"catalogs"');
await write(join(packageDir, "bun.lock"), trimmed);
const { stderr } = await frozen(packageDir, "hoisted", 1);
expect(stderr).toContain(changedSectionNote("the catalog"));
expect(await lockText(packageDir)).toBe(trimmed);
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
test.concurrent("bun.lock may not carry catalog entries package.json lacks", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", catalogTree, ["packages/app"]);
await write(
join(packageDir, "package.json"),
JSON.stringify({ name: "mono", workspaces: { packages: ["packages/*"], catalog: { "a-dep": "1.0.1" } } }),
);
await frozen(packageDir, "hoisted", 1);
expect(await lockText(packageDir)).toBe(full);
});
test.concurrent("a differing specifier is still a change even when the lockfile is a subset", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", catalogTree, ["packages/app"]);
const trimmed = trimCatalogLine(full, "left-pad", "1.0.0");
await write(join(packageDir, "bun.lock"), trimmed);
await write(
join(packageDir, "package.json"),
JSON.stringify({
name: "mono",
workspaces: { packages: ["packages/*"], catalog: { "a-dep": "1.0.2", "left-pad": "1.0.0" } },
}),
);
const { stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile"]);
expect(stderr).toContain("error:");
expect(await lockText(packageDir)).toBe(trimmed);
expect(exitCode).toBe(1);
});
// A hand-maintained bun.lock may list the default catalog under both spellings; the loaded side then has more entries than package.json.
test.concurrent("a default catalog entry listed under both catalog and catalogs.default in bun.lock", async () => {
const { packageDir, doubled } = await doubleListedScenario();
const { stdout, stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile"]);
expect(normalizeBunSnapshot(stdout, packageDir)).toMatchInlineSnapshot(`
"bun install <version> (<revision>)
2 packages installed"
`);
expect(normalizeBunSnapshot(stderr, packageDir)).toMatchInlineSnapshot(`""`);
expect(exitCode).toBe(0);
expect(await lockText(packageDir)).toBe(doubled);
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toStrictEqual({
name: "a-dep",
version: "1.0.1",
});
});
test.concurrent("the double-listed entry does not count toward the skipped catalog entries", async () => {
const { packageDir, doubled } = await doubleListedScenario();
await write(
join(packageDir, "package.json"),
JSON.stringify({
name: "mono",
workspaces: { packages: ["packages/*"], catalog: { "a-dep": "1.0.1", "left-pad": "1.0.0" } },
}),
);
const { stdout, stderr, exitCode } = await raw(packageDir, "hoisted", ["install", "--frozen-lockfile"]);
expect(normalizeBunSnapshot(stdout, packageDir)).toMatchInlineSnapshot(`
"bun install <version> (<revision>)
2 packages installed"
`);
expect(normalizeBunSnapshot(stderr, packageDir)).toMatchInlineSnapshot(
`"note: skipped 1 catalog entry not in bun.lock (unused by the workspaces on disk): "left-pad""`,
);
expect(exitCode).toBe(0);
expect(await lockText(packageDir)).toBe(doubled);
expect(await exists(join(packageDir, "node_modules", "left-pad"))).toBeFalse();
});
test.concurrent("a plain install writes the full catalog back", async () => {
const { packageDir, full } = await verbatimScenario("hoisted", catalogTree, ["packages/app"]);
await write(join(packageDir, "bun.lock"), trimCatalogLine(full, "left-pad", "1.0.0"));
const { stderr } = await install(packageDir, "hoisted");
expect(stderr).toContain("Saved lockfile");
expect(stderr).not.toContain("catalog entr");
const lock = await lockText(packageDir);
expect(lock).toContain('"left-pad": "1.0.0"');
expect(lock).not.toContain('"packages/other"');
});
// A new edge to a package bun.lock already hoists is invisible to the frozen tree comparison even without pruning, so the change here has to bring in a new package.
test.concurrent("verbatim lockfile with a hook still fails on a real change", async () => {
const tree = withApp({ scripts: { postinstall: "echo ok" } });
const { packageDir, full } = await verbatimScenario("hoisted", tree, survivors);
await editApp(packageDir, app => (app.dependencies["no-deps"] = "2.0.0"));
await frozen(packageDir, "hoisted", 1);
expect(await lockText(packageDir)).toBe(full);
});
});