Files
2026-08-27 21:09:14 +00:00

1637 lines
65 KiB
TypeScript

import { file, spawn, write } from "bun";
import { readTarball } from "bun:internal-for-testing";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, lstatSync, readlinkSync } from "fs";
import { exists, readdir, realpath, rm } from "fs/promises";
import { VerdaccioRegistry, bunEnv, bunExe, normalizeBunSnapshot, pack, runBunInstall, tempDir } from "harness";
import { join } from "path";
var registry = new VerdaccioRegistry();
type Manifests = Record<string, Record<string, Record<string, Record<string, string>>>>;
// Registry packages that ship a raw `catalog:` specifier; verdaccio has none.
const catalogManifests: Manifests = {
"leaf": { "1.0.0": {}, "2.0.0": {} },
"wants-leaf-peer": { "1.0.0": { peerDependencies: { leaf: "catalog:" } } },
"wants-leaf-dep": { "1.0.0": { dependencies: { leaf: "catalog:" } } },
"wants-leaf-optional": { "1.0.0": { optionalDependencies: { leaf: "catalog:" } } },
"needs-leaf-2": { "1.0.0": { dependencies: { leaf: "2.0.0" } } },
"no-deps": { "1.0.0": {}, "2.0.0": {} },
"catalog-peer": {
"1.0.0": { peerDependencies: { "no-deps": "catalog:" } },
"2.0.0": { peerDependencies: { "no-deps": "catalog:peers" } },
},
};
async function serveRegistry(manifests: Manifests) {
const tarballs = new Map<string, Uint8Array>();
for (const [name, versions] of Object.entries(manifests)) {
for (const [version, extra] of Object.entries(versions)) {
const archive = new Bun.Archive(
{ "package/package.json": JSON.stringify({ name, version, ...extra }) },
{ compress: "gzip" },
);
tarballs.set(`/${name}-${version}.tgz`, await archive.bytes());
}
}
return Bun.serve({
port: 0,
fetch(request) {
const { origin, pathname } = new URL(request.url);
const tarball = tarballs.get(pathname);
if (tarball) return new Response(tarball);
const name = pathname.slice(1);
const entry = manifests[name];
if (!entry) return new Response("not found", { status: 404 });
const versions: Record<string, unknown> = {};
for (const [version, extra] of Object.entries(entry)) {
versions[version] = { name, version, dist: { tarball: `${origin}/${name}-${version}.tgz` }, ...extra };
}
const latest = Object.keys(entry).sort(Bun.semver.order).at(-1);
return Response.json({ name, versions, "dist-tags": { latest } });
},
});
}
let catalogRegistry: Awaited<ReturnType<typeof serveRegistry>>;
function writeRegistryProject(files: Record<string, string>, registryUrl: string) {
return tempDir("catalog-registry-", {
...files,
"bunfig.toml": ({ root }) =>
Bun.TOML.stringify({
install: { cache: join(root, ".bun-cache"), registry: registryUrl, saveTextLockfile: true },
}),
});
}
beforeAll(async () => {
[catalogRegistry] = await Promise.all([serveRegistry(catalogManifests), registry.start()]);
});
afterAll(() => {
registry.stop();
catalogRegistry.stop(true);
});
describe("basic", () => {
async function createBasicCatalogMonorepo(packageDir: string, name: string, inTopLevelKey: boolean = false) {
const catalogs = {
catalog: {
"no-deps": "2.0.0",
},
catalogs: {
a: {
"a-dep": "1.0.1",
},
},
};
const packageJson = !inTopLevelKey
? {
name,
workspaces: {
packages: ["packages/*"],
...catalogs,
},
}
: {
name,
...catalogs,
workspaces: {
packages: ["packages/*"],
},
};
await Promise.all([
write(join(packageDir, "package.json"), JSON.stringify(packageJson)),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
dependencies: {
"no-deps": "catalog:",
"a-dep": "catalog:a",
},
}),
),
]);
return packageJson;
}
for (const isTopLevel of [true, false]) {
test(`both catalog and catalogs ${isTopLevel ? "in top-level" : "in workspaces"}`, async () => {
const { packageDir } = await registry.createTestDir();
await createBasicCatalogMonorepo(packageDir, "catalog-basic-1", isTopLevel);
await runBunInstall(bunEnv, packageDir);
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toEqual({
name: "no-deps",
version: "2.0.0",
});
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toEqual({
name: "a-dep",
version: "1.0.1",
});
// another install does not save the lockfile
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
});
}
for (const binaryLockfile of [true, false]) {
test(`detect changes (${binaryLockfile ? "bun.lockb" : "bun.lock"})`, async () => {
const { packageDir } = await registry.createTestDir({
bunfigOpts: { saveTextLockfile: !binaryLockfile, linker: "hoisted" },
});
const packageJson = await createBasicCatalogMonorepo(packageDir, "catalog-basic-2");
let { err } = await runBunInstall(bunEnv, packageDir);
expect(err).toContain("Saved lockfile");
const initialLockfile = !binaryLockfile
? (await file(join(packageDir, "bun.lock")).text()).replaceAll(/localhost:\d+/g, "localhost:1234")
: undefined;
if (!binaryLockfile) {
expect(initialLockfile).toMatchSnapshot();
} else {
expect(await exists(join(packageDir, "bun.lockb"))).toBeTrue();
}
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toEqual({
name: "no-deps",
version: "2.0.0",
});
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toEqual({
name: "a-dep",
version: "1.0.1",
});
// update catalog
packageJson.workspaces.catalog["no-deps"] = "1.0.0";
await write(join(packageDir, "package.json"), JSON.stringify(packageJson));
({ err } = await runBunInstall(bunEnv, packageDir, { savesLockfile: true }));
expect(err).toContain("Saved lockfile");
if (!binaryLockfile) {
const newLockfile = (await file(join(packageDir, "bun.lock")).text()).replaceAll(
/localhost:\d+/g,
"localhost:1234",
);
expect(newLockfile).not.toEqual(initialLockfile);
expect(newLockfile).toMatchSnapshot();
} else {
expect(await exists(join(packageDir, "bun.lockb"))).toBeTrue();
}
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toEqual({
name: "no-deps",
version: "1.0.0",
});
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toEqual({
name: "a-dep",
version: "1.0.1",
});
// update catalogs
packageJson.workspaces!.catalogs!.a["a-dep"] = "1.0.10";
await write(join(packageDir, "package.json"), JSON.stringify(packageJson));
({ err } = await runBunInstall(bunEnv, packageDir, { savesLockfile: true }));
expect(err).toContain("Saved lockfile");
if (!binaryLockfile) {
const newLockfile = (await file(join(packageDir, "bun.lock")).text()).replaceAll(
/localhost:\d+/g,
"localhost:1234",
);
expect(newLockfile).not.toEqual(initialLockfile);
expect(newLockfile).toMatchSnapshot();
} else {
expect(await exists(join(packageDir, "bun.lockb"))).toBeTrue();
}
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toEqual({
name: "no-deps",
version: "1.0.0",
});
expect(await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).toEqual({
name: "a-dep",
version: "1.0.10",
});
});
}
test("switching a dependency to a different catalog is detected", async () => {
const { packageDir } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: true, linker: "hoisted" } });
const pkg1Path = join(packageDir, "packages", "pkg1", "package.json");
await Promise.all([
write(
join(packageDir, "package.json"),
JSON.stringify({
name: "catalog-switch",
workspaces: {
packages: ["packages/*"],
catalogs: { a: { "no-deps": "1.0.0" }, b: { "no-deps": "2.0.0" } },
},
}),
),
write(pkg1Path, JSON.stringify({ name: "pkg1", dependencies: { "no-deps": "catalog:a" } })),
]);
await runBunInstall(bunEnv, packageDir);
expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe("1.0.0");
await write(pkg1Path, JSON.stringify({ name: "pkg1", dependencies: { "no-deps": "catalog:b" } }));
await runBunInstall(bunEnv, packageDir);
expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe("2.0.0");
const lock = Bun.JSONC.parse(await file(join(packageDir, "bun.lock")).text()) as any;
expect(lock.workspaces["packages/pkg1"].dependencies).toStrictEqual({ "no-deps": "catalog:b" });
expect(Object.keys(lock.packages)).toStrictEqual(["no-deps", "pkg1"]);
expect(lock.packages["no-deps"][0]).toBe("[email protected]");
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
});
test.concurrent("catalog and catalogs.default may split different packages between them", async () => {
const { packageDir } = await registry.createTestDir({
files: {
"package.json": JSON.stringify({
name: "catalog-split-default",
workspaces: {
catalog: { "no-deps": "1.0.0" },
catalogs: { default: { "a-dep": "1.0.1" } },
},
dependencies: { "no-deps": "catalog:default", "a-dep": "catalog:" },
}),
},
});
await runBunInstall(bunEnv, packageDir);
expect((await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe("1.0.0");
expect((await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).version).toBe("1.0.1");
await runBunInstall(bunEnv, packageDir, { savesLockfile: false });
});
});
describe("update", () => {
async function createUpdateMonorepo(packageDir: string, name: string, inTopLevelKey: boolean = false) {
const catalogs = {
catalog: {
"no-deps": "^1.0.0",
},
catalogs: {
a: {
"a-dep": "~1.0.1",
},
},
};
const packageJson = !inTopLevelKey
? {
name,
workspaces: {
packages: ["packages/*"],
...catalogs,
},
}
: {
name,
...catalogs,
workspaces: {
packages: ["packages/*"],
},
};
await Promise.all([
write(join(packageDir, "package.json"), JSON.stringify(packageJson)),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
dependencies: {
"no-deps": "catalog:",
"a-dep": "catalog:a",
},
}),
),
]);
return packageJson;
}
async function runUpdate(cwd: string, ...args: string[]) {
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "update", ...args],
cwd,
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]);
return { out, err, exitCode };
}
// https://github.com/oven-sh/bun/issues/23739
for (const [flags, label] of [
[["--latest"], "in top-level"],
[["--latest"], "in workspaces"],
[["-r", "--latest"], "with -r"],
] as const) {
const isTopLevel = label === "in top-level";
test(`--latest updates catalog versions ${label}`, async () => {
const { packageDir } = await registry.createTestDir();
await createUpdateMonorepo(packageDir, `catalog-update-latest-${label.replace(/\W+/g, "-")}`, isTopLevel);
await runBunInstall(bunEnv, packageDir);
const { err, exitCode } = await runUpdate(packageDir, ...flags);
expect(err).not.toContain("error:");
// catalog entries are updated, preserving the pinning style
const root = await file(join(packageDir, "package.json")).json();
const { catalog, catalogs } = isTopLevel ? root : root.workspaces;
expect(catalog).toEqual({ "no-deps": "^2.0.0" });
expect(catalogs).toEqual({ a: { "a-dep": "~1.0.10" } });
// workspace packages keep their catalog references
expect((await file(join(packageDir, "packages", "pkg1", "package.json")).json()).dependencies).toEqual({
"no-deps": "catalog:",
"a-dep": "catalog:a",
});
// the new versions are installed
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toEqual({
name: "no-deps",
version: "2.0.0",
});
expect((await file(join(packageDir, "node_modules", "a-dep", "package.json")).json()).version).toBe("1.0.10");
expect(exitCode).toBe(0);
});
}
test("--frozen-lockfile passes after --latest updates catalogs", async () => {
const { packageDir } = await registry.createTestDir();
await createUpdateMonorepo(packageDir, "catalog-update-frozen");
await runBunInstall(bunEnv, packageDir);
const update = await runUpdate(packageDir, "--latest");
expect(update.err).not.toContain("error:");
expect(update.exitCode).toBe(0);
expect((await file(join(packageDir, "package.json")).json()).workspaces.catalog).toEqual({ "no-deps": "^2.0.0" });
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--frozen-lockfile"],
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).not.toContain("lockfile had changes");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
});
test("--latest run from inside a workspace package updates the root catalog", async () => {
const { packageDir } = await registry.createTestDir();
await createUpdateMonorepo(packageDir, "catalog-update-in-workspace");
await runBunInstall(bunEnv, packageDir);
const { err, exitCode } = await runUpdate(join(packageDir, "packages", "pkg1"), "--latest");
expect(err).not.toContain("error:");
const root = await file(join(packageDir, "package.json")).json();
expect(root.workspaces.catalog).toEqual({ "no-deps": "^2.0.0" });
expect(root.workspaces.catalogs).toEqual({ a: { "a-dep": "~1.0.10" } });
expect((await file(join(packageDir, "packages", "pkg1", "package.json")).json()).dependencies).toEqual({
"no-deps": "catalog:",
"a-dep": "catalog:a",
});
expect(exitCode).toBe(0);
});
test("--latest updates the same package independently per catalog", async () => {
const { packageDir } = await registry.createTestDir();
await Promise.all([
write(
join(packageDir, "package.json"),
JSON.stringify({
name: "catalog-update-identity",
workspaces: {
packages: ["packages/*"],
catalog: {
"no-deps": "^1.0.0",
},
catalogs: {
pinned: {
"no-deps": "1.0.1",
},
unused: {
"no-deps": "1.0.0",
},
},
},
}),
),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
dependencies: {
"no-deps": "catalog:",
},
}),
),
write(
join(packageDir, "packages", "pkg2", "package.json"),
JSON.stringify({
name: "pkg2",
dependencies: {
"no-deps": "catalog:pinned",
},
}),
),
]);
await runBunInstall(bunEnv, packageDir);
const { err, exitCode } = await runUpdate(packageDir, "--latest");
expect(err).not.toContain("error:");
const root = await file(join(packageDir, "package.json")).json();
// each catalog entry keeps its own pinning style
expect(root.workspaces.catalog).toEqual({ "no-deps": "^2.0.0" });
expect(root.workspaces.catalogs.pinned).toEqual({ "no-deps": "2.0.0" });
// entries not referenced by any workspace are left unchanged
expect(root.workspaces.catalogs.unused).toEqual({ "no-deps": "1.0.0" });
expect(exitCode).toBe(0);
});
for (const fromWorkspace of [false, true]) {
test(`--latest --dry-run does not modify any package.json (from ${fromWorkspace ? "workspace" : "root"})`, async () => {
const { packageDir } = await registry.createTestDir();
await createUpdateMonorepo(packageDir, `catalog-update-dry-run-${fromWorkspace ? "ws" : "root"}`);
await runBunInstall(bunEnv, packageDir);
const rootBefore = await file(join(packageDir, "package.json")).text();
const pkg1Before = await file(join(packageDir, "packages", "pkg1", "package.json")).text();
const cwd = fromWorkspace ? join(packageDir, "packages", "pkg1") : packageDir;
const { err, exitCode } = await runUpdate(cwd, "--latest", "--dry-run");
expect(err).not.toContain("error:");
expect(await file(join(packageDir, "package.json")).text()).toBe(rootBefore);
expect(await file(join(packageDir, "packages", "pkg1", "package.json")).text()).toBe(pkg1Before);
expect(exitCode).toBe(0);
});
}
for (const args of [[], ["-r"]] as const) {
test(`update without --latest from root moves catalogs within range (${args.join(" ") || "no args"})`, async () => {
// The lockfile pins [email protected] (as if 1.1.0 was published after install).
// `bun update` from the workspace root must re-resolve catalog references
// within range the same as a direct dependency would be.
const { packageDir } = await registry.createTestDir();
const url = registry.registryUrl();
await Promise.all([
write(
join(packageDir, "package.json"),
JSON.stringify({
name: "catalog-update-from-root",
workspaces: {
packages: ["packages/*"],
catalog: { "no-deps": "^1.0.0" },
},
}),
),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
dependencies: { "no-deps": "catalog:" },
}),
),
write(
join(packageDir, "bun.lock"),
JSON.stringify({
lockfileVersion: 1,
configVersion: 1,
workspaces: {
"": { name: "catalog-update-from-root" },
"packages/pkg1": { name: "pkg1", dependencies: { "no-deps": "catalog:" } },
},
catalog: { "no-deps": "^1.0.0" },
packages: {
"no-deps": [
"[email protected]",
`${url}no-deps/-/no-deps-1.0.0.tgz`,
{},
"sha512-v4w12JRjUGvfHDUP8vFDwu0gUWu04j0cv9hLb1Abf9VdaXu4XcrddYFTMVBVvmldKViGWH7jrb6xPJRF0wq6gw==",
],
"pkg1": ["pkg1@workspace:packages/pkg1"],
},
}),
),
]);
const { err, exitCode } = await runUpdate(packageDir, ...args);
expect(err).not.toContain("error:");
const root = await file(join(packageDir, "package.json")).json();
expect(root.workspaces.catalog).toEqual({ "no-deps": "^1.1.0" });
expect((await file(join(packageDir, "packages", "pkg1", "package.json")).json()).dependencies).toEqual({
"no-deps": "catalog:",
});
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toEqual({
name: "no-deps",
version: "1.1.0",
});
const lock = await file(join(packageDir, "bun.lock")).text();
expect(lock).toContain("[email protected]");
expect(lock).not.toContain("[email protected]");
expect(exitCode).toBe(0);
});
}
test("update without --latest stays in range and keeps catalog references", async () => {
const { packageDir } = await registry.createTestDir();
await Promise.all([
write(
join(packageDir, "package.json"),
JSON.stringify({
name: "catalog-update-no-latest",
workspaces: {
packages: ["packages/*"],
catalog: {
"no-deps": "^1.0.0",
},
catalogs: {
pinned: {
"a-dep": "1.0.1",
},
},
},
}),
),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
dependencies: {
"no-deps": "catalog:",
"a-dep": "catalog:pinned",
},
}),
),
]);
await runBunInstall(bunEnv, packageDir);
const { err, exitCode } = await runUpdate(join(packageDir, "packages", "pkg1"));
expect(err).not.toContain("error:");
const root = await file(join(packageDir, "package.json")).json();
// ranges move within the range (latest of ^1.0.0 is 1.1.0, not 2.0.0)...
expect(root.workspaces.catalog).toEqual({ "no-deps": "^1.1.0" });
// ...and exact versions are not moved by a plain `bun update`
expect(root.workspaces.catalogs).toEqual({ pinned: { "a-dep": "1.0.1" } });
expect((await file(join(packageDir, "packages", "pkg1", "package.json")).json()).dependencies).toEqual({
"no-deps": "catalog:",
"a-dep": "catalog:pinned",
});
expect(exitCode).toBe(0);
});
test("update <pkg> --latest keeps the catalog reference", async () => {
const { packageDir } = await registry.createTestDir();
await createUpdateMonorepo(packageDir, "catalog-update-targeted");
await runBunInstall(bunEnv, packageDir);
const { err, exitCode } = await runUpdate(join(packageDir, "packages", "pkg1"), "no-deps", "--latest");
expect(err).not.toContain("error:");
expect((await file(join(packageDir, "packages", "pkg1", "package.json")).json()).dependencies).toEqual({
"no-deps": "catalog:",
"a-dep": "catalog:a",
});
expect(exitCode).toBe(0);
});
});
describe("errors", () => {
async function failingInstall(cwd: string) {
await using proc = spawn({
cmd: [bunExe(), "install"],
cwd,
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { err, exitCode };
}
test("fails gracefully when no catalog is found for a package", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await write(
packageJson,
JSON.stringify({
name: "catalog-error-1",
workspaces: {
// empty, any catalog should fail to resolve
catalog: {},
catalogs: {},
},
dependencies: {
"no-deps": "catalog:",
// longer than 8
"a-dep": "catalog:aaaaaaaaaaaaaaaaa",
},
}),
);
const { err, exitCode } = await failingInstall(packageDir);
expect(err).toContain(
'error: a-dep@catalog:aaaaaaaaaaaaaaaaa: there is no catalog named "aaaaaaaaaaaaaaaaa" in the root package.json\n',
);
expect(err).toContain("error: no-deps@catalog: is not in the catalog\n bun add --catalog no-deps\n");
expect(exitCode).not.toBe(0);
});
test.concurrent("a package missing from an existing named catalog suggests bun add --catalog=<name>", async () => {
const { packageDir } = await registry.createTestDir({
files: {
"package.json": JSON.stringify({
name: "catalog-error-3",
workspaces: {
catalog: { "a-dep": "1.0.1" },
catalogs: { tools: { "a-dep": "1.0.1" } },
},
dependencies: {
"no-deps": "catalog:tools",
"left-pad": "catalog:default",
},
}),
},
});
const { err, exitCode } = await failingInstall(packageDir);
expect(err).toContain(
'error: no-deps@catalog:tools is not in catalog "tools"\n bun add --catalog=tools no-deps\n',
);
expect(err).toContain("error: left-pad@catalog:default is not in the catalog\n bun add --catalog left-pad\n");
expect(err).not.toContain("there is no catalog named");
expect(exitCode).not.toBe(0);
});
test("invalid dependency version", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await write(
packageJson,
JSON.stringify({
name: "catalog-error-2",
workspaces: {
catalog: {
"no-deps": ".:",
},
},
dependencies: {
"no-deps": "catalog:",
},
}),
);
const { err, exitCode } = await failingInstall(packageDir);
expect(err).toContain("error: Invalid dependency version\n");
expect(err).toContain("error: no-deps@catalog: is not in the catalog\n bun add --catalog no-deps\n");
expect(exitCode).not.toBe(0);
});
function rootWithDuplicateDefault(placement: "workspaces" | "top-level", definitions: object, dependencies: object) {
return JSON.stringify(
placement === "workspaces"
? { name: "catalog-duplicate-default", workspaces: { packages: [], ...definitions }, dependencies }
: { name: "catalog-duplicate-default", ...definitions, workspaces: [], dependencies },
);
}
for (const placement of ["workspaces", "top-level"] as const) {
test.concurrent(`the same package in both catalog and catalogs.default is rejected (${placement})`, async () => {
const { packageDir } = await registry.createTestDir({
files: {
"package.json": rootWithDuplicateDefault(
placement,
{ catalog: { "no-deps": "1.0.0" }, catalogs: { default: { "no-deps": "2.0.0" } } },
{ "no-deps": "catalog:" },
),
},
});
const { err, exitCode } = await failingInstall(packageDir);
expect(err).toContain('error: "no-deps" is defined in both "catalog" and "catalogs.default"; keep one of them');
expect(exitCode).not.toBe(0);
expect(await exists(join(packageDir, "bun.lock"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules", "no-deps"))).toBeFalse();
});
}
test.concurrent("every package in both catalog and catalogs.default is reported", async () => {
const { packageDir } = await registry.createTestDir({
files: {
"package.json": rootWithDuplicateDefault(
"workspaces",
{
catalog: { "no-deps": "1.0.0", "a-dep": "1.0.1" },
catalogs: { default: { "no-deps": "2.0.0", "a-dep": "1.0.10" } },
},
{ "no-deps": "catalog:", "a-dep": "catalog:" },
),
},
});
const { err, exitCode } = await failingInstall(packageDir);
expect(err).toContain('error: "no-deps" is defined in both "catalog" and "catalogs.default"; keep one of them');
expect(err).toContain('error: "a-dep" is defined in both "catalog" and "catalogs.default"; keep one of them');
expect(exitCode).not.toBe(0);
expect(await exists(join(packageDir, "bun.lock"))).toBeFalse();
expect(await exists(join(packageDir, "node_modules"))).toBeFalse();
});
// pnpm: deps-installer/test/catalogs.ts "external dependency using catalog protocol errors"
test.concurrent("a catalog: dependency inside a registry package fails to resolve", async () => {
using dir = writeRegistryProject(
{
"package.json": JSON.stringify({
name: "root",
workspaces: { catalog: { leaf: "^2.0.0" } },
dependencies: { "wants-leaf-dep": "1.0.0" },
}),
},
catalogRegistry.url.href,
);
const { err, exitCode } = await failingInstall(String(dir));
expect(err).toContain("leaf@catalog: failed to resolve");
expect(exitCode).not.toBe(0);
expect(await exists(join(String(dir), "bun.lock"))).toBeFalse();
});
test.concurrent(
"a catalog: dependency inside a file: folder dependency fails to resolve, the same package as a workspace works",
async () => {
const localLib = JSON.stringify({ name: "local-lib", dependencies: { "no-deps": "catalog:" } });
const [folder, workspace] = await Promise.all([
registry.createTestDir({
bunfigOpts: { linker: "hoisted" },
files: {
"package.json": JSON.stringify({
name: "root",
workspaces: { catalog: { "no-deps": "^1.0.0" } },
dependencies: { "local-lib": "file:./local-lib" },
}),
"local-lib/package.json": localLib,
},
}),
registry.createTestDir({
bunfigOpts: { linker: "hoisted" },
files: {
"package.json": JSON.stringify({
name: "root",
workspaces: { packages: ["packages/*"], catalog: { "no-deps": "^1.0.0" } },
}),
"packages/local-lib/package.json": localLib,
},
}),
]);
const { err, exitCode } = await failingInstall(folder.packageDir);
expect(err).toContain("no-deps@catalog: failed to resolve");
expect(exitCode).not.toBe(0);
await runBunInstall(bunEnv, workspace.packageDir);
expect((await file(join(workspace.packageDir, "node_modules", "no-deps", "package.json")).json()).version).toBe(
"1.1.0",
);
},
);
});
describe("optionalDependencies", () => {
type Linker = "hoisted" | "isolated";
type Lockfile = {
workspaces: Record<string, Record<string, unknown>>;
packages: Record<string, unknown[]>;
};
async function spawnInstall(dir: string, linker: Linker, ...args: string[]) {
await using proc = spawn({
cmd: [bunExe(), "install", "--linker", linker, ...args],
cwd: dir,
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") },
stdout: "pipe",
stderr: "pipe",
});
const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { out, err, code };
}
async function install(dir: string, linker: Linker, ...args: string[]) {
const result = await spawnInstall(dir, linker, ...args);
expect(result.err).not.toContain("error:");
expect(result.code).toBe(0);
return result;
}
async function readLockfile(dir: string): Promise<Lockfile> {
return Bun.JSONC.parse(await file(join(dir, "bun.lock")).text()) as Lockfile;
}
async function installedVersion(dir: string, name: string) {
return (await file(join(dir, "node_modules", name, "package.json")).json()).version;
}
describe.each(["hoisted", "isolated"] as const)("linker=%s", linker => {
test.concurrent("catalog: in the root's and a workspace's optionalDependencies resolves", async () => {
const { packageDir: dir } = await registry.createTestDir({
bunfigOpts: { linker },
files: {
"package.json": JSON.stringify({
name: "root",
workspaces: {
packages: ["packages/*"],
catalog: { "no-deps": "2.0.0" },
catalogs: { a: { "a-dep": "1.0.1" } },
},
optionalDependencies: { "no-deps": "catalog:" },
}),
"packages/pkg1/package.json": JSON.stringify({
name: "pkg1",
optionalDependencies: { "a-dep": "catalog:a" },
}),
},
});
const first = await install(dir, linker);
expect(first.err).toContain("Saved lockfile");
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
const aDepOwner = linker === "isolated" ? join(dir, "packages", "pkg1") : dir;
expect(await installedVersion(aDepOwner, "a-dep")).toBe("1.0.1");
const lockfile = await readLockfile(dir);
expect(lockfile.workspaces[""]).toStrictEqual({
name: "root",
optionalDependencies: { "no-deps": "catalog:" },
});
expect(lockfile.workspaces["packages/pkg1"]).toStrictEqual({
name: "pkg1",
optionalDependencies: { "a-dep": "catalog:a" },
});
expect(Object.keys(lockfile.packages).sort()).toStrictEqual(["a-dep", "no-deps", "pkg1"]);
expect(lockfile.packages["no-deps"][0]).toBe("[email protected]");
expect(lockfile.packages["a-dep"][0]).toBe("[email protected]");
const lockfileText = await file(join(dir, "bun.lock")).text();
await rm(join(dir, "node_modules"), { recursive: true, force: true });
const warm = await install(dir, linker);
expect(warm.err).not.toContain("Saved lockfile");
expect(await file(join(dir, "bun.lock")).text()).toBe(lockfileText);
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
const frozen = await install(dir, linker, "--frozen-lockfile");
expect(frozen.err).not.toContain("lockfile had changes");
});
// The registry package's optional `catalog:` edge is stripped to unresolvable instead of reading the consumer's catalog, and stays that way when bun.lock is reloaded.
test.concurrent("a registry package's optional catalog: dependency is skipped, fresh and on reload", async () => {
using dir = writeRegistryProject(
{
"package.json": JSON.stringify({
name: "root",
workspaces: { catalog: { leaf: "^2.0.0" } },
dependencies: { "wants-leaf-optional": "1.0.0" },
}),
},
catalogRegistry.url.href,
);
const root = String(dir);
const expectLayout = async () => {
expect(await installedVersion(root, "wants-leaf-optional")).toBe("1.0.0");
expect(existsSync(join(root, "node_modules", "leaf"))).toBeFalse();
const lockfile = await readLockfile(root);
expect(Object.keys(lockfile.packages)).toStrictEqual(["wants-leaf-optional"]);
expect(lockfile.packages["wants-leaf-optional"]).toStrictEqual([
"[email protected]",
expect.stringMatching(/^http:\/\/.*\/wants-leaf-optional-1\.0\.0\.tgz$/),
{ optionalDependencies: { leaf: "catalog:" } },
expect.any(String),
]);
};
const first = await install(root, linker);
expect(first.err).toContain("Saved lockfile");
await expectLayout();
const lockfileText = await file(join(root, "bun.lock")).text();
await rm(join(root, "node_modules"), { recursive: true, force: true });
const warm = await install(root, linker);
expect(warm.err).not.toContain("Saved lockfile");
expect(await file(join(root, "bun.lock")).text()).toBe(lockfileText);
await expectLayout();
await rm(join(root, "node_modules"), { recursive: true, force: true });
const frozen = await install(root, linker, "--frozen-lockfile");
expect(frozen.err).not.toContain("lockfile had changes");
expect(await file(join(root, "bun.lock")).text()).toBe(lockfileText);
await expectLayout();
});
});
});
describe("peer dependencies", () => {
type Linker = "hoisted" | "isolated";
async function spawnInstall(dir: string, linker: Linker, ...args: string[]) {
await using proc = Bun.spawn({
cmd: [bunExe(), "install", "--linker", linker, ...args],
cwd: dir,
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") },
stdout: "pipe",
stderr: "pipe",
});
const [out, err, code] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { out, err, code };
}
async function install(dir: string, linker: Linker, ...args: string[]) {
const result = await spawnInstall(dir, linker, ...args);
expect(result.err).not.toContain("error:");
expect(result.code).toBe(0);
return result;
}
async function packageKeys(dir: string): Promise<string[]> {
const lockfile = Bun.JSONC.parse(await Bun.file(join(dir, "bun.lock")).text()) as {
packages: Record<string, unknown>;
};
return Object.keys(lockfile.packages).sort();
}
async function layout(dir: string, peerName = "no-deps"): Promise<string> {
const path = join(dir, "packages", "lib", "node_modules", peerName);
if (!existsSync(path)) return "<hoisted>";
if (lstatSync(path).isSymbolicLink()) return readlinkSync(path);
const { version } = await Bun.file(join(path, "package.json")).json();
return `nested:${version}`;
}
function rootPackageJson(opts: {
catalog?: Record<string, string>;
catalogs?: Record<string, Record<string, string>>;
rootDependencies?: Record<string, string>;
overrides?: Record<string, string>;
}) {
return {
name: "root",
workspaces: {
packages: ["packages/*"],
...(opts.catalog ? { catalog: opts.catalog } : {}),
...(opts.catalogs ? { catalogs: opts.catalogs } : {}),
},
dependencies: opts.rootDependencies ?? { "one-fixed-dep": "2.0.0" },
...(opts.overrides ? { overrides: opts.overrides } : {}),
};
}
type RepoOpts = {
peerSpec: string;
peerName?: string;
catalog?: Record<string, string>;
catalogs?: Record<string, Record<string, string>>;
rootDependencies?: Record<string, string>;
overrides?: Record<string, string>;
optionalPeer?: boolean;
appDependencies?: Record<string, string>;
extraWorkspaces?: Record<string, object>;
libVersion?: string;
linker: Linker;
saveTextLockfile?: boolean;
registry?: string;
};
async function makeRepo(opts: RepoOpts): Promise<string> {
const peerName = opts.peerName ?? "no-deps";
const extraFiles: Record<string, string> = {};
for (const [name, pkg] of Object.entries(opts.extraWorkspaces ?? {})) {
extraFiles[`packages/${name}/package.json`] = JSON.stringify({ name, ...pkg });
}
const { packageDir } = await registry.createTestDir({
bunfigOpts: { linker: opts.linker, saveTextLockfile: opts.saveTextLockfile },
files: {
"package.json": JSON.stringify(rootPackageJson(opts)),
"packages/app/package.json": JSON.stringify({
name: "app",
dependencies: opts.appDependencies ?? {
"no-deps": "1.0.0",
lib: "workspace:*",
},
}),
"packages/lib/package.json": JSON.stringify({
name: "lib",
...(opts.libVersion ? { version: opts.libVersion } : {}),
peerDependencies: {
[peerName]: opts.peerSpec,
},
...(opts.optionalPeer ? { peerDependenciesMeta: { [peerName]: { optional: true } } } : {}),
}),
...extraFiles,
},
});
if (opts.registry) {
await Bun.write(
join(packageDir, "bunfig.toml"),
Bun.TOML.stringify({
install: {
cache: join(packageDir, ".bun-cache"),
registry: opts.registry,
linker: opts.linker,
saveTextLockfile: opts.saveTextLockfile,
},
}),
);
}
return packageDir;
}
async function rewriteRootPackageJson(dir: string, opts: Parameters<typeof rootPackageJson>[0]) {
await Bun.write(join(dir, "package.json"), JSON.stringify(rootPackageJson(opts)));
}
async function rmNodeModules(dir: string) {
const workspaces = await readdir(join(dir, "packages"));
await Promise.all(
[join(dir, "node_modules"), ...workspaces.map(ws => join(dir, "packages", ws, "node_modules"))].map(path =>
rm(path, { recursive: true, force: true }),
),
);
}
const dedupedKeys = ["app", "lib", "no-deps", "one-fixed-dep", "one-fixed-dep/no-deps"];
const nestedKeys = ["app", "lib", "lib/no-deps", "no-deps", "one-fixed-dep", "one-fixed-dep/no-deps"];
const isolatedNoDeps = (version: string) =>
join("..", "..", "..", "node_modules", ".bun", `no-deps@${version}`, "node_modules", "no-deps");
const isolatedNoDeps2 = isolatedNoDeps("2.0.0");
const linkers = ["hoisted", "isolated"] as const;
test.concurrent("default catalog peer dedupes onto the satisfying ancestor", async () => {
const dir = await makeRepo({ catalog: { "no-deps": ">=1.0.0" }, peerSpec: "catalog:", linker: "hoisted" });
await install(dir, "hoisted");
expect(await packageKeys(dir)).toStrictEqual(dedupedKeys);
expect(existsSync(join(dir, "packages", "lib", "node_modules", "no-deps"))).toBeFalse();
});
test.concurrent("named catalog peer (catalog:peers) dedupes the same way", async () => {
const dir = await makeRepo({
catalogs: { peers: { "no-deps": ">=1.0.0" } },
peerSpec: "catalog:peers",
linker: "hoisted",
});
await install(dir, "hoisted");
expect(await packageKeys(dir)).toStrictEqual(dedupedKeys);
expect(existsSync(join(dir, "packages", "lib", "node_modules", "no-deps"))).toBeFalse();
});
test.concurrent("optional catalog peer dedupes too", async () => {
const dir = await makeRepo({
catalog: { "no-deps": ">=1.0.0" },
peerSpec: "catalog:",
optionalPeer: true,
linker: "hoisted",
});
await install(dir, "hoisted");
expect(await packageKeys(dir)).toStrictEqual(dedupedKeys);
expect(existsSync(join(dir, "packages", "lib", "node_modules", "no-deps"))).toBeFalse();
});
test.concurrent("scoped package name as a catalog peer", async () => {
const dir = await makeRepo({
rootDependencies: {},
catalog: { "@scoped/has-bin-entry": ">=1.0.0" },
peerName: "@scoped/has-bin-entry",
peerSpec: "catalog:",
appDependencies: { "@scoped/has-bin-entry": "1.0.0", lib: "workspace:*" },
linker: "hoisted",
});
await install(dir, "hoisted");
expect(await packageKeys(dir)).toStrictEqual(["@scoped/has-bin-entry", "app", "lib"]);
expect(existsSync(join(dir, "packages", "lib", "node_modules", "@scoped"))).toBeFalse();
});
async function record(dir: string, linker: Linker, peerName?: string) {
await install(dir, linker);
const keys = await packageKeys(dir);
const fresh = await layout(dir, peerName);
await rmNodeModules(dir);
const { err } = await install(dir, linker);
const keysAfterReload = await packageKeys(dir);
const reload = await layout(dir, peerName);
return { keys, fresh, keysAfterReload, reload, reloadSavedLockfile: err.includes("Saved lockfile") };
}
describe.each([
[">=1.0.0", "dedupes"],
["^2.0.0", "stays nested"],
] as const)("peer range %s (%s)", (range, outcome) => {
describe.each(linkers)("linker=%s", linker => {
test.concurrent("catalog: peer produces the same lockfile and layout as the inline range", async () => {
const [catalogDir, inlineDir] = await Promise.all([
makeRepo({ catalog: { "no-deps": range }, peerSpec: "catalog:", linker }),
makeRepo({ peerSpec: range, linker }),
]);
const [fromCatalog, fromInline] = await Promise.all([record(catalogDir, linker), record(inlineDir, linker)]);
const expectedKeys = outcome === "dedupes" ? dedupedKeys : nestedKeys;
expect(fromInline.keys).toStrictEqual(expectedKeys);
expect(fromInline.keysAfterReload).toStrictEqual(expectedKeys);
expect(fromInline.reloadSavedLockfile).toBeFalse();
if (linker === "isolated") {
expect(fromInline.fresh).toEndWith(isolatedNoDeps2);
expect(fromInline.reload).toEndWith(isolatedNoDeps2);
} else {
const expectedLayout = outcome === "dedupes" ? "<hoisted>" : "nested:2.0.0";
expect(fromInline.fresh).toBe(expectedLayout);
expect(fromInline.reload).toBe(expectedLayout);
}
expect(fromCatalog).toStrictEqual(fromInline);
});
});
});
describe.each(linkers)("linker=%s", linker => {
test.concurrent("catalog `*` peer behaves exactly like an inline `*` peer", async () => {
const [catalogDir, inlineDir] = await Promise.all([
makeRepo({ catalog: { "no-deps": "*" }, peerSpec: "catalog:", linker }),
makeRepo({ peerSpec: "*", linker }),
]);
const [fromCatalog, fromInline] = await Promise.all([record(catalogDir, linker), record(inlineDir, linker)]);
expect(fromInline.keys).toStrictEqual(dedupedKeys);
expect(fromInline.keysAfterReload).toStrictEqual(dedupedKeys);
expect(fromInline.reloadSavedLockfile).toBeFalse();
expect(fromCatalog).toStrictEqual(fromInline);
});
test.concurrent("aliased catalog entry peer matches the inline alias", async () => {
const [catalogDir, inlineDir] = await Promise.all([
makeRepo({ catalog: { "no-deps": "npm:no-deps@>=1.0.0" }, peerSpec: "catalog:", linker }),
makeRepo({ peerSpec: "npm:no-deps@>=1.0.0", linker }),
]);
const [fromCatalog, fromInline] = await Promise.all([record(catalogDir, linker), record(inlineDir, linker)]);
expect(fromInline.keysAfterReload).toStrictEqual(fromInline.keys);
expect(fromInline.reloadSavedLockfile).toBeFalse();
expect(fromCatalog).toStrictEqual(fromInline);
});
test.concurrent("optional catalog peer matches the inline optional peer on reload", async () => {
const [catalogDir, inlineDir] = await Promise.all([
makeRepo({ catalog: { "no-deps": ">=1.0.0" }, peerSpec: "catalog:", optionalPeer: true, linker }),
makeRepo({ peerSpec: ">=1.0.0", optionalPeer: true, linker }),
]);
const [fromCatalog, fromInline] = await Promise.all([record(catalogDir, linker), record(inlineDir, linker)]);
expect(fromInline.keys).toStrictEqual(dedupedKeys);
expect(fromInline.keysAfterReload).toStrictEqual(dedupedKeys);
expect(fromInline.reloadSavedLockfile).toBeFalse();
expect(fromCatalog).toStrictEqual(fromInline);
});
// pnpm: deps-installer/test/catalogs.ts "importer with different peers uses correct peer"
test.concurrent(
"two consumers providing different peer versions: catalog peer still equals inline peer",
async () => {
const twoConsumers = (peerSpec: string, catalog?: Record<string, string>) =>
makeRepo({
peerSpec,
catalog,
rootDependencies: {},
appDependencies: { "no-deps": "1.0.0", lib: "workspace:*" },
extraWorkspaces: { app2: { dependencies: { "no-deps": "2.0.0", lib: "workspace:*" } } },
linker,
});
const [catalogDir, inlineDir] = await Promise.all([
twoConsumers("catalog:", { "no-deps": ">=1.0.0" }),
twoConsumers(">=1.0.0"),
]);
const [fromCatalog, fromInline] = await Promise.all([record(catalogDir, linker), record(inlineDir, linker)]);
expect(fromInline.keys).toStrictEqual(["app", "app2", "app2/no-deps", "lib", "no-deps"]);
expect(fromInline.keysAfterReload).toStrictEqual(fromInline.keys);
expect(fromInline.reloadSavedLockfile).toBeFalse();
expect(fromCatalog).toStrictEqual(fromInline);
},
);
// pnpm: deps-installer/test/catalogs.ts "catalog resolutions should be consistent with peer dependencies"
test.concurrent("warm install leaves bun.lock byte-identical and --frozen-lockfile passes", async () => {
const dir = await makeRepo({ catalog: { "no-deps": ">=1.0.0" }, peerSpec: "catalog:", linker });
await install(dir, linker);
const lockfile = await Bun.file(join(dir, "bun.lock")).text();
await rmNodeModules(dir);
const warm = await install(dir, linker);
expect(warm.err).not.toContain("Saved lockfile");
expect(await Bun.file(join(dir, "bun.lock")).text()).toBe(lockfile);
await rmNodeModules(dir);
const frozen = await install(dir, linker, "--frozen-lockfile");
expect(frozen.err).not.toContain("lockfile had changes");
if (linker === "hoisted") expect(await layout(dir)).toBe("<hoisted>");
else expect(await layout(dir)).toEndWith(isolatedNoDeps2);
});
});
// pnpm: deps-installer/test/catalogs.ts "lockfile is updated if catalog config changes"
test.concurrent("changing the catalog range of a peer re-hoists on the next install (both directions)", async () => {
const dir = await makeRepo({ catalog: { "no-deps": ">=1.0.0" }, peerSpec: "catalog:", linker: "hoisted" });
await install(dir, "hoisted");
expect(await packageKeys(dir)).toStrictEqual(dedupedKeys);
await rewriteRootPackageJson(dir, { catalog: { "no-deps": "^2.0.0" } });
let { err } = await install(dir, "hoisted");
expect(err).toContain("Saved lockfile");
expect(await packageKeys(dir)).toStrictEqual(nestedKeys);
expect(await layout(dir)).toBe("nested:2.0.0");
await rewriteRootPackageJson(dir, { catalog: { "no-deps": ">=1.0.0" } });
({ err } = await install(dir, "hoisted"));
expect(err).toContain("Saved lockfile");
expect(await packageKeys(dir)).toStrictEqual(dedupedKeys);
});
// pnpm: deps-installer/test/catalogs.ts "frozen lockfile error is thrown if catalog config changes"
test.concurrent("--frozen-lockfile fails when only a peer's catalog range changed", async () => {
const dir = await makeRepo({ catalog: { "no-deps": ">=1.0.0" }, peerSpec: "catalog:", linker: "hoisted" });
await install(dir, "hoisted");
const lockfile = await Bun.file(join(dir, "bun.lock")).text();
await rewriteRootPackageJson(dir, { catalog: { "no-deps": "^2.0.0" } });
const { err, code } = await spawnInstall(dir, "hoisted", "--frozen-lockfile");
expect(err).toContain("error: lockfile had changes, but lockfile is frozen");
expect(code).not.toBe(0);
expect(await Bun.file(join(dir, "bun.lock")).text()).toBe(lockfile);
});
test.concurrent("catalog peer with bun.lockb dedupes and reloads identically", async () => {
const dir = await makeRepo({
catalog: { "no-deps": ">=1.0.0" },
peerSpec: "catalog:",
linker: "hoisted",
saveTextLockfile: false,
});
await install(dir, "hoisted");
expect(existsSync(join(dir, "bun.lock"))).toBeFalse();
const lockb = await Bun.file(join(dir, "bun.lockb")).bytes();
expect(await layout(dir)).toBe("<hoisted>");
await rmNodeModules(dir);
await install(dir, "hoisted");
expect(await layout(dir)).toBe("<hoisted>");
expect(await Bun.file(join(dir, "bun.lockb")).bytes()).toStrictEqual(lockb);
});
// pnpm applies overrides before catalogs, keyed by the peer's own name, even when the catalog entry aliases another package.
describe.each([
["plain", ">=2.0.0"],
["aliased", "npm:[email protected]"],
] as const)("override beats the %s catalog entry of a peer", (_, entry) => {
test.concurrent("fresh == reload", async () => {
const dir = await makeRepo({
overrides: { "no-deps": "1.0.0" },
rootDependencies: { "one-fixed-dep": "2.0.0", "a-dep": "1.0.1" },
catalog: { "no-deps": entry },
peerSpec: "catalog:",
linker: "isolated",
});
const result = await record(dir, "isolated");
expect(result.fresh).toEndWith(isolatedNoDeps("1.0.0"));
const keys = ["a-dep", "app", "lib", "no-deps", "one-fixed-dep"];
expect(result).toStrictEqual({
keys,
fresh: result.fresh,
keysAfterReload: keys,
reload: result.fresh,
reloadSavedLockfile: false,
});
});
});
// Only the entry the spec names is `^2.0.0`; a `>=1.0.0` decoy (in another group, or under the other default spelling — one name in both is an error) or an unresolved peer would give dedupedKeys instead (pnpm: resolveFromCatalog.test.ts).
describe.each([
["catalog:peers", { catalog: { "no-deps": ">=1.0.0" }, catalogs: { peers: { "no-deps": "^2.0.0" } } }],
["catalog:", { catalog: { "no-deps": "^2.0.0" }, catalogs: { peers: { "no-deps": ">=1.0.0" } } }],
["catalog:default", { catalog: { "no-deps": "^2.0.0" } }],
["catalog:", { catalogs: { default: { "no-deps": "^2.0.0" } } }],
["catalog:default", { catalogs: { default: { "no-deps": "^2.0.0" } } }],
["catalog:default", { catalog: { "a-dep": ">=1.0.0" }, catalogs: { default: { "no-deps": "^2.0.0" } } }],
["catalog:", { catalog: { "no-deps": "^2.0.0" }, catalogs: { default: { "a-dep": ">=1.0.0" } } }],
] as const)("peer %s resolves through the entry named by its spec (%o)", (peerSpec, catalogFields) => {
test.concurrent("fresh and reload", async () => {
const dir = await makeRepo({ ...catalogFields, peerSpec, linker: "hoisted" });
expect(await record(dir, "hoisted")).toStrictEqual({
keys: nestedKeys,
fresh: "nested:2.0.0",
keysAfterReload: nestedKeys,
reload: "nested:2.0.0",
reloadSavedLockfile: false,
});
});
});
// pnpm errors here; Bun never fails an install over an unresolved peer, so the peer must simply not get a nested copy.
describe.each([
["catalog:", { catalog: {} }],
["catalog:peers", { catalogs: { other: { "no-deps": ">=1.0.0" } } }],
["catalog:default", { catalogs: { other: { "no-deps": ">=1.0.0" } } }],
["catalog:", { catalog: { "no-deps": "catalog:other" }, catalogs: { other: { "no-deps": ">=1.0.0" } } }],
] as const)("peer %s with no usable catalog entry (%o)", (peerSpec, catalogFields) => {
test.concurrent("installs without a nested copy and is stable on reload", async () => {
const dir = await makeRepo({ ...catalogFields, peerSpec, linker: "hoisted" });
expect(await record(dir, "hoisted")).toStrictEqual({
keys: dedupedKeys,
fresh: "<hoisted>",
keysAfterReload: dedupedKeys,
reload: "<hoisted>",
reloadSavedLockfile: false,
});
});
});
const registryPeerKeys = ["app", "catalog-peer", "lib", "no-deps"];
describe.each(linkers)("linker=%s", linker => {
// app hoists [email protected] (a root dependency would dedupe every peer onto itself) and needs-leaf-2 locks [email protected]: the workspace's catalog: peer nests it, the registry package's identical spec is satisfied by the hoisted [email protected] (unstripped it would add "wants-leaf-peer/leaf").
test.concurrent("a catalog: peer inside a registry package never reads the consumer's catalog", async () => {
const dir = await makeRepo({
catalog: { leaf: "^2.0.0" },
peerName: "leaf",
peerSpec: "catalog:",
rootDependencies: { "needs-leaf-2": "1.0.0", "wants-leaf-peer": "1.0.0" },
appDependencies: { leaf: "1.0.0" },
linker,
registry: catalogRegistry.url.href,
});
const keys = ["app", "leaf", "lib", "lib/leaf", "needs-leaf-2", "needs-leaf-2/leaf", "wants-leaf-peer"];
await install(dir, linker);
expect(await packageKeys(dir)).toStrictEqual(keys);
const lockfile = await Bun.file(join(dir, "bun.lock")).text();
expect(lockfile).toContain('"peerDependencies": { "leaf": "catalog:" }');
await rmNodeModules(dir);
const frozen = await install(dir, linker, "--frozen-lockfile");
expect(frozen.err).not.toContain("lockfile had changes");
expect(await Bun.file(join(dir, "bun.lock")).text()).toBe(lockfile);
expect(await packageKeys(dir)).toStrictEqual(keys);
});
// pnpm: resolving-deps-resolver walk.rs resolves_children_through_catalogs — only importers substitute catalogs.
test.concurrent(
"a registry package's catalog: peer ignores the root catalog and binds to the workspace's copy",
async () => {
const dir = await makeRepo({
rootDependencies: {},
catalog: { "no-deps": "^2.0.0" },
appDependencies: { "no-deps": "1.0.0", "catalog-peer": "1.0.0" },
peerSpec: "*",
linker,
registry: catalogRegistry.url.href,
});
const boundPeer = async () => {
if (linker === "isolated") {
const storeEntry = await realpath(join(dir, "packages", "app", "node_modules", "catalog-peer"));
return (await Bun.file(join(storeEntry, "..", "no-deps", "package.json")).json()).version;
}
return existsSync(join(dir, "node_modules", "catalog-peer", "node_modules")) ? "nested" : "hoisted";
};
const expected = linker === "isolated" ? "1.0.0" : "hoisted";
await install(dir, linker);
expect(await packageKeys(dir)).toStrictEqual(registryPeerKeys);
expect(await boundPeer()).toBe(expected);
await rmNodeModules(dir);
const { err } = await install(dir, linker);
expect(err).not.toContain("Saved lockfile");
expect(await packageKeys(dir)).toStrictEqual(registryPeerKeys);
expect(await boundPeer()).toBe(expected);
expect(await Bun.file(join(dir, "bun.lock")).text()).not.toContain("[email protected]");
},
);
// Overrides are root-owned: an override VALUE of `catalog:` still applies to a registry package's peer.
test.concurrent("an override valued catalog: still applies to a registry package's peer", async () => {
const dir = await makeRepo({
rootDependencies: {},
overrides: { "no-deps": "catalog:" },
catalog: { "no-deps": "1.0.0" },
appDependencies: { "no-deps": "2.0.0", "peer-deps-fixed": "1.0.0" },
peerSpec: "*",
linker,
});
const result = await record(dir, linker);
const keys = ["app", "lib", "no-deps", "peer-deps-fixed"];
expect(result.keys).toStrictEqual(keys);
expect(result.keysAfterReload).toStrictEqual(keys);
expect(result.reloadSavedLockfile).toBeFalse();
const lockfile = await Bun.file(join(dir, "bun.lock")).text();
expect(lockfile).not.toContain("[email protected]");
const { packages } = Bun.JSONC.parse(lockfile) as { packages: Record<string, [string, ...unknown[]]> };
expect(packages["no-deps"][0]).toBe("[email protected]");
});
});
test.concurrent("a registry package's catalog: peer with no provider anywhere installs nothing for it", async () => {
const dir = await makeRepo({
rootDependencies: {},
catalog: { "no-deps": "^2.0.0" },
appDependencies: { "catalog-peer": "1.0.0" },
peerSpec: "*",
optionalPeer: true,
linker: "hoisted",
registry: catalogRegistry.url.href,
});
const keys = ["app", "catalog-peer", "lib"];
const result = await record(dir, "hoisted");
expect(result.keys).toStrictEqual(keys);
expect(result.keysAfterReload).toStrictEqual(keys);
expect(result.reloadSavedLockfile).toBeFalse();
expect(await Bun.file(join(dir, "bun.lock")).text()).not.toContain("no-deps@");
expect(existsSync(join(dir, "node_modules", "no-deps"))).toBeFalse();
});
test.concurrent("a registry package's named catalog:peers peer is scoped the same way", async () => {
const dir = await makeRepo({
rootDependencies: {},
catalogs: { peers: { "no-deps": "^2.0.0" } },
appDependencies: { "no-deps": "1.0.0", "catalog-peer": "2.0.0" },
peerSpec: "*",
linker: "hoisted",
registry: catalogRegistry.url.href,
});
const result = await record(dir, "hoisted");
expect(result.keys).toStrictEqual(registryPeerKeys);
expect(result.keysAfterReload).toStrictEqual(registryPeerKeys);
expect(result.reloadSavedLockfile).toBeFalse();
expect(await Bun.file(join(dir, "bun.lock")).text()).not.toContain("[email protected]");
expect(existsSync(join(dir, "node_modules", "catalog-peer", "node_modules"))).toBeFalse();
});
test.concurrent("changing the root catalog does not re-resolve a registry package's catalog: peer", async () => {
const dir = await makeRepo({
rootDependencies: {},
catalog: { "no-deps": "^1.0.0" },
appDependencies: { "no-deps": "1.0.0", "catalog-peer": "1.0.0" },
peerSpec: "*",
linker: "hoisted",
registry: catalogRegistry.url.href,
});
await install(dir, "hoisted");
expect(await packageKeys(dir)).toStrictEqual(registryPeerKeys);
await rewriteRootPackageJson(dir, { rootDependencies: {}, catalog: { "no-deps": "^2.0.0" } });
await install(dir, "hoisted");
expect(await packageKeys(dir)).toStrictEqual(registryPeerKeys);
expect(await Bun.file(join(dir, "bun.lock")).text()).not.toContain("[email protected]");
const { err } = await install(dir, "hoisted");
expect(err).not.toContain("Saved lockfile");
});
test.concurrent("a file: folder dependency does not see the catalog either", async () => {
const { packageDir: dir } = await registry.createTestDir({
bunfigOpts: { linker: "hoisted" },
files: {
"package.json": JSON.stringify({
name: "root",
workspaces: ["packages/*"],
catalog: { "no-deps": "^2.0.0" },
}),
"packages/app/package.json": JSON.stringify({
name: "app",
dependencies: { "no-deps": "1.0.0", "vendored": "file:../../vendor/vendored" },
}),
"vendor/vendored/package.json": JSON.stringify({
name: "vendored",
version: "1.0.0",
peerDependencies: { "no-deps": "catalog:" },
}),
},
});
const expectNoNestedCopy = async () => {
const lockfile = await Bun.file(join(dir, "bun.lock")).text();
expect(lockfile).not.toContain("[email protected]");
expect((await packageKeys(dir)).filter(key => key.endsWith("vendored/no-deps"))).toStrictEqual([]);
};
await install(dir, "hoisted");
await expectNoNestedCopy();
await rmNodeModules(dir);
const { err } = await install(dir, "hoisted");
expect(err).not.toContain("Saved lockfile");
await expectNoNestedCopy();
});
// pnpm #12159 shape: an override whose value is a catalog reference wins over the peer's own range, fresh and on reload.
describe.each(linkers)("linker=%s", linker => {
describe.each([
[">=1.0.0", "catalog:", { catalog: { "no-deps": "1.0.0" } }],
["^2.0.0", "catalog:", { catalog: { "no-deps": "1.0.0" } }],
["catalog:", "catalog:", { catalog: { "no-deps": "1.0.0" } }],
["catalog:", "catalog:pins", { catalog: { "no-deps": ">=1.0.0" }, catalogs: { pins: { "no-deps": "1.0.0" } } }],
] as const)("peer %s overridden to %s", (peerSpec, override, catalogFields) => {
test.concurrent("binds to the overriding catalog entry, fresh == reload", async () => {
const dir = await makeRepo({ ...catalogFields, overrides: { "no-deps": override }, peerSpec, linker });
const result = await record(dir, linker);
const keys = ["app", "lib", "no-deps", "one-fixed-dep"];
expect(result).toStrictEqual({
keys,
fresh: linker === "isolated" ? expect.stringContaining(isolatedNoDeps("1.0.0")) : "<hoisted>",
keysAfterReload: keys,
reload: result.fresh,
reloadSavedLockfile: false,
});
const { packages } = Bun.JSONC.parse(await Bun.file(join(dir, "bun.lock")).text()) as {
packages: Record<string, [string, ...unknown[]]>;
};
expect(packages["no-deps"][0]).toBe("[email protected]");
});
});
});
// pnpm #8996 (`catalog:` peers survive `pack` unsubstituted) and #7072 (`catalog:` / `catalog:default` are one catalog).
describe.each([
["catalog:", { catalog: { "no-deps": ">=1.0.0" } }],
["catalog:peers", { catalogs: { peers: { "no-deps": ">=1.0.0" } } }],
["catalog:", { catalogs: { default: { "no-deps": ">=1.0.0" } } }],
["catalog:default", { catalog: { "no-deps": ">=1.0.0" } }],
["catalog: peers", { catalogs: { peers: { "no-deps": ">=1.0.0" } } }],
] as const)("bun pm pack substitutes the %s peer (%o)", (peerSpec, catalogFields) => {
test.concurrent("with the catalog's range", async () => {
const dir = await makeRepo({ ...catalogFields, peerSpec, libVersion: "1.2.3", linker: "hoisted" });
await install(dir, "hoisted");
const libDir = join(dir, "packages", "lib");
await pack(libDir, bunEnv);
const tarball = readTarball(join(libDir, "lib-1.2.3.tgz"));
const packageJson = tarball.entries.find(
(entry: { pathname: string }) => entry.pathname === "package/package.json",
);
expect(JSON.parse(packageJson.contents)).toStrictEqual({
name: "lib",
version: "1.2.3",
peerDependencies: { "no-deps": ">=1.0.0" },
});
});
});
// package.json is edited after the install so bun.lock's catalogs are the ones that lack the entry.
describe.each([
["a-dep", "catalog:"],
["no-deps", "catalog:missing"],
] as const)("bun pm pack with a %s peer of %s missing from the lockfile's catalogs", (peerName, peerSpec) => {
test.concurrent("fails without writing a tarball", async () => {
const dir = await makeRepo({
catalog: { "no-deps": ">=1.0.0" },
peerSpec: "catalog:",
libVersion: "1.2.3",
linker: "hoisted",
});
await install(dir, "hoisted");
const libDir = join(dir, "packages", "lib");
await Bun.write(
join(libDir, "package.json"),
JSON.stringify({ name: "lib", version: "1.2.3", peerDependencies: { [peerName]: peerSpec } }),
);
await using proc = spawn({
cmd: [bunExe(), "pm", "pack"],
cwd: libDir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(normalizeBunSnapshot(err, dir)).toBe(
`error: Failed to resolve catalog version for "${peerName}" in \`peerDependencies\` (no matching catalog dependency).`,
);
expect(normalizeBunSnapshot(out, dir)).toBe("bun pack <version> (<revision>)");
expect(exitCode).toBe(1);
expect(existsSync(join(libDir, "lib-1.2.3.tgz"))).toBeFalse();
});
});
});