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

873 lines
42 KiB
TypeScript

import { Archive, file, write } from "bun";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { appendFile, exists } from "fs/promises";
import { VerdaccioRegistry, bunEnv, bunExe, normalizeBunSnapshot, runBunInstall } from "harness";
import { join } from "path";
// Registry: no-deps 1.0.0/1.0.1/1.1.0/2.0.0, @types/no-deps 1.0.0/2.0.0, a-dep 1.0.1..1.0.10, [email protected] -> no-deps ^1.0.0, dep-with-tags 1.0.0..3.0.1 (latest=3.0.0, pre-2=2.0.1).
const verdaccio = new VerdaccioRegistry();
beforeAll(async () => {
await verdaccio.start();
});
afterAll(() => {
verdaccio.stop();
});
type Json = Record<string, any>;
const GROUPS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const;
// CI exports one BUN_INSTALL_CACHE_DIR per file, which overrides bunfig's cache; concurrent cases sharing a cache race on Windows.
const envFor = (dir: string) => ({ ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") });
function json(contents: Json | string) {
return typeof contents === "string" ? contents : JSON.stringify(contents, null, 2) + "\n";
}
async function setup(
files: Record<string, Json | string>,
opts: { exact?: boolean; text?: boolean; install?: boolean; allowWarnings?: boolean } = {},
): Promise<string> {
const { packageDir: dir } = await verdaccio.createTestDir({
bunfigOpts: { linker: "hoisted", saveTextLockfile: opts.text ?? true },
files: Object.fromEntries(Object.entries(files).map(([path, contents]) => [path, json(contents)])),
});
if (opts.exact) await appendFile(join(dir, "bunfig.toml"), "exact = true\n");
if (opts.install !== false) await runBunInstall(envFor(dir), dir, { allowWarnings: opts.allowWarnings });
return dir;
}
async function tryRun(dir: string, rel: string, ...args: string[]) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
cwd: join(dir, rel),
env: envFor(dir),
stdout: "pipe",
stderr: "pipe",
stdin: "ignore",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
async function runIn(dir: string, rel: string, ...args: string[]) {
const result = await tryRun(dir, rel, ...args);
expect(result.stderr).not.toContain("error:");
expect(result.exitCode).toBe(0);
return result;
}
const run = (dir: string, ...args: string[]) => runIn(dir, "", ...args);
const pkg = (dir: string, rel = ""): Promise<Json> => file(join(dir, rel, "package.json")).json();
const pkgText = (dir: string, rel = "") => file(join(dir, rel, "package.json")).text();
const writePkg = (dir: string, contents: Json, rel = "") => write(join(dir, rel, "package.json"), json(contents));
const installed = (dir: string, name: string): Promise<Json> =>
file(join(dir, "node_modules", name, "package.json")).json();
const lockText = (dir: string) => file(join(dir, "bun.lock")).text();
const lock = async (dir: string): Promise<Json> => Bun.JSONC.parse(await lockText(dir)) as Json;
async function resolutions(dir: string, name: string): Promise<string[]> {
const entries = Object.values((await lock(dir)).packages) as [string, ...unknown[]][];
return [...new Set(entries.map(entry => entry[0]).filter(res => res.startsWith(`${name}@`)))].sort();
}
async function reinstall(dir: string, contents: Json, rel = "") {
await writePkg(dir, contents, rel);
await runBunInstall(envFor(dir), dir);
}
function declaredLiteral(manifest: Json, name: string): string | undefined {
for (const group of GROUPS) {
const literal = manifest[group]?.[name];
if (literal !== undefined) return literal;
}
}
async function expectInSync(
dir: string,
workspaces: string[] = [""],
opts: { allowWarnings?: boolean; reinstall?: boolean } = {},
) {
const lockfile = await lock(dir);
for (const key of workspaces) {
const manifest = await pkg(dir, key);
for (const group of GROUPS) {
if (manifest[group] === undefined) continue;
expect({ [key || "."]: { [group]: lockfile.workspaces[key]?.[group] } }).toStrictEqual({
[key || "."]: { [group]: manifest[group] },
});
}
if (key !== "") continue;
const overrides = manifest.overrides ?? manifest.resolutions;
if (overrides !== undefined) {
const expected = Object.fromEntries(
Object.entries(overrides).map(([name, value]) => [
name,
typeof value === "string" && value.startsWith("$") ? declaredLiteral(manifest, value.slice(1)) : value,
]),
);
expect(lockfile.overrides).toStrictEqual(expected);
}
const catalog = manifest.workspaces?.catalog ?? manifest.catalog;
if (catalog !== undefined) expect(lockfile.catalog).toStrictEqual(catalog);
const catalogs = manifest.workspaces?.catalogs ?? manifest.catalogs;
if (catalogs !== undefined) expect(lockfile.catalogs).toStrictEqual(catalogs);
}
const env = envFor(dir);
await runBunInstall(env, dir, { frozenLockfile: true, allowWarnings: opts.allowWarnings });
if (!opts.reinstall) return;
const before = await lockText(dir);
const { err } = await runBunInstall(env, dir, { savesLockfile: false, allowWarnings: opts.allowWarnings });
expect(err).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(before);
}
const root = (fields: Json): Json => ({ name: "foo", ...fields });
const wsRoot = (fields: Json = {}): Json => ({ name: "root", workspaces: ["packages/*"], ...fields });
const member = (name: string, fields: Json = {}): Json => ({ name, version: "1.0.0", ...fields });
const WORKSPACES = (rootFields: Json, members: Record<string, Json>) => ({
"package.json": wsRoot(rootFields),
...Object.fromEntries(
Object.entries(members).map(([name, fields]) => [`packages/${name}/package.json`, member(name, fields)]),
),
});
const MONOREPO = (pkg1: Json = {}, rootFields: Json = {}) => WORKSPACES(rootFields, { pkg1 });
const PKG1 = "packages/pkg1";
const PKG2 = "packages/pkg2";
describe.concurrent("bun update rewrites bun.lock together with package.json", () => {
test("bun update", async () => {
const dir = await setup({
"package.json": root({ dependencies: { "no-deps": "^1.0.0", aliased: "npm:no-deps@~1.0.0" } }),
});
await run(dir, "update");
const expected = { "no-deps": "^1.1.0", aliased: "npm:no-deps@~1.0.1" };
expect((await pkg(dir)).dependencies).toStrictEqual(expected);
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(expected);
await expectInSync(dir, [""], { reinstall: true });
});
test("bun update --latest", async () => {
const dir = await setup({
"package.json": root({ dependencies: { "no-deps": "~1.0.0", aliased: "npm:no-deps@~1.0.0" } }),
});
await run(dir, "update", "--latest");
const expected = { "no-deps": "~2.0.0", aliased: "npm:no-deps@~2.0.0" };
expect((await pkg(dir)).dependencies).toStrictEqual(expected);
const ws = (await lock(dir)).workspaces[""];
expect(ws.dependencies).toStrictEqual(expected);
expect(JSON.stringify(ws)).not.toContain("latest");
await expectInSync(dir);
});
test.each([
["*", "2.0.0"],
["1", "1.1.0"],
["1.x", "1.1.0"],
[">=1.0.0", "2.0.0"],
["1.0.0 - 1.0.1", "1.0.1"],
])("bun update leaves a %s range as written and moves bun.lock to %s", async (literal, resolved) => {
const dir = await setup({ "package.json": root({ dependencies: { "no-deps": "1.0.0" } }) });
await reinstall(dir, root({ dependencies: { "no-deps": literal } }));
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
const pkgBefore = await pkgText(dir);
const { stdout } = await run(dir, "update");
expect(stdout).toMatch(new RegExp(`no-deps 1\\.0\\.0 (→|->) ${resolved.replaceAll(".", "\\.")}`));
expect(await pkgText(dir)).toBe(pkgBefore);
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual({ "no-deps": literal });
expect(await resolutions(dir, "no-deps")).toStrictEqual([`no-deps@${resolved}`]);
expect(await installed(dir, "no-deps")).toMatchObject({ version: resolved });
await expectInSync(dir);
});
test("bun update on a * range that already resolves the newest version leaves both files byte-identical", async () => {
const dir = await setup({ "package.json": root({ dependencies: { "no-deps": "*" } }) });
const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]);
const { stdout } = await run(dir, "update");
expect(stdout).not.toContain("->");
expect(stdout).not.toContain("→");
expect(await pkgText(dir)).toBe(pkgBefore);
expect(await lockText(dir)).toBe(lockBefore);
});
test("bun update --latest rewrites a * range", async () => {
const dir = await setup({ "package.json": root({ dependencies: { "no-deps": "*" } }) });
await run(dir, "update", "--latest");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
await expectInSync(dir);
});
test("bun update <name> keeps the pin style", async () => {
const dir = await setup({ "package.json": root({ dependencies: { "no-deps": "~1.0.0" } }) });
await run(dir, "update", "no-deps");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
await expectInSync(dir);
});
test("bun update <name> on an exact literal", async () => {
const dir = await setup({ "package.json": root({ dependencies: { "no-deps": "1.0.0" } }) });
await run(dir, "update", "no-deps");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "1.0.0" });
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual({ "no-deps": "1.0.0" });
await expectInSync(dir);
});
test("bun update <name> keeps a * literal and leaves the unnamed sibling alone", async () => {
const dir = await setup({ "package.json": root({ dependencies: { "no-deps": "1.0.0", "a-dep": "1.0.1" } }) });
await reinstall(dir, root({ dependencies: { "no-deps": "*", "a-dep": "^1.0.1" } }));
await run(dir, "update", "no-deps");
const expected = { "no-deps": "*", "a-dep": "^1.0.1" };
expect((await pkg(dir)).dependencies).toStrictEqual(expected);
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(expected);
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
expect(await resolutions(dir, "a-dep")).toStrictEqual(["[email protected]"]);
await expectInSync(dir);
});
test("bun update <alias> keeps the alias", async () => {
const dir = await setup({ "package.json": root({ dependencies: { aliased: "npm:no-deps@~1.0.0" } }) });
await run(dir, "update", "aliased");
expect((await pkg(dir)).dependencies).toStrictEqual({ aliased: "npm:no-deps@~1.0.1" });
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual({ aliased: "npm:no-deps@~1.0.1" });
await expectInSync(dir);
});
test("bun update <name>@<range>", async () => {
const dir = await setup({ "package.json": root({ dependencies: { "no-deps": "~1.0.0" } }) });
await run(dir, "update", "no-deps@^1.0.0");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "~1.1.0" });
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual({ "no-deps": "~1.1.0" });
await expectInSync(dir);
});
// `bun install` warns about a name declared in two groups, so these two allow warnings.
const inBothGroups = (version: string) =>
root({ dependencies: { "no-deps": version }, devDependencies: { "no-deps": version } });
test("bun update <name> with the name in dependencies and devDependencies", async () => {
const dir = await setup({ "package.json": inBothGroups("~1.0.0") }, { allowWarnings: true });
await run(dir, "update", "no-deps");
const manifest = await pkg(dir);
expect(manifest.dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect(manifest.devDependencies).toStrictEqual({ "no-deps": "~1.0.0" });
await expectInSync(dir, [""], { allowWarnings: true });
});
test("bun update with the name in dependencies and devDependencies moves one group", async () => {
const dir = await setup({ "package.json": inBothGroups("1.0.0") }, { allowWarnings: true });
await writePkg(dir, inBothGroups("~1.0.0"));
const { stdout } = await run(dir, "update");
expect(stdout).toMatch(/no-deps 1\.0\.0 (→|->) 1\.0\.1/);
const manifest = await pkg(dir);
expect([manifest.dependencies["no-deps"], manifest.devDependencies["no-deps"]].sort()).toStrictEqual([
"~1.0.0",
"~1.0.1",
]);
await expectInSync(dir, [""], { allowWarnings: true });
});
test("install.exact", async () => {
const dir = await setup({ "package.json": root({ dependencies: { "no-deps": "^1.0.0" } }) }, { exact: true });
await run(dir, "update");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "1.1.0" });
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual({ "no-deps": "1.1.0" });
await expectInSync(dir);
});
test.each([[[]], [["--latest"]]])("bun update %j leaves folder, tarball and workspace literals alone", async args => {
const dependencies = {
"no-deps": "^1.0.0",
"folder-dep": "file:./folder-target",
"tgz-dep": "file:./tgz-dep-1.0.0.tgz",
pkg1: "workspace:*",
};
const dir = await setup(
{
...MONOREPO({}, { dependencies }),
"folder-target/package.json": { name: "folder-dep", version: "1.0.0" },
},
{ install: false },
);
await Archive.write(
join(dir, "tgz-dep-1.0.0.tgz"),
{ "package/package.json": JSON.stringify({ name: "tgz-dep", version: "1.0.0" }) },
{ compress: "gzip" },
);
await runBunInstall(envFor(dir), dir);
await run(dir, "update", ...args);
const expected = { ...dependencies, "no-deps": args.length ? "^2.0.0" : "^1.1.0" };
expect((await pkg(dir)).dependencies).toStrictEqual(expected);
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(expected);
await expectInSync(dir, ["", PKG1]);
});
test("bun update -r", async () => {
const dir = await setup(MONOREPO({ dependencies: { "no-deps": "~1.0.0" } }));
await run(dir, "update", "-r");
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect((await lock(dir)).workspaces[PKG1].dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
await expectInSync(dir, ["", PKG1]);
});
test("bun update -r keeps a member's 1.x literal", async () => {
const dir = await setup(MONOREPO({ dependencies: { "no-deps": "1.0.0" } }));
await reinstall(dir, member("pkg1", { dependencies: { "no-deps": "1.x" } }), PKG1);
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await run(dir, "update", "-r");
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "1.x" });
expect((await lock(dir)).workspaces[PKG1].dependencies).toStrictEqual({ "no-deps": "1.x" });
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await expectInSync(dir, ["", PKG1]);
});
test("bun update -r keeps a member's dist-tag literal", async () => {
const dir = await setup(MONOREPO({ dependencies: { "dep-with-tags": "pre-2", "no-deps": "~1.0.0" } }));
await run(dir, "update", "-r");
const expected = { "dep-with-tags": "pre-2", "no-deps": "~1.0.1" };
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual(expected);
expect((await lock(dir)).workspaces[PKG1].dependencies).toStrictEqual(expected);
expect(await resolutions(dir, "dep-with-tags")).toStrictEqual(["[email protected]"]);
await expectInSync(dir, ["", PKG1]);
});
test("bun update -r --latest replaces a member's dist-tag literal with the latest version", async () => {
const dir = await setup(MONOREPO({ dependencies: { "dep-with-tags": "pre-2", "no-deps": "~1.0.0" } }));
await run(dir, "update", "-r", "--latest");
const expected = { "dep-with-tags": "^3.0.0", "no-deps": "~2.0.0" };
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual(expected);
expect((await lock(dir)).workspaces[PKG1].dependencies).toStrictEqual(expected);
expect(await resolutions(dir, "dep-with-tags")).toStrictEqual(["[email protected]"]);
await expectInSync(dir, ["", PKG1]);
});
test("bun update <name> --filter <workspace> rewrites only the selected member", async () => {
const dir = await setup(
MONOREPO({ dependencies: { "no-deps": "~1.0.0", "a-dep": "^1.0.1" } }, { dependencies: { "no-deps": "~1.0.0" } }),
);
await run(dir, "update", "no-deps", "--filter", "pkg1");
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "~1.0.1", "a-dep": "^1.0.1" });
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "~1.0.0" });
await expectInSync(dir, ["", PKG1], { reinstall: true });
});
test("bun update <name> -r --latest rewrites only the workspaces that declare the name", async () => {
const dir = await setup(
WORKSPACES(
{ dependencies: { "no-deps": "~1.0.0" } },
{ pkg1: { dependencies: { "a-dep": "1.0.1" } }, pkg2: { dependencies: { "no-deps": "~1.0.0" } } },
),
);
await run(dir, "update", "no-deps", "-r", "--latest");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "~2.0.0" });
expect((await pkg(dir, PKG2)).dependencies).toStrictEqual({ "no-deps": "~2.0.0" });
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "a-dep": "1.0.1" });
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await expectInSync(dir, ["", PKG1, PKG2]);
});
test("bun update <name>@<version> -r keeps each workspace's operator", async () => {
const dir = await setup(
WORKSPACES({ dependencies: { "no-deps": "1.0.0" } }, { pkg1: { dependencies: { "no-deps": "1.0.0" } } }),
);
await writePkg(dir, wsRoot({ dependencies: { "no-deps": "^1.0.0" } }));
await reinstall(dir, member("pkg1", { dependencies: { "no-deps": "~1.0.0" } }), PKG1);
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await run(dir, "update", "[email protected]", "-r");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "^1.0.1" });
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await expectInSync(dir, ["", PKG1]);
});
test("bun update <name> -r --dry-run writes nothing", async () => {
const dir = await setup(MONOREPO({ dependencies: { "no-deps": "1.0.0" } }));
await reinstall(dir, member("pkg1", { dependencies: { "no-deps": "~1.0.0" } }), PKG1);
const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir, PKG1), lockText(dir)]);
const { stderr } = await run(dir, "update", "no-deps", "-r", "--dry-run");
expect(stderr).not.toContain("Saved lockfile");
expect(await pkgText(dir, PKG1)).toBe(pkgBefore);
expect(await lockText(dir)).toBe(lockBefore);
});
test("bun update from a workspace member", async () => {
const dir = await setup(MONOREPO({ dependencies: { "no-deps": "~1.0.0" } }));
await runIn(dir, PKG1, "update");
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect((await lock(dir)).workspaces[PKG1].dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
await expectInSync(dir, ["", PKG1]);
});
});
describe.concurrent("named update is scoped to the invoking workspace", () => {
test("from the root, another workspace's row stays put; -r moves it too", async () => {
const dir = await setup(
WORKSPACES({ dependencies: { "no-deps": "1.0.0" } }, { pkg1: { dependencies: { "no-deps": "1.0.0" } } }),
);
await writePkg(dir, wsRoot({ dependencies: { "no-deps": "^1.0.0" } }));
await reinstall(dir, member("pkg1", { dependencies: { "no-deps": "~1.0.0" } }), PKG1);
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await run(dir, "update", "no-deps");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "^1.1.0" });
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "~1.0.0" });
expect((await lock(dir)).workspaces[PKG1].dependencies).toStrictEqual({ "no-deps": "~1.0.0" });
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]", "[email protected]"]);
await expectInSync(dir, ["", PKG1]);
await run(dir, "update", "no-deps", "-r");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "^1.1.0" });
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]", "[email protected]"]);
await expectInSync(dir, ["", PKG1]);
});
test("inside a member, a sibling's row is not re-resolved", async () => {
const dir = await setup(
WORKSPACES(
{},
{ pkg1: { dependencies: { "no-deps": "1.0.0" } }, pkg2: { dependencies: { "no-deps": "1.0.0" } } },
),
);
await writePkg(dir, member("pkg1", { dependencies: { "no-deps": "^1.0.0" } }), PKG1);
await reinstall(dir, member("pkg2", { dependencies: { "no-deps": "~1.0.0" } }), PKG2);
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await runIn(dir, PKG1, "update", "no-deps");
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "^1.1.0" });
expect((await pkg(dir, PKG2)).dependencies).toStrictEqual({ "no-deps": "~1.0.0" });
expect((await lock(dir)).workspaces[PKG2].dependencies).toStrictEqual({ "no-deps": "~1.0.0" });
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]", "[email protected]"]);
await expectInSync(dir, ["", PKG1, PKG2]);
});
test("bun update <name> --filter <workspace> from the root leaves the root's own row out of scope", async () => {
const dir = await setup(
WORKSPACES(
{ dependencies: { "no-deps": "1.0.0" } },
{ pkg1: { dependencies: { "no-deps": "1.0.0" } }, pkg2: { dependencies: { "no-deps": "1.0.0" } } },
),
);
await writePkg(dir, wsRoot({ dependencies: { "no-deps": "^1.0.0" } }));
await writePkg(dir, member("pkg1", { dependencies: { "no-deps": "~1.0.0" } }), PKG1);
await reinstall(dir, member("pkg2", { dependencies: { "no-deps": "~1.0.0" } }), PKG2);
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await run(dir, "update", "no-deps", "--filter", "pkg1");
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "^1.0.0" });
expect((await pkg(dir, PKG2)).dependencies).toStrictEqual({ "no-deps": "~1.0.0" });
const { workspaces } = await lock(dir);
expect(workspaces[""].dependencies).toStrictEqual({ "no-deps": "^1.0.0" });
expect(workspaces[PKG2].dependencies).toStrictEqual({ "no-deps": "~1.0.0" });
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await expectInSync(dir, ["", PKG1, PKG2]);
});
test("a name declared only by another workspace is an error that points at -r", async () => {
const dir = await setup(WORKSPACES({}, { pkg1: { dependencies: { "no-deps": "1.0.0" } } }));
await reinstall(dir, member("pkg1", { dependencies: { "no-deps": "~1.0.0" } }), PKG1);
const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir, PKG1), lockText(dir)]);
const { stderr, exitCode } = await tryRun(dir, "", "update", "no-deps");
expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
"error: "no-deps" is not a dependency of this workspace
bun update -r no-deps
bun update --filter pkg1 no-deps"
`);
expect(await pkgText(dir, PKG1)).toBe(pkgBefore);
expect(await lockText(dir)).toBe(lockBefore);
expect(exitCode).toBe(1);
});
});
describe.concurrent("npm: aliases", () => {
const ROWS: { pinned?: string; before: string; args: string[]; after: string; installs: [string, string] }[] = [
{
before: "npm:no-deps@~1.0.0",
args: ["aliased", "--latest"],
after: "npm:no-deps@~2.0.0",
installs: ["no-deps", "2.0.0"],
},
{ before: "npm:no-deps", args: [], after: "npm:no-deps", installs: ["no-deps", "2.0.0"] },
{ before: "npm:no-deps", args: ["aliased"], after: "npm:no-deps", installs: ["no-deps", "2.0.0"] },
{
pinned: "npm:[email protected]",
before: "npm:no-deps@*",
args: [],
after: "npm:no-deps@*",
installs: ["no-deps", "2.0.0"],
},
{
before: "npm:@types/no-deps",
args: ["--latest"],
after: "npm:@types/no-deps@^2.0.0",
installs: ["@types/no-deps", "2.0.0"],
},
{
before: "npm:dep-with-tags@pre-2",
args: ["--latest"],
after: "npm:dep-with-tags@^3.0.0",
installs: ["dep-with-tags", "3.0.0"],
},
{
before: "npm:no-deps@~1.0.0",
args: ["[email protected]"],
after: "npm:no-deps@~2.0.0",
installs: ["no-deps", "2.0.0"],
},
{ before: "npm:no-deps@^1.0.0", args: ["aliased"], after: "npm:no-deps@^1.1.0", installs: ["no-deps", "1.1.0"] },
];
test.each(ROWS)(
'"aliased": "$before" + bun update $args -> "$after"',
async ({ pinned, before, args, after, installs }) => {
const dir = await setup({ "package.json": root({ dependencies: { aliased: pinned ?? before } }) });
if (pinned !== undefined) {
await reinstall(dir, root({ dependencies: { aliased: before } }));
expect(await installed(dir, "aliased")).toMatchObject({ version: "1.0.0" });
}
await run(dir, "update", ...args);
expect((await pkg(dir)).dependencies.aliased).toBe(after);
const [name, version] = installs;
expect(await installed(dir, "aliased")).toMatchObject({ name, version });
await expectInSync(dir);
},
);
test("bun update <alias>@npm:<other> retargets the alias", async () => {
const dir = await setup({ "package.json": root({ dependencies: { aliased: "npm:no-deps@~1.0.0" } }) });
await run(dir, "update", "aliased@npm:a-dep");
expect((await pkg(dir)).dependencies).toStrictEqual({ aliased: "npm:a-dep@~1.0.10" });
expect(await installed(dir, "aliased")).toMatchObject({ name: "a-dep", version: "1.0.10" });
await expectInSync(dir);
});
test("bun update <new>@npm:<scoped>@<range> refuses to add; bun add keeps the target", async () => {
const dir = await setup({ "package.json": root({ dependencies: { "a-dep": "1.0.1" } }) });
const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]);
const { stderr, exitCode } = await tryRun(dir, "", "update", "new-alias@npm:@types/no-deps@^1.0.0");
expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
"error: "new-alias" is not in bun.lock
bun add new-alias"
`);
expect(exitCode).toBe(1);
expect(await pkgText(dir)).toBe(pkgBefore);
expect(await lockText(dir)).toBe(lockBefore);
await run(dir, "add", "new-alias@npm:@types/no-deps@^1.0.0");
const expected = { "a-dep": "1.0.1", "new-alias": "npm:@types/no-deps@^1.0.0" };
expect((await pkg(dir)).dependencies).toStrictEqual(expected);
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(expected);
expect(await installed(dir, "new-alias")).toMatchObject({ name: "@types/no-deps", version: "1.0.0" });
await expectInSync(dir);
});
test("bun update <alias>@npm:<scoped>@<range> retargets a scoped alias in the declared pin style", async () => {
const dir = await setup({ "package.json": root({ dependencies: { aliased: "npm:no-deps@~1.0.0" } }) });
await run(dir, "update", "aliased@npm:@types/no-deps@^1.0.0");
expect((await pkg(dir)).dependencies).toStrictEqual({ aliased: "npm:@types/no-deps@~1.0.0" });
expect(await installed(dir, "aliased")).toMatchObject({ name: "@types/no-deps", version: "1.0.0" });
await expectInSync(dir);
});
});
describe.concurrent("bun add", () => {
test.each([
{ args: ["no-deps"], expected: { "no-deps": "^2.0.0" } },
{ args: ["no-deps", "--exact"], expected: { "no-deps": "2.0.0" } },
{ args: ["no-deps@~1.0.0"], expected: { "no-deps": "~1.0.0" } },
{ args: ["no-deps@latest"], expected: { "no-deps": "^2.0.0" } },
{ args: ["x@npm:no-deps@~1.0.0"], expected: { x: "npm:no-deps@~1.0.0" } },
{ args: ["x@npm:no-deps@latest"], expected: { x: "npm:no-deps@^2.0.0" } },
{ args: ["x@npm:no-deps"], expected: { x: "npm:no-deps@^2.0.0" } },
{ args: ["x@npm:no-deps", "--exact"], expected: { x: "npm:[email protected]" } },
])("bun add $args", async ({ args, expected }) => {
const dir = await setup({ "package.json": root({}) }, { install: false });
await run(dir, "add", ...args);
expect((await pkg(dir)).dependencies).toStrictEqual(expected);
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(expected);
await expectInSync(dir);
});
test("bun add <workspace>@workspace:*", async () => {
const dir = await setup(MONOREPO());
await run(dir, "add", "pkg1@workspace:*");
expect((await pkg(dir)).dependencies).toStrictEqual({ pkg1: "workspace:*" });
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual({ pkg1: "workspace:*" });
await expectInSync(dir, ["", PKG1]);
});
test("bun add --filter", async () => {
const dir = await setup(MONOREPO());
await run(dir, "add", "x@npm:no-deps@latest", "--filter", "pkg1");
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ x: "npm:no-deps@^2.0.0" });
expect((await lock(dir)).workspaces[PKG1].dependencies).toStrictEqual({ x: "npm:no-deps@^2.0.0" });
await expectInSync(dir, ["", PKG1]);
});
test("bun add --lockfile-only", async () => {
const dir = await setup({ "package.json": root({}) }, { install: false });
await run(dir, "add", "no-deps", "--lockfile-only");
expect((await pkg(dir)).dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
expect(await exists(join(dir, "node_modules"))).toBe(false);
await expectInSync(dir);
});
test("bun add --trust", async () => {
const dir = await setup({ "package.json": root({}) }, { install: false });
await run(dir, "add", "[email protected]", "--trust");
expect(await pkg(dir)).toStrictEqual({
name: "foo",
dependencies: { "uses-what-bin": "1.0.0" },
trustedDependencies: ["uses-what-bin"],
});
expect(await exists(join(dir, "node_modules", "uses-what-bin", "what-bin.txt"))).toBe(true);
await expectInSync(dir);
});
test("bun add --trust of a package already listed in devDependencies", async () => {
const dir = await setup({ "package.json": root({ devDependencies: { "uses-what-bin": "1.0.0" } }) });
await run(dir, "add", "[email protected]", "--trust");
const manifest = await pkg(dir);
expect(manifest.dependencies).toBeUndefined();
expect(manifest.devDependencies).toStrictEqual({ "uses-what-bin": "1.0.0" });
expect(manifest.trustedDependencies).toStrictEqual(["uses-what-bin"]);
expect(await exists(join(dir, "node_modules", "uses-what-bin", "what-bin.txt"))).toBe(true);
await expectInSync(dir);
});
});
describe.concurrent("catalogs", () => {
const CATALOG_REPO = (catalog: Json = { "no-deps": "^1.0.0", aliased: "npm:no-deps" }, protocol = "catalog:") =>
MONOREPO(
{ dependencies: Object.fromEntries(Object.keys(catalog).map(name => [name, protocol])) },
{ workspaces: { packages: ["packages/*"], catalog } },
);
async function expectCatalog(dir: string, expected: Json) {
expect((await pkg(dir)).workspaces.catalog).toStrictEqual(expected);
expect((await lock(dir)).catalog).toStrictEqual(expected);
}
test("bun update", async () => {
const dir = await setup(CATALOG_REPO());
await run(dir, "update");
await expectCatalog(dir, { "no-deps": "^1.1.0", aliased: "npm:no-deps" });
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "catalog:", aliased: "catalog:" });
await expectInSync(dir, ["", PKG1]);
});
test.each([[""], [PKG1]])("bun update --latest from '%s'", async cwd => {
const dir = await setup(CATALOG_REPO());
await runIn(dir, cwd, "update", "--latest");
await expectCatalog(dir, { "no-deps": "^2.0.0", aliased: "npm:no-deps@^2.0.0" });
expect(await lockText(dir)).not.toContain('"latest"');
await expectInSync(dir, ["", PKG1]);
});
test("bun update keeps a dist-tag catalog entry", async () => {
const dir = await setup(CATALOG_REPO({ "dep-with-tags": "pre-2" }));
await run(dir, "update");
await expectCatalog(dir, { "dep-with-tags": "pre-2" });
expect(await installed(dir, "dep-with-tags")).toMatchObject({ version: "2.0.1" });
await expectInSync(dir, ["", PKG1]);
});
test("bun update --latest replaces a dist-tag catalog entry with the latest version", async () => {
const dir = await setup(CATALOG_REPO({ "dep-with-tags": "pre-2" }));
await run(dir, "update", "--latest");
await expectCatalog(dir, { "dep-with-tags": "^3.0.0" });
expect(await installed(dir, "dep-with-tags")).toMatchObject({ version: "3.0.0" });
await expectInSync(dir, ["", PKG1]);
});
test("bun update keeps a 1.x catalog entry", async () => {
const dir = await setup(CATALOG_REPO({ "no-deps": "1.x" }));
await run(dir, "update");
await expectCatalog(dir, { "no-deps": "1.x" });
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "catalog:" });
await expectInSync(dir, ["", PKG1]);
});
test("bun update keeps a * catalog entry and moves only bun.lock", async () => {
const dir = await setup(CATALOG_REPO({ "no-deps": "1.0.0" }));
await reinstall(dir, wsRoot({ workspaces: { packages: ["packages/*"], catalog: { "no-deps": "*" } } }));
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await run(dir, "update");
await expectCatalog(dir, { "no-deps": "*" });
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ "no-deps": "catalog:" });
await expectInSync(dir, ["", PKG1]);
});
const ALIASED_CATALOG = { aliased: "npm:no-deps@~1.0.0", tagged: "npm:dep-with-tags@pre-2" };
test("bun update bumps an aliased catalog range in place and keeps an aliased dist-tag", async () => {
const dir = await setup(CATALOG_REPO(ALIASED_CATALOG));
await run(dir, "update");
await expectCatalog(dir, { aliased: "npm:no-deps@~1.0.1", tagged: "npm:dep-with-tags@pre-2" });
expect(await installed(dir, "aliased")).toMatchObject({ name: "no-deps", version: "1.0.1" });
expect(await installed(dir, "tagged")).toMatchObject({ name: "dep-with-tags", version: "2.0.1" });
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({ aliased: "catalog:", tagged: "catalog:" });
await expectInSync(dir, ["", PKG1]);
});
test("bun update --latest rewrites aliased catalog entries and keeps the alias prefix", async () => {
const dir = await setup(CATALOG_REPO(ALIASED_CATALOG));
await run(dir, "update", "--latest");
await expectCatalog(dir, { aliased: "npm:no-deps@~2.0.0", tagged: "npm:dep-with-tags@^3.0.0" });
expect(await installed(dir, "aliased")).toMatchObject({ name: "no-deps", version: "2.0.0" });
expect(await installed(dir, "tagged")).toMatchObject({ name: "dep-with-tags", version: "3.0.0" });
expect(await lockText(dir)).not.toContain('"latest"');
await expectInSync(dir, ["", PKG1]);
});
test.each([
[[], { "no-deps": "^1.1.0", aliased: "npm:no-deps@~1.0.1" }, "1.1.0"],
[["--latest"], { "no-deps": "^2.0.0", aliased: "npm:no-deps@~2.0.0" }, "2.0.0"],
])("bun update %j rewrites the singular catalog referenced as catalog:default", async (args, expected, version) => {
const dir = await setup(CATALOG_REPO({ "no-deps": "^1.0.0", aliased: "npm:no-deps@~1.0.0" }, "catalog:default"));
await run(dir, "update", ...args);
await expectCatalog(dir, expected);
expect(await installed(dir, "no-deps")).toMatchObject({ version });
expect((await pkg(dir, PKG1)).dependencies).toStrictEqual({
"no-deps": "catalog:default",
aliased: "catalog:default",
});
await expectInSync(dir, ["", PKG1]);
});
test("bun update --latest with install.exact pins a catalog entry", async () => {
const dir = await setup(CATALOG_REPO({ "no-deps": "^1.0.0", aliased: "npm:no-deps@~1.0.0" }), { exact: true });
await run(dir, "update", "--latest");
await expectCatalog(dir, { "no-deps": "2.0.0", aliased: "npm:[email protected]" });
expect(await installed(dir, "no-deps")).toMatchObject({ version: "2.0.0" });
await expectInSync(dir, ["", PKG1]);
});
test("bun update with install.exact pins a catalog range in place", async () => {
const dir = await setup(CATALOG_REPO({ "no-deps": "^1.0.0" }), { exact: true });
await run(dir, "update");
await expectCatalog(dir, { "no-deps": "1.1.0" });
expect(await installed(dir, "no-deps")).toMatchObject({ version: "1.1.0" });
await expectInSync(dir, ["", PKG1]);
});
test("bun update --latest keeps the = prefix of a catalog entry", async () => {
const dir = await setup(CATALOG_REPO({ "no-deps": "=1.0.0" }));
const [pkgBefore, lockBefore] = await Promise.all([pkgText(dir), lockText(dir)]);
await run(dir, "update");
expect(await pkgText(dir)).toBe(pkgBefore);
expect(await lockText(dir)).toBe(lockBefore);
await run(dir, "update", "--latest");
await expectCatalog(dir, { "no-deps": "=2.0.0" });
expect(await installed(dir, "no-deps")).toMatchObject({ version: "2.0.0" });
await expectInSync(dir, ["", PKG1]);
});
test("bun add --catalog --filter", async () => {
const dir = await setup(CATALOG_REPO());
await run(dir, "add", "a-dep", "--catalog", "--filter", "pkg1");
expect((await pkg(dir)).workspaces.catalog["a-dep"]).toBe("^1.0.10");
expect((await lock(dir)).catalog["a-dep"]).toBe("^1.0.10");
expect((await pkg(dir, PKG1)).dependencies["a-dep"]).toBe("catalog:");
await expectInSync(dir, ["", PKG1]);
});
});
describe.concurrent("$ref overrides", () => {
test("$name follows the rewritten dependency; a literal override stays as written", async () => {
const overrides = { "no-deps": "$no-deps", "one-range-dep": "1.0.0" };
const dir = await setup({
"package.json": root({ dependencies: { "no-deps": "1.0.0", "one-range-dep": "1.0.0" }, overrides }),
});
await writePkg(dir, root({ dependencies: { "no-deps": "^1.0.0", "one-range-dep": "1.0.0" }, overrides }));
await run(dir, "update");
const manifest = await pkg(dir);
expect(manifest.dependencies).toStrictEqual({ "no-deps": "^1.1.0", "one-range-dep": "1.0.0" });
expect(manifest.overrides).toStrictEqual(overrides);
expect((await lock(dir)).overrides).toStrictEqual({ "no-deps": "^1.1.0", "one-range-dep": "1.0.0" });
await expectInSync(dir);
});
test("$alias follows the rewritten alias", async () => {
const dir = await setup({
"package.json": root({ dependencies: { a1: "npm:no-deps@^1.0.0" }, overrides: { a1: "$a1" } }),
});
await run(dir, "update");
expect((await pkg(dir)).dependencies).toStrictEqual({ a1: "npm:no-deps@^1.1.0" });
expect((await lock(dir)).overrides).toStrictEqual({ a1: "npm:no-deps@^1.1.0" });
await expectInSync(dir);
});
});
describe.concurrent("bumping a direct dependency re-points its dependents", () => {
const nested = (dir: string) => exists(join(dir, "node_modules", "one-range-dep", "node_modules"));
const deps = (noDeps?: string) =>
root({ dependencies: { "one-range-dep": "1.0.0", ...(noDeps === undefined ? {} : { "no-deps": noDeps }) } });
test.each([
["text", true],
["binary", false],
])("bun install after editing package.json (%s lockfile)", async (_, text) => {
const dir = await setup({ "package.json": deps("1.0.0") }, { text });
expect(await installed(dir, "no-deps")).toMatchObject({ version: "1.0.0" });
expect(await nested(dir)).toBe(false);
await writePkg(dir, deps("1.0.1"));
await runBunInstall(envFor(dir), dir);
expect(await installed(dir, "no-deps")).toMatchObject({ version: "1.0.1" });
expect(await nested(dir)).toBe(false);
if (text) {
const { packages } = await lock(dir);
expect(Object.keys(packages).sort()).toStrictEqual(["no-deps", "one-range-dep"]);
expect(packages["no-deps"][0]).toBe("[email protected]");
}
await writePkg(dir, deps("2.0.0"));
await runBunInstall(envFor(dir), dir);
expect(await installed(dir, "no-deps")).toMatchObject({ version: "2.0.0" });
expect(await installed(dir, "one-range-dep/node_modules/no-deps")).toMatchObject({ version: "1.0.1" });
if (!text) return;
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]", "[email protected]"]);
await writePkg(dir, deps());
await runBunInstall(envFor(dir), dir);
expect(await resolutions(dir, "no-deps")).toStrictEqual(["[email protected]"]);
await writePkg(dir, deps("1.0.0"));
await runBunInstall(envFor(dir), dir);
const { packages } = await lock(dir);
expect(packages["no-deps"][0]).toBe("[email protected]");
expect(packages["one-range-dep/no-deps"][0]).toBe("[email protected]");
await expectInSync(dir);
});
test("declared by a workspace member", async () => {
const dir = await setup(MONOREPO({ dependencies: { "one-range-dep": "1.0.0", "no-deps": "1.0.0" } }));
expect((await lock(dir)).packages["no-deps"][0]).toBe("[email protected]");
await reinstall(dir, member("pkg1", { dependencies: { "one-range-dep": "1.0.0", "no-deps": "1.0.1" } }), PKG1);
const { packages } = await lock(dir);
expect(packages["no-deps"][0]).toBe("[email protected]");
expect(packages["one-range-dep/no-deps"]).toBeUndefined();
expect(await nested(dir)).toBe(false);
});
test("bun update", async () => {
const dir = await setup({ "package.json": deps("1.0.0") });
await writePkg(dir, deps("^1.0.0"));
await run(dir, "update");
const { packages } = await lock(dir);
expect(packages["no-deps"][0]).toBe("[email protected]");
expect(packages["one-range-dep/no-deps"]).toBeUndefined();
expect(await nested(dir)).toBe(false);
await expectInSync(dir);
});
});