3325 lines
122 KiB
TypeScript
3325 lines
122 KiB
TypeScript
import { file, spawn, write } from "bun";
|
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
import { existsSync, lstatSync, readlinkSync, statSync } from "fs";
|
|
import { mkdir, readlink, rm, symlink } from "fs/promises";
|
|
import { VerdaccioRegistry, bunEnv, bunExe, readdirSorted, runBunInstall, tempDir } from "harness";
|
|
import { createRequire } from "module";
|
|
import { basename, dirname, join } from "path";
|
|
|
|
const registry = new VerdaccioRegistry();
|
|
|
|
// With the global virtual store enabled, dependency symlinks inside a store
|
|
// entry point at sibling global-store directories whose names carry a 16-hex
|
|
// content-hash suffix (`<name>@<ver>-<hash>/...`). The hash is deterministic
|
|
// for a given dependency closure but would make these layout assertions
|
|
// brittle, so strip it before comparing.
|
|
function withoutEntryHash(link: string): string {
|
|
return link.replace(/(-[0-9a-f]{16})(?=[\\/])/, "");
|
|
}
|
|
|
|
// Extract just the `<storepath>-<hash>` segment from a global-store link
|
|
// target. Tests that compare entries across two test dirs need this because
|
|
// each `createTestDir` gets its own `.bun-cache/` (so the absolute targets
|
|
// always differ) but the hash suffix is what proves sharing/isolation.
|
|
function entryStoreName(link: string): string {
|
|
return link.slice(link.lastIndexOf("links") + "links".length + 1);
|
|
}
|
|
|
|
// What a store entry name carries in place of its URL's userinfo and query
|
|
// string (see "store entry names of URL dependencies").
|
|
function urlHash(url: string): string {
|
|
return Bun.hash(url).toString(16).padStart(16, "0");
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await registry.start();
|
|
});
|
|
|
|
afterAll(() => {
|
|
registry.stop();
|
|
});
|
|
|
|
describe("basic", () => {
|
|
test("single dependency", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-1",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", "no-deps"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "no-deps"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "no-deps",
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
test("scope package", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-2",
|
|
dependencies: {
|
|
"@types/is-number": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", "@types", "is-number"))).toBe(
|
|
join("..", ".bun", "@[email protected]", "node_modules", "@types", "is-number"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "@types", "is-number"))).toBe(
|
|
join("..", "..", "@[email protected]", "node_modules", "@types", "is-number"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(
|
|
packageDir,
|
|
"node_modules",
|
|
".bun",
|
|
"@[email protected]",
|
|
"node_modules",
|
|
"@types",
|
|
"is-number",
|
|
"package.json",
|
|
),
|
|
).json(),
|
|
).toEqual({
|
|
name: "@types/is-number",
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
test("transitive dependencies", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-3",
|
|
dependencies: {
|
|
"two-range-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "two-range-deps"]);
|
|
expect(readlinkSync(join(packageDir, "node_modules", "two-range-deps"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "two-range-deps"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "two-range-deps"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "two-range-deps"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "no-deps"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "@types", "is-number"))).toBe(
|
|
join("..", "..", "@[email protected]", "node_modules", "@types", "is-number"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(
|
|
packageDir,
|
|
"node_modules",
|
|
".bun",
|
|
"[email protected]",
|
|
"node_modules",
|
|
"two-range-deps",
|
|
"package.json",
|
|
),
|
|
).json(),
|
|
).toEqual({
|
|
name: "two-range-deps",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "^1.0.0",
|
|
"@types/is-number": ">=1.0.0",
|
|
},
|
|
});
|
|
expect(
|
|
await readdirSorted(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules")),
|
|
).toEqual(["@types", "no-deps", "two-range-deps"]);
|
|
expect(
|
|
withoutEntryHash(
|
|
readlinkSync(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "@types", "is-number"),
|
|
),
|
|
),
|
|
).toBe(join("..", "..", "..", "@[email protected]", "node_modules", "@types", "is-number"));
|
|
expect(
|
|
withoutEntryHash(
|
|
readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps")),
|
|
),
|
|
).toBe(join("..", "..", "[email protected]", "node_modules", "no-deps"));
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "no-deps",
|
|
version: "1.1.0",
|
|
});
|
|
expect(
|
|
await file(
|
|
join(
|
|
packageDir,
|
|
"node_modules",
|
|
".bun",
|
|
"@[email protected]",
|
|
"node_modules",
|
|
"@types",
|
|
"is-number",
|
|
"package.json",
|
|
),
|
|
).json(),
|
|
).toEqual({
|
|
name: "@types/is-number",
|
|
version: "2.0.0",
|
|
});
|
|
});
|
|
});
|
|
|
|
test("handles cyclic dependencies", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-cyclic",
|
|
dependencies: {
|
|
"a-dep-b": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", "a-dep-b"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "a-dep-b"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "a-dep-b"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "a-dep-b"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "b-dep-a"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "b-dep-a"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "a-dep-b", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "a-dep-b",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"b-dep-a": "1.0.0",
|
|
},
|
|
});
|
|
|
|
expect(
|
|
withoutEntryHash(
|
|
readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "b-dep-a")),
|
|
),
|
|
).toBe(join("..", "..", "[email protected]", "node_modules", "b-dep-a"));
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "b-dep-a", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "b-dep-a",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"a-dep-b": "1.0.0",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("package with dependency on previous self works", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-transitive-self-dep",
|
|
dependencies: {
|
|
"self-dep": "1.0.2",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(
|
|
await Promise.all([
|
|
file(join(packageDir, "node_modules", "self-dep", "package.json")).json(),
|
|
file(join(packageDir, "node_modules", "self-dep", "node_modules", "self-dep", "package.json")).json(),
|
|
]),
|
|
).toEqual([
|
|
{
|
|
name: "self-dep",
|
|
version: "1.0.2",
|
|
dependencies: {
|
|
"self-dep": "1.0.1",
|
|
},
|
|
},
|
|
{
|
|
name: "self-dep",
|
|
version: "1.0.1",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("can install folder dependencies", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-folder-deps",
|
|
dependencies: {
|
|
"folder-dep": "file:./pkg-1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await write(join(packageDir, "pkg-1", "package.json"), JSON.stringify({ name: "folder-dep", version: "1.0.0" }));
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", "folder-dep"))).toBe(
|
|
join(".bun", "folder-dep@file+pkg-1", "node_modules", "folder-dep"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "folder-dep@file+pkg-1", "node_modules", "folder-dep", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "folder-dep",
|
|
version: "1.0.0",
|
|
});
|
|
|
|
await write(join(packageDir, "pkg-1", "index.js"), "module.exports = 'hello from pkg-1';");
|
|
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
expect(readlinkSync(join(packageDir, "node_modules", "folder-dep"))).toBe(
|
|
join(".bun", "folder-dep@file+pkg-1", "node_modules", "folder-dep"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "folder-dep@file+pkg-1", "node_modules", "folder-dep", "index.js"),
|
|
).text(),
|
|
).toBe("module.exports = 'hello from pkg-1';");
|
|
});
|
|
|
|
test("can install folder dependencies on root package", async () => {
|
|
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await Promise.all([
|
|
write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "root-file-dep",
|
|
workspaces: ["packages/*"],
|
|
dependencies: {
|
|
self: "file:.",
|
|
},
|
|
}),
|
|
),
|
|
write(
|
|
join(packageDir, "packages", "pkg1", "package.json"),
|
|
JSON.stringify({
|
|
name: "pkg1",
|
|
dependencies: {
|
|
root: "file:../..",
|
|
},
|
|
}),
|
|
),
|
|
]);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(
|
|
await Promise.all([
|
|
readlink(join(packageDir, "node_modules", "self")),
|
|
readlink(join(packageDir, "packages", "pkg1", "node_modules", "root")),
|
|
file(join(packageDir, "node_modules", "self", "package.json")).json(),
|
|
]),
|
|
).toEqual([
|
|
join(".bun", "root-file-dep@root", "node_modules", "root-file-dep"),
|
|
join("..", "..", "..", "node_modules", ".bun", "root-file-dep@root", "node_modules", "root-file-dep"),
|
|
await file(packageJson).json(),
|
|
]);
|
|
});
|
|
|
|
describe("isolated workspaces", () => {
|
|
test("basic", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await Promise.all([
|
|
write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-workspaces",
|
|
workspaces: {
|
|
packages: ["pkg-1", "pkg-2"],
|
|
},
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
),
|
|
write(
|
|
join(packageDir, "pkg-1", "package.json"),
|
|
JSON.stringify({
|
|
name: "pkg-1",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"a-dep": "1.0.1",
|
|
"pkg-2": "workspace:",
|
|
"@types/is-number": "1.0.0",
|
|
},
|
|
}),
|
|
),
|
|
write(
|
|
join(packageDir, "pkg-2", "package.json"),
|
|
JSON.stringify({
|
|
name: "pkg-2",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"b-dep-a": "1.0.0",
|
|
},
|
|
}),
|
|
),
|
|
]);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(existsSync(join(packageDir, "node_modules", "pkg-1"))).toBeFalse();
|
|
expect(readlinkSync(join(packageDir, "pkg-1", "node_modules", "pkg-2"))).toBe(join("..", "..", "pkg-2"));
|
|
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "no-deps"]);
|
|
expect(readlinkSync(join(packageDir, "node_modules", "no-deps"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
|
|
expect(await readdirSorted(join(packageDir, "pkg-1", "node_modules"))).toEqual(["@types", "a-dep", "pkg-2"]);
|
|
expect(await readdirSorted(join(packageDir, "pkg-2", "node_modules"))).toEqual(["b-dep-a"]);
|
|
expect(await readdirSorted(join(packageDir, "node_modules", ".bun"))).toEqual([
|
|
"@[email protected]",
|
|
"[email protected]",
|
|
"[email protected]",
|
|
"[email protected]",
|
|
"[email protected]",
|
|
"node_modules",
|
|
]);
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "no-deps"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "no-deps",
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
test("workspace self dependencies create symlinks", async () => {
|
|
const { packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
files: {
|
|
"package.json": JSON.stringify({
|
|
name: "monorepo-workspace-self-dep",
|
|
workspaces: ["packages/*"],
|
|
}),
|
|
"packages/pkg1/package.json": JSON.stringify({
|
|
name: "pkg1",
|
|
dependencies: {
|
|
pkg1: "workspace:*",
|
|
},
|
|
}),
|
|
"packages/pkg2/package.json": JSON.stringify({
|
|
name: "pkg2",
|
|
dependencies: {
|
|
"pkg1": "workspace:*",
|
|
"pkg2": "workspace:*",
|
|
},
|
|
}),
|
|
"packages/pkg3/package.json": JSON.stringify({
|
|
name: "pkg3",
|
|
dependencies: {
|
|
"different-name": "workspace:.",
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(
|
|
await Promise.all([
|
|
readdirSorted(join(packageDir, "node_modules")),
|
|
file(join(packageDir, "packages", "pkg1", "node_modules", "pkg1", "package.json")).json(),
|
|
file(join(packageDir, "packages", "pkg2", "node_modules", "pkg1", "package.json")).json(),
|
|
file(join(packageDir, "packages", "pkg2", "node_modules", "pkg2", "package.json")).json(),
|
|
file(join(packageDir, "packages", "pkg3", "node_modules", "different-name", "package.json")).json(),
|
|
]),
|
|
).toEqual([
|
|
[".bun"],
|
|
{ name: "pkg1", dependencies: { pkg1: "workspace:*" } },
|
|
{ name: "pkg1", dependencies: { pkg1: "workspace:*" } },
|
|
{ name: "pkg2", dependencies: { pkg1: "workspace:*", pkg2: "workspace:*" } },
|
|
{ name: "pkg3", dependencies: { "different-name": "workspace:." } },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("optional peers", () => {
|
|
const tests = [
|
|
// non-optional versions
|
|
{
|
|
name: "non-optional transitive only",
|
|
deps: [{ "one-optional-peer-dep": "1.0.1" }, { "one-optional-peer-dep": "1.0.1" }],
|
|
expected: ["[email protected]", "node_modules", "[email protected]+7ff199101204a65d"],
|
|
},
|
|
{
|
|
name: "non-optional direct pkg1",
|
|
deps: [{ "one-optional-peer-dep": "1.0.1", "no-deps": "1.0.1" }, { "one-optional-peer-dep": "1.0.1" }],
|
|
expected: ["[email protected]", "node_modules", "[email protected]+f8a822eca018d0a1"],
|
|
},
|
|
{
|
|
name: "non-optional direct pkg2",
|
|
deps: [{ "one-optional-peer-dep": "1.0.1" }, { "one-optional-peer-dep": "1.0.1", "no-deps": "1.0.1" }],
|
|
expected: ["[email protected]", "node_modules", "[email protected]+f8a822eca018d0a1"],
|
|
},
|
|
// optional versions
|
|
{
|
|
name: "optional transitive only",
|
|
deps: [{ "one-optional-peer-dep": "1.0.2" }, { "one-optional-peer-dep": "1.0.2" }],
|
|
expected: ["node_modules", "[email protected]"],
|
|
},
|
|
{
|
|
name: "optional direct pkg1",
|
|
deps: [{ "one-optional-peer-dep": "1.0.2", "no-deps": "1.0.1" }, { "one-optional-peer-dep": "1.0.2" }],
|
|
expected: ["[email protected]", "node_modules", "[email protected]+f8a822eca018d0a1"],
|
|
},
|
|
{
|
|
name: "optional direct pkg2",
|
|
deps: [{ "one-optional-peer-dep": "1.0.2" }, { "one-optional-peer-dep": "1.0.2", "no-deps": "1.0.1" }],
|
|
expected: ["[email protected]", "node_modules", "[email protected]+f8a822eca018d0a1"],
|
|
},
|
|
];
|
|
|
|
for (const { deps, expected, name } of tests) {
|
|
test(`will resolve if available through another importer (${name})`, async () => {
|
|
const { packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
files: {
|
|
"package.json": JSON.stringify({
|
|
name: "optional-peers",
|
|
workspaces: ["packages/*"],
|
|
}),
|
|
"packages/pkg1/package.json": JSON.stringify({
|
|
name: "pkg1",
|
|
dependencies: deps[0],
|
|
}),
|
|
"packages/pkg2/package.json": JSON.stringify({
|
|
name: "pkg2",
|
|
dependencies: deps[1],
|
|
}),
|
|
},
|
|
});
|
|
|
|
async function checkInstall() {
|
|
const { exited } = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
});
|
|
|
|
expect(await exited).toBe(0);
|
|
expect(await readdirSorted(join(packageDir, "node_modules/.bun"))).toEqual(expected);
|
|
}
|
|
|
|
// without lockfile
|
|
// without node_modules
|
|
await checkInstall();
|
|
|
|
// with lockfile
|
|
// without node_modules
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await checkInstall();
|
|
|
|
// without lockfile
|
|
// with node_modules
|
|
await rm(join(packageDir, "bun.lock"), { force: true });
|
|
await checkInstall();
|
|
|
|
// with lockfile
|
|
// with node_modules
|
|
await checkInstall();
|
|
});
|
|
}
|
|
|
|
test("successfully resolves optional peer with nested package", async () => {
|
|
const { packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
files: {
|
|
"package.json": JSON.stringify({
|
|
name: "optional-peer-nested-resolve",
|
|
dependencies: {
|
|
"one-one-dep": "1.0.0",
|
|
},
|
|
peerDependencies: {
|
|
"one-dep": "1.0.0",
|
|
},
|
|
peerDependenciesMeta: {
|
|
"one-dep": {
|
|
optional: true,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
async function checkInstall() {
|
|
let { exited } = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
});
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "one-dep", "one-one-dep"]);
|
|
expect(await readdirSorted(join(packageDir, "node_modules/.bun"))).toEqual([
|
|
"[email protected]",
|
|
"node_modules",
|
|
"[email protected]",
|
|
"[email protected]",
|
|
]);
|
|
}
|
|
|
|
await checkInstall();
|
|
await checkInstall();
|
|
});
|
|
});
|
|
|
|
// https://github.com/oven-sh/bun/issues/28147
|
|
test("patched package shared by multiple peer variants is materialized into the cache once", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
// `[email protected]` has `peerDependencies: { "no-deps": "*" }`. Giving each
|
|
// workspace a different `no-deps` version forces one isolated store variant
|
|
// of `peer-deps` per workspace, all sharing one patched cache directory.
|
|
const noDepsVersions = ["1.0.0", "1.0.1", "1.1.0", "2.0.0"];
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "patched-peer-variants",
|
|
workspaces: ["packages/*"],
|
|
patchedDependencies: {
|
|
"[email protected]": "patches/[email protected]",
|
|
},
|
|
}),
|
|
);
|
|
await write(
|
|
join(packageDir, "patches", "[email protected]"),
|
|
`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
|
|
`,
|
|
);
|
|
for (const version of noDepsVersions) {
|
|
await write(
|
|
join(packageDir, "packages", `pkg-${version}`, "package.json"),
|
|
JSON.stringify({
|
|
name: `pkg-${version}`,
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"peer-deps": "1.0.0",
|
|
"no-deps": version,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
// CI exports BUN_INSTALL_CACHE_DIR, which overrides bunfig's `cache`. Pin it
|
|
// so the patched cache directory is created where the assertions look.
|
|
const cacheDir = join(packageDir, ".bun-cache");
|
|
|
|
// Force the hardlink backend so the inode assertions below hold on every
|
|
// platform (macOS defaults to clonefile, which copies).
|
|
async function install() {
|
|
const { stdout, stderr, exited } = spawn({
|
|
cmd: [bunExe(), "install", "--backend", "hardlink"],
|
|
cwd: packageDir,
|
|
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: cacheDir },
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]);
|
|
expect(err).not.toContain("error:");
|
|
expect(out).toContain("packages installed");
|
|
expect(exitCode).toBe(0);
|
|
}
|
|
|
|
async function checkInstall() {
|
|
const storeDirs = (await readdirSorted(join(packageDir, "node_modules", ".bun"))).filter(dir =>
|
|
dir.startsWith("[email protected]"),
|
|
);
|
|
expect(storeDirs.length).toBe(noDepsVersions.length);
|
|
|
|
// Exactly one patched cache directory exists for the package.
|
|
const cacheDirs = (await readdirSorted(cacheDir)).filter(
|
|
dir => dir.startsWith("[email protected]") && dir.includes("_patch_hash="),
|
|
);
|
|
expect(cacheDirs.length).toBe(1);
|
|
const cacheFile = join(cacheDir, cacheDirs[0], "index.js");
|
|
|
|
// The patch is applied in every variant, and every variant is hardlinked
|
|
// from the single patched cache materialization. Before the fix each
|
|
// variant re-applied the patch, replacing the shared cache directory the
|
|
// previous variant was concurrently hardlinking from (EPERM on Windows,
|
|
// divergent inodes on POSIX).
|
|
const inodes = new Set<number>();
|
|
for (const storeDir of storeDirs) {
|
|
const pkgDir = join(packageDir, "node_modules", ".bun", storeDir, "node_modules", "peer-deps");
|
|
expect(await file(join(pkgDir, "patched.txt")).text()).toBe("hello world\n");
|
|
inodes.add(statSync(join(pkgDir, "index.js")).ino);
|
|
}
|
|
inodes.add(statSync(cacheFile).ino);
|
|
expect(inodes.size).toBe(1);
|
|
}
|
|
|
|
await install();
|
|
await checkInstall();
|
|
|
|
// Reinstall with a warm cache and no node_modules: the patched cache
|
|
// directory already exists and must not be rebuilt per variant.
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await install();
|
|
await checkInstall();
|
|
});
|
|
|
|
test("adding, removing and re-adding a patch for an npm dependency", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
const rootPackageJson = { name: "npm-patch-cycle", dependencies: { "no-deps": "1.0.0" } };
|
|
const patched = {
|
|
...rootPackageJson,
|
|
patchedDependencies: { "[email protected]": "patches/[email protected]" },
|
|
};
|
|
await write(
|
|
join(packageDir, "patches", "[email protected]"),
|
|
`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 patchedFile = join(packageDir, "node_modules", "no-deps", "patched.txt");
|
|
|
|
const steps = [
|
|
[rootPackageJson, false],
|
|
[patched, true],
|
|
[rootPackageJson, false],
|
|
[patched, true],
|
|
] as const;
|
|
for (const [step, [manifest, expectPatched]] of steps.entries()) {
|
|
await write(packageJson, JSON.stringify(manifest));
|
|
// hardlink (the Linux default) installs into the store entry in place; clonefile replaces it.
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "install", "--backend", "hardlink"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
|
|
expect(err).not.toContain("error:");
|
|
expect(exitCode).toBe(0);
|
|
expect({ step, patched: existsSync(patchedFile) }).toEqual({ step, patched: expectPatched });
|
|
}
|
|
});
|
|
|
|
// Adding a patchedDependencies entry for a github: dependency of a workspace
|
|
// member deadlocked `bun install` forever with the isolated linker:
|
|
// re-resolution re-downloaded the github tarball, and the install phase then
|
|
// re-enqueued the same tarball task and parked the store entry on the
|
|
// completed task's already-drained callback list, so the pending-task count
|
|
// never reached zero.
|
|
test("adding and removing a patch for a github dependency in a workspace completes", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
// Minimal gzipped tarball shaped like a github codeload tarball: a single
|
|
// root directory wrapping the package contents.
|
|
function tarHeader(name: string, size: number, isDir: boolean): Uint8Array {
|
|
const header = new Uint8Array(512);
|
|
const encoder = new TextEncoder();
|
|
header.set(encoder.encode(name), 0);
|
|
header.set(encoder.encode(isDir ? "0000755 " : "0000644 "), 100);
|
|
header.set(encoder.encode("0000000 "), 108);
|
|
header.set(encoder.encode("0000000 "), 116);
|
|
header.set(encoder.encode(size.toString(8).padStart(11, "0") + " "), 124);
|
|
header.set(encoder.encode("00000000000 "), 136);
|
|
header.set(encoder.encode(" "), 148);
|
|
header[156] = (isDir ? "5" : "0").charCodeAt(0);
|
|
header.set(encoder.encode("ustar"), 257);
|
|
header.set(encoder.encode("00"), 263);
|
|
let checksum = 0;
|
|
for (const byte of header) checksum += byte;
|
|
header.set(encoder.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148);
|
|
return header;
|
|
}
|
|
const blocks: Uint8Array[] = [];
|
|
blocks.push(tarHeader("testowner-testrepo-aaaaaaa/", 0, true));
|
|
for (const [name, contents] of [
|
|
["package.json", JSON.stringify({ name: "gh-dep", version: "1.0.0" })],
|
|
["index.js", 'console.log("original");\n'],
|
|
]) {
|
|
const bytes = new TextEncoder().encode(contents);
|
|
blocks.push(tarHeader(`testowner-testrepo-aaaaaaa/${name}`, bytes.length, false));
|
|
blocks.push(bytes);
|
|
if (bytes.length % 512 !== 0) blocks.push(new Uint8Array(512 - (bytes.length % 512)));
|
|
}
|
|
blocks.push(new Uint8Array(1024));
|
|
const tarball = Bun.gzipSync(Buffer.concat(blocks));
|
|
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
fetch: () => new Response(tarball, { headers: { "Content-Type": "application/gzip" } }),
|
|
});
|
|
|
|
const env = {
|
|
...bunEnv,
|
|
GITHUB_API_URL: `http://localhost:${server.port}`,
|
|
// CI exports BUN_INSTALL_CACHE_DIR; pin it so this test's cache state is
|
|
// its own.
|
|
BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"),
|
|
};
|
|
|
|
async function install() {
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]);
|
|
expect(err).not.toContain("error:");
|
|
expect(exitCode).toBe(0);
|
|
}
|
|
|
|
const rootPackageJson = {
|
|
name: "patched-github-workspace",
|
|
workspaces: ["packages/*"],
|
|
};
|
|
await write(packageJson, JSON.stringify(rootPackageJson));
|
|
await write(
|
|
join(packageDir, "packages", "member", "package.json"),
|
|
JSON.stringify({
|
|
name: "member",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"gh-dep": "github:testowner/testrepo#aaaaaaa",
|
|
},
|
|
}),
|
|
);
|
|
await write(
|
|
join(packageDir, "patches", "gh-dep.patch"),
|
|
`diff --git a/index.js b/index.js
|
|
index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1d 100644
|
|
--- a/index.js
|
|
+++ b/index.js
|
|
@@ -1 +1 @@
|
|
-console.log("original");
|
|
+console.log("patched");
|
|
`,
|
|
);
|
|
|
|
const installedIndexJs = file(join(packageDir, "packages", "member", "node_modules", "gh-dep", "index.js"));
|
|
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("original");\n');
|
|
|
|
// Adding the patch triggers a re-resolution; this install hung forever
|
|
// before the fix.
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
...rootPackageJson,
|
|
patchedDependencies: {
|
|
"gh-dep@github:testowner/testrepo#aaaaaaa": "patches/gh-dep.patch",
|
|
},
|
|
}),
|
|
);
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("patched");\n');
|
|
|
|
// Cold cache with the patch still in the lockfile: the install phase itself
|
|
// downloads the tarball and applies the patch after extraction.
|
|
await rm(join(packageDir, ".bun-cache"), { recursive: true, force: true });
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await rm(join(packageDir, "packages", "member", "node_modules"), { recursive: true, force: true });
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("patched");\n');
|
|
|
|
// Removing the patch re-resolves again and rebuilds the store entry from
|
|
// the unpatched cache folder (the PatchInfo::Remove path, which hung the
|
|
// same way).
|
|
await write(packageJson, JSON.stringify(rootPackageJson));
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("original");\n');
|
|
|
|
// Re-adding the same patch must patch again.
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
...rootPackageJson,
|
|
patchedDependencies: {
|
|
"gh-dep@github:testowner/testrepo#aaaaaaa": "patches/gh-dep.patch",
|
|
},
|
|
}),
|
|
);
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("patched");\n');
|
|
});
|
|
|
|
// Same deadlock through the git: task-id space (clone + checkout tasks
|
|
// instead of a tarball download). The repo is served over git's dumb HTTP
|
|
// protocol: after `git update-server-info`, a bare repo is plain static
|
|
// files. Requires the git executable to build the fixture repository.
|
|
const gitExecutable = Bun.which("git");
|
|
test.skipIf(!gitExecutable)("adding and removing a patch for a git dependency in a workspace completes", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
const srcDir = join(packageDir, "git-src");
|
|
const bareDir = join(packageDir, "repo.git");
|
|
// Isolate git from system/global config (e.g. core.autocrlf on Windows
|
|
// would rewrite the checked-out file contents this test asserts on).
|
|
const gitConfigEnv = {
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_CONFIG_GLOBAL: join(packageDir, "gitconfig"),
|
|
};
|
|
const gitEnv = {
|
|
...bunEnv,
|
|
...gitConfigEnv,
|
|
GIT_AUTHOR_NAME: "bun-test",
|
|
GIT_AUTHOR_EMAIL: "[email protected]",
|
|
GIT_COMMITTER_NAME: "bun-test",
|
|
GIT_COMMITTER_EMAIL: "[email protected]",
|
|
};
|
|
async function git(args: string[], cwd: string): Promise<string> {
|
|
await using proc = spawn({ cmd: [gitExecutable!, ...args], cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" });
|
|
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(err).not.toContain("fatal:");
|
|
expect(exitCode).toBe(0);
|
|
return out;
|
|
}
|
|
|
|
await write(join(packageDir, "gitconfig"), "[core]\n\tautocrlf = false\n");
|
|
await write(join(srcDir, "package.json"), JSON.stringify({ name: "git-dep", version: "1.0.0" }));
|
|
await write(join(srcDir, "index.js"), 'console.log("original");\n');
|
|
await git(["init", "-q"], srcDir);
|
|
await git(["add", "-A"], srcDir);
|
|
await git(["commit", "-qm", "init"], srcDir);
|
|
const sha = (await git(["rev-parse", "HEAD"], srcDir)).trim();
|
|
await git(["clone", "-q", "--bare", srcDir, bareDir], packageDir);
|
|
await git(["update-server-info"], bareDir);
|
|
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const { pathname } = new URL(req.url);
|
|
if (!pathname.startsWith("/repo.git/")) return new Response("not found", { status: 404 });
|
|
const f = file(join(bareDir, pathname.slice("/repo.git/".length)));
|
|
return (await f.exists()) ? new Response(f) : new Response("not found", { status: 404 });
|
|
},
|
|
});
|
|
const repoUrl = `git+http://127.0.0.1:${server.port}/repo.git`;
|
|
|
|
const env = {
|
|
...bunEnv,
|
|
...gitConfigEnv,
|
|
BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"),
|
|
};
|
|
|
|
async function install() {
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]);
|
|
expect(err).not.toContain("error:");
|
|
expect(exitCode).toBe(0);
|
|
}
|
|
|
|
const rootPackageJson = {
|
|
name: "patched-git-workspace",
|
|
workspaces: ["packages/*"],
|
|
};
|
|
await write(packageJson, JSON.stringify(rootPackageJson));
|
|
await write(
|
|
join(packageDir, "packages", "member", "package.json"),
|
|
JSON.stringify({
|
|
name: "member",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"git-dep": repoUrl,
|
|
},
|
|
}),
|
|
);
|
|
await write(
|
|
join(packageDir, "patches", "git-dep.patch"),
|
|
`diff --git a/index.js b/index.js
|
|
index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1d 100644
|
|
--- a/index.js
|
|
+++ b/index.js
|
|
@@ -1 +1 @@
|
|
-console.log("original");
|
|
+console.log("patched");
|
|
`,
|
|
);
|
|
|
|
const installedIndexJs = file(join(packageDir, "packages", "member", "node_modules", "git-dep", "index.js"));
|
|
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("original");\n');
|
|
|
|
// The patchedDependencies key must carry the resolved commit; a key without
|
|
// it is silently ignored, which the patched-content assertion would catch.
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
...rootPackageJson,
|
|
patchedDependencies: {
|
|
[`git-dep@${repoUrl}#${sha}`]: "patches/git-dep.patch",
|
|
},
|
|
}),
|
|
);
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("patched");\n');
|
|
|
|
// Cold cache with the patch still in the lockfile: the install phase
|
|
// clones and checks out itself, applying the patch after the checkout.
|
|
await rm(join(packageDir, ".bun-cache"), { recursive: true, force: true });
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await rm(join(packageDir, "packages", "member", "node_modules"), { recursive: true, force: true });
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("patched");\n');
|
|
|
|
await write(packageJson, JSON.stringify(rootPackageJson));
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("original");\n');
|
|
|
|
// Re-adding the same patch must patch again.
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
...rootPackageJson,
|
|
patchedDependencies: {
|
|
[`git-dep@${repoUrl}#${sha}`]: "patches/git-dep.patch",
|
|
},
|
|
}),
|
|
);
|
|
await install();
|
|
expect(await installedIndexJs.text()).toBe('console.log("patched");\n');
|
|
});
|
|
|
|
for (const backend of ["clonefile", "hardlink", "copyfile"]) {
|
|
test(`isolated install with backend: ${backend}`, async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await Promise.all([
|
|
write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-backend",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
"alias-loop-2": "1.0.0",
|
|
"alias-loop-1": "1.0.0",
|
|
"1-peer-dep-a": "1.0.0",
|
|
"basic-1": "1.0.0",
|
|
"is-number": "1.0.0",
|
|
"file-dep": "file:./file-dep",
|
|
"@scoped/file-dep": "file:./scoped-file-dep",
|
|
},
|
|
}),
|
|
),
|
|
write(join(packageDir, "file-dep", "package.json"), JSON.stringify({ name: "file-dep", version: "1.0.0" })),
|
|
write(
|
|
join(packageDir, "file-dep", "dir1", "dir2", "dir3", "dir4", "dir5", "index.js"),
|
|
"module.exports = 'hello from file-dep';",
|
|
),
|
|
write(
|
|
join(packageDir, "scoped-file-dep", "package.json"),
|
|
JSON.stringify({ name: "@scoped/file-dep", version: "1.0.0" }),
|
|
),
|
|
]);
|
|
|
|
const { stdout, stderr, exited } = spawn({
|
|
cmd: [bunExe(), "install", "--backend", backend],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
expect(await exited).toBe(0);
|
|
const out = await stdout.text();
|
|
const err = await stderr.text();
|
|
|
|
expect(err).not.toContain("error");
|
|
expect(err).not.toContain("warning");
|
|
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "no-deps",
|
|
version: "1.0.0",
|
|
});
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", "file-dep"))).toBe(
|
|
join(".bun", "file-dep@file+file-dep", "node_modules", "file-dep"),
|
|
);
|
|
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "file-dep@file+file-dep", "node_modules", "file-dep", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "file-dep",
|
|
version: "1.0.0",
|
|
});
|
|
|
|
expect(
|
|
await file(
|
|
join(
|
|
packageDir,
|
|
"node_modules",
|
|
".bun",
|
|
"file-dep@file+file-dep",
|
|
"node_modules",
|
|
"file-dep",
|
|
"dir1",
|
|
"dir2",
|
|
"dir3",
|
|
"dir4",
|
|
"dir5",
|
|
"index.js",
|
|
),
|
|
).text(),
|
|
).toBe("module.exports = 'hello from file-dep';");
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", "@scoped", "file-dep"))).toBe(
|
|
join("..", ".bun", "@scoped+file-dep@file+scoped-file-dep", "node_modules", "@scoped", "file-dep"),
|
|
);
|
|
|
|
expect(
|
|
await file(
|
|
join(
|
|
packageDir,
|
|
"node_modules",
|
|
".bun",
|
|
"@scoped+file-dep@file+scoped-file-dep",
|
|
"node_modules",
|
|
"@scoped",
|
|
"file-dep",
|
|
"package.json",
|
|
),
|
|
).json(),
|
|
).toEqual({
|
|
name: "@scoped/file-dep",
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
}
|
|
|
|
test("ranged peer dependency resolution is stable across installs from bun.lock", async () => {
|
|
// `peer-deps-fixed` has a peer on `no-deps@^1.0.0`. The graph contains both
|
|
// [email protected] (exact pin via normal-dep-and-dev-dep, hoisted to the root
|
|
// of the saved tree) and [email protected] (via two-range-deps). The fresh
|
|
// resolve binds the peer edge to the highest satisfying version (1.1.0) in
|
|
// its deferred-peer phase; reloading bun.lock used to re-derive the edge
|
|
// from the saved tree paths instead, rebinding it to the hoisted 1.0.1.
|
|
// That silently changed the runtime dependency tree on the second install
|
|
// and re-keyed the isolated store entry (`+<peer hash>` suffix) on every
|
|
// warm install.
|
|
const { packageJson, packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
});
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "stable-ranged-peers",
|
|
dependencies: {
|
|
"peer-deps-fixed": "1.0.0",
|
|
"normal-dep-and-dev-dep": "1.0.0",
|
|
"two-range-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
const bunDir = join(packageDir, "node_modules", ".bun");
|
|
// highest satisfying ^1.0.0 in the graph; `toContain` prints the full
|
|
// listing when the entry is missing or keyed with a different peer hash
|
|
const entryName = "[email protected]+7ff199101204a65d";
|
|
expect(await readdirSorted(bunDir)).toContain(entryName);
|
|
expect(await file(join(bunDir, entryName, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
version: "1.1.0",
|
|
});
|
|
|
|
// reinstall from bun.lock: same peer variant, same resolved version
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
expect((await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@"))).toEqual([entryName]);
|
|
expect(await file(join(bunDir, entryName, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
version: "1.1.0",
|
|
});
|
|
});
|
|
|
|
test("aliased peer dependency binds to its real package across installs from bun.lock", async () => {
|
|
// The peer alias `no-deps` points at `npm:a-dep@^1.0.2` while the real
|
|
// no-deps package (in two versions) is also in the graph. Loading bun.lock
|
|
// must look the edge up under the aliased *real* name (a-dep) the way the
|
|
// fresh resolver does; a lookup under the alias would find the real
|
|
// no-deps packages, whose versions also satisfy ^1.0.2, and rebind the
|
|
// edge to the wrong package.
|
|
const { packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
files: {
|
|
"package.json": JSON.stringify({
|
|
name: "aliased-peer-root",
|
|
workspaces: ["packages/*"],
|
|
dependencies: {
|
|
"normal-dep-and-dev-dep": "1.0.0",
|
|
"two-range-deps": "1.0.0",
|
|
},
|
|
}),
|
|
"packages/m/package.json": JSON.stringify({
|
|
name: "m",
|
|
version: "1.0.0",
|
|
peerDependencies: { "no-deps": "npm:a-dep@^1.0.2" },
|
|
}),
|
|
},
|
|
});
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
const aliasLink = join(packageDir, "packages", "m", "node_modules", "no-deps", "package.json");
|
|
const fresh = await file(aliasLink).json();
|
|
expect(fresh).toMatchObject({ name: "a-dep" });
|
|
|
|
// reinstall from bun.lock: still the aliased package, same version
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await rm(join(packageDir, "packages", "m", "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
expect(await file(aliasLink).json()).toEqual(fresh);
|
|
});
|
|
|
|
test("optional ranged peer keeps its hoisted-tree binding across installs from bun.lock", async () => {
|
|
// Optional peers never reach the fresh resolver's deferred-peer phase: it
|
|
// returns before the version scan and the edge is bound to the
|
|
// hoisted-tree sibling during tree resolution, which the printed tree's
|
|
// path walk reproduces exactly. With both [email protected] and [email protected]
|
|
// in the graph, binding the optional peer by version on load would pick
|
|
// the highest satisfying (1.1.0) while the fresh install bound the hoisted
|
|
// 1.0.1, re-keying the entry on the first reinstall.
|
|
const { packageJson, packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
});
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "stable-optional-peers",
|
|
dependencies: {
|
|
"one-optional-peer-dep": "1.0.2",
|
|
"normal-dep-and-dev-dep": "1.0.0",
|
|
"two-range-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
const bunDir = join(packageDir, "node_modules", ".bun");
|
|
const freshEntries = (await readdirSorted(bunDir)).filter(e => e.startsWith("one-optional-peer-dep@"));
|
|
expect(freshEntries).toHaveLength(1);
|
|
// the hoisted candidate, not the highest satisfying (1.1.0)
|
|
expect(await file(join(bunDir, freshEntries[0], "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
version: "1.0.1",
|
|
});
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
expect((await readdirSorted(bunDir)).filter(e => e.startsWith("one-optional-peer-dep@"))).toEqual(freshEntries);
|
|
expect(await file(join(bunDir, freshEntries[0], "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
version: "1.0.1",
|
|
});
|
|
});
|
|
|
|
test("overridden peer dependency keeps the override across installs from bun.lock", async () => {
|
|
// `overrides` rewrites the peer range before the fresh resolver's version
|
|
// scan, binding the peer to [email protected] even though the graph also
|
|
// contains [email protected] (via an override-exempt npm: alias). Loading
|
|
// bun.lock must not re-filter candidates with the raw ^1.0.0 manifest
|
|
// range, which the override replaced; that would rebind the edge to 1.1.0
|
|
// and re-key the entry on the first reinstall.
|
|
const { packageJson, packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
});
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "stable-overridden-peers",
|
|
dependencies: {
|
|
"peer-deps-fixed": "1.0.0",
|
|
"nd11": "npm:[email protected]",
|
|
// provides [email protected] so the overridden peer range resolves to an
|
|
// installed package
|
|
"normal-dep-and-dev-dep": "1.0.0",
|
|
},
|
|
overrides: { "no-deps": "1.0.1" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
const bunDir = join(packageDir, "node_modules", ".bun");
|
|
const entryName = "[email protected]+f8a822eca018d0a1";
|
|
// the override-exempt alias keeps the competing 1.1.0 candidate in the graph
|
|
const aliasManifest = join(packageDir, "node_modules", "nd11", "package.json");
|
|
expect(await file(aliasManifest).json()).toMatchObject({ name: "no-deps", version: "1.1.0" });
|
|
expect(await readdirSorted(bunDir)).toContain(entryName);
|
|
expect(await file(join(bunDir, entryName, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
version: "1.0.1",
|
|
});
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
expect(await file(aliasManifest).json()).toMatchObject({ name: "no-deps", version: "1.1.0" });
|
|
expect((await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@"))).toEqual([entryName]);
|
|
expect(await file(join(bunDir, entryName, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
version: "1.0.1",
|
|
});
|
|
});
|
|
|
|
test("peer satisfied by a workspace package keeps the workspace across installs from bun.lock", async () => {
|
|
// The fresh resolver binds an npm-range peer to a same-named workspace
|
|
// package before any deferral when linkWorkspacePackages is on and the
|
|
// workspace version satisfies the range. The graph also contains npm
|
|
// [email protected] (normal-dep-and-dev-dep's exact pin, which the workspace's
|
|
// 1.0.0 cannot satisfy), and that version satisfies the peer's ^1.0.0 too;
|
|
// loading bun.lock must not rebind the peer from the workspace to the npm
|
|
// package through the version scan.
|
|
const { packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
files: {
|
|
"package.json": JSON.stringify({
|
|
name: "workspace-peer-root",
|
|
workspaces: ["packages/*"],
|
|
dependencies: {
|
|
"peer-deps-fixed": "1.0.0",
|
|
"normal-dep-and-dev-dep": "1.0.0",
|
|
},
|
|
}),
|
|
"packages/no-deps/package.json": JSON.stringify({
|
|
name: "no-deps",
|
|
version: "1.0.0",
|
|
workspaceMarker: true,
|
|
}),
|
|
},
|
|
});
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
const bunDir = join(packageDir, "node_modules", ".bun");
|
|
const freshEntries = (await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@"));
|
|
expect(freshEntries).toHaveLength(1);
|
|
expect(await file(join(bunDir, freshEntries[0], "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
version: "1.0.0",
|
|
workspaceMarker: true,
|
|
});
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
expect((await readdirSorted(bunDir)).filter(e => e.startsWith("peer-deps-fixed@"))).toEqual(freshEntries);
|
|
expect(await file(join(bunDir, freshEntries[0], "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
version: "1.0.0",
|
|
workspaceMarker: true,
|
|
});
|
|
});
|
|
|
|
describe("existing node_modules, missing node_modules/.bun", () => {
|
|
test("root and workspace node_modules are reset", async () => {
|
|
const { packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
files: {
|
|
"package.json": JSON.stringify({
|
|
name: "delete-node-modules",
|
|
workspaces: ["packages/*"],
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
"a-dep": "1.0.1",
|
|
},
|
|
}),
|
|
"packages/pkg1/package.json": JSON.stringify({
|
|
name: "pkg1",
|
|
dependencies: {
|
|
"no-deps": "1.0.1",
|
|
},
|
|
}),
|
|
"packages/pkg2/package.json": JSON.stringify({
|
|
name: "pkg2",
|
|
dependencies: {
|
|
"no-deps": "2.0.0",
|
|
},
|
|
}),
|
|
"node_modules/oops": "delete me!",
|
|
"packages/pkg1/node_modules/oops1": "delete me!",
|
|
"packages/pkg2/node_modules/oops2": "delete me!",
|
|
},
|
|
});
|
|
|
|
let { exited } = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
});
|
|
|
|
expect(await exited).toBe(0);
|
|
expect(
|
|
await Promise.all([
|
|
readdirSorted(join(packageDir, "node_modules")),
|
|
readdirSorted(join(packageDir, "packages", "pkg1", "node_modules")),
|
|
readdirSorted(join(packageDir, "packages", "pkg2", "node_modules")),
|
|
]),
|
|
).toEqual([[".bun", expect.stringContaining(".old_modules-"), "a-dep", "no-deps"], ["no-deps"], ["no-deps"]]);
|
|
});
|
|
test("some workspaces don't have node_modules", async () => {
|
|
const { packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
files: {
|
|
"package.json": JSON.stringify({
|
|
name: "missing-workspace-node_modules",
|
|
workspaces: ["packages/*"],
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
"node_modules/hi": "BUN",
|
|
"packages/pkg1/package.json": JSON.stringify({
|
|
name: "pkg-one",
|
|
dependencies: {
|
|
"no-deps": "2.0.0",
|
|
},
|
|
}),
|
|
"packages/pkg1/node_modules/foo": "HI",
|
|
"packages/pkg2/package.json": JSON.stringify({
|
|
name: "pkg-two",
|
|
dependencies: {
|
|
"a-dep": "1.0.1",
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
|
|
let { exited } = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
});
|
|
|
|
expect(await exited).toBe(0);
|
|
expect(
|
|
await Promise.all([
|
|
readdirSorted(join(packageDir, "node_modules")),
|
|
readdirSorted(join(packageDir, "packages", "pkg1", "node_modules")),
|
|
readdirSorted(join(packageDir, "packages", "pkg2", "node_modules")),
|
|
]),
|
|
).toEqual([[".bun", expect.stringContaining(".old_modules-"), "no-deps"], ["no-deps"], ["a-dep"]]);
|
|
|
|
// another install will not reset the node_modules
|
|
|
|
const entries = await readdirSorted(join(packageDir, "node_modules"));
|
|
|
|
for (const entry of entries) {
|
|
if (entry.startsWith(".old_modules-")) {
|
|
await rm(join(packageDir, "node_modules", entry), { recursive: true, force: true });
|
|
}
|
|
}
|
|
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "no-deps"]);
|
|
|
|
// add things to workspace node_modules. these will go undetected
|
|
await Promise.all([
|
|
write(join(packageDir, "packages", "pkg1", "node_modules", "oops1"), "HI1"),
|
|
write(join(packageDir, "packages", "pkg2", "node_modules", "oops2"), "HI2"),
|
|
]);
|
|
|
|
({ exited } = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
}));
|
|
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(
|
|
await Promise.all([
|
|
readdirSorted(join(packageDir, "node_modules")),
|
|
readdirSorted(join(packageDir, "packages", "pkg1", "node_modules")),
|
|
readdirSorted(join(packageDir, "packages", "pkg2", "node_modules")),
|
|
]),
|
|
).toEqual([
|
|
[".bun", "no-deps"],
|
|
["no-deps", "oops1"],
|
|
["a-dep", "oops2"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("--linker flag", () => {
|
|
test("can override linker from bunfig", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-linker",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
let { exited } = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
});
|
|
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeTrue();
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
|
|
({ exited } = spawn({
|
|
cmd: [bunExe(), "install", "--linker", "hoisted"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
}));
|
|
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeFalse();
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
|
|
({ exited } = spawn({
|
|
cmd: [bunExe(), "install", "--linker", "isolated"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
}));
|
|
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeTrue();
|
|
});
|
|
|
|
test("works as the only config option", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir();
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-linker",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
let { exited } = spawn({
|
|
cmd: [bunExe(), "install", "--linker", "isolated"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
});
|
|
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeTrue();
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
|
|
({ exited } = spawn({
|
|
cmd: [bunExe(), "install", "--linker", "hoisted"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
}));
|
|
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeFalse();
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
|
|
({ exited } = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
}));
|
|
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeFalse();
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
|
|
({ exited } = spawn({
|
|
cmd: [bunExe(), "install", "--linker", "isolated"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
}));
|
|
|
|
expect(await exited).toBe(0);
|
|
|
|
expect(lstatSync(join(packageDir, "node_modules", "no-deps")).isSymbolicLink()).toBeTrue();
|
|
});
|
|
});
|
|
test("many transitive dependencies", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-many-transitive-deps",
|
|
dependencies: {
|
|
"alias-loop-1": "1.0.0",
|
|
"alias-loop-2": "1.0.0",
|
|
"1-peer-dep-a": "1.0.0",
|
|
"basic-1": "1.0.0",
|
|
"is-number": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([
|
|
".bun",
|
|
"1-peer-dep-a",
|
|
"alias-loop-1",
|
|
"alias-loop-2",
|
|
"basic-1",
|
|
"is-number",
|
|
]);
|
|
expect(readlinkSync(join(packageDir, "node_modules", "alias-loop-1"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "alias-loop-1"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "alias-loop-1"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "alias-loop-1"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "alias-loop-2"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "alias-loop-2"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "alias-loop-1", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "alias-loop-1",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"alias1": "npm:alias-loop-2@*",
|
|
},
|
|
});
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "alias-loop-2", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "alias-loop-2",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"alias2": "npm:alias-loop-1@*",
|
|
},
|
|
});
|
|
expect(await readdirSorted(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules"))).toEqual([
|
|
"alias-loop-1",
|
|
"alias1",
|
|
]);
|
|
expect(await readdirSorted(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules"))).toEqual([
|
|
"alias-loop-2",
|
|
"alias2",
|
|
]);
|
|
expect(
|
|
withoutEntryHash(
|
|
readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "alias1")),
|
|
),
|
|
).toBe(join("..", "..", "[email protected]", "node_modules", "alias-loop-2"));
|
|
expect(
|
|
withoutEntryHash(
|
|
readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "alias2")),
|
|
),
|
|
).toBe(join("..", "..", "[email protected]", "node_modules", "alias-loop-1"));
|
|
});
|
|
|
|
test("dependency names are preserved", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-dependency-names",
|
|
dependencies: {
|
|
"alias-loop-1": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "alias-loop-1"]);
|
|
expect(readlinkSync(join(packageDir, "node_modules", "alias-loop-1"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "alias-loop-1"),
|
|
);
|
|
expect(await readdirSorted(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules"))).toEqual([
|
|
"alias-loop-1",
|
|
"alias1",
|
|
]);
|
|
expect(await readdirSorted(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules"))).toEqual([
|
|
"alias-loop-2",
|
|
"alias2",
|
|
]);
|
|
expect(
|
|
withoutEntryHash(
|
|
readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "alias1")),
|
|
),
|
|
).toBe(join("..", "..", "[email protected]", "node_modules", "alias-loop-2"));
|
|
expect(
|
|
withoutEntryHash(
|
|
readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "alias2")),
|
|
),
|
|
).toBe(join("..", "..", "[email protected]", "node_modules", "alias-loop-1"));
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "alias-loop-1", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "alias-loop-1",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"alias1": "npm:alias-loop-2@*",
|
|
},
|
|
});
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "alias-loop-2", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "alias-loop-2",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"alias2": "npm:alias-loop-1@*",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("same resolution, different dependency name", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-same-resolution",
|
|
dependencies: {
|
|
"no-deps-1": "npm:[email protected]",
|
|
"no-deps-2": "npm:[email protected]",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "no-deps-1", "no-deps-2"]);
|
|
expect(readlinkSync(join(packageDir, "node_modules", "no-deps-1"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", "no-deps-2"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toEqual({
|
|
name: "no-deps",
|
|
version: "1.0.0",
|
|
});
|
|
expect(await readdirSorted(join(packageDir, "node_modules", ".bun"))).toEqual(["[email protected]", "node_modules"]);
|
|
});
|
|
|
|
test("successfully removes and corrects symlinks", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
await Promise.all([
|
|
write(join(packageDir, "old-package", "package.json"), JSON.stringify({ name: "old-package", version: "1.0.0" })),
|
|
mkdir(join(packageDir, "node_modules")),
|
|
]);
|
|
await Promise.all([
|
|
write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-dangling-symlinks",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
),
|
|
symlink(join("..", "old-package"), join(packageDir, "node_modules", "no-deps"), "dir"),
|
|
]);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(existsSync(join(packageDir, "node_modules", "no-deps"))).toBeTrue();
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", "no-deps"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
});
|
|
|
|
test("runs lifecycle scripts correctly", async () => {
|
|
// due to binary linking between preinstall and the remaining lifecycle scripts
|
|
// there is special handling for preinstall scripts we should test.
|
|
// 1. only preinstall
|
|
// 2. only postinstall (or any other script that isn't preinstall)
|
|
// 3. preinstall and any other script
|
|
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-lifecycle-scripts",
|
|
dependencies: {
|
|
"lifecycle-preinstall": "1.0.0",
|
|
"lifecycle-postinstall": "1.0.0",
|
|
"all-lifecycle-scripts": "1.0.0",
|
|
},
|
|
trustedDependencies: ["lifecycle-preinstall", "lifecycle-postinstall", "all-lifecycle-scripts"],
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
const [
|
|
preinstallLink,
|
|
postinstallLink,
|
|
allScriptsLink,
|
|
preinstallFile,
|
|
postinstallFile,
|
|
allScriptsPreinstallFile,
|
|
allScriptsInstallFile,
|
|
allScriptsPostinstallFile,
|
|
bunDir,
|
|
lifecyclePreinstallDir,
|
|
lifecyclePostinstallDir,
|
|
allLifecycleScriptsDir,
|
|
] = await Promise.all([
|
|
readlink(join(packageDir, "node_modules", "lifecycle-preinstall")),
|
|
readlink(join(packageDir, "node_modules", "lifecycle-postinstall")),
|
|
readlink(join(packageDir, "node_modules", "all-lifecycle-scripts")),
|
|
file(join(packageDir, "node_modules", "lifecycle-preinstall", "preinstall.txt")).text(),
|
|
file(join(packageDir, "node_modules", "lifecycle-postinstall", "postinstall.txt")).text(),
|
|
file(join(packageDir, "node_modules", "all-lifecycle-scripts", "preinstall.txt")).text(),
|
|
file(join(packageDir, "node_modules", "all-lifecycle-scripts", "install.txt")).text(),
|
|
file(join(packageDir, "node_modules", "all-lifecycle-scripts", "postinstall.txt")).text(),
|
|
readdirSorted(join(packageDir, "node_modules", ".bun")),
|
|
readdirSorted(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules")),
|
|
readdirSorted(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules")),
|
|
readdirSorted(join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules")),
|
|
]);
|
|
|
|
expect(preinstallLink).toBe(join(".bun", "[email protected]", "node_modules", "lifecycle-preinstall"));
|
|
expect(postinstallLink).toBe(join(".bun", "[email protected]", "node_modules", "lifecycle-postinstall"));
|
|
expect(allScriptsLink).toBe(join(".bun", "[email protected]", "node_modules", "all-lifecycle-scripts"));
|
|
|
|
expect(preinstallFile).toBe("preinstall!");
|
|
expect(postinstallFile).toBe("postinstall!");
|
|
expect(allScriptsPreinstallFile).toBe("preinstall!");
|
|
expect(allScriptsInstallFile).toBe("install!");
|
|
expect(allScriptsPostinstallFile).toBe("postinstall!");
|
|
|
|
expect(bunDir).toEqual([
|
|
"[email protected]",
|
|
"[email protected]",
|
|
"[email protected]",
|
|
"node_modules",
|
|
]);
|
|
|
|
expect(lifecyclePreinstallDir).toEqual(["lifecycle-preinstall"]);
|
|
expect(lifecyclePostinstallDir).toEqual(["lifecycle-postinstall"]);
|
|
expect(allLifecycleScriptsDir).toEqual(["all-lifecycle-scripts"]);
|
|
});
|
|
|
|
// When an auto-installed peer dependency has its OWN peer deps, those
|
|
// transitive peers get re-queued during peer processing. If all manifest
|
|
// loads are synchronous (cached with valid max-age) AND the transitive peer's
|
|
// version constraint doesn't match what's already in the lockfile,
|
|
// pendingTaskCount() stays at 0 and waitForPeers was skipped — leaving
|
|
// the transitive peer's resolution unset (= invalid_package_id → filtered
|
|
// from the install).
|
|
test("transitive peer deps are resolved when resolution is fully synchronous", async () => {
|
|
const packagesDir = join(import.meta.dir, "registry", "packages");
|
|
|
|
// Self-contained HTTP server that serves package manifests & tarballs
|
|
// directly from the Verdaccio fixtures, with Cache-Control: max-age=300
|
|
// to replicate npmjs.org behavior (fully synchronous on warm cache).
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
const pathname = url.pathname;
|
|
|
|
// Tarball: /<name>/-/<name>-<version>.tgz
|
|
if (pathname.endsWith(".tgz")) {
|
|
const match = pathname.match(/\/([^/]+)\/-\/(.+\.tgz)$/);
|
|
if (match) {
|
|
const tarball = file(join(packagesDir, match[1], match[2]));
|
|
if (await tarball.exists()) {
|
|
return new Response(tarball, {
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
});
|
|
}
|
|
}
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
|
|
// Manifest: /<name>
|
|
const packageName = decodeURIComponent(pathname.slice(1));
|
|
const metaFile = file(join(packagesDir, packageName, "package.json"));
|
|
if (!(await metaFile.exists())) {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
|
|
// Rewrite tarball URLs to point at this server
|
|
const meta = await metaFile.json();
|
|
const port = server.port;
|
|
for (const [ver, info] of Object.entries(meta.versions ?? {}) as [string, any][]) {
|
|
if (info?.dist?.tarball) {
|
|
info.dist.tarball = `http://localhost:${port}/${packageName}/-/${packageName}-${ver}.tgz`;
|
|
}
|
|
}
|
|
|
|
return new Response(JSON.stringify(meta), {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Cache-Control": "public, max-age=300",
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
using packageDir = tempDir("transitive-peer-test-", {});
|
|
const packageJson = join(String(packageDir), "package.json");
|
|
const cacheDir = join(String(packageDir), ".bun-cache");
|
|
const bunfig = Bun.TOML.stringify({
|
|
install: {
|
|
cache: cacheDir,
|
|
registry: `http://localhost:${server.port}/`,
|
|
linker: "isolated",
|
|
},
|
|
});
|
|
await write(join(String(packageDir), "bunfig.toml"), bunfig);
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-transitive-peer",
|
|
dependencies: {
|
|
// Chain: uses-strict-peer → (peer) strict-peer-dep → (peer) no-deps@^2.0.0
|
|
// Root has [email protected], which does NOT satisfy ^2.0.0. This forces
|
|
// strict-peer-dep's peer `no-deps` through the full resolution pass
|
|
// (can't reuse root's no-deps via getPackageID).
|
|
"no-deps": "1.0.0",
|
|
"uses-strict-peer": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
// First install: populates manifest cache (with max-age=300 from server)
|
|
await runBunInstall(bunEnv, String(packageDir), { allowWarnings: true });
|
|
|
|
// Second install with NO lockfile and WARM cache. Manifests are fresh
|
|
// (within max-age) so all loads are synchronous — this is the bug trigger.
|
|
await rm(join(String(packageDir), "node_modules"), { recursive: true, force: true });
|
|
await rm(join(String(packageDir), "bun.lock"), { force: true });
|
|
await runBunInstall(bunEnv, String(packageDir), { allowWarnings: true });
|
|
|
|
// Entry names have peer hashes; find them dynamically
|
|
const bunDir = join(String(packageDir), "node_modules", ".bun");
|
|
const entries = await readdirSorted(bunDir);
|
|
const strictPeerEntry = entries.find(e => e.startsWith("[email protected]"));
|
|
const usesStrictEntry = entries.find(e => e.startsWith("[email protected]"));
|
|
|
|
// strict-peer-dep must exist (auto-installed via uses-strict-peer's peer)
|
|
expect(strictPeerEntry).toBeDefined();
|
|
expect(usesStrictEntry).toBeDefined();
|
|
|
|
// strict-peer-dep's own peer `no-deps` must be resolved and symlinked.
|
|
// Without the fix: this symlink is missing because the transitive peer
|
|
// queue was never drained after drainDependencyList re-queued it.
|
|
expect(existsSync(join(bunDir, strictPeerEntry!, "node_modules", "no-deps"))).toBe(true);
|
|
|
|
// Verify the chain is intact
|
|
expect(withoutEntryHash(readlinkSync(join(bunDir, usesStrictEntry!, "node_modules", "strict-peer-dep")))).toBe(
|
|
join("..", "..", strictPeerEntry!, "node_modules", "strict-peer-dep"),
|
|
);
|
|
});
|
|
|
|
// https://github.com/oven-sh/bun/issues/36987
|
|
// A tarball URL with a query string must not put a literal `?` in the .bun
|
|
// store directory name: module resolution parses `?` as a query-string
|
|
// delimiter (truncating the path), and `?` is invalid in Windows filenames.
|
|
// (The query string is now left out of the name altogether, see "store entry
|
|
// names of URL dependencies" below.)
|
|
test("tarball URL with query string resolves at runtime", async () => {
|
|
const tarball = file(join(import.meta.dir, "registry", "packages", "no-deps", "no-deps-1.0.0.tgz"));
|
|
|
|
using server = Bun.serve({
|
|
port: 0,
|
|
fetch(req) {
|
|
if (new URL(req.url).pathname !== "/pkg.tgz") {
|
|
return new Response("Not found", { status: 404 });
|
|
}
|
|
return new Response(tarball, {
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
});
|
|
},
|
|
});
|
|
|
|
const url = `http://localhost:${server.port}/pkg.tgz?x=y`;
|
|
using cacheDir = tempDir("tarball-query-cache-", {});
|
|
using packageDir = tempDir("tarball-query-test-", {
|
|
"bunfig.toml": `[install]\ncache = "${String(cacheDir).replaceAll("\\", "\\\\")}"\nlinker = "isolated"\n`,
|
|
"package.json": JSON.stringify({
|
|
name: "test-tarball-query",
|
|
dependencies: {
|
|
"no-deps": url,
|
|
},
|
|
}),
|
|
"index.mjs": `import pkg from "no-deps";\nconsole.log(pkg.version);`,
|
|
});
|
|
|
|
await runBunInstall(bunEnv, String(packageDir));
|
|
|
|
const bunDir = join(String(packageDir), "node_modules", ".bun");
|
|
const storeEntries = (await readdirSorted(bunDir)).filter(entry => entry !== "node_modules");
|
|
expect(storeEntries).toEqual([`no-deps@http+++localhost+${server.port}+pkg.tgz+${urlHash(url)}`]);
|
|
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "index.mjs"],
|
|
env: bunEnv,
|
|
cwd: String(packageDir),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(stderr).toBe("");
|
|
expect(stdout).toBe("1.0.0\n");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
// The store entry of a tarball or git dependency is named after its URL, and
|
|
// that name ends up in every path under the entry (realpaths, stack traces,
|
|
// `bun pm` output). Credentials travel in the userinfo (`user:token@`) or the
|
|
// query string (`?token=`), so those parts are left out of the name; the hash
|
|
// of the complete URL takes their place (see `urlHash`), which also keeps URLs
|
|
// that differ only in those parts in separate entries. A URL without either
|
|
// part keeps the name it always had.
|
|
describe("store entry names of URL dependencies", () => {
|
|
const installEnv = (dir: string, env: NodeJS.Dict<string> = bunEnv) => ({
|
|
...env,
|
|
BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache"),
|
|
});
|
|
|
|
async function storeEntries(dir: string): Promise<string[]> {
|
|
return (await readdirSorted(join(dir, "node_modules", ".bun"))).filter(entry => entry !== "node_modules");
|
|
}
|
|
|
|
async function runIndexMjs(dir: string) {
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "index.mjs"],
|
|
cwd: dir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
return { stdout, stderr, exitCode };
|
|
}
|
|
|
|
// Serves the registry fixture `no-deps-<v>.tgz` at `/cdn/pkg.tgz?v=<v>`
|
|
// (1.0.0 without a query string), whatever else the query string contains.
|
|
function serveTarballs() {
|
|
return Bun.serve({
|
|
port: 0,
|
|
fetch(req) {
|
|
const { pathname, searchParams } = new URL(req.url);
|
|
if (pathname !== "/cdn/pkg.tgz") return new Response("not found", { status: 404 });
|
|
const version = searchParams.get("v") ?? "1.0.0";
|
|
return new Response(file(join(import.meta.dir, "registry", "packages", "no-deps", `no-deps-${version}.tgz`)));
|
|
},
|
|
});
|
|
}
|
|
|
|
// (A username without a password, `http://token@host:port/...`, is covered
|
|
// by the git cases below: the tarball downloader currently sends such a URL
|
|
// with the userinfo still in the Host header, which the server rejects.)
|
|
const tarballCases: [description: string, url: (port: number) => string, hashed: boolean][] = [
|
|
["a plain URL", port => `http://127.0.0.1:${port}/cdn/pkg.tgz`, false],
|
|
["a password in the URL", port => `http://carol:[email protected]:${port}/cdn/pkg.tgz`, true],
|
|
["a token in the query string", port => `http://127.0.0.1:${port}/cdn/pkg.tgz?token=npm_s3cret`, true],
|
|
[
|
|
"credentials in both places",
|
|
port => `http://carol:[email protected]:${port}/cdn/pkg.tgz?token=npm_s3cret#fragment`,
|
|
true,
|
|
],
|
|
];
|
|
|
|
test.concurrent.each(tarballCases)("tarball dependency with %s", async (_, urlFor, hashed) => {
|
|
using server = serveTarballs();
|
|
const url = urlFor(server.port);
|
|
using dir = tempDir("store-name-tarball-", {
|
|
"bunfig.toml": `[install]\nlinker = "isolated"\n`,
|
|
"package.json": JSON.stringify({ name: "app", dependencies: { "no-deps": url } }),
|
|
"index.mjs": `import pkg from "no-deps";\nconsole.log(pkg.version);`,
|
|
});
|
|
const env = installEnv(String(dir));
|
|
|
|
await runBunInstall(env, String(dir));
|
|
|
|
const entry = `no-deps@http+++127.0.0.1+${server.port}+cdn+pkg.tgz${hashed ? `+${urlHash(url)}` : ""}`;
|
|
expect(await storeEntries(String(dir))).toEqual([entry]);
|
|
expect(readlinkSync(join(String(dir), "node_modules", "no-deps"))).toBe(
|
|
join(".bun", entry, "node_modules", "no-deps"),
|
|
);
|
|
// Only the directory is named differently; the dependency still resolves
|
|
// to the URL as written.
|
|
expect(await file(join(String(dir), "bun.lock")).text()).toContain(`no-deps@${url}`);
|
|
expect(await runIndexMjs(String(dir))).toEqual({ stdout: "1.0.0\n", stderr: "", exitCode: 0 });
|
|
|
|
// The next install derives the same name from the lockfile.
|
|
await runBunInstall(env, String(dir), { savesLockfile: false });
|
|
expect(await storeEntries(String(dir))).toEqual([entry]);
|
|
});
|
|
|
|
test.concurrent("tarball URLs that differ only in their query string get separate store entries", async () => {
|
|
using server = serveTarballs();
|
|
const base = `http://127.0.0.1:${server.port}/cdn/pkg.tgz`;
|
|
const urls = { "no-deps-v1": `${base}?v=1.0.0`, "no-deps-v2": `${base}?v=2.0.0` };
|
|
using dir = tempDir("store-name-tarball-query-", {
|
|
"bunfig.toml": `[install]\nlinker = "isolated"\n`,
|
|
"package.json": JSON.stringify({ name: "app", dependencies: urls }),
|
|
"index.mjs": `import v1 from "no-deps-v1";\nimport v2 from "no-deps-v2";\nconsole.log(v1.version, v2.version);`,
|
|
});
|
|
|
|
await runBunInstall(installEnv(String(dir)), String(dir));
|
|
|
|
expect(await storeEntries(String(dir))).toEqual(
|
|
Object.values(urls)
|
|
.map(url => `no-deps@http+++127.0.0.1+${server.port}+cdn+pkg.tgz+${urlHash(url)}`)
|
|
.sort(),
|
|
);
|
|
expect(await runIndexMjs(String(dir))).toEqual({ stdout: "1.0.0 2.0.0\n", stderr: "", exitCode: 0 });
|
|
});
|
|
|
|
test.concurrent("the global store entry of a tarball dependency is named the same way", async () => {
|
|
using server = serveTarballs();
|
|
const url = `http://carol:[email protected]:${server.port}/cdn/pkg.tgz?token=npm_s3cret`;
|
|
using dir = tempDir("store-name-tarball-global-", {
|
|
"bunfig.toml": `[install]\nlinker = "isolated"\nglobalStore = true\n`,
|
|
"package.json": JSON.stringify({ name: "app", dependencies: { "no-deps": url } }),
|
|
});
|
|
|
|
await runBunInstall(installEnv(String(dir)), String(dir));
|
|
|
|
const entry = `no-deps@http+++127.0.0.1+${server.port}+cdn+pkg.tgz+${urlHash(url)}`;
|
|
expect(await storeEntries(String(dir))).toEqual([entry]);
|
|
// `node_modules/.bun/<entry>` links to `<cache>/links/<entry>-<entry hash>`.
|
|
const target = readlinkSync(join(String(dir), "node_modules", ".bun", entry));
|
|
expect(basename(dirname(target))).toBe("links");
|
|
expect(basename(target).slice(0, entry.length)).toBe(entry);
|
|
expect(basename(target).slice(entry.length)).toMatch(/^-[0-9a-f]{16}$/);
|
|
expect(await file(join(target, "node_modules", "no-deps", "package.json")).json()).toEqual({
|
|
name: "no-deps",
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!gitExecutable)("git dependencies", () => {
|
|
const gitEnv = {
|
|
...bunEnv,
|
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
GIT_AUTHOR_NAME: "bun-test",
|
|
GIT_AUTHOR_EMAIL: "[email protected]",
|
|
GIT_COMMITTER_NAME: "bun-test",
|
|
GIT_COMMITTER_EMAIL: "[email protected]",
|
|
};
|
|
|
|
async function git(cwd: string, ...args: string[]): Promise<string> {
|
|
await using proc = spawn({ cmd: [gitExecutable!, ...args], cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" });
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(stderr).not.toContain("fatal:");
|
|
expect(exitCode).toBe(0);
|
|
return stdout;
|
|
}
|
|
|
|
// `<root>/repo.git`: a bare repository with one commit of the package
|
|
// `git-dep`, prepared for git's dumb HTTP protocol (after
|
|
// `git update-server-info` a bare repository is plain static files).
|
|
async function createBareRepo(root: string): Promise<{ bare: string; sha: string }> {
|
|
const src = join(root, "git-src");
|
|
const bare = join(root, "repo.git");
|
|
await write(join(src, "package.json"), JSON.stringify({ name: "git-dep", version: "1.0.0" }));
|
|
await git(src, "init", "-q");
|
|
await git(src, "add", "package.json");
|
|
await git(src, "commit", "-q", "-m", "init", "--no-gpg-sign");
|
|
const sha = (await git(src, "rev-parse", "HEAD")).trim();
|
|
await git(root, "clone", "-q", "--bare", src, bare);
|
|
await git(bare, "update-server-info");
|
|
return { bare, sha };
|
|
}
|
|
|
|
function serveBareRepo(bare: string) {
|
|
return Bun.serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
const { pathname } = new URL(req.url);
|
|
if (!pathname.startsWith("/repo.git/")) return new Response("not found", { status: 404 });
|
|
const f = file(join(bare, pathname.slice("/repo.git/".length)));
|
|
return (await f.exists()) ? new Response(f) : new Response("not found", { status: 404 });
|
|
},
|
|
});
|
|
}
|
|
|
|
const gitCases: [description: string, repo: (port: number) => string, hashed: boolean][] = [
|
|
["a plain URL", port => `http://127.0.0.1:${port}/repo.git`, false],
|
|
["a password in the URL", port => `http://carol:[email protected]:${port}/repo.git`, true],
|
|
["a token as the username", port => `http://[email protected]:${port}/repo.git`, true],
|
|
];
|
|
|
|
test.concurrent.each(gitCases)("git dependency with %s", async (_, repoFor, hashed) => {
|
|
using dir = tempDir("store-name-git-", {});
|
|
const { bare, sha } = await createBareRepo(String(dir));
|
|
using server = serveBareRepo(bare);
|
|
const repo = repoFor(server.port);
|
|
const project = join(String(dir), "project");
|
|
await write(
|
|
join(project, "package.json"),
|
|
JSON.stringify({ name: "app", dependencies: { "git-dep": `git+${repo}` } }),
|
|
);
|
|
await write(join(project, "bunfig.toml"), `[install]\nlinker = "isolated"\n`);
|
|
const env = installEnv(String(dir), gitEnv);
|
|
|
|
await runBunInstall(env, project);
|
|
|
|
const entry = `git-dep@git+http+++127.0.0.1+${server.port}+repo.git${hashed ? `+${urlHash(repo)}` : ""}+${sha}`;
|
|
expect(await storeEntries(project)).toEqual([entry]);
|
|
expect(readlinkSync(join(project, "node_modules", "git-dep"))).toBe(
|
|
join(".bun", entry, "node_modules", "git-dep"),
|
|
);
|
|
expect(await file(join(project, "node_modules", "git-dep", "package.json")).json()).toEqual({
|
|
name: "git-dep",
|
|
version: "1.0.0",
|
|
});
|
|
expect(await file(join(project, "bun.lock")).text()).toContain(`git-dep@git+${repo}#${sha}`);
|
|
|
|
await runBunInstall(env, project, { savesLockfile: false });
|
|
expect(await storeEntries(project)).toEqual([entry]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("global virtual store", () => {
|
|
// The global virtual store is off by default; tests that exercise it opt
|
|
// in via bunfig `install.globalStore = true`.
|
|
const gvsBunfigOpts = { linker: "isolated", globalStore: true } as const;
|
|
|
|
test("is disabled by default", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-default-off",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
// With the global store disabled (the default) the entry is a real
|
|
// directory under `node_modules/.bun/` (the pre-global-store layout).
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(entry).isDirectory()).toBe(true);
|
|
expect(existsSync(join(entry, "node_modules", "no-deps", "package.json"))).toBe(true);
|
|
});
|
|
|
|
test("can be enabled via BUN_INSTALL_GLOBAL_STORE=1", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-on-env",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall({ ...bunEnv, BUN_INSTALL_GLOBAL_STORE: "1" }, packageDir);
|
|
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
expect(readlinkSync(entry)).toMatch(/links[\/\\]no-deps@1\.0\.0-[0-9a-f]{16}$/);
|
|
});
|
|
|
|
test("can be enabled via bunfig install.globalStore", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-on-bunfig",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
expect(readlinkSync(entry)).toMatch(/links[\/\\]no-deps@1\.0\.0-[0-9a-f]{16}$/);
|
|
});
|
|
|
|
test("survives node_modules wipe", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
// First install: populates `<cache>/links/` and creates project symlinks.
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
// `node_modules/.bun/<storepath>` is a symlink (to the global virtual store),
|
|
// not a real directory containing a clonefiled copy of the package.
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
const target = readlinkSync(entry);
|
|
expect(target).toMatch(/links[\/\\]two-range-deps@1\.0\.0-[0-9a-f]{16}$/);
|
|
expect(existsSync(join(target, "node_modules", "two-range-deps", "package.json"))).toBe(true);
|
|
// dep symlink inside the global entry points at a sibling global entry
|
|
expect(readlinkSync(join(target, "node_modules", "no-deps"))).toMatch(
|
|
/^\.\.[\/\\]\.\.[\/\\]no-deps@1\.1\.0-[0-9a-f]{16}[\/\\]node_modules[\/\\]no-deps$/,
|
|
);
|
|
|
|
// Second install after wiping node_modules: the global entry persists, so
|
|
// the project entry is re-created as a symlink to the *same* global path
|
|
// without re-materialising package files.
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
expect(readlinkSync(entry)).toBe(target);
|
|
expect(
|
|
await file(
|
|
join(
|
|
packageDir,
|
|
"node_modules",
|
|
".bun",
|
|
"[email protected]",
|
|
"node_modules",
|
|
"two-range-deps",
|
|
"package.json",
|
|
),
|
|
).json(),
|
|
).toMatchObject({ name: "two-range-deps", version: "1.0.0" });
|
|
});
|
|
|
|
test("--force replaces a corrupted global-store entry", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-force-heal",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
const gvsTarget = readlinkSync(entry);
|
|
const pkgDir = join(gvsTarget, "node_modules", "no-deps");
|
|
const pkgJsonPath = join(pkgDir, "package.json");
|
|
const indexPath = join(pkgDir, "index.js");
|
|
const original = await file(pkgJsonPath).text();
|
|
|
|
// Corrupt the published global-store entry: delete files (the directory
|
|
// still exists, so the warm-hit `directoryExistsAt` check is satisfied
|
|
// and a plain reinstall takes the symlink-only fast path). The
|
|
// extraction cache the entry was hardlinked from keeps its own
|
|
// hardlinks, so the source bytes for --force to rebuild from are intact.
|
|
await rm(pkgJsonPath, { force: true });
|
|
await rm(indexPath, { force: true });
|
|
|
|
// Sanity: a non-force reinstall does NOT heal — it sees the directory
|
|
// present and reuses it. (This pins the warm-hit semantics so the
|
|
// assertion below is meaningful.)
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
expect(existsSync(pkgJsonPath)).toBe(false);
|
|
|
|
// --force must rebuild staging and swap it into place over the corrupt
|
|
// final directory instead of discarding the fresh tree on EEXIST.
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
{
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "install", "--force"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(stderr).not.toContain("error:");
|
|
expect(exitCode).toBe(0);
|
|
}
|
|
|
|
expect(readlinkSync(entry)).toBe(gvsTarget);
|
|
expect(await file(pkgJsonPath).text()).toBe(original);
|
|
expect(existsSync(indexPath)).toBe(true);
|
|
|
|
// The swap-aside `.old-<rand>` tree is removed once publish succeeds, so
|
|
// the links/ directory is left with only final entries (no `.old-` and no
|
|
// `.tmp-` siblings).
|
|
const linksDir = dirname(gvsTarget);
|
|
const siblings = await readdirSorted(linksDir);
|
|
expect(siblings.some(n => n.includes(".old-") || n.includes(".tmp-"))).toBe(false);
|
|
});
|
|
|
|
test("BUN_INSTALL_GLOBAL_STORE=0 overrides bunfig globalStore = true", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-off-env",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall({ ...bunEnv, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir);
|
|
|
|
// With the global store disabled the entry is a real directory under
|
|
// `node_modules/.bun/` (the pre-global-store layout).
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(entry).isDirectory()).toBe(true);
|
|
expect(existsSync(join(entry, "node_modules", "no-deps", "package.json"))).toBe(true);
|
|
});
|
|
|
|
test("entry hash is deterministic across fresh installs", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-determinism",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
const target1 = readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]"));
|
|
|
|
// Full reset (lockfile + node_modules + global links). The hash is derived
|
|
// from the resolved dependency closure, so a fresh resolve must reproduce
|
|
// it exactly — otherwise warm-hit reuse across machines/CI is broken.
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await rm(join(packageDir, "bun.lock"), { force: true });
|
|
const linksDir = target1.slice(0, target1.lastIndexOf("links") + "links".length);
|
|
await rm(linksDir, { recursive: true, force: true });
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
const target2 = readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]"));
|
|
|
|
expect(target2).toBe(target1);
|
|
});
|
|
|
|
test("different resolved transitive dep produces a different entry hash", async () => {
|
|
// Two projects that depend on the same direct package but force different
|
|
// versions of one of its transitive deps must NOT share a global entry —
|
|
// the dep symlink inside the entry would point at the wrong version for
|
|
// one of them.
|
|
const a = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
const b = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
a.packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-hash-a",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
overrides: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
await write(
|
|
b.packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-hash-b",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
overrides: { "no-deps": "1.1.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, a.packageDir);
|
|
await runBunInstall(bunEnv, b.packageDir);
|
|
|
|
const targetA = entryStoreName(readlinkSync(join(a.packageDir, "node_modules", ".bun", "[email protected]")));
|
|
const targetB = entryStoreName(readlinkSync(join(b.packageDir, "node_modules", ".bun", "[email protected]")));
|
|
expect(targetA).not.toBe(targetB);
|
|
|
|
// Each entry's dep symlink resolves to the version that *its* project
|
|
// overrode — proving the entries really are independent on disk.
|
|
expect(
|
|
await file(
|
|
join(a.packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toMatchObject({ version: "1.0.0" });
|
|
expect(
|
|
await file(
|
|
join(b.packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toMatchObject({ version: "1.1.0" });
|
|
});
|
|
|
|
test("re-resolving the same project re-points the entry link at the new-hash global entry", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-reresolve",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
const nestedNoDeps = join(entry, "node_modules", "no-deps", "package.json");
|
|
const before = readlinkSync(entry);
|
|
expect(entryStoreName(before)).toMatch(/^two-range-deps@1\.0\.0-[0-9a-f]{16}$/);
|
|
expect(await file(nestedNoDeps).json()).toStrictEqual({ name: "no-deps", version: "1.1.0" });
|
|
|
|
// Same node_modules; the override changes two-range-deps' closure so its existing link must move.
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-reresolve",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
overrides: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
const after = readlinkSync(entry);
|
|
expect(entryStoreName(after)).toMatch(/^two-range-deps@1\.0\.0-[0-9a-f]{16}$/);
|
|
expect(after).not.toBe(before);
|
|
expect(await file(nestedNoDeps).json()).toStrictEqual({ name: "no-deps", version: "1.0.0" });
|
|
expect(readlinkSync(join(after, "node_modules", "no-deps"))).toMatch(
|
|
/^\.\.[\/\\]\.\.[\/\\]no-deps@1\.0\.0-[0-9a-f]{16}[\/\\]node_modules[\/\\]no-deps$/,
|
|
);
|
|
|
|
// The old-closure entry is untouched in the shared store.
|
|
expect(await file(join(before, "node_modules", "no-deps", "package.json")).json()).toStrictEqual({
|
|
name: "no-deps",
|
|
version: "1.1.0",
|
|
});
|
|
});
|
|
|
|
test("two projects with the same closure share one global entry", async () => {
|
|
const a = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
const b = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
for (const { packageJson } of [a, b]) {
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-share",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
}
|
|
|
|
await runBunInstall(bunEnv, a.packageDir);
|
|
await runBunInstall(bunEnv, b.packageDir);
|
|
|
|
const targetA = entryStoreName(readlinkSync(join(a.packageDir, "node_modules", ".bun", "[email protected]")));
|
|
const targetB = entryStoreName(readlinkSync(join(b.packageDir, "node_modules", ".bun", "[email protected]")));
|
|
expect(targetA).toMatch(/^two-range-deps@1\.0\.0-[0-9a-f]{16}$/);
|
|
expect(targetA).toBe(targetB);
|
|
});
|
|
|
|
test("workspace dependency makes the parent entry project-local", async () => {
|
|
// Dep symlinks inside a global entry are sibling-relative within
|
|
// `<cache>/links/`. If one of those siblings were a workspace package
|
|
// (which lives under the project, not the cache) the link would be
|
|
// dangling for any other project that shared the entry. The eligibility
|
|
// check propagates: an entry that links to anything project-local is
|
|
// itself project-local.
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await mkdir(join(packageDir, "packages", "ws-pkg"), { recursive: true });
|
|
await write(
|
|
join(packageDir, "packages", "ws-pkg", "package.json"),
|
|
JSON.stringify({ name: "ws-pkg", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }),
|
|
);
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-ws",
|
|
workspaces: ["packages/*"],
|
|
dependencies: { "ws-pkg": "workspace:*", "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
// `no-deps` has no project-local deps so it stays global.
|
|
const noDepsEntry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(noDepsEntry).isSymbolicLink()).toBe(true);
|
|
expect(readlinkSync(noDepsEntry)).toMatch(/links[\/\\]no-deps@1\.0\.0-[0-9a-f]{16}$/);
|
|
|
|
// The workspace itself is always project-local (its source lives in the
|
|
// project tree).
|
|
expect(readlinkSync(join(packageDir, "node_modules", "ws-pkg"))).toBe(join("..", "packages", "ws-pkg"));
|
|
});
|
|
|
|
test("packages with trusted lifecycle scripts stay project-local", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-scripts",
|
|
dependencies: { "lifecycle-postinstall": "1.0.0", "no-deps": "1.0.0" },
|
|
trustedDependencies: ["lifecycle-postinstall"],
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
// The script may mutate the install dir, so the entry must not be shared.
|
|
const scriptEntry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(scriptEntry).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(scriptEntry).isDirectory()).toBe(true);
|
|
|
|
// A neighbouring scriptless package is unaffected and stays global.
|
|
const noDepsEntry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(noDepsEntry).isSymbolicLink()).toBe(true);
|
|
|
|
// `meta.hasInstallScript` isn't serialised in `bun.lock`, so a warm
|
|
// install must reach the same conclusion from the trustedDependencies
|
|
// list alone — the cold install above isn't sufficient on its own.
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
expect(lstatSync(scriptEntry).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(scriptEntry).isDirectory()).toBe(true);
|
|
expect(lstatSync(noDepsEntry).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
test("concurrent installs into a cold global store both succeed", async () => {
|
|
// Two `bun install` processes may race to create the same content-addressed
|
|
// global entry; the loser sees EEXIST from clonefile/symlink/bin-link and
|
|
// must treat it as success rather than failing the install.
|
|
const a = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
const b = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
// Both projects must share one cache for the race to be real; the harness
|
|
// gives each test dir its own `.bun-cache/` by default.
|
|
const sharedCache = join(a.packageDir, ".bun-cache");
|
|
await write(
|
|
join(b.packageDir, "bunfig.toml"),
|
|
Bun.TOML.stringify({
|
|
install: {
|
|
cache: sharedCache,
|
|
registry: registry.registryUrl(),
|
|
linker: "isolated",
|
|
globalStore: true,
|
|
},
|
|
}),
|
|
);
|
|
|
|
for (const { packageJson } of [a, b]) {
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-concurrent",
|
|
// `what-bin` exercises the `.bin/` symlink race; the others give the
|
|
// clonefile + dep-symlink paths something to fight over.
|
|
dependencies: { "two-range-deps": "1.0.0", "a-dep-b": "1.0.0", "what-bin": "1.0.0" },
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Prime the package cache so the parallel installs only race on
|
|
// global-store creation, not network downloads.
|
|
await runBunInstall(bunEnv, a.packageDir);
|
|
const linksDir = join(sharedCache, "links");
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
await rm(linksDir, { recursive: true, force: true });
|
|
await rm(join(a.packageDir, "node_modules"), { recursive: true, force: true });
|
|
await rm(join(b.packageDir, "node_modules"), { recursive: true, force: true });
|
|
|
|
const [ra, rb] = await Promise.all([
|
|
spawn({ cmd: [bunExe(), "install"], cwd: a.packageDir, env: bunEnv, stderr: "pipe", stdout: "pipe" }).exited,
|
|
spawn({ cmd: [bunExe(), "install"], cwd: b.packageDir, env: bunEnv, stderr: "pipe", stdout: "pipe" }).exited,
|
|
]);
|
|
expect({ iter: i, a: ra, b: rb }).toEqual({ iter: i, a: 0, b: 0 });
|
|
}
|
|
|
|
// Both projects' `.bun/<X>` symlinks point at the same physical directory
|
|
// in the shared cache.
|
|
expect(readlinkSync(join(a.packageDir, "node_modules", ".bun", "[email protected]"))).toBe(
|
|
readlinkSync(join(b.packageDir, "node_modules", ".bun", "[email protected]")),
|
|
);
|
|
for (const { packageDir } of [a, b]) {
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toMatchObject({ name: "no-deps" });
|
|
const bin = process.platform === "win32" ? "what-bin.bunx" : "what-bin";
|
|
expect(existsSync(join(packageDir, "node_modules", ".bin", bin))).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("a leftover staging directory does not shadow the published entry", async () => {
|
|
// Entries are built under `<entry>.tmp-<suffix>/` and renamed into
|
|
// `<entry>/` as the final step, so a published entry is always complete.
|
|
// A crashed earlier install can leave a staging directory behind; the
|
|
// warm-hit check must look at the final path only.
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-staging",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
const target = readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]"));
|
|
expect(existsSync(join(target, "node_modules", "no-deps", "package.json"))).toBe(true);
|
|
// No stamp file: the directory existing *is* the completeness signal.
|
|
expect(existsSync(join(target, ".bun-ok"))).toBe(false);
|
|
|
|
// Fake a leftover staging sibling and re-install — the published entry
|
|
// should warm-hit unchanged.
|
|
await mkdir(`${target}.tmp-deadbeef`, { recursive: true });
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "[email protected]"))).toBe(target);
|
|
expect(existsSync(join(target, "node_modules", "no-deps", "package.json"))).toBe(true);
|
|
});
|
|
|
|
test("bun's resolver follows the double-hop chain into the global store", async () => {
|
|
// Regression test for the Windows EISDIR: `node_modules/.bun/<pkg>` is a
|
|
// symlink into `<cache>/links/`, and the dep symlinks inside the global
|
|
// entry are *relative* to the entry's physical location. The Windows
|
|
// `RealFS.kind()` symlink walk used to join those relative targets
|
|
// against the *logical* `dirname()` (which still contains the
|
|
// `node_modules/.bun/<pkg>` segment), miss, fall back to `.file`, and the
|
|
// resolver would then `ReadFile` a directory. Exercising an actual
|
|
// `require()` through a transitive dep proves the chain resolves
|
|
// end-to-end on every platform.
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-resolver",
|
|
// two-range-deps → no-deps gives a transitive hop inside the GVS.
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
await write(
|
|
join(packageDir, "index.js"),
|
|
`console.log(JSON.stringify({
|
|
direct: require("two-range-deps/package.json").name,
|
|
transitive: require(require.resolve("no-deps/package.json", {
|
|
paths: [require("path").dirname(require.resolve("two-range-deps/package.json"))],
|
|
})).name,
|
|
}));`,
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
// The entry must actually be a global-store symlink for this test to mean
|
|
// anything (guards against a future default flip silently neutering it).
|
|
expect(lstatSync(join(packageDir, "node_modules", ".bun", "[email protected]")).isSymbolicLink()).toBe(true);
|
|
|
|
const { stdout, stderr, exited } = spawn({
|
|
cmd: [bunExe(), "index.js"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
|
|
expect(err).not.toContain("EISDIR");
|
|
expect(code).toBe(0);
|
|
expect(JSON.parse(out.trim())).toEqual({ direct: "two-range-deps", transitive: "no-deps" });
|
|
});
|
|
|
|
test("an entry that loses global-store eligibility detaches without mutating the shared entry", async () => {
|
|
// Regression: a previously-GVS entry that becomes project-local on the
|
|
// next install (newly patched, newly trusted, …) used to write its new
|
|
// tree *through* the stale `node_modules/.bun/<storepath>` symlink into
|
|
// the shared cache. On Windows the `.expect_missing` dep-symlink rewrite
|
|
// then baked a project-absolute junction target into the shared entry,
|
|
// which dangled after `rm -rf node_modules`.
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-gvs-to-local",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
const gvsTarget = readlinkSync(entry);
|
|
const gvsDepLink = readlinkSync(join(gvsTarget, "node_modules", "no-deps"));
|
|
|
|
// Flip eligibility: trustedDependencies makes it project-local on the
|
|
// next install regardless of whether it actually has scripts.
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-gvs-to-local",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
trustedDependencies: ["two-range-deps"],
|
|
}),
|
|
);
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
// The project entry is now a real directory…
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(entry).isDirectory()).toBe(true);
|
|
// …and the shared GVS entry's dep symlink is untouched (still the
|
|
// global-relative target, not a project-absolute path).
|
|
expect(readlinkSync(join(gvsTarget, "node_modules", "no-deps"))).toBe(gvsDepLink);
|
|
expect(gvsDepLink).toMatch(/^\.\.[\/\\]\.\.[\/\\]no-deps@/);
|
|
});
|
|
|
|
test("upgrades a pre-global-store node_modules in place", async () => {
|
|
// A project installed before this change has `node_modules/.bun/<X>` as a
|
|
// real directory. Re-running install with the global store enabled must
|
|
// replace that directory with a symlink (not fail with EEXIST or leave the
|
|
// stale tree behind).
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-upgrade",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall({ ...bunEnv, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir);
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isDirectory()).toBe(true);
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(false);
|
|
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
expect(existsSync(join(entry, "node_modules", "no-deps", "package.json"))).toBe(true);
|
|
});
|
|
|
|
test("disabling the global store detaches entries on the next install", async () => {
|
|
// The reverse of the upgrade test above: a project installed with the
|
|
// global store enabled has `node_modules/.bun/<X>` symlinks into
|
|
// `<cache>/links/`. Re-running install with the store disabled must
|
|
// replace those links with real project-local directories — the
|
|
// warm-path existence check passes *through* a live link, so without
|
|
// stale-link detection the project would silently keep running against
|
|
// (and a later rebuild would write into) the shared store.
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-global-store-disable",
|
|
dependencies: { "two-range-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
const entry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(true);
|
|
const globalTarget = readlinkSync(entry);
|
|
|
|
await runBunInstall({ ...bunEnv, BUN_INSTALL_GLOBAL_STORE: "0" }, packageDir, { savesLockfile: false });
|
|
|
|
// Every entry is detached into a real project-local directory.
|
|
expect(lstatSync(entry).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(entry).isDirectory()).toBe(true);
|
|
const depEntry = join(packageDir, "node_modules", ".bun", "[email protected]");
|
|
expect(lstatSync(depEntry).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(depEntry).isDirectory()).toBe(true);
|
|
|
|
// The rebuilt project-local tree resolves, including dep links between
|
|
// detached entries.
|
|
expect(await file(join(entry, "node_modules", "two-range-deps", "package.json")).json()).toMatchObject({
|
|
name: "two-range-deps",
|
|
version: "1.0.0",
|
|
});
|
|
expect(await file(join(entry, "node_modules", "no-deps", "package.json")).json()).toMatchObject({
|
|
name: "no-deps",
|
|
});
|
|
expect(await file(join(packageDir, "node_modules", "two-range-deps", "package.json")).json()).toMatchObject({
|
|
name: "two-range-deps",
|
|
});
|
|
|
|
// The shared global entry is left untouched for other projects.
|
|
expect(existsSync(join(globalTarget, "node_modules", "two-range-deps", "package.json"))).toBe(true);
|
|
});
|
|
|
|
test("preserves bun patch workspace when install runs before --commit", async () => {
|
|
// Regression: `bun patch <pkg>` detaches the project store entry from the
|
|
// global virtual store (symlink → real directory) so the user can edit it.
|
|
// A subsequent `bun install` (e.g. to add another dep) before `--commit`
|
|
// must not see that real directory as a stale pre-GVS layout and
|
|
// `deleteTree` the user's in-progress edits.
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: gvsBunfigOpts });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-patch-preserve",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
const workspace = join(packageDir, "node_modules", "no-deps");
|
|
expect(lstatSync(workspace).isSymbolicLink()).toBe(true);
|
|
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "patch", "no-deps"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(stderr).not.toContain("error");
|
|
expect(stdout).toContain("To patch");
|
|
expect(exitCode).toBe(0);
|
|
|
|
// `bun patch` detached the top-level dep symlink into a real directory
|
|
// for the user to edit. The `.bun/<storepath>` GVS symlink is untouched.
|
|
expect(lstatSync(workspace).isSymbolicLink()).toBe(false);
|
|
expect(lstatSync(workspace).isDirectory()).toBe(true);
|
|
|
|
const edited = join(workspace, "index.js");
|
|
await write(edited, "module.exports = 'USER_EDITS';\n");
|
|
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
// The real-directory workspace is preserved across the install; before
|
|
// this fix `.expect_existing` would `deleteTree` it on readlink EINVAL
|
|
// and re-symlink, wiping the edits.
|
|
expect(lstatSync(workspace).isSymbolicLink()).toBe(false);
|
|
expect(await file(edited).text()).toBe("module.exports = 'USER_EDITS';\n");
|
|
});
|
|
});
|
|
|
|
test("rejects dependency aliases that traverse outside node_modules", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
// A (transitively) malicious package.json can use an arbitrary string as a
|
|
// dependency alias. The alias becomes a `node_modules/<alias>` path
|
|
// component in the isolated store layout, so a `..` segment lets it plant
|
|
// symlinks outside of node_modules.
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-unsafe-alias",
|
|
dependencies: {
|
|
"../pwned-by-alias": "npm:[email protected]",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
|
|
|
|
expect(stderr).toContain("is not a valid install folder name");
|
|
// Nothing may be created outside of node_modules. `lstatSync` instead of
|
|
// `existsSync` because the escaped artifact would be a dangling symlink.
|
|
expect(() => lstatSync(join(packageDir, "pwned-by-alias"))).toThrow();
|
|
expect(exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("rejects a dependency alias with more than one path component", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "test-pkg-nested-alias",
|
|
dependencies: {
|
|
"somepkg/lib": "npm:[email protected]",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
|
|
expect(stderr).toContain(`"somepkg/lib" is not a valid install folder name`);
|
|
expect(() => lstatSync(join(packageDir, "node_modules", "somepkg", "lib"))).toThrow();
|
|
expect(exitCode).not.toBe(0);
|
|
});
|
|
|
|
test("invalid --linker value is echoed back in the error", async () => {
|
|
using dir = tempDir("install-linker-err", {
|
|
"package.json": JSON.stringify({ name: "t" }),
|
|
});
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "install", "--linker=isoalted"],
|
|
cwd: String(dir),
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(stderr).toContain('--linker: "isoalted"');
|
|
expect(stderr).toContain("'isolated' or 'hoisted'");
|
|
expect(exitCode).toBe(1);
|
|
});
|
|
|
|
test("store build timings are printed by --verbose only", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "store-timings",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
async function install(...args: string[]) {
|
|
await using proc = spawn({
|
|
cmd: [bunExe(), "install", ...args],
|
|
cwd: packageDir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
expect(stderr).not.toContain("error:");
|
|
expect(exitCode).toBe(0);
|
|
return stderr;
|
|
}
|
|
|
|
const verbose = await install("--verbose");
|
|
expect(verbose).toMatch(/^Resolved peers \[\S+\]$/m);
|
|
expect(verbose).toMatch(/^Created store \[\S+\]$/m);
|
|
|
|
await rm(join(packageDir, "node_modules"), { recursive: true, force: true });
|
|
const quiet = await install();
|
|
expect(quiet).not.toContain("Resolved peers");
|
|
expect(quiet).not.toContain("Created store");
|
|
});
|
|
|
|
describe("hoist", () => {
|
|
// `node_modules/.bun/node_modules` holds a symlink to every installed
|
|
// package and sits on the upward resolution path of every store entry, so
|
|
// by default a store package can resolve dependencies it never declared.
|
|
// `install.hoist = false` (pnpm's `hoist=false`) skips that fallback
|
|
// directory without touching the rest of the layout.
|
|
test("hoist = false skips the hidden fallback directory", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated", hoist: false },
|
|
});
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "hoist-false",
|
|
dependencies: {
|
|
"two-range-deps": "1.0.0",
|
|
"a-dep": "1.0.1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse();
|
|
|
|
// declared dependency symlinks are unchanged
|
|
expect(readlinkSync(join(packageDir, "node_modules", "a-dep"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "a-dep"),
|
|
);
|
|
expect(readlinkSync(join(packageDir, "node_modules", "two-range-deps"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "two-range-deps"),
|
|
);
|
|
|
|
// a store package still resolves the dependencies it declares
|
|
const fromTwoRangeDeps = createRequire(
|
|
join(
|
|
packageDir,
|
|
"node_modules",
|
|
".bun",
|
|
"[email protected]",
|
|
"node_modules",
|
|
"two-range-deps",
|
|
"package.json",
|
|
),
|
|
);
|
|
expect(fromTwoRangeDeps.resolve("no-deps/package.json")).toEndWith(
|
|
join(".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
);
|
|
|
|
// and cannot resolve a package it does not declare
|
|
const fromADep = createRequire(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "a-dep", "package.json"),
|
|
);
|
|
expect(() => fromADep.resolve("no-deps/package.json")).toThrow(
|
|
expect.objectContaining({ code: "MODULE_NOT_FOUND" }),
|
|
);
|
|
|
|
// packages linked at the project root (here: a direct dependency) remain
|
|
// reachable because `.bun` lives inside node_modules, matching pnpm
|
|
expect(fromADep.resolve("two-range-deps/package.json")).toEndWith(
|
|
join(".bun", "[email protected]", "node_modules", "two-range-deps", "package.json"),
|
|
);
|
|
});
|
|
|
|
test("hoist = false keeps publicHoistPattern working", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated", hoist: false, publicHoistPattern: "*types*" },
|
|
});
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "hoist-public",
|
|
dependencies: {
|
|
"two-range-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
// the transitive @types/is-number is still publicly hoisted to the root
|
|
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual([".bun", "@types", "two-range-deps"]);
|
|
expect(readlinkSync(join(packageDir, "node_modules", "@types", "is-number"))).toBe(
|
|
join("..", ".bun", "@[email protected]", "node_modules", "@types", "is-number"),
|
|
);
|
|
// while the hidden fallback is not created
|
|
expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse();
|
|
});
|
|
|
|
test("hoist = false removes a stale fallback directory from a previous install", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
});
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "hoist-stale",
|
|
dependencies: {
|
|
"two-range-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
// hoisting is on by default: transitive deps resolve through the fallback
|
|
expect(readlinkSync(join(packageDir, "node_modules", ".bun", "node_modules", "no-deps"))).toBe(
|
|
join("..", "[email protected]", "node_modules", "no-deps"),
|
|
);
|
|
|
|
await registry.writeBunfig(packageDir, { linker: "isolated", hoist: false });
|
|
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
|
|
|
|
expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse();
|
|
|
|
// the rest of the install is untouched: removing the fallback symlinks
|
|
// must not touch the store entries they pointed at
|
|
expect(readlinkSync(join(packageDir, "node_modules", "two-range-deps"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "two-range-deps"),
|
|
);
|
|
expect(await file(join(packageDir, "node_modules", "two-range-deps", "package.json")).json()).toMatchObject({
|
|
name: "two-range-deps",
|
|
});
|
|
expect(
|
|
await file(
|
|
join(packageDir, "node_modules", ".bun", "[email protected]", "node_modules", "no-deps", "package.json"),
|
|
).json(),
|
|
).toMatchObject({ name: "no-deps", version: "1.1.0" });
|
|
});
|
|
|
|
test("hoist = false takes precedence over hoistPattern", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated", hoist: false, hoistPattern: "*" },
|
|
});
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "hoist-precedence",
|
|
dependencies: {
|
|
"two-range-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse();
|
|
});
|
|
|
|
test("npmrc hoist=false", async () => {
|
|
const { packageJson, packageDir } = await registry.createTestDir({
|
|
bunfigOpts: { linker: "isolated" },
|
|
files: {
|
|
".npmrc": "hoist=false",
|
|
},
|
|
});
|
|
|
|
await write(
|
|
packageJson,
|
|
JSON.stringify({
|
|
name: "hoist-npmrc",
|
|
dependencies: {
|
|
"two-range-deps": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await runBunInstall(bunEnv, packageDir);
|
|
|
|
expect(existsSync(join(packageDir, "node_modules", ".bun", "node_modules"))).toBeFalse();
|
|
expect(readlinkSync(join(packageDir, "node_modules", "two-range-deps"))).toBe(
|
|
join(".bun", "[email protected]", "node_modules", "two-range-deps"),
|
|
);
|
|
});
|
|
});
|