Files
bun-src/test/cli/install/bun-lock.test.ts
T
2026-08-27 21:09:14 +00:00

1829 lines
63 KiB
TypeScript

import { file, spawn, write } from "bun";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { readlinkSync } from "fs";
import { access, copyFile, cp, exists, open, rm, writeFile } from "fs/promises";
import {
bunExe,
bunEnv as env,
isWindows,
normalizeBunSnapshot,
readdirSorted,
runBunInstall,
tempDir,
toBeValidBin,
VerdaccioRegistry,
} from "harness";
import { join } from "path";
expect.extend({
toBeValidBin,
});
var registry = new VerdaccioRegistry();
beforeAll(async () => {
await registry.start();
});
afterAll(() => {
registry.stop();
});
it("should write plaintext lockfiles", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
// copy bar-0.0.2.tgz to package_dir
await copyFile(join(__dirname, "bar-0.0.2.tgz"), join(packageDir, "bar-0.0.2.tgz"));
// Create a simple package.json
await writeFile(
packageJson,
JSON.stringify({
name: "test-package",
version: "1.0.0",
dependencies: {
"dummy-package": "file:./bar-0.0.2.tgz",
},
}),
);
// Run 'bun install' to generate the lockfile
const installResult = spawn({
cmd: [bunExe(), "install", "--save-text-lockfile"],
cwd: packageDir,
env,
});
await installResult.exited;
// Ensure the lockfile was created
await access(join(packageDir, "bun.lock"));
// Assert that the lockfile has the correct permissions
await using file = await open(join(packageDir, "bun.lock"), "r");
const stat = await file.stat();
// in unix, 0o644 == 33188
let mode = 33188;
// ..but windows is different
if (isWindows) {
mode = 33206;
}
expect(stat.mode).toBe(mode);
expect(await file.readFile({ encoding: "utf8" })).toMatchSnapshot();
});
// won't work on windows, " is not a valid character in a filename
it.skipIf(isWindows)("should escape names", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "quote-in-dependency-name",
workspaces: ["packages/*"],
}),
),
write(join(packageDir, "packages", '"', "package.json"), JSON.stringify({ name: '"' })),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
dependencies: {
'"': "*",
},
}),
),
]);
const { exited } = spawn({
cmd: [bunExe(), "install", "--save-text-lockfile"],
cwd: packageDir,
stdout: "ignore",
stderr: "ignore",
env,
});
expect(await exited).toBe(0);
expect(await file(join(packageDir, "bun.lock")).text()).toMatchSnapshot();
});
it("should be the default save format", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await write(
packageJson,
JSON.stringify({
name: "jquery-4",
version: "4.0.0",
dependencies: {
"no-deps": "1.0.0",
},
}),
);
await runBunInstall(env, packageDir);
expect(await exists(join(packageDir, "bun.lockb"))).toBe(false);
expect(
(await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234"),
).toMatchSnapshot();
// adding a package will add to the text lockfile
await runBunInstall(env, packageDir, { packages: ["a-dep"] });
expect(await exists(join(packageDir, "bun.lockb"))).toBe(false);
expect(
(await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234"),
).toMatchSnapshot();
});
it("should save the lockfile if --save-text-lockfile and --frozen-lockfile are used", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } });
await Promise.all([
write(packageJson, JSON.stringify({ name: "test-pkg", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } })),
]);
async function checkLockfiles() {
return await Promise.all([exists(join(packageDir, "bun.lock")), exists(join(packageDir, "bun.lockb"))]);
}
// save a binary lockfile
await runBunInstall(env, packageDir, {});
expect(await checkLockfiles()).toEqual([false, true]);
// --save-text-lockfile with --frozen-lockfile
await runBunInstall(env, packageDir, { saveTextLockfile: true, frozenLockfile: true });
expect(await checkLockfiles()).toEqual([true, false]);
const firstLockfile = (await file(join(packageDir, "bun.lock")).text()).replaceAll(
/localhost:\d+/g,
"localhost:1234",
);
expect(firstLockfile).toMatchSnapshot();
// adding a package without --save-text-lockfile will continue to use the text lockfile
await runBunInstall(env, packageDir, { packages: ["a-dep"] });
expect(await checkLockfiles()).toEqual([true, false]);
const secondLockfile = (await file(join(packageDir, "bun.lock")).text()).replaceAll(
/localhost:\d+/g,
"localhost:1234",
);
expect(firstLockfile).not.toBe(secondLockfile);
expect(secondLockfile).toMatchSnapshot();
});
it("should convert a binary lockfile with invalid optional peers", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { npm: true } });
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "pkg1",
dependencies: {
"langchain": "^0.0.194",
},
}),
),
cp(join(import.meta.dir, "fixtures", "invalid-optional-peer.lockb"), join(packageDir, "bun.lockb")),
]);
let { exited, stdout, stderr } = spawn({
cmd: [bunExe(), "install", "--save-text-lockfile", "--lockfile-only"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
});
let [out, err] = await Promise.all([stdout.text(), stderr.text()]);
expect(err).toContain("Saved lockfile");
expect(out).toContain("Saved bun.lock (69 packages)");
expect(await exited).toBe(0);
const [firstLockfile, lockbExists] = await Promise.all([
await file(join(packageDir, "bun.lock")).text(),
exists(join(packageDir, "bun.lockb")),
]);
expect(firstLockfile).toMatchSnapshot();
expect(lockbExists).toBeFalse();
// running again should not change the lockfile
({ exited, stdout, stderr } = spawn({
cmd: [bunExe(), "install", "--lockfile-only"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
}));
[out, err] = await Promise.all([stdout.text(), stderr.text()]);
expect(err).not.toContain("Saved lockfile");
expect(out).toContain("Done! Checked 69 packages (no changes)");
expect(await exited).toBe(0);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(firstLockfile);
});
it("should not deduplicate bundled packages with un-bundled packages", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "bundled-deps",
dependencies: {
"debug-1": "4.4.0",
"npm-1": "10.9.2",
},
}),
),
]);
let { exited, stdout } = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "inherit",
});
expect(await exited).toBe(0);
async function checkModules() {
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual(["debug-1", "ms-1", "npm-1"]);
}
await checkModules();
const out1 = (await stdout.text())
.replaceAll(/\s*\[[0-9\.]+m?s\]\s*$/g, "")
.split(/\r?\n/)
.slice(1);
expect(out1).toMatchSnapshot();
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
// running install again will install all packages to node_modules
({ exited, stdout } = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "inherit",
}));
expect(await exited).toBe(0);
await checkModules();
const out2 = (await stdout.text())
.replaceAll(/\s*\[[0-9\.]+m?s\]\s*$/g, "")
.split(/\r?\n/)
.slice(1);
expect(out2).toEqual(out1);
// force saving a lockfile does not increase the number of packages
({ exited, stdout } = spawn({
cmd: [bunExe(), "install", "--lockfile-only"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "inherit",
}));
expect(await exited).toBe(0);
await checkModules();
const out3 = (await stdout.text())
.replaceAll(/\s*\[[0-9\.]+m?s\]\s*$/g, "")
.split(/\r?\n/)
.slice(1);
({ exited, stdout } = spawn({
cmd: [bunExe(), "install", "--lockfile-only"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "inherit",
}));
expect(await exited).toBe(0);
await checkModules();
const out4 = (await stdout.text())
.replaceAll(/\s*\[[0-9\.]+m?s\]\s*$/g, "")
.split(/\r?\n/)
.slice(1);
expect(out4).toEqual(out3);
expect(out4).toMatchSnapshot();
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
// --frozen-lockfile is successful
({ exited, stdout } = spawn({
cmd: [bunExe(), "install", "--frozen-lockfile"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "inherit",
}));
expect(await exited).toBe(0);
await checkModules();
});
it("should not change formatting unexpectedly", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
const patch = `diff --git a/package.json b/package.json
index d156130662798530e852e1afaec5b1c03d429cdc..b4ddf35975a952fdaed99f2b14236519694f850d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"name": "optional-peer-deps",
"version": "1.0.0",
+ "hi": true,
"peerDependencies": {
"no-deps": "*"
},
`;
// attempt to snapshot most things that can be printed
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "pkg-root",
version: "1.0.0",
workspaces: ["packages/*"],
scripts: {
preinstall: "echo 'preinstall'",
},
overrides: {
"hoist-lockfile-shared": "1.0.1",
},
bin: "index.js",
optionalDependencies: {
"optional-native": "1.0.0",
},
devDependencies: {
"optional-peer-deps": "1.0.0",
},
dependencies: {
"uses-what-bin": "1.0.0",
},
trustedDependencies: ["uses-what-bin"],
patchedDependencies: {
"[email protected]": "patches/[email protected]",
},
}),
),
write(join(packageDir, "patches", "[email protected]"), patch),
write(join(packageDir, "index.js"), "console.log('hello world')"),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
version: "2.2.2",
peerDependenciesMeta: {
"a-dep": {
optional: true,
},
},
peerDependencies: {
"a-dep": "1.0.1",
},
dependencies: {
"bundled-1": "1.0.0",
},
bin: {
"pkg1-1": "bin-1.js",
"pkg1-2": "bin-2.js",
"pkg1-3": "bin-3.js",
},
scripts: {
install: "echo 'install'",
postinstall: "echo 'postinstall'",
},
}),
),
write(join(packageDir, "packages", "pkg1", "bin-1.js"), "console.log('bin-1')"),
write(join(packageDir, "packages", "pkg1", "bin-2.js"), "console.log('bin-2')"),
write(join(packageDir, "packages", "pkg1", "bin-3.js"), "console.log('bin-3')"),
write(
join(packageDir, "packages", "pkg2", "package.json"),
JSON.stringify({
name: "pkg2",
bin: {
"pkg2-1": "bin-1.js",
},
dependencies: {
"map-bin": "1.0.2",
},
}),
),
write(join(packageDir, "packages", "pkg2", "bin-1.js"), "console.log('bin-1')"),
write(
join(packageDir, "packages", "pkg3", "package.json"),
JSON.stringify({
name: "pkg3",
directories: {
bin: "bin",
},
devDependencies: {
"hoist-lockfile-1": "1.0.0",
},
}),
),
write(join(packageDir, "packages", "pkg3", "bin", "bin-1.js"), "console.log('bin-1')"),
]);
async function checkInstall() {
expect(
await Promise.all([
exists(join(packageDir, "node_modules", "pkg1", "package.json")),
exists(join(packageDir, "node_modules", "pkg2", "package.json")),
exists(join(packageDir, "node_modules", "pkg3", "package.json")),
file(join(packageDir, "node_modules", "hoist-lockfile-shared", "package.json")).json(),
exists(join(packageDir, "node_modules", "uses-what-bin", "what-bin.txt")),
file(join(packageDir, "node_modules", "optional-peer-deps", "package.json")).json(),
]),
).toMatchObject([true, true, true, { name: "hoist-lockfile-shared", version: "1.0.1" }, true, { hi: true }]);
expect(join(packageDir, "node_modules", ".bin", "bin-1.js")).toBeValidBin(join("..", "pkg3", "bin", "bin-1.js"));
expect(join(packageDir, "node_modules", ".bin", "map-bin")).toBeValidBin(join("..", "map-bin", "bin", "map-bin"));
expect(join(packageDir, "node_modules", ".bin", "map_bin")).toBeValidBin(join("..", "map-bin", "bin", "map-bin"));
expect(join(packageDir, "node_modules", ".bin", "pkg1-1")).toBeValidBin(join("..", "pkg1", "bin-1.js"));
expect(join(packageDir, "node_modules", ".bin", "pkg1-2")).toBeValidBin(join("..", "pkg1", "bin-2.js"));
expect(join(packageDir, "node_modules", ".bin", "pkg1-3")).toBeValidBin(join("..", "pkg1", "bin-3.js"));
expect(join(packageDir, "node_modules", ".bin", "pkg2-1")).toBeValidBin(join("..", "pkg2", "bin-1.js"));
expect(join(packageDir, "node_modules", ".bin", "what-bin")).toBeValidBin(join("..", "what-bin", "what-bin.js"));
}
let { exited, stdout } = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "inherit",
});
expect(await exited).toBe(0);
const out1 = (await stdout.text())
.replaceAll(/\s*\[[0-9\.]+m?s\]\s*$/g, "")
.split(/\r?\n/)
.slice(1);
expect(out1).toMatchInlineSnapshot(`
[
"preinstall",
"",
"+ [email protected] (v1.0.1 available)",
"+ [email protected]",
"+ [email protected] (v1.5.0 available)",
"",
"13 packages installed",
]
`);
await checkInstall();
const lockfile = (await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234");
expect(lockfile).toMatchSnapshot();
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
({ exited, stdout } = spawn({
cmd: [bunExe(), "install"],
cwd: join(packageDir, "packages", "pkg1"),
env,
stdout: "pipe",
stderr: "inherit",
}));
expect(await exited).toBe(0);
const out2 = (await stdout.text())
.replaceAll(/\s*\[[0-9\.]+m?s\]\s*$/g, "")
.split(/\r?\n/)
.slice(1);
expect(out2).toMatchInlineSnapshot(`
[
"preinstall",
"",
"+ [email protected]",
"",
"13 packages installed",
]
`);
await checkInstall();
expect((await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234")).toBe(
lockfile,
);
});
describe("writes trustedDependencies and patchedDependencies in the order earlier versions wrote them", () => {
// Neither section is sorted: each is written in the iteration order of the
// map that collects it, so that order is part of the format. The expected
// blocks below are what bun 1.3.14 writes for these package.json files.
// Writing a different order would reorder both sections in every existing
// bun.lock on the next `bun add`/`bun remove`, and the older version would
// flip them back. The second shape uses a larger trusted map (13 entries
// instead of 7) and fills the patched map up to its growth threshold (6 of
// 8 slots), so a change to how the maps size themselves shows up here too.
const shapes = [
{
trusted: ["esbuild", "sharp", "@prisma/client", "prisma", "bcrypt", "core-js", "@prisma/engines"],
patched: ["esbuild", "sharp", "prisma", "bcrypt", "core-js"],
expected: ` "trustedDependencies": [
"bcrypt",
"esbuild",
"sharp",
"@prisma/engines",
"@prisma/client",
"core-js",
"prisma",
],
"patchedDependencies": {
"[email protected]": "patches/prisma.patch",
"[email protected]": "patches/bcrypt.patch",
"[email protected]": "patches/core-js.patch",
"[email protected]": "patches/esbuild.patch",
"[email protected]": "patches/sharp.patch",
},
`,
},
{
trusted: [
"esbuild",
"sharp",
"@prisma/client",
"prisma",
"bcrypt",
"core-js",
"@prisma/engines",
"puppeteer",
"playwright",
"electron",
"better-sqlite3",
"fsevents",
"@swc/core",
],
patched: ["esbuild", "sharp", "prisma", "bcrypt", "core-js", "puppeteer"],
expected: ` "trustedDependencies": [
"bcrypt",
"@swc/core",
"core-js",
"playwright",
"esbuild",
"sharp",
"@prisma/engines",
"fsevents",
"@prisma/client",
"electron",
"better-sqlite3",
"prisma",
"puppeteer",
],
"patchedDependencies": {
"[email protected]": "patches/prisma.patch",
"[email protected]": "patches/bcrypt.patch",
"[email protected]": "patches/core-js.patch",
"[email protected]": "patches/esbuild.patch",
"[email protected]": "patches/puppeteer.patch",
"[email protected]": "patches/sharp.patch",
},
`,
},
];
const trustedAndPatchedSections = (lockfile: string) =>
lockfile.slice(lockfile.indexOf(' "trustedDependencies"'), lockfile.indexOf(' "packages"'));
it.each(shapes)("$trusted.length trusted, $patched.length patched", async ({ trusted, patched, expected }) => {
const scopes = new Set(trusted.filter(name => name.startsWith("@")).map(name => name.split("/")[0]));
const files: Record<string, string> = {
"package.json": JSON.stringify({
name: "trusted-and-patched-order",
version: "1.0.0",
workspaces: ["packages/*", ...Array.from(scopes, scope => `packages/${scope}/*`)],
trustedDependencies: trusted,
patchedDependencies: Object.fromEntries(patched.map(name => [`${name}@1.0.0`, `patches/${name}.patch`])),
}),
};
for (const name of trusted) {
files[`packages/${name}/package.json`] = JSON.stringify({ name, version: "1.0.0" });
}
for (const name of patched) {
files[`patches/${name}.patch`] = `diff --git a/index.js b/index.js
new file mode 100644
index 0000000..e69de29
`;
}
const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" }, files });
await runBunInstall(env, packageDir);
expect(trustedAndPatchedSections(await file(join(packageDir, "bun.lock")).text())).toBe(expected);
// Re-saving the lockfile because something else changed must leave both
// sections untouched.
await write(
join(packageDir, "packages", "left-pad", "package.json"),
JSON.stringify({ name: "left-pad", version: "1.0.0" }),
);
await runBunInstall(env, packageDir);
const resaved = await file(join(packageDir, "bun.lock")).text();
expect(resaved).toContain('"left-pad": ["left-pad@workspace:packages/left-pad"]');
expect(trustedAndPatchedSections(resaved)).toBe(expected);
});
});
it("should sort overrides before comparing", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
const pkg = {
name: "pkg-with-overrides",
dependencies: {
"one-dep": "1.0.0",
"uses-what-bin": "1.5.0",
},
peerDependencies: {
"what-bin": "1.0.0",
"no-deps": "2.0.0",
},
peerDependenciesMeta: {
"what-bin": {
optional: true,
},
"no-deps": {
optional: true,
},
},
resolutions: {
"what-bin": "1.0.0",
"no-deps": "2.0.0",
},
};
await write(packageJson, JSON.stringify(pkg));
await runBunInstall(env, packageDir);
const lockfile = (await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234");
expect(lockfile).toMatchSnapshot();
await runBunInstall(env, packageDir, { frozenLockfile: true });
// now swap "what-bin" and "no-deps" in resolutions
pkg.resolutions = {
"no-deps": "2.0.0",
"what-bin": "1.0.0",
};
await write(packageJson, JSON.stringify(pkg));
await runBunInstall(env, packageDir, { frozenLockfile: true });
// --frozen-lockfile was a success. lockfile will be the same as the first
const secondLockfile = (await file(join(packageDir, "bun.lock")).text()).replaceAll(
/localhost:\d+/g,
"localhost:1234",
);
expect(secondLockfile).toBe(lockfile);
});
it("should pass frozen lockfile check when a bundled dependency has an optional peer satisfiable from the root", async () => {
// A bundled dependency's optional peer must not resolve across the bundle
// hoist root when the lockfile is loaded, otherwise a fresh install and a
// loaded lockfile disagree about the tree and --frozen-lockfile rejects a
// lockfile bun itself just wrote (issue #37346).
const { packageDir, packageJson } = await registry.createTestDir();
await write(
packageJson,
JSON.stringify({
name: "frozen-bundled-optional-peer",
dependencies: {
// bundles `optional-peer-deps`, which has an optional peer on `no-deps`
"bundled-optional-peer": "1.0.0",
"no-deps": "1.0.0",
},
}),
);
await runBunInstall(env, packageDir);
const lockfile = await file(join(packageDir, "bun.lock")).text();
expect(lockfile).toContain('"bundled": true');
await runBunInstall(env, packageDir, { frozenLockfile: true });
// and from a cold start with no node_modules
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
await runBunInstall(env, packageDir, { frozenLockfile: true });
expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile);
});
it("should include unused resolutions in the lockfile", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
// we need to include unused resolutions in order to detect changes from package.json
const pkg = {
name: "pkg-with-unused-override",
dependencies: {
"one-dep": "1.0.0",
"uses-what-bin": "1.5.0",
},
peerDependencies: {
"what-bin": "1.0.0",
"no-deps": "2.0.0",
},
peerDependenciesMeta: {
"what-bin": {
optional: true,
},
"no-deps": {
optional: true,
},
},
resolutions: {
"what-bin": "1.0.0",
"no-deps": "2.0.0",
// unused resolution
"jquery": "4.0.0",
},
};
await write(packageJson, JSON.stringify(pkg));
await runBunInstall(env, packageDir);
const lockfile = (await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234");
expect(lockfile).toMatchSnapshot();
// --frozen-lockfile works
await runBunInstall(env, packageDir, { frozenLockfile: true });
});
it("requires an integrity hash for an off-registry npm tarball URL at lockfileVersion 2", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
// Stand-in for a host that is not the configured registry. Parsing fails
// before any fetch, so this is never actually contacted.
let offRegistryRequests = 0;
await using offRegistry = Bun.serve({
port: 0,
hostname: "127.0.0.1",
fetch() {
offRegistryRequests++;
return new Response("not found", { status: 404 });
},
});
await write(
packageJson,
JSON.stringify({
name: "redirected-tarball-url",
dependencies: {
"no-deps": "1.0.0",
},
}),
);
const lockfileWithUrl = (tarballUrl: string) =>
JSON.stringify({
lockfileVersion: 2,
configVersion: 1,
workspaces: {
"": {
name: "redirected-tarball-url",
dependencies: {
"no-deps": "1.0.0",
},
},
},
packages: {
"no-deps": ["[email protected]", tarballUrl, {}, ""],
},
});
// The entry keeps the well-known name and version but points the tarball at a
// different host and provides no integrity hash. At lockfileVersion 2 this
// fails closed: parsing rejects it before any fetch. (The v1 backward-compat
// case — parsing accepts such an entry — is covered in lockfile-version-2.test.ts.)
await write(
join(packageDir, "bun.lock"),
lockfileWithUrl(`http://127.0.0.1:${offRegistry.port}/no-deps/-/no-deps-1.0.0.tgz`),
);
let { exited, stdout, stderr } = spawn({
cmd: [bunExe(), "install", "--frozen-lockfile"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
});
let [out, err] = await Promise.all([stdout.text(), stderr.text()]);
expect(err).toContain(
"Missing integrity hash for npm package resolved to a tarball URL outside the configured registry",
);
expect(offRegistryRequests).toBe(0);
expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBe(false);
expect(await exited).not.toBe(0);
// The same entry with the tarball URL *under* the configured registry and no
// integrity hash is accepted even at v2 (the off-registry gate does not apply,
// so `npm_url_needs_integrity` is false — registry-hosted tarballs may still
// omit the hash).
await write(join(packageDir, "bun.lock"), lockfileWithUrl(`${registry.registryUrl()}no-deps/-/no-deps-1.0.0.tgz`));
({ exited, stdout, stderr } = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
}));
[out, err] = await Promise.all([stdout.text(), stderr.text()]);
expect(err).not.toContain("Missing integrity hash");
expect(offRegistryRequests).toBe(0);
expect(await exited).toBe(0);
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
name: "no-deps",
version: "1.0.0",
});
});
it("escapes double quotes in npm registry tarball URLs when saving bun.lock", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await write(
packageJson,
JSON.stringify({
name: "registry-url-escaping",
dependencies: {
"no-deps": "1.0.0",
},
}),
);
// A registry-controlled tarball URL containing a double quote and JSON syntax.
// When the lockfile is saved again, the URL must stay confined to its own
// string value instead of contributing top-level lockfile structure.
const tarballUrl = `${registry.registryUrl()}no-deps/-/no-deps-1.0.0.tgz?x=", "trustedDependencies": ["no-deps"], "y": "`;
await write(
join(packageDir, "bun.lock"),
JSON.stringify({
lockfileVersion: 1,
configVersion: 1,
workspaces: {
"": {
name: "registry-url-escaping",
dependencies: {
"no-deps": "1.0.0",
},
},
},
packages: {
"no-deps": ["[email protected]", tarballUrl, {}, ""],
},
}),
);
let { exited, stdout, stderr } = spawn({
cmd: [bunExe(), "install", "--lockfile-only"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
});
let [out, err] = await Promise.all([stdout.text(), stderr.text()]);
expect(out).toContain("Saved bun.lock");
expect(await exited).toBe(0);
const lockfile = await file(join(packageDir, "bun.lock")).text();
// The embedded quote is escaped, keeping the URL a single JSON string value.
expect(lockfile).toContain('?x=\\"');
expect(lockfile).toContain('\\"trustedDependencies\\"');
// No top-level key can be forged from the URL contents.
expect(lockfile).not.toContain('"trustedDependencies":');
// The saved lockfile still parses and is stable on a subsequent install.
({ exited, stdout, stderr } = spawn({
cmd: [bunExe(), "install", "--lockfile-only"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
}));
[out, err] = await Promise.all([stdout.text(), stderr.text()]);
expect(err).not.toContain("Saved lockfile");
expect(out).toContain("Done! Checked");
expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile);
expect(await exited).toBe(0);
});
// --frozen-lockfile compares the tree built from bun.lock with the tree a clean install
// builds, so an entry nothing depends on (the clean drops it) must not change the
// outcome, wherever it sits in the file. The comparison used to skip the loaded side's
// highest package ids once the clean had dropped an entry, so the entries listed after
// the unused one went missing from the comparison.
it("--frozen-lockfile accepts a bun.lock with an entry nothing depends on, wherever it is listed", async () => {
const noDeps = { "no-deps": ["[email protected]", "", {}, ""] };
const unused = { "a-dep": ["[email protected]", "", {}, ""] };
for (const packages of [
{ ...unused, ...noDeps },
{ ...noDeps, ...unused },
]) {
const { packageDir, packageJson } = await registry.createTestDir();
await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } }));
const lockfile = JSON.stringify({
lockfileVersion: 1,
configVersion: 1,
workspaces: { "": { name: "foo", dependencies: { "no-deps": "1.0.0" } } },
packages,
});
await write(join(packageDir, "bun.lock"), lockfile);
await using proc = spawn({
cmd: [bunExe(), "install", "--frozen-lockfile"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ order: Object.keys(packages), err, exitCode }).toEqual({
order: Object.keys(packages),
err: expect.not.stringContaining("lockfile had changes"),
exitCode: 0,
});
expect(out).toContain("[email protected]");
expect(await exists(join(packageDir, "node_modules", "no-deps", "package.json"))).toBeTrue();
expect(await exists(join(packageDir, "node_modules", "a-dep"))).toBeFalse();
expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile);
}
});
it("escapes quotes and newlines in requested version literals when writing yarn.lock", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
// A version range carrying a quote and a newline. The extra characters are
// skipped by the lenient range parser (it still resolves to 1.0.0), but the
// stored literal keeps them, so the yarn.lock printer must keep the whole
// literal inside a single quoted scalar.
const craftedRange = '1.0.0 "\n resolved "http://injected.example/forged-by-yarn-printer';
await write(
packageJson,
JSON.stringify({
name: "yarn-lock-escaping",
dependencies: {
"no-deps": craftedRange,
},
}),
);
const { exited, stderr } = spawn({
cmd: [bunExe(), "install", "--yarn"],
cwd: packageDir,
env,
stdout: "ignore",
stderr: "pipe",
});
const err = await stderr.text();
const exitCode = await exited;
expect(err).toContain("Saved yarn.lock");
expect(exitCode).toBe(0);
const yarnLock = await file(join(packageDir, "yarn.lock")).text();
const lines = yarnLock.split("\n");
// The package resolves normally and its real resolved URL points at the test registry.
expect(lines.some(line => /^ {2}resolved "http:\/\/localhost:\d+\//.test(line))).toBe(true);
// The literal's embedded quote is escaped, so the requested range stays inside one quoted key.
expect(yarnLock).toContain('\\"http://injected.example');
// No yarn.lock line is forged from the version literal's contents.
expect(lines.filter(line => line.trimStart().startsWith('resolved "http://injected.example'))).toEqual([]);
});
it("prints an actionable error for a lockfile version newer than this build supports", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await write(
packageJson,
JSON.stringify({
name: "future-lockfile",
dependencies: {},
}),
);
await write(
join(packageDir, "bun.lock"),
JSON.stringify({
lockfileVersion: 99,
workspaces: {
"": {
name: "future-lockfile",
},
},
packages: {},
}),
);
const { exited, stdout, stderr } = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
});
const [out, err] = await Promise.all([stdout.text(), stderr.text()]);
expect(err).toContain("Unsupported lockfile version 99");
expect(err).toContain("newer version of Bun");
expect(err).toMatch(/This is Bun v\d+\.\d+\.\d+/);
expect(err).toMatch(/supports lockfile versions up to \d+/);
expect(err).toContain("Run 'bun upgrade'");
// the old message gave no hint at all
expect(err).not.toContain("Unknown lockfile version");
expect(await exited).toBe(0);
});
async function installWithHandEditedOverrides(overrides: Record<string, unknown>) {
const { packageDir, packageJson } = await registry.createTestDir();
const lockfile = JSON.stringify(
{
lockfileVersion: 1,
configVersion: 1,
workspaces: { "": { name: "invalid-overrides" } },
overrides,
packages: {},
},
null,
2,
);
await Promise.all([
write(packageJson, JSON.stringify({ name: "invalid-overrides" })),
write(join(packageDir, "bun.lock"), lockfile),
]);
await using proc = spawn({
cmd: [bunExe(), "install", "--frozen-lockfile"],
cwd: packageDir,
env,
stdout: "pipe",
stderr: "pipe",
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile);
return { out: normalizeBunSnapshot(out, packageDir), err: normalizeBunSnapshot(err, packageDir), exitCode };
}
describe.concurrent("hand-edited bun.lock overrides", () => {
it("rejects a top-level row whose value is a number", async () => {
const { out, err, exitCode } = await installWithHandEditedOverrides({ "no-deps": 1 });
expect(err).toMatchInlineSnapshot(`
"10 | "no-deps": 1
^
error: Expected a string or an object
at bun.lock:10:16
InvalidLockfile: failed to parse lockfile: 'bun.lock'
warn: Ignoring lockfile
error: lockfile had changes, but lockfile is frozen"
`);
expect(out).toMatchInlineSnapshot(`"bun install <version> (<revision>)"`);
expect(exitCode).toBe(1);
});
it("rejects a group whose key is a bare scope", async () => {
const { out, err, exitCode } = await installWithHandEditedOverrides({ "@scope": { ".": "1.0.0" } });
expect(err).toMatchInlineSnapshot(`
"10 | "@scope": {
^
error: Invalid override key
at bun.lock:10:5
InvalidLockfile: failed to parse lockfile: 'bun.lock'
warn: Ignoring lockfile
error: lockfile had changes, but lockfile is frozen"
`);
expect(out).toMatchInlineSnapshot(`"bun install <version> (<revision>)"`);
expect(exitCode).toBe(1);
});
it("rejects a group child whose key is a bare scope", async () => {
const { out, err, exitCode } = await installWithHandEditedOverrides({ "no-deps": { "@scope": "1.0.0" } });
expect(err).toMatchInlineSnapshot(`
"11 | "@scope": "1.0.0"
^
error: Invalid override key
at bun.lock:11:7
InvalidLockfile: failed to parse lockfile: 'bun.lock'
warn: Ignoring lockfile
error: lockfile had changes, but lockfile is frozen"
`);
expect(out).toMatchInlineSnapshot(`"bun install <version> (<revision>)"`);
expect(exitCode).toBe(1);
});
it("rejects a group child whose value is a number", async () => {
const { out, err, exitCode } = await installWithHandEditedOverrides({ "no-deps": { "a-dep": 1 } });
expect(err).toMatchInlineSnapshot(`
"11 | "a-dep": 1
^
error: Expected a string
at bun.lock:11:16
InvalidLockfile: failed to parse lockfile: 'bun.lock'
warn: Ignoring lockfile
error: lockfile had changes, but lockfile is frozen"
`);
expect(out).toMatchInlineSnapshot(`"bun install <version> (<revision>)"`);
expect(exitCode).toBe(1);
});
it("rejects a group whose key carries a non-npm range", async () => {
const { out, err, exitCode } = await installWithHandEditedOverrides({
"no-deps@file:./vendored": { ".": "1.0.0" },
});
expect(err).toMatchInlineSnapshot(`
"11 | ".": "1.0.0"
^
error: Invalid override version
at bun.lock:11:12
InvalidLockfile: failed to parse lockfile: 'bun.lock'
warn: Ignoring lockfile
error: lockfile had changes, but lockfile is frozen"
`);
expect(out).toMatchInlineSnapshot(`"bun install <version> (<revision>)"`);
expect(exitCode).toBe(1);
});
it("rejects a group child whose value does not parse as a dependency", async () => {
const { out, err, exitCode } = await installWithHandEditedOverrides({ "no-deps": { "a-dep": "./a:dep" } });
expect(err).toMatchInlineSnapshot(`
"error: Unsupported protocol ./a:dep
11 | "a-dep": "./a:dep"
^
error: Invalid override version
at bun.lock:11:16
InvalidLockfile: failed to parse lockfile: 'bun.lock'
warn: Ignoring lockfile
error: lockfile had changes, but lockfile is frozen"
`);
expect(out).toMatchInlineSnapshot(`"bun install <version> (<revision>)"`);
expect(exitCode).toBe(1);
});
});
describe.concurrent("hand-edited bun.lock that lists workspaces but has no packages object", () => {
const lockfileWithoutPackages = (lockfileVersion: number) =>
JSON.stringify(
{
lockfileVersion,
workspaces: {
"": { name: "no-packages-object" },
"packages/member": { name: "member", version: "1.0.0" },
},
},
null,
2,
);
const projectFiles = (lockfileVersion: number) => ({
"package.json": JSON.stringify({ name: "no-packages-object", workspaces: ["packages/*"] }),
"packages/member/package.json": JSON.stringify({ name: "member", version: "1.0.0" }),
"bun.lock": lockfileWithoutPackages(lockfileVersion),
});
async function install(cwd: string, ...args: string[]) {
await using proc = spawn({
cmd: [bunExe(), "install", ...args],
cwd,
env,
stdout: "pipe",
stderr: "pipe",
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { out: normalizeBunSnapshot(out, cwd), err: normalizeBunSnapshot(err, cwd), exitCode };
}
it("bun install links the workspace and writes the packages object back", async () => {
using dir = tempDir("bun-lock-no-packages-object", projectFiles(1));
const { out, err, exitCode } = await install(String(dir));
expect(err).toMatchInlineSnapshot(`"Saved lockfile"`);
expect(out).toMatchInlineSnapshot(`
"bun install <version> (<revision>)
1 package installed"
`);
expect(exitCode).toBe(0);
expect(await file(join(String(dir), "node_modules", "member", "package.json")).json()).toEqual({
name: "member",
version: "1.0.0",
});
expect(await file(join(String(dir), "bun.lock")).text()).toContain(
`"packages": {\n "member": ["member@workspace:packages/member"],\n }`,
);
});
it("bun install --frozen-lockfile treats it like an empty packages object", async () => {
using dir = tempDir("bun-lock-no-packages-object-frozen", projectFiles(2));
const { out, err, exitCode } = await install(String(dir), "--frozen-lockfile");
expect(err).toMatchInlineSnapshot(`""`);
expect(out).toMatchInlineSnapshot(`
"bun install <version> (<revision>)
1 package installed"
`);
expect(exitCode).toBe(0);
expect(await file(join(String(dir), "node_modules", "member", "package.json")).json()).toEqual({
name: "member",
version: "1.0.0",
});
expect(await file(join(String(dir), "bun.lock")).text()).toBe(lockfileWithoutPackages(2));
});
});
const makeInstallRunner = (cwd: string) => async (args: string[]) => {
await using proc = spawn({
cmd: [bunExe(), ...args],
cwd,
env,
stdout: "pipe",
stderr: "pipe",
});
const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ args, err, code }).toMatchObject({ args, err: expect.not.stringContaining("error:"), code: 0 });
return { out, err };
};
// https://github.com/oven-sh/bun/issues/8662#issuecomment-3379529330
it("bun remove drops a package that was only otherwise an optional peer", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } });
const run = makeInstallRunner(packageDir);
// A `packages` entry for `no-deps` serializes as `"no-deps": ["no-deps@...`.
// (The literal "no-deps" also appears inside optional-peer-deps's
// peerDependencies/optionalPeers metadata, so match the entry prefix.)
const noDepsEntry = '"no-deps": ["no-deps@';
await write(packageJson, JSON.stringify({ name: "foo", version: "1.0.0" }));
// step 1: optional-peer-deps has an optional peer on no-deps; no-deps is NOT in the lockfile yet.
await run(["add", "-D", "[email protected]"]);
const afterStep1 = await file(join(packageDir, "bun.lock")).text();
expect(afterStep1).not.toContain(noDepsEntry);
// step 2: add no-deps as a direct dependency; the optional peer slot is now satisfied.
await run(["add", "[email protected]"]);
const afterStep2 = await file(join(packageDir, "bun.lock")).text();
expect(afterStep2).toContain(noDepsEntry);
// step 3: remove no-deps. The lockfile must return to the step-1 state.
await run(["remove", "no-deps"]);
const afterStep3 = await file(join(packageDir, "bun.lock")).text();
expect(afterStep3).not.toContain(noDepsEntry);
expect(afterStep3).toBe(afterStep1);
// --frozen-lockfile must accept the result (round-trip).
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
await run(["install", "--frozen-lockfile"]);
});
it("bun remove keeps an optional peer that is still reachable via a non-peer edge", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } });
const run = makeInstallRunner(packageDir);
// optional-peer-deps: optional peer on no-deps
// one-dep: hard dependency on [email protected]
await write(
packageJson,
JSON.stringify({
name: "foo",
version: "1.0.0",
devDependencies: { "optional-peer-deps": "1.0.0", "one-dep": "1.0.0" },
}),
);
const noDepsEntry = '"no-deps": ["no-deps@';
await run(["install"]);
const baseline = await file(join(packageDir, "bun.lock")).text();
expect(baseline).toContain(noDepsEntry);
await run(["add", "[email protected]"]);
await run(["remove", "no-deps"]);
// no-deps must remain (one-dep still depends on it), and the lockfile must be
// byte-identical to before the add/remove pair.
const after = await file(join(packageDir, "bun.lock")).text();
expect(after).toContain(noDepsEntry);
expect(after).toBe(baseline);
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
await run(["install", "--frozen-lockfile"]);
});
it("bun install drops a once-resolved optional peer after the providing dependency leaves package.json", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } });
const run = makeInstallRunner(packageDir);
// Same as the first test but via editing package.json + `bun install` instead
// of `bun remove`, which is the other path into clean_with_logger.
const noDepsEntry = '"no-deps": ["no-deps@';
await write(
packageJson,
JSON.stringify({
name: "foo",
version: "1.0.0",
devDependencies: { "optional-peer-deps": "1.0.0" },
dependencies: { "no-deps": "1.0.0" },
}),
);
await run(["install"]);
expect(await file(join(packageDir, "bun.lock")).text()).toContain(noDepsEntry);
await write(
packageJson,
JSON.stringify({
name: "foo",
version: "1.0.0",
devDependencies: { "optional-peer-deps": "1.0.0" },
}),
);
await run(["install"]);
expect(await file(join(packageDir, "bun.lock")).text()).not.toContain(noDepsEntry);
});
it("optional peer with a non-wildcard range is idempotent with two versions of the target in the tree", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } });
const run = makeInstallRunner(packageDir);
// [email protected]: optional peer no-deps@^1.0.0
// one-dep: hard dep [email protected] (satisfies ^1.0.0)
// [email protected]: hard dep [email protected] (does not satisfy ^1.0.0)
await write(
packageJson,
JSON.stringify({
name: "foo",
version: "1.0.0",
dependencies: {
"one-optional-peer-dep": "1.0.2",
"one-dep": "1.0.0",
"one-fixed-dep": "2.0.0",
},
}),
);
await run(["install"]);
const first = await file(join(packageDir, "bun.lock")).text();
expect(first).toContain('"no-deps": ["no-deps@');
// A second install over the same lockfile must be a byte-for-byte no-op: the
// optional peer stays bound to the no-deps the fresh install bound it to.
await run(["install"]);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(first);
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
await run(["install", "--frozen-lockfile"]);
});
// Lockfiles saved by versions that did not drop such packages (see the `bun remove`
// tests above) still list packages that only an optional peer slot reaches. The
// committed file is all a frozen install may use, so it has to keep installing them.
// The workspace's lifecycle script is what separates this from a no-op install:
// bun.lock does not record workspace scripts, so this project's package.json diff is
// never empty.
it("--frozen-lockfile keeps a package that an older lockfile lists only as an optional peer", async () => {
const { packageDir, packageJson } = await registry.createTestDir({
bunfigOpts: { saveTextLockfile: true, linker: "hoisted" },
});
const run = makeInstallRunner(packageDir);
const noDepsEntry = '"no-deps": ["no-deps@';
const rootPackageJson = (dependencies: Record<string, string>) =>
JSON.stringify({ name: "foo", version: "1.0.0", workspaces: ["packages/*"], dependencies });
await Promise.all([
write(packageJson, rootPackageJson({ "optional-peer-deps": "1.0.0", "no-deps": "1.0.0" })),
write(
join(packageDir, "packages", "pkg", "package.json"),
JSON.stringify({ name: "pkg", version: "1.0.0", scripts: { postinstall: "exit 0" } }),
),
]);
await run(["install", "--ignore-scripts"]);
// What an older `bun remove no-deps` left behind: the root no longer depends on
// no-deps, but its entry stayed because optional-peer-deps's peer slot pointed at it.
const written = await file(join(packageDir, "bun.lock")).text();
const stale = written.replace(/^ +"no-deps": "1\.0\.0",\n/m, "");
expect(stale).not.toBe(written);
expect(stale).toContain(noDepsEntry);
await Promise.all([
write(join(packageDir, "bun.lock"), stale),
write(packageJson, rootPackageJson({ "optional-peer-deps": "1.0.0" })),
rm(join(packageDir, "node_modules"), { recursive: true, force: true }),
]);
await run(["install", "--frozen-lockfile", "--ignore-scripts"]);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(stale);
expect(await exists(join(packageDir, "node_modules", "no-deps", "package.json"))).toBeTrue();
});
// The optional-peer-hoist-* fixtures are described in
// registry/packages/create-optional-peer-hoist-packages.ts. In short: consumer
// has an optional peer on target, and deep -> deep-child reaches [email protected]
// (which depends on [email protected]) as well as [email protected]. Hoisting is
// breadth-first, so [email protected] only wins the root slot if consumer's peer is
// already bound to target when the tree is built. A loaded bun.lock always has
// the peer bound, so that is the tree every install has to build, otherwise
// --frozen-lockfile compares two different trees.
const optionalPeerHoistDeps = {
"optional-peer-hoist-consumer": "1.0.0",
"optional-peer-hoist-deep": "1.0.0",
};
it("a fresh install hoists around an optional peer the same way a reinstall does", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } });
const run = makeInstallRunner(packageDir);
await write(packageJson, JSON.stringify({ name: "foo", dependencies: optionalPeerHoistDeps }));
await run(["install"]);
const fresh = await file(join(packageDir, "bun.lock")).text();
expect(fresh).toContain('"optional-peer-hoist-leaf": ["[email protected]"');
expect(fresh).toContain(
'"optional-peer-hoist-deep-child/optional-peer-hoist-leaf": ["[email protected]"',
);
await run(["install", "--frozen-lockfile"]);
// --lockfile-only always writes, so this checks the tree a reload builds
// prints back to the same text.
await run(["install", "--lockfile-only"]);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(fresh);
});
it("a fresh install settles hoisting around a peer that only becomes bindable once another peer is bound", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } });
const run = makeInstallRunner(packageDir);
// Shape 2 in the fixture generator: binding consumer's peer hoists [email protected]
// out from under target, which is what lets [email protected] reach consumer2's
// peer, and only with that one bound too does target2's [email protected] beat
// deep-child's [email protected] to the root, the way it does on every reload.
await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: {
"optional-peer-hoist-consumer": "1.0.0",
"optional-peer-hoist-consumer2": "1.0.0",
"optional-peer-hoist-deep": "2.0.0",
},
}),
);
await run(["install"]);
const fresh = await file(join(packageDir, "bun.lock")).text();
expect(fresh).toContain('"optional-peer-hoist-tail": ["[email protected]"');
expect(fresh).toContain(
'"optional-peer-hoist-deep-child/optional-peer-hoist-tail": ["[email protected]"',
);
await run(["install", "--frozen-lockfile"]);
await run(["install", "--lockfile-only"]);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(fresh);
});
it.each([
[
"[email protected] hoisted (target placed from consumer)",
{
"optional-peer-hoist-leaf": "2.0.0",
"optional-peer-hoist-deep-child/optional-peer-hoist-leaf": "1.0.0",
},
],
[
// What a fresh install wrote before the peer binding was carried over.
"[email protected] hoisted (target placed from deep-child)",
{
"optional-peer-hoist-leaf": "1.0.0",
"optional-peer-hoist-target/optional-peer-hoist-leaf": "2.0.0",
},
],
])("--frozen-lockfile accepts an existing bun.lock with %s", async (_, leafPlacement) => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } });
const run = makeInstallRunner(packageDir);
const pkg = (name: string, version: string, info: object = {}) => [
`${name}@${version}`,
`${registry.registryUrl()}${name}/-/${name}-${version}.tgz`,
info,
"",
];
const packages: Record<string, unknown[]> = {
"optional-peer-hoist-consumer": pkg("optional-peer-hoist-consumer", "1.0.0", {
peerDependencies: { "optional-peer-hoist-target": "*" },
optionalPeers: ["optional-peer-hoist-target"],
}),
"optional-peer-hoist-deep": pkg("optional-peer-hoist-deep", "1.0.0", {
dependencies: { "optional-peer-hoist-deep-child": "1.0.0" },
}),
"optional-peer-hoist-deep-child": pkg("optional-peer-hoist-deep-child", "1.0.0", {
dependencies: { "optional-peer-hoist-leaf": "1.0.0", "optional-peer-hoist-target": "1.0.0" },
}),
"optional-peer-hoist-target": pkg("optional-peer-hoist-target", "1.0.0", {
dependencies: { "optional-peer-hoist-leaf": "2.0.0" },
}),
};
for (const [path, version] of Object.entries(leafPlacement)) {
packages[path] = pkg("optional-peer-hoist-leaf", version);
}
await write(packageJson, JSON.stringify({ name: "foo", dependencies: optionalPeerHoistDeps }));
await write(
join(packageDir, "bun.lock"),
JSON.stringify({
lockfileVersion: 2,
configVersion: 1,
workspaces: { "": { name: "foo", dependencies: optionalPeerHoistDeps } },
packages,
}),
);
await run(["install", "--frozen-lockfile"]);
});
it("adding a dependency keeps an optional peer on the package bun.lock bound it to while that package stays next to it", async () => {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true } });
const run = makeInstallRunner(packageDir);
await write(packageJson, JSON.stringify({ name: "foo", dependencies: optionalPeerHoistDeps }));
await run(["install"]);
expect(await file(join(packageDir, "bun.lock")).text()).toContain(
'"optional-peer-hoist-target": ["[email protected]"',
);
// provider brings in [email protected], which consumer's peer range would accept
// too. bun.lock binds consumer to [email protected], and consumer sorts before
// provider, so [email protected] is placed from consumer first, keeps the root
// slot and the binding, and [email protected] nests under provider.
await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: { ...optionalPeerHoistDeps, "optional-peer-hoist-provider": "1.0.0" },
}),
);
await run(["install"]);
const lockfile = await file(join(packageDir, "bun.lock")).text();
expect(lockfile).toContain('"optional-peer-hoist-target": ["[email protected]"');
expect(lockfile).toContain(
'"optional-peer-hoist-provider/optional-peer-hoist-target": ["[email protected]"',
);
expect(lockfile).toContain('"optional-peer-hoist-leaf": ["[email protected]"');
await run(["install", "--frozen-lockfile"]);
await run(["install", "--lockfile-only"]);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile);
});
it("an optional peer is rebound when another version of its package takes the slot next to it", async () => {
// The isolated linker is the one consumer of the binding itself: consumer's
// store entry is keyed by the target it was linked against.
const { packageDir, packageJson } = await registry.createTestDir({
bunfigOpts: { saveTextLockfile: true, linker: "isolated" },
});
const run = makeInstallRunner(packageDir);
const consumerLink = () => readlinkSync(join(packageDir, "node_modules", "optional-peer-hoist-consumer"));
await write(packageJson, JSON.stringify({ name: "foo", dependencies: optionalPeerHoistDeps }));
await run(["install"]);
const boundToTarget1 = consumerLink();
// Same as the previous test, but aliased so the provider sorts before
// consumer: [email protected] takes the root slot before consumer's bound
// [email protected] can be placed, and since the peer range accepts it, consumer
// dedupes onto it. That is what a reload of this bun.lock binds consumer to,
// so it is also what this install has to link consumer against.
await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: { "a-provider": "npm:[email protected]", ...optionalPeerHoistDeps },
}),
);
await run(["install"]);
const lockfile = await file(join(packageDir, "bun.lock")).text();
expect(lockfile).toContain('"optional-peer-hoist-target": ["[email protected]"');
expect(lockfile).toContain(
'"optional-peer-hoist-deep-child/optional-peer-hoist-target": ["[email protected]"',
);
const linkedByThisInstall = consumerLink();
expect(linkedByThisInstall).not.toBe(boundToTarget1);
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
await run(["install", "--frozen-lockfile"]);
expect(consumerLink()).toBe(linkedByThisInstall);
await run(["install", "--lockfile-only"]);
expect(await file(join(packageDir, "bun.lock")).text()).toBe(lockfile);
});
// https://github.com/oven-sh/bun/issues/26046
// A required peer that nothing in the tree provides and that no published
// version satisfies stays unresolved. The bun.lock written afterwards has to
// load back, and resolving it again with every manifest already in the cache
// has to finish (it used to retry the cached manifest forever).
describe.each(["hoisted", "isolated"] as const)("peer no published version satisfies (%s linker)", linker => {
const manifests: Record<string, Record<string, Record<string, unknown>>> = {
"has-unmet-peer": { "1.0.0": { peerDependencies: { "peer-target": "^1.0.1" } } },
"peer-target": { "2.0.1": {} },
};
const unmetPeerWarning =
'warn: No version matching "^1.0.1" found for peer dependency "peer-target" (but package exists)';
async function serveRegistry() {
const tarballs = new Map<string, Uint8Array>();
for (const [name, versions] of Object.entries(manifests)) {
for (const [version, extra] of Object.entries(versions)) {
const archive = new Bun.Archive(
{ "package/package.json": JSON.stringify({ name, version, ...extra }) },
{ compress: "gzip" },
);
tarballs.set(`/${name}-${version}.tgz`, await archive.bytes());
}
}
const requests: string[] = [];
const server = Bun.serve({
port: 0,
fetch(request) {
const { origin, pathname } = new URL(request.url);
requests.push(pathname);
const tarball = tarballs.get(pathname);
if (tarball) return new Response(tarball);
const name = pathname.slice(1);
const entry = manifests[name];
if (!entry) return new Response("not found", { status: 404 });
const versions: Record<string, unknown> = {};
for (const [version, extra] of Object.entries(entry)) {
versions[version] = { name, version, dist: { tarball: `${origin}/${name}-${version}.tgz` }, ...extra };
}
return Response.json(
{ name, versions, "dist-tags": { latest: Object.keys(entry).at(-1) } },
// Like registry.npmjs.org. Within this window bun resolves from the
// manifest cache without going back to the registry.
{ headers: { "cache-control": "public, max-age=300" } },
);
},
});
return {
url: server.url.href,
origin: server.url.origin,
requests,
[Symbol.dispose]() {
server.stop(true);
},
};
}
function createProject(registryUrl: string, files: Record<string, string>) {
return tempDir("unmet-peer-", {
...files,
"bunfig.toml": Bun.TOML.stringify({ install: { registry: registryUrl, linker } }),
});
}
async function install(cwd: string, ...args: string[]) {
await using proc = spawn({
cmd: [bunExe(), "install", ...args],
cwd,
// The request assertions below need a cache of their own per project: the
// environment's cache dir takes precedence over bunfig, and a package
// extracted there by one of the concurrent tests is not downloaded again.
env: { ...env, BUN_INSTALL_CACHE_DIR: join(cwd, ".bun-cache") },
stdout: "pipe",
stderr: "pipe",
// Only matters if an install never returns.
timeout: 30_000,
});
const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ args, err, code }).toMatchObject({ args, err: expect.not.stringContaining("error:"), code: 0 });
return { out, err };
}
it.concurrent("declared by a registry package", async () => {
using registry = await serveRegistry();
using dir = createProject(registry.url, {
"package.json": JSON.stringify({ name: "app", dependencies: { "has-unmet-peer": "1.0.0" } }),
});
const lockfilePath = join(String(dir), "bun.lock");
let { err } = await install(String(dir));
expect(err).toContain(unmetPeerWarning);
expect(err).toContain("Saved lockfile");
expect(registry.requests.toSorted()).toEqual(["/has-unmet-peer", "/has-unmet-peer-1.0.0.tgz", "/peer-target"]);
const lockfile = await file(lockfilePath).text();
expect(lockfile.replaceAll(registry.origin, "<registry>")).toMatchInlineSnapshot(`
"{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "app",
"dependencies": {
"has-unmet-peer": "1.0.0",
},
},
},
"packages": {
"has-unmet-peer": ["[email protected]", "<registry>/has-unmet-peer-1.0.0.tgz", { "peerDependencies": { "peer-target": "^1.0.1" } }, ""],
}
}
"
`);
expect(await exists(join(String(dir), "node_modules", "peer-target"))).toBeFalse();
({ err } = await install(String(dir), "--frozen-lockfile"));
expect(err).not.toContain("Ignoring lockfile");
expect(await file(lockfilePath).text()).toBe(lockfile);
// Resolve from scratch again. Both manifests are cached now, so the peer
// is looked up synchronously instead of through a network task.
await rm(lockfilePath);
await rm(join(String(dir), "node_modules"), { recursive: true });
registry.requests.length = 0;
({ err } = await install(String(dir)));
expect(err).toContain(unmetPeerWarning);
expect(registry.requests).toEqual([]);
expect(await file(lockfilePath).text()).toBe(lockfile);
});
it.concurrent("declared by the root package and a workspace", async () => {
using registry = await serveRegistry();
using dir = createProject(registry.url, {
"package.json": JSON.stringify({
name: "app",
workspaces: ["packages/*"],
peerDependencies: { "peer-target": "^1.0.1" },
}),
"packages/ws/package.json": JSON.stringify({ name: "ws", peerDependencies: { "peer-target": "^1.0.1" } }),
});
const lockfilePath = join(String(dir), "bun.lock");
let { err } = await install(String(dir));
expect(err).toContain(unmetPeerWarning);
expect(err).toContain("Saved lockfile");
expect(registry.requests).toEqual(["/peer-target"]);
const lockfile = await file(lockfilePath).text();
expect(lockfile).toMatchInlineSnapshot(`
"{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "app",
"peerDependencies": {
"peer-target": "^1.0.1",
},
},
"packages/ws": {
"name": "ws",
"peerDependencies": {
"peer-target": "^1.0.1",
},
},
},
"packages": {
"ws": ["ws@workspace:packages/ws"],
}
}
"
`);
({ err } = await install(String(dir), "--frozen-lockfile"));
expect(err).not.toContain("Ignoring lockfile");
expect(await file(lockfilePath).text()).toBe(lockfile);
});
});