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

2838 lines
121 KiB
TypeScript

import { file, spawn } from "bun";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { access, appendFile, exists, mkdir, readFile, rm, writeFile } from "fs/promises";
import { VerdaccioRegistry, bunExe, bunEnv as env, pack, readdirSorted, toBeValidBin, toHaveBins } from "harness";
import { basename, dirname, join } from "path";
import {
dummyAfterAll,
dummyAfterEach,
dummyBeforeAll,
dummyBeforeEach,
dummyRegistry,
package_dir,
requested,
root_url,
setHandler,
} from "./dummy.registry.js";
beforeAll(dummyBeforeAll);
afterAll(dummyAfterAll);
beforeEach(async () => {
await dummyBeforeEach();
});
afterEach(dummyAfterEach);
expect.extend({
toBeValidBin,
toHaveBins,
});
async function runInstall(cwd = package_dir, ...args: string[]) {
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted", ...args],
cwd,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, code] = await Promise.all([stderr.text(), exited]);
expect(err).not.toContain("error:");
expect(code).toBe(0);
}
async function writeTextLockfileBunfig() {
await writeFile(
join(package_dir, "bunfig.toml"),
`[install]\ncache = false\nregistry = "${root_url}/"\nsaveTextLockfile = true\nlinker = "hoisted"\n`,
);
}
for (const { input } of [{ input: { baz: "~0.0.3", moo: "~0.1.0" } }]) {
it(`should update to latest version of dependency (${input.baz[0]})`, async () => {
const urls: string[] = [];
const tilde = input.baz[0] === "~";
const registry = {
"0.0.3": {
bin: {
"baz-run": "index.js",
},
},
"0.0.5": {
bin: {
"baz-exec": "index.js",
},
},
latest: "0.0.3",
};
setHandler(dummyRegistry(urls, registry));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "foo",
dependencies: {
baz: input.baz,
},
}),
);
const {
stdout: stdout1,
stderr: stderr1,
exited: exited1,
} = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const err1 = await new Response(stderr1).text();
expect(err1).not.toContain("error:");
expect(err1).toContain("Saved lockfile");
const out1 = await new Response(stdout1).text();
expect(out1.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",
"+ [email protected]",
"",
"1 package installed",
]);
expect(await exited1).toBe(0);
expect(urls.sort()).toEqual([`${root_url}/baz`, `${root_url}/baz-0.0.3.tgz`]);
expect(requested).toBe(2);
expect(await readdirSorted(join(package_dir, "node_modules"))).toEqual([".bin", ".cache", "baz"]);
expect(await readdirSorted(join(package_dir, "node_modules", ".bin"))).toHaveBins(["baz-run"]);
expect(join(package_dir, "node_modules", ".bin", "baz-run")).toBeValidBin(join("..", "baz", "index.js"));
expect(await readdirSorted(join(package_dir, "node_modules", "baz"))).toEqual(["index.js", "package.json"]);
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toEqual({
name: "baz",
version: "0.0.3",
bin: {
"baz-run": "index.js",
},
});
await access(join(package_dir, "bun.lockb"));
// Perform `bun update` with updated registry & lockfile from before
await rm(join(package_dir, "node_modules"), { force: true, recursive: true });
urls.length = 0;
registry.latest = "0.0.5";
setHandler(dummyRegistry(urls, registry));
const {
stdout: stdout2,
stderr: stderr2,
exited: exited2,
} = spawn({
cmd: [bunExe(), "update", "baz", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const err2 = await new Response(stderr2).text();
expect(err2).not.toContain("error:");
expect(err2).toContain("Saved lockfile");
const out2 = await new Response(stdout2).text();
expect(out2.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toStrictEqual([
expect.stringContaining("bun update v1."),
"",
tilde ? "^ baz 0.0.3 -> 0.0.5" : "+ [email protected]",
"",
"1 package installed",
]);
expect(await exited2).toBe(0);
expect(urls.sort()).toEqual([`${root_url}/baz`, `${root_url}/baz-${tilde ? "0.0.5" : "0.0.3"}.tgz`]);
expect(requested).toBe(4);
expect(await readdirSorted(join(package_dir, "node_modules"))).toEqual([".bin", ".cache", "baz"]);
expect(await readdirSorted(join(package_dir, "node_modules", ".bin"))).toHaveBins([tilde ? "baz-exec" : "baz-run"]);
expect(join(package_dir, "node_modules", ".bin", tilde ? "baz-exec" : "baz-run")).toBeValidBin(
join("..", "baz", "index.js"),
);
expect(await readdirSorted(join(package_dir, "node_modules", "baz"))).toEqual(["index.js", "package.json"]);
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toEqual({
name: "baz",
version: tilde ? "0.0.5" : "0.0.3",
bin: {
[tilde ? "baz-exec" : "baz-run"]: "index.js",
},
});
expect(await file(join(package_dir, "package.json")).json()).toEqual({
name: "foo",
dependencies: {
baz: tilde ? "~0.0.5" : "^0.0.3",
},
});
await access(join(package_dir, "bun.lockb"));
});
it(`should update to latest versions of dependencies (${input.baz[0]})`, async () => {
const tilde = input.baz[0] === "~";
const urls: string[] = [];
const registry = {
"0.0.3": {
bin: {
"baz-run": "index.js",
},
},
"0.0.5": {
bin: {
"baz-exec": "index.js",
},
},
"0.1.0": {},
latest: "0.0.3",
};
setHandler(dummyRegistry(urls, registry));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "foo",
dependencies: {
"@barn/moo": input.moo,
baz: input.baz,
},
}),
);
const {
stdout: stdout1,
stderr: stderr1,
exited: exited1,
} = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const err1 = await new Response(stderr1).text();
expect(err1).not.toContain("error:");
expect(err1).toContain("Saved lockfile");
const out1 = await new Response(stdout1).text();
expect(out1.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",
"+ @barn/[email protected]",
expect.stringContaining("+ [email protected]"),
"",
"2 packages installed",
]);
expect(await exited1).toBe(0);
expect(urls.sort()).toEqual([
`${root_url}/@barn%2fmoo`,
`${root_url}/@barn/moo-0.1.0.tgz`,
`${root_url}/baz`,
`${root_url}/baz-0.0.3.tgz`,
]);
expect(requested).toBe(4);
expect(await readdirSorted(join(package_dir, "node_modules"))).toEqual([".bin", ".cache", "@barn", "baz"]);
expect(await readdirSorted(join(package_dir, "node_modules", ".bin"))).toHaveBins(["baz-run"]);
expect(join(package_dir, "node_modules", ".bin", "baz-run")).toBeValidBin(join("..", "baz", "index.js"));
expect(await readdirSorted(join(package_dir, "node_modules", "@barn"))).toEqual(["moo"]);
expect(await readdirSorted(join(package_dir, "node_modules", "@barn", "moo"))).toEqual(["package.json"]);
expect(await readdirSorted(join(package_dir, "node_modules", "baz"))).toEqual(["index.js", "package.json"]);
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toEqual({
name: "baz",
version: "0.0.3",
bin: {
"baz-run": "index.js",
},
});
await access(join(package_dir, "bun.lockb"));
// Perform `bun update` with updated registry & lockfile from before
await rm(join(package_dir, "node_modules"), { force: true, recursive: true });
urls.length = 0;
registry.latest = "0.0.5";
setHandler(dummyRegistry(urls, registry));
const {
stdout: stdout2,
stderr: stderr2,
exited: exited2,
} = spawn({
cmd: [bunExe(), "update", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
const err2 = await new Response(stderr2).text();
expect(err2).not.toContain("error:");
expect(err2).toContain("Saved lockfile");
const out2 = await new Response(stdout2).text();
if (tilde) {
expect(out2.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun update v1."),
"",
"^ baz 0.0.3 -> 0.0.5",
"",
"+ @barn/[email protected]",
"",
"2 packages installed",
]);
} else {
expect(out2.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun update v1."),
"",
expect.stringContaining("+ @barn/[email protected]"),
expect.stringContaining("+ [email protected]"),
"",
"2 packages installed",
]);
}
expect(await exited2).toBe(0);
expect(urls.sort()).toEqual([
`${root_url}/@barn%2fmoo`,
`${root_url}/@barn/moo-0.1.0.tgz`,
`${root_url}/baz`,
tilde ? `${root_url}/baz-0.0.5.tgz` : `${root_url}/baz-0.0.3.tgz`,
]);
expect(requested).toBe(8);
expect(await readdirSorted(join(package_dir, "node_modules"))).toEqual([".bin", ".cache", "@barn", "baz"]);
expect(await readdirSorted(join(package_dir, "node_modules", ".bin"))).toHaveBins([tilde ? "baz-exec" : "baz-run"]);
expect(join(package_dir, "node_modules", ".bin", tilde ? "baz-exec" : "baz-run")).toBeValidBin(
join("..", "baz", "index.js"),
);
expect(await readdirSorted(join(package_dir, "node_modules", "@barn"))).toEqual(["moo"]);
expect(await readdirSorted(join(package_dir, "node_modules", "@barn", "moo"))).toEqual(["package.json"]);
expect(await readdirSorted(join(package_dir, "node_modules", "baz"))).toEqual(["index.js", "package.json"]);
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toEqual({
name: "baz",
version: tilde ? "0.0.5" : "0.0.3",
bin: {
[tilde ? "baz-exec" : "baz-run"]: "index.js",
},
});
expect(await file(join(package_dir, "package.json")).json()).toEqual({
name: "foo",
dependencies: {
"@barn/moo": tilde ? "~0.1.0" : "^0.1.0",
baz: tilde ? "~0.0.5" : "^0.0.3",
},
});
await access(join(package_dir, "bun.lockb"));
});
}
it("lockfile should not be modified when there are no version changes, issue#5888", async () => {
// Install packages
const urls: string[] = [];
const registry = {
"0.0.3": {
bin: {
"baz-run": "index.js",
},
},
latest: "0.0.3",
};
setHandler(dummyRegistry(urls, registry));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "foo",
dependencies: {
baz: "0.0.3",
},
}),
);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
expect(await exited).toBe(0);
const err1 = await stderr.text();
expect(err1).not.toContain("error:");
expect(err1).toContain("Saved lockfile");
const out1 = await stdout.text();
expect(out1.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",
"+ [email protected]",
"",
"1 package installed",
]);
// Test if the lockb has been modified by `bun update`.
const getLockbContent = async () => {
const { exited } = spawn({
cmd: [bunExe(), "update"],
cwd: package_dir, // package.json is not changed
stdout: "pipe",
stdin: "pipe",
stderr: "pipe",
env,
});
expect(await exited).toBe(0);
return await readFile(join(package_dir, "bun.lockb"));
};
// no changes
expect(await file(join(package_dir, "package.json")).json()).toEqual({
name: "foo",
dependencies: {
baz: "0.0.3",
},
});
let prev = await getLockbContent();
urls.length = 0;
const count = 5;
for (let i = 0; i < count; i++) {
const content = await getLockbContent();
expect(prev).toStrictEqual(content);
prev = content;
}
// Assert we actually made a request to the registry for each update
expect(urls).toHaveLength(count);
});
// https://github.com/oven-sh/bun/issues/33176
it("--recursive updates dependencies and peerDependencies in workspace members", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "root", private: true, workspaces: ["packages/*"] }),
);
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
await mkdir(join(package_dir, "packages", "pkg-b"), { recursive: true });
// Same dependency name in two workspaces, one as a regular dep and one as a
// peer dep, so the update must fan out to both members and handle each
// workspace's dependency groups independently.
await writeFile(
join(package_dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", dependencies: { baz: "~0.0.3" } }),
);
await writeFile(
join(package_dir, "packages", "pkg-b", "package.json"),
JSON.stringify({ name: "pkg-b", peerDependencies: { baz: "~0.0.3" } }),
);
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--recursive", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
const a = await file(join(package_dir, "packages", "pkg-a", "package.json")).json();
const b = await file(join(package_dir, "packages", "pkg-b", "package.json")).json();
expect(a.dependencies.baz).toBe("~0.0.5");
expect(b.peerDependencies.baz).toBe("~0.0.5");
});
// https://github.com/oven-sh/bun/issues/33176
it("--recursive --latest updates workspace members to the latest version", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "root", private: true, workspaces: ["packages/*"] }),
);
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
// Exact pin below latest: only `--latest` moves it, so this also proves the
// member goes through the `--latest` path rather than range-constrained update.
await writeFile(
join(package_dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", dependencies: { baz: "0.0.3" } }),
);
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--recursive", "--latest", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
const a = await file(join(package_dir, "packages", "pkg-a", "package.json")).json();
expect(a.dependencies.baz).toBe("0.0.5");
});
// https://github.com/oven-sh/bun/issues/33176
it("--filter updates only matching workspaces, leaving siblings and root untouched", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "root",
private: true,
workspaces: ["packages/*"],
dependencies: { baz: "~0.0.3" },
}),
);
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
await mkdir(join(package_dir, "packages", "pkg-b"), { recursive: true });
await writeFile(
join(package_dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", dependencies: { baz: "~0.0.3" } }),
);
await writeFile(
join(package_dir, "packages", "pkg-b", "package.json"),
JSON.stringify({ name: "pkg-b", dependencies: { baz: "~0.0.3" } }),
);
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--filter", "pkg-a", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
const root = await file(join(package_dir, "package.json")).json();
const a = await file(join(package_dir, "packages", "pkg-a", "package.json")).json();
const b = await file(join(package_dir, "packages", "pkg-b", "package.json")).json();
expect(a.dependencies.baz).toBe("~0.0.5");
// Unmatched workspace and the root are left untouched.
expect(b.dependencies.baz).toBe("~0.0.3");
expect(root.dependencies.baz).toBe("~0.0.3");
});
// The exact pin is installed first, then widened to a range its locked resolution satisfies, so the nested copy stays.
async function nestedBazRepo(rootRange: string, pkgARange: string, rewrite: { root?: string; pkgA?: string }) {
setHandler(dummyRegistry([], { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
const rootJson = (range: string) =>
JSON.stringify({ name: "root", private: true, workspaces: ["packages/*"], dependencies: { baz: range } });
const pkgAJson = (range: string) => JSON.stringify({ name: "pkg-a", dependencies: { baz: range } });
await writeFile(join(package_dir, "package.json"), rootJson(rootRange));
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
await writeFile(join(package_dir, "packages", "pkg-a", "package.json"), pkgAJson(pkgARange));
await runInstall();
if (rewrite.root) await writeFile(join(package_dir, "package.json"), rootJson(rewrite.root));
if (rewrite.pkgA) await writeFile(join(package_dir, "packages", "pkg-a", "package.json"), pkgAJson(rewrite.pkgA));
await runInstall();
}
const rootBazVersion = async () =>
(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).version;
const pkgABazDir = () => join(package_dir, "packages", "pkg-a", "node_modules", "baz");
const pkgABazVersion = async () => (await file(join(pkgABazDir(), "package.json")).json()).version;
it("--filter pkg-a removes the nested copy whose row it collapsed", async () => {
await nestedBazRepo("0.0.5", "0.0.3", { pkgA: "~0.0.3" });
expect(await rootBazVersion()).toBe("0.0.5");
expect(await pkgABazVersion()).toBe("0.0.3");
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--filter", "pkg-a", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, code] = await Promise.all([stderr.text(), exited]);
expect(err).not.toContain("error:");
expect((await file(join(package_dir, "packages", "pkg-a", "package.json")).json()).dependencies).toStrictEqual({
baz: "~0.0.5",
});
expect(await exists(pkgABazDir())).toBeFalse();
expect(await rootBazVersion()).toBe("0.0.5");
expect(code).toBe(0);
});
it("--filter excluding a workspace leaves that workspace's node_modules alone", async () => {
await nestedBazRepo("0.0.3", "0.0.5", { root: "~0.0.3" });
expect(await rootBazVersion()).toBe("0.0.3");
expect(await pkgABazVersion()).toBe("0.0.5");
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--filter", "root", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, code] = await Promise.all([stderr.text(), exited]);
expect(err).not.toContain("error:");
expect((await file(join(package_dir, "package.json")).json()).dependencies).toStrictEqual({ baz: "~0.0.5" });
expect(await rootBazVersion()).toBe("0.0.5");
expect(await pkgABazVersion()).toBe("0.0.5");
expect(code).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "prune", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, code] = await Promise.all([stderr.text(), exited]);
expect(err).not.toContain("error:");
expect(await exists(pkgABazDir())).toBeFalse();
expect(await rootBazVersion()).toBe("0.0.5");
expect(code).toBe(0);
});
// Multiple `--filter` patterns select the union of matches (any positive), minus negations.
it("--filter with multiple patterns selects the union of matching workspaces", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "root",
private: true,
workspaces: ["packages/*"],
dependencies: { baz: "~0.0.3" },
}),
);
for (const n of ["pkg-a", "pkg-b", "pkg-c"]) {
await mkdir(join(package_dir, "packages", n), { recursive: true });
await writeFile(
join(package_dir, "packages", n, "package.json"),
JSON.stringify({ name: n, dependencies: { baz: "~0.0.3" } }),
);
}
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--filter", "pkg-a", "--filter", "pkg-b", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
expect({
root: (await file(join(package_dir, "package.json")).json()).dependencies.baz,
a: (await file(join(package_dir, "packages", "pkg-a", "package.json")).json()).dependencies.baz,
b: (await file(join(package_dir, "packages", "pkg-b", "package.json")).json()).dependencies.baz,
c: (await file(join(package_dir, "packages", "pkg-c", "package.json")).json()).dependencies.baz,
}).toEqual({ root: "~0.0.3", a: "~0.0.5", b: "~0.0.5", c: "~0.0.3" });
});
// https://github.com/oven-sh/bun/issues/33176
const FAN_OUT_FILES = {
"package.json": { name: "root", private: true, workspaces: ["packages/*"], dependencies: { baz: "0.0.3" } },
"packages/pkg-a/package.json": { name: "pkg-a", dependencies: { baz: "~0.0.3" } },
"packages/pkg-b/package.json": { name: "pkg-b", devDependencies: { baz: "^0.0.3" } },
"packages/pkg-c/package.json": { name: "pkg-c" },
};
async function fanOutTexts() {
const texts: string[] = [];
for (const rel of Object.keys(FAN_OUT_FILES)) texts.push(await file(join(package_dir, rel)).text());
return texts;
}
const fanOutJson = (rel: keyof typeof FAN_OUT_FILES) => file(join(package_dir, rel)).json();
// Root pins baz exactly, pkg-a uses `~`, pkg-b uses `^` in devDependencies and pkg-c does not depend on it.
async function fanOutRepo(
registryVersions: Record<string, object | string> = { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" },
) {
setHandler(dummyRegistry([], registryVersions));
await writeTextLockfileBunfig();
for (const [rel, json] of Object.entries(FAN_OUT_FILES)) {
await mkdir(dirname(join(package_dir, rel)), { recursive: true });
await writeFile(join(package_dir, rel), JSON.stringify(json, null, 2) + "\n");
}
await runInstall();
return fanOutTexts();
}
async function spawnUpdate(...args: string[]) {
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "update", ...args, "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]);
return { out, err, exitCode };
}
it("named update -r --latest rewrites every workspace that declares the name, keeping each file's style", async () => {
const [, , , pkgC] = await fanOutRepo();
const { err, exitCode } = await spawnUpdate("baz", "-r", "--latest");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
expect((await fanOutJson("package.json")).dependencies.baz).toBe("0.0.5");
expect((await fanOutJson("packages/pkg-a/package.json")).dependencies.baz).toBe("~0.0.5");
expect((await fanOutJson("packages/pkg-b/package.json")).devDependencies.baz).toBe("^0.0.5");
expect(await file(join(package_dir, "packages", "pkg-c", "package.json")).text()).toBe(pkgC);
});
it("named update --filter rewrites only the selected workspace", async () => {
const [root, , pkgB, pkgC] = await fanOutRepo();
const { err, exitCode } = await spawnUpdate("baz", "--filter", "pkg-a", "--latest");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
expect((await fanOutJson("packages/pkg-a/package.json")).dependencies.baz).toBe("~0.0.5");
expect(await fanOutTexts()).toStrictEqual([root, expect.any(String), pkgB, pkgC]);
});
it("named update accepts -F as the short form of --filter", async () => {
const [root, , pkgB, pkgC] = await fanOutRepo();
const { err, exitCode } = await spawnUpdate("baz", "-F", "pkg-a", "--latest");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
expect((await fanOutJson("packages/pkg-a/package.json")).dependencies.baz).toBe("~0.0.5");
expect(await fanOutTexts()).toStrictEqual([root, expect.any(String), pkgB, pkgC]);
});
it("named update --filter of a workspace that does not depend on the name is an error", async () => {
const before = await fanOutRepo();
const lockBefore = await file(join(package_dir, "bun.lock")).text();
const { err, exitCode } = await spawnUpdate("baz", "--filter", "pkg-c");
expect(err).toBe(
'error: "baz" is not a dependency of the selected workspaces\n bun update -r baz\n bun update --filter root baz\n bun update --filter pkg-a baz\n bun update --filter pkg-b baz\n',
);
expect(exitCode).toBe(1);
expect(await fanOutTexts()).toStrictEqual(before);
expect(await file(join(package_dir, "bun.lock")).text()).toBe(lockBefore);
});
it("named update -r with a name missing from the lockfile is an error", async () => {
const before = await fanOutRepo();
const { err, exitCode } = await spawnUpdate("nope", "-r");
expect(err).toBe('error: "nope" is not in bun.lock\n bun add nope\n');
expect(exitCode).toBe(1);
expect(await fanOutTexts()).toStrictEqual(before);
});
// Root's exact pin and pkg-b's `^0.0.3` (which excludes 0.0.5) only move with --latest.
it("named update -r moves the ranges and keeps bun.lock in sync", async () => {
const [, , , pkgC] = await fanOutRepo({ "0.0.3": {}, latest: "0.0.3" });
setHandler(dummyRegistry([], { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
const { err, exitCode } = await spawnUpdate("baz", "-r");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
expect((await fanOutJson("packages/pkg-a/package.json")).dependencies.baz).toBe("~0.0.5");
expect((await fanOutJson("packages/pkg-b/package.json")).devDependencies.baz).toBe("^0.0.3");
expect((await fanOutJson("package.json")).dependencies.baz).toBe("0.0.3");
expect(await file(join(package_dir, "packages", "pkg-c", "package.json")).text()).toBe(pkgC);
await runInstall(package_dir, "--frozen-lockfile");
});
it("named update -r --dry-run writes nothing", async () => {
const before = await fanOutRepo();
const { err, exitCode } = await spawnUpdate("baz", "-r", "--latest", "--dry-run");
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
expect(await fanOutTexts()).toStrictEqual(before);
});
// https://github.com/oven-sh/bun/issues/33176
// A workspace member's `catalog:` reference must survive `bun update`: the
// version lives in the root catalog, not inline in the member.
it("--recursive preserves a workspace member's catalog: reference", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "root",
private: true,
workspaces: ["packages/*"],
catalog: { baz: "^0.0.3" },
}),
);
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
await writeFile(
join(package_dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", dependencies: { baz: "catalog:" } }),
);
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--recursive", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
const root = await file(join(package_dir, "package.json")).json();
const a = await file(join(package_dir, "packages", "pkg-a", "package.json")).json();
// The member keeps the catalog reference; the catalog definition is unchanged.
expect(a.dependencies.baz).toBe("catalog:");
expect(root.catalog.baz).toBe("^0.0.3");
});
// `--filter` excluding root must not touch the root package.json (catalogs included),
// so the lockfile stays consistent with the on-disk root.
it("--filter excluding root leaves root (and its catalog) untouched", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
const rootJson = JSON.stringify({
name: "root",
private: true,
workspaces: ["packages/*"],
catalog: { baz: "^0.0.3" },
dependencies: { baz: "^0.0.3" },
});
await writeFile(join(package_dir, "package.json"), rootJson);
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
await writeFile(
join(package_dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", dependencies: { baz: "catalog:" } }),
);
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--filter", "pkg-a", "--latest", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
// Root is not a `--filter pkg-a` target; nothing in its package.json changes.
expect(await file(join(package_dir, "package.json")).text()).toBe(rootJson);
expect((await file(join(package_dir, "packages", "pkg-a", "package.json")).json()).dependencies.baz).toBe("catalog:");
// pkg-a's `catalog:` dep stays within the catalog range; `--latest` must not bypass it.
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toMatchObject({
version: "0.0.3",
});
// A subsequent frozen-lockfile install must pass (no catalog drift vs. lockfile).
const frozen = spawn({
cmd: [bunExe(), "install", "--frozen-lockfile", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(frozen.stderr).text()).not.toContain("error:");
expect(await frozen.exited).toBe(0);
});
// `-r` from inside a member must write root's catalog and direct deps in one
// pass (the member-commit path carries both).
it("--recursive --latest from a member updates root's catalog and direct deps together", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "root",
private: true,
workspaces: ["packages/*"],
catalog: { baz: "^0.0.3" },
dependencies: { baz: "^0.0.3" },
}),
);
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
await writeFile(
join(package_dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", dependencies: { baz: "catalog:" } }),
);
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--recursive", "--latest", "--linker=hoisted"],
cwd: join(package_dir, "packages", "pkg-a"),
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
const root = await file(join(package_dir, "package.json")).json();
const a = await file(join(package_dir, "packages", "pkg-a", "package.json")).json();
expect(root.dependencies.baz).toBe("^0.0.5");
expect(root.catalog.baz).toBe("^0.0.5");
expect(a.dependencies.baz).toBe("catalog:");
});
// https://github.com/oven-sh/bun/issues/33176
it("--filter with a path targets only the matching workspace", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "root",
private: true,
workspaces: ["packages/*"],
dependencies: { baz: "~0.0.3" },
}),
);
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
await mkdir(join(package_dir, "packages", "pkg-b"), { recursive: true });
await writeFile(
join(package_dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", dependencies: { baz: "~0.0.3" } }),
);
await writeFile(
join(package_dir, "packages", "pkg-b", "package.json"),
JSON.stringify({ name: "pkg-b", dependencies: { baz: "~0.0.3" } }),
);
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--filter", "./packages/pkg-a", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
const root = await file(join(package_dir, "package.json")).json();
const a = await file(join(package_dir, "packages", "pkg-a", "package.json")).json();
const b = await file(join(package_dir, "packages", "pkg-b", "package.json")).json();
expect(a.dependencies.baz).toBe("~0.0.5");
expect(b.dependencies.baz).toBe("~0.0.3");
expect(root.dependencies.baz).toBe("~0.0.3");
});
// https://github.com/oven-sh/bun/issues/33176
it("--filter with a negated pattern updates everything except the excluded workspace", async () => {
const urls: string[] = [];
setHandler(dummyRegistry(urls, { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "root",
private: true,
workspaces: ["packages/*"],
dependencies: { baz: "~0.0.3" },
}),
);
await mkdir(join(package_dir, "packages", "pkg-a"), { recursive: true });
await mkdir(join(package_dir, "packages", "pkg-b"), { recursive: true });
await writeFile(
join(package_dir, "packages", "pkg-a", "package.json"),
JSON.stringify({ name: "pkg-a", dependencies: { baz: "~0.0.3" } }),
);
await writeFile(
join(package_dir, "packages", "pkg-b", "package.json"),
JSON.stringify({ name: "pkg-b", dependencies: { baz: "~0.0.3" } }),
);
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
}
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "--filter", "!pkg-a", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await new Response(stderr).text()).not.toContain("error:");
expect(await exited).toBe(0);
const root = await file(join(package_dir, "package.json")).json();
const a = await file(join(package_dir, "packages", "pkg-a", "package.json")).json();
const b = await file(join(package_dir, "packages", "pkg-b", "package.json")).json();
// `!pkg-a` excludes pkg-a but keeps the root and sibling members.
expect(a.dependencies.baz).toBe("~0.0.3");
expect(b.dependencies.baz).toBe("~0.0.5");
expect(root.dependencies.baz).toBe("~0.0.5");
});
async function setupWorkspaces(
root: object,
members: Record<string, object>,
versions: Record<string, object | string> = { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" },
) {
setHandler(dummyRegistry([], versions));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "root", private: true, workspaces: ["packages/*"], ...root }),
);
for (const [name, body] of Object.entries(members)) {
await mkdir(join(package_dir, "packages", name), { recursive: true });
await writeFile(join(package_dir, "packages", name, "package.json"), JSON.stringify({ name, ...body }));
}
await runInstall();
}
const BAZ_0_0_3_ONLY = { "0.0.3": {}, latest: "0.0.3" };
const bumpBazTo_0_0_5 = () => setHandler(dummyRegistry([], { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
async function runUpdate(args: string[], cwd = package_dir) {
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", ...args, "--linker=hoisted"],
cwd,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, code] = await Promise.all([stderr.text(), exited]);
expect(err).not.toContain("error:");
expect(code).toBe(0);
return err;
}
const pkgJson = (name: string) => file(join(package_dir, "packages", name, "package.json")).json();
// https://github.com/oven-sh/bun/issues/23507
for (const group of ["dependencies", "devDependencies", "optionalDependencies"] as const) {
it(`--recursive --latest updates a member's ${group}`, async () => {
await setupWorkspaces({}, { "pkg-a": { [group]: { baz: "0.0.3" } } });
await runUpdate(["--recursive", "--latest"]);
expect((await pkgJson("pkg-a"))[group]).toEqual({ baz: "0.0.5" });
});
}
// https://github.com/oven-sh/bun/issues/23507
it("--recursive --latest updates an npm: aliased dep in a member, preserving the alias", async () => {
await setupWorkspaces({}, { "pkg-a": { dependencies: { aliased: "npm:[email protected]" } } });
await runUpdate(["--recursive", "--latest"]);
expect((await pkgJson("pkg-a")).dependencies).toEqual({ aliased: "npm:[email protected]" });
});
// https://github.com/oven-sh/bun/issues/23507
it("--recursive does not rewrite workspace: protocol references between members", async () => {
await setupWorkspaces(
{},
{
"pkg-a": { version: "1.0.0", dependencies: { baz: "~0.0.3" } },
"pkg-b": { dependencies: { "pkg-a": "workspace:*", baz: "~0.0.3" } },
},
);
await runUpdate(["--recursive"]);
expect((await pkgJson("pkg-b")).dependencies).toEqual({ "pkg-a": "workspace:*", baz: "~0.0.5" });
expect((await pkgJson("pkg-a")).dependencies).toEqual({ baz: "~0.0.5" });
});
// https://github.com/oven-sh/bun/issues/23507
it("--recursive from inside a member updates siblings and root", async () => {
await setupWorkspaces(
{ dependencies: { baz: "~0.0.3" } },
{
"pkg-a": { dependencies: { baz: "~0.0.3" } },
"pkg-b": { dependencies: { baz: "~0.0.3" } },
},
);
await runUpdate(["--recursive"], join(package_dir, "packages", "pkg-a"));
expect({
root: (await file(join(package_dir, "package.json")).json()).dependencies.baz,
a: (await pkgJson("pkg-a")).dependencies.baz,
b: (await pkgJson("pkg-b")).dependencies.baz,
}).toEqual({ root: "~0.0.5", a: "~0.0.5", b: "~0.0.5" });
});
// https://github.com/oven-sh/bun/issues/23507
it("--filter with a glob plus a negation scopes to the matched set minus the exclusion", async () => {
await setupWorkspaces(
{ dependencies: { baz: "~0.0.3" } },
{
"pkg-a": { dependencies: { baz: "~0.0.3" } },
"pkg-b": { dependencies: { baz: "~0.0.3" } },
"pkg-c": { dependencies: { baz: "~0.0.3" } },
},
);
await runUpdate(["--filter", "pkg-*", "--filter", "!pkg-c"]);
expect({
root: (await file(join(package_dir, "package.json")).json()).dependencies.baz,
a: (await pkgJson("pkg-a")).dependencies.baz,
b: (await pkgJson("pkg-b")).dependencies.baz,
c: (await pkgJson("pkg-c")).dependencies.baz,
}).toEqual({ root: "~0.0.3", a: "~0.0.5", b: "~0.0.5", c: "~0.0.3" });
});
// https://github.com/oven-sh/bun/issues/23507
it("--recursive --dry-run writes no workspace package.json", async () => {
await setupWorkspaces({ dependencies: { baz: "~0.0.3" } }, { "pkg-a": { dependencies: { baz: "~0.0.3" } } });
const rootBefore = await file(join(package_dir, "package.json")).text();
const aBefore = await file(join(package_dir, "packages", "pkg-a", "package.json")).text();
await runUpdate(["--recursive", "--latest", "--dry-run"]);
expect(await file(join(package_dir, "package.json")).text()).toBe(rootBefore);
expect(await file(join(package_dir, "packages", "pkg-a", "package.json")).text()).toBe(aBefore);
});
// https://github.com/oven-sh/bun/issues/23507
it("--recursive preserves each member's pin style independently", async () => {
await setupWorkspaces(
{},
{
"pkg-a": { dependencies: { baz: "^0.0.3" } },
"pkg-b": { dependencies: { baz: "~0.0.3" } },
"pkg-c": { dependencies: { baz: "0.0.3" } },
},
);
await runUpdate(["--recursive", "--latest"]);
expect({
a: (await pkgJson("pkg-a")).dependencies.baz,
b: (await pkgJson("pkg-b")).dependencies.baz,
c: (await pkgJson("pkg-c")).dependencies.baz,
}).toEqual({ a: "^0.0.5", b: "~0.0.5", c: "0.0.5" });
});
// https://github.com/oven-sh/bun/issues/23507
it("--filter with a scoped glob (@scope/*) targets only those members", async () => {
await setupWorkspaces(
{ dependencies: { baz: "~0.0.3" } },
{
"scope-a": { name: "@scope/a", dependencies: { baz: "~0.0.3" } },
"scope-b": { name: "@scope/b", dependencies: { baz: "~0.0.3" } },
"other": { dependencies: { baz: "~0.0.3" } },
},
);
await runUpdate(["--filter", "@scope/*"]);
expect({
root: (await file(join(package_dir, "package.json")).json()).dependencies.baz,
a: (await pkgJson("scope-a")).dependencies.baz,
b: (await pkgJson("scope-b")).dependencies.baz,
other: (await pkgJson("other")).dependencies.baz,
}).toEqual({ root: "~0.0.3", a: "~0.0.5", b: "~0.0.5", other: "~0.0.3" });
});
// https://github.com/oven-sh/bun/issues/23507
for (const depLiteral of ["^0.0.3", "0.0.3"]) {
it(`--recursive --latest does not bypass a root overrides entry (member dep literal ${depLiteral})`, async () => {
await setupWorkspaces({ overrides: { baz: "0.0.3" } }, { "pkg-a": { dependencies: { baz: depLiteral } } });
await runUpdate(["--recursive", "--latest"]);
// The override pins baz to 0.0.3; --latest must not resolve past it.
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toMatchObject({
version: "0.0.3",
});
});
}
// https://github.com/oven-sh/bun/issues/23507
it("--recursive --no-save updates node_modules but not any package.json", async () => {
await setupWorkspaces({}, { "pkg-a": { dependencies: { baz: "~0.0.3" } } });
const aBefore = await file(join(package_dir, "packages", "pkg-a", "package.json")).text();
await runUpdate(["--recursive", "--no-save"]);
expect(await file(join(package_dir, "packages", "pkg-a", "package.json")).text()).toBe(aBefore);
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toMatchObject({
version: "0.0.5",
});
});
it("--recursive keeps a member's dist-tag literal and only moves bun.lock", async () => {
await setupWorkspaces({}, { "pkg-a": { dependencies: { baz: "latest" } } }, BAZ_0_0_3_ONLY);
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toMatchObject({
version: "0.0.3",
});
bumpBazTo_0_0_5();
await runUpdate(["--recursive"]);
expect((await pkgJson("pkg-a")).dependencies).toStrictEqual({ baz: "latest" });
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toMatchObject({
version: "0.0.5",
});
});
it("--recursive --latest replaces a member's dist-tag literal with the resolved version", async () => {
await setupWorkspaces({}, { "pkg-a": { dependencies: { baz: "latest" } } }, BAZ_0_0_3_ONLY);
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toMatchObject({
version: "0.0.3",
});
bumpBazTo_0_0_5();
await runUpdate(["--recursive", "--latest"]);
expect((await pkgJson("pkg-a")).dependencies).toStrictEqual({ baz: "^0.0.5" });
expect(await file(join(package_dir, "node_modules", "baz", "package.json")).json()).toMatchObject({
version: "0.0.5",
});
});
// https://github.com/oven-sh/bun/issues/23507
it("--recursive is idempotent: a second run changes nothing", async () => {
await setupWorkspaces({ dependencies: { baz: "~0.0.3" } }, { "pkg-a": { dependencies: { baz: "~0.0.3" } } });
await runUpdate(["--recursive"]);
const rootAfter = await file(join(package_dir, "package.json")).json();
const aAfter = await pkgJson("pkg-a");
expect(aAfter.dependencies.baz).toBe("~0.0.5");
await runUpdate(["--recursive"]);
expect(await file(join(package_dir, "package.json")).json()).toEqual(rootAfter);
expect(await pkgJson("pkg-a")).toEqual(aAfter);
});
it("--filter matching nothing is an error that writes no package.json", async () => {
await setupWorkspaces({ dependencies: { baz: "~0.0.3" } }, { "pkg-a": { dependencies: { baz: "~0.0.3" } } });
const rootBefore = await file(join(package_dir, "package.json")).text();
const aBefore = await file(join(package_dir, "packages", "pkg-a", "package.json")).text();
const { err, exitCode } = await spawnUpdate("--filter", "does-not-exist");
expect(err).toBe('error: No workspace packages matched the filter "does-not-exist"\n');
expect(exitCode).toBe(1);
expect(await file(join(package_dir, "package.json")).text()).toBe(rootBefore);
expect(await file(join(package_dir, "packages", "pkg-a", "package.json")).text()).toBe(aBefore);
});
it("--recursive tolerates a member with no dependency groups", async () => {
await setupWorkspaces(
{ dependencies: { baz: "~0.0.3" } },
{ empty: { version: "1.0.0" }, "pkg-a": { dependencies: { baz: "~0.0.3" } } },
);
await runUpdate(["--recursive"]);
expect((await pkgJson("pkg-a")).dependencies.baz).toBe("~0.0.5");
expect(await pkgJson("empty")).toEqual({ name: "empty", version: "1.0.0" });
});
// https://github.com/oven-sh/bun/issues/23507
it("--recursive updates only one group when a member lists the same dep in two groups", async () => {
await setupWorkspaces({}, { "pkg-a": { dependencies: { baz: "~0.0.3" }, devDependencies: { baz: "~0.0.3" } } });
await runUpdate(["--recursive"]);
const a = await pkgJson("pkg-a");
// Matches the existing single-workspace behavior: only one group's entry is rewritten.
expect([a.dependencies.baz, a.devDependencies.baz].sort()).toEqual(["~0.0.3", "~0.0.5"]);
});
// https://github.com/oven-sh/bun/issues/23507
it("--recursive with multiple workspace globs fans out to every matched directory", async () => {
setHandler(dummyRegistry([], { "0.0.3": {}, "0.0.5": {}, latest: "0.0.5" }));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "root", private: true, workspaces: ["apps/*", "packages/*"] }),
);
for (const [dir, name] of [
["apps", "app-a"],
["packages", "pkg-a"],
] as const) {
await mkdir(join(package_dir, dir, name), { recursive: true });
await writeFile(
join(package_dir, dir, name, "package.json"),
JSON.stringify({ name, dependencies: { baz: "~0.0.3" } }),
);
}
{
const { stderr, exited } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
const [err, code] = await Promise.all([stderr.text(), exited]);
expect(err).not.toContain("error:");
expect(code).toBe(0);
}
await runUpdate(["--recursive"]);
expect((await file(join(package_dir, "apps", "app-a", "package.json")).json()).dependencies.baz).toBe("~0.0.5");
expect((await file(join(package_dir, "packages", "pkg-a", "package.json")).json()).dependencies.baz).toBe("~0.0.5");
});
// https://github.com/oven-sh/bun/issues/23507
it("bun outdated -r is empty after bun update -r --latest", async () => {
await setupWorkspaces(
{ dependencies: { baz: "~0.0.3" } },
{
"pkg-a": { dependencies: { baz: "~0.0.3" } },
"pkg-b": { devDependencies: { baz: "0.0.3" } },
},
);
await runUpdate(["--recursive", "--latest"]);
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), "outdated", "--recursive"],
cwd: package_dir,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, code] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).not.toContain("error:");
expect(out).not.toContain("baz");
expect(code).toBe(0);
});
it("should print UTF-8 arrows correctly with colors enabled", async () => {
const urls: string[] = [];
const registry = {
"0.0.3": {},
"0.0.5": {},
latest: "0.0.3",
};
setHandler(dummyRegistry(urls, registry));
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "foo",
dependencies: {
baz: "0.0.3",
},
}),
);
let { exited, stderr: stderr1 } = spawn({
cmd: [bunExe(), "install", "--linker=hoisted"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
const err1 = await new Response(stderr1).text();
expect(err1).not.toContain("error:");
expect(await exited).toBe(0);
registry.latest = "0.0.5";
setHandler(dummyRegistry(urls, registry));
const { stdout, exited: exited2 } = spawn({
cmd: [bunExe(), "update", "--latest", "--linker=hoisted"],
cwd: package_dir,
stdout: "pipe",
stderr: "ignore",
env: { ...env, FORCE_COLOR: "1" },
});
const out = await new Response(stdout).text();
expect(out).toContain("↑");
expect(out).toContain("→");
// double-encoded UTF-8 (each byte of the arrow re-encoded as Latin-1)
expect(out).not.toContain("â†");
expect(await exited2).toBe(0);
});
type PerNameManifests = Record<
string,
{ versions: Record<string, { dependencies?: Record<string, string> }>; latest: string }
>;
async function packPerName(tgzDir: string, manifests: PerNameManifests) {
for (const [name, { versions }] of Object.entries(manifests)) {
for (const [version, extra] of Object.entries(versions)) {
const staging = join(tgzDir, ".staging", `${name}-${version}`);
await mkdir(staging, { recursive: true });
await writeFile(join(staging, "package.json"), JSON.stringify({ name, version, ...extra }));
await pack(staging, env, "--destination", tgzDir);
}
}
}
// Unlike `dummyRegistry`, this serves a distinct manifest per package name from tarballs packed by `packPerName`.
function perNameHandler(tgzDir: string, manifests: PerNameManifests) {
return (request: Request) => {
const url = request.url;
if (url.endsWith(".tgz")) return new Response(file(join(tgzDir, basename(url))));
const name = url.slice(url.indexOf("/", root_url.length) + 1);
const entry = manifests[name];
if (!entry) return new Response("not found", { status: 404 });
const versions: Record<string, object> = {};
for (const [version, extra] of Object.entries(entry.versions)) {
versions[version] = { name, version, dist: { tarball: `${url}-${version}.tgz` }, ...extra };
}
return new Response(JSON.stringify({ name, versions, "dist-tags": { latest: entry.latest } }));
};
}
async function perNameRegistry(tgzDir: string, manifests: PerNameManifests) {
await packPerName(tgzDir, manifests);
return perNameHandler(tgzDir, manifests);
}
async function runIn(cwd: string, ...args: string[]) {
const { stdout, stderr, exited } = spawn({
cmd: [bunExe(), ...args],
cwd,
stdout: "pipe",
stderr: "pipe",
env,
});
const [out, err, exitCode] = await Promise.all([stdout.text(), stderr.text(), exited]);
expect(err).not.toContain("error:");
expect(exitCode).toBe(0);
return out;
}
const runInPackageDir = (...args: string[]) => runIn(package_dir, ...args);
// The set of `shared@<version>` resolutions in the text lockfile.
async function lockedSharedResolutions() {
const lock = await file(join(package_dir, "bun.lock")).text();
return [...new Set(lock.match(/"shared@[\d.]+"/g))].sort();
}
// A named update only re-resolves the rows of the workspace it runs in; another workspace's own entry is left alone.
it("bun update <name> from the root leaves a member's own entry alone; running it inside the member moves it", async () => {
setHandler(
await perNameRegistry(join(package_dir, ".tarballs"), {
shared: { versions: { "1.0.0": {}, "1.1.0": {}, "2.0.0": {} }, latest: "2.0.0" },
}),
);
await writeTextLockfileBunfig();
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "root", workspaces: ["packages/*"], dependencies: { shared: "^2.0.0" } }),
);
const pkgOneDir = join(package_dir, "packages", "pkg-one");
const pkgOneJson = join(pkgOneDir, "package.json");
await mkdir(pkgOneDir, { recursive: true });
await writeFile(pkgOneJson, JSON.stringify({ name: "pkg-one", version: "1.0.0", dependencies: { shared: "1.0.0" } }));
await runInPackageDir("install");
// Widening the range keeps the stale 1.0.0 on a plain install, since it still satisfies the new range.
await writeFile(
pkgOneJson,
JSON.stringify({ name: "pkg-one", version: "1.0.0", dependencies: { shared: "^1.0.0" } }),
);
await runInPackageDir("install");
expect(await lockedSharedResolutions()).toEqual(['"[email protected]"', '"[email protected]"']);
const pkgOneShared = () => file(join(pkgOneDir, "node_modules", "shared", "package.json")).json();
await runInPackageDir("update", "shared");
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"', '"[email protected]"']);
expect(await pkgOneShared()).toMatchObject({ version: "1.0.0" });
await runIn(pkgOneDir, "update", "shared");
expect(await lockedSharedResolutions()).toEqual(['"[email protected]"', '"[email protected]"']);
expect(await pkgOneShared()).toMatchObject({ version: "1.1.0" });
await runIn(pkgOneDir, "update", "shared");
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"', '"[email protected]"']);
expect(await pkgOneShared()).toMatchObject({ version: "1.1.0" });
});
// The same invariant one level deeper: a dependency on `<name>` owned by a
// preserved parent package must also re-enter the resolve queue.
it("should update transitive resolutions of a named package", async () => {
setHandler(
await perNameRegistry(join(package_dir, ".tarballs"), {
shared: { versions: { "1.0.0": {}, "1.1.0": {} }, latest: "1.1.0" },
"dep-x": { versions: { "1.0.0": { dependencies: { shared: "^1.0.0" } } }, latest: "1.0.0" },
}),
);
await writeTextLockfileBunfig();
// [email protected] depends on shared@^1.0.0, which dedupes onto the root's
// exact [email protected] at install time.
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "root", dependencies: { shared: "1.0.0", "dep-x": "^1.0.0" } }),
);
await runInPackageDir("install");
expect(await lockedSharedResolutions()).toEqual(['"[email protected]"']);
// The root's exact `1.0.0` cannot move; dep-x's `^1.0.0` must move to 1.1.0.
await runInPackageDir("update", "shared");
expect(await lockedSharedResolutions()).toEqual(['"[email protected]"', '"[email protected]"']);
expect(
await file(join(package_dir, "node_modules", "dep-x", "node_modules", "shared", "package.json")).json(),
).toMatchObject({ version: "1.1.0" });
});
it("bun update <name> --latest holds back only the root's entry; a transitive edge declared as a dist-tag keeps following it", async () => {
setHandler(
await perNameRegistry(join(package_dir, ".tarballs"), {
shared: { versions: { "1.0.0": {}, "1.1.0": {} }, latest: "1.0.0" },
"dep-x": { versions: { "1.0.0": { dependencies: { shared: "latest" } } }, latest: "1.0.0" },
}),
);
await writeTextLockfileBunfig();
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({ name: "root", dependencies: { shared: "1.1.0", "dep-x": "1.0.0" } }),
);
await runInPackageDir("install");
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"', '"[email protected]"']);
await runInPackageDir("update", "shared", "--latest");
expect((await file(join(package_dir, "package.json")).json()).dependencies).toStrictEqual({
shared: "1.1.0",
"dep-x": "1.0.0",
});
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"', '"[email protected]"']);
expect(
await file(join(package_dir, "node_modules", "dep-x", "node_modules", "shared", "package.json")).json(),
).toMatchObject({ version: "1.0.0" });
expect(await file(join(package_dir, "node_modules", "shared", "package.json")).json()).toMatchObject({
version: "1.1.0",
});
});
it("bun update <name> on a dist-tag entry follows the tag even when it moved backwards; --latest then writes that version", async () => {
const tgzDir = join(package_dir, ".tarballs");
const versions = { "1.0.0": {}, "1.1.0": {} };
await packPerName(tgzDir, { shared: { versions, latest: "1.1.0" } });
setHandler(perNameHandler(tgzDir, { shared: { versions, latest: "1.1.0" } }));
await writeTextLockfileBunfig();
const packageJson = { name: "root", dependencies: { shared: "latest" } };
await writeFile(join(package_dir, "package.json"), JSON.stringify(packageJson));
await runInPackageDir("install");
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"']);
setHandler(perNameHandler(tgzDir, { shared: { versions, latest: "1.0.0" } }));
await runInPackageDir("update", "shared");
expect(await file(join(package_dir, "package.json")).json()).toStrictEqual(packageJson);
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"']);
expect(await file(join(package_dir, "node_modules", "shared", "package.json")).json()).toMatchObject({
version: "1.0.0",
});
await runInPackageDir("update");
expect(await file(join(package_dir, "package.json")).json()).toStrictEqual(packageJson);
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"']);
await runInPackageDir("update", "shared", "--latest");
expect(await file(join(package_dir, "package.json")).json()).toStrictEqual({
...packageJson,
dependencies: { shared: "^1.0.0" },
});
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"']);
expect(await file(join(package_dir, "node_modules", "shared", "package.json")).json()).toMatchObject({
version: "1.0.0",
});
});
// `shared` is only reachable through dep-x; the install below pins 1.0.0 before the registry starts serving 1.1.0.
async function setupTransitiveOnlyShared() {
const tgzDir = join(package_dir, ".tarballs");
const depX = { "dep-x": { versions: { "1.0.0": { dependencies: { shared: "^1.0.0" } } }, latest: "1.0.0" } };
const published = { ...depX, shared: { versions: { "1.0.0": {}, "1.1.0": {} }, latest: "1.1.0" } };
await packPerName(tgzDir, published);
setHandler(perNameHandler(tgzDir, { ...depX, shared: { versions: { "1.0.0": {} }, latest: "1.0.0" } }));
await writeTextLockfileBunfig();
const packageJson = { name: "root", dependencies: { "dep-x": "^1.0.0" } };
await writeFile(join(package_dir, "package.json"), JSON.stringify(packageJson));
await runInPackageDir("install");
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"']);
setHandler(perNameHandler(tgzDir, published));
return packageJson;
}
it("bun update <name> updates a package that is only a transitive dependency without adding it to package.json", async () => {
const packageJson = await setupTransitiveOnlyShared();
await runInPackageDir("update", "shared");
expect(await file(join(package_dir, "package.json")).json()).toStrictEqual(packageJson);
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"']);
expect(await file(join(package_dir, "node_modules", "shared", "package.json")).json()).toMatchObject({
version: "1.1.0",
});
});
it("bun update updates transitive dependencies", async () => {
const packageJson = await setupTransitiveOnlyShared();
const out = await runInPackageDir("update");
expect(out).not.toContain("updating:");
expect(out).toContain("^ shared 1.0.0 -> 1.1.0\n");
expect(out).not.toContain("+ [email protected]");
expect(await file(join(package_dir, "package.json")).json()).toStrictEqual(packageJson);
expect(await lockedSharedResolutions()).toStrictEqual(['"[email protected]"']);
expect(await file(join(package_dir, "node_modules", "shared", "package.json")).json()).toMatchObject({
version: "1.1.0",
});
});
it("bun update <name> rejects a name that is not in the lockfile", async () => {
const packageJson = await setupTransitiveOnlyShared();
const lockBefore = await file(join(package_dir, "bun.lock")).text();
const { stderr, exited } = spawn({
cmd: [bunExe(), "update", "not-a-dep"],
cwd: package_dir,
stdout: "ignore",
stderr: "pipe",
env,
});
expect(await stderr.text()).toBe('error: "not-a-dep" is not in bun.lock\n bun add not-a-dep\n');
expect(await exited).toBe(1);
expect(await file(join(package_dir, "package.json")).json()).toStrictEqual(packageJson);
expect(await file(join(package_dir, "bun.lock")).text()).toBe(lockBefore);
});
// Registry: no-deps 1.0.0/1.0.1/1.1.0/2.0.0; a-dep 1.0.1..1.0.10; dep-with-tags latest=3.0.0, pre-2=2.0.1; @types/* 1.0.0/2.0.0.
describe("bun update <name> semantics", () => {
type Json = Record<string, any>;
const verdaccio = new VerdaccioRegistry();
beforeAll(async () => {
await verdaccio.start();
});
afterAll(() => {
verdaccio.stop();
});
const GROUPS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
// A manifest argument is either a bare dependency map or an object of package.json fields (groups, scripts).
const isFields = (json: Json) => Object.keys(json).some(key => GROUPS.includes(key) || key === "scripts");
const manifest = (json: Json): Json =>
isFields(json) ? { name: "foo", ...json } : { name: "foo", dependencies: json };
const declared = (json: Json): Record<string, string> =>
isFields(json) ? Object.assign({}, ...GROUPS.map(group => json[group] ?? {})) : json;
const stringify = (json: Json) => JSON.stringify(json, null, 2) + "\n";
const packageJsonOf = (dir: string, rel = ""): Promise<Json> => file(join(dir, rel, "package.json")).json();
const packageJsonText = (dir: string, rel = "") => file(join(dir, rel, "package.json")).text();
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;
const installedVersion = async (dir: string, name: string): Promise<string> =>
(await file(join(dir, "node_modules", name, "package.json")).json()).version;
// CI exports one BUN_INSTALL_CACHE_DIR per test file, which overrides the per-test-dir bunfig cache; concurrent cases racing on one cache fail on Windows.
const envFor = (dir: string) => ({ ...env, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") });
async function run(dir: string, ...args: string[]) {
return runFrom(dir, dir, ...args);
}
async function runFrom(cwd: string, dir: string, ...args: string[]) {
await using proc = spawn({
cmd: [bunExe(), ...args],
cwd,
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 install(dir: string, ...args: string[]) {
const { stderr, exitCode } = await run(dir, "install", ...args);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
return stderr;
}
async function update(dir: string, ...args: string[]) {
const result = await run(dir, "update", ...args);
expect(result.stderr).not.toContain("error:");
expect(result.exitCode).toBe(0);
return result;
}
async function createDir(files: Record<string, Json | string>) {
const { packageDir } = await verdaccio.createTestDir({
bunfigOpts: { saveTextLockfile: true, linker: "hoisted" },
files: Object.fromEntries(
Object.entries(files).map(([path, json]) => [path, typeof json === "string" ? json : stringify(json)]),
),
});
return packageDir;
}
async function setup(json: Json) {
const dir = await createDir({ "package.json": manifest(json) });
await install(dir);
return dir;
}
// Pin exactly, then widen: the locked versions still satisfy the new ranges, so only `bun update` moves them.
async function stale(pinned: Json, widened: Json) {
const dir = await setup(pinned);
await writeFile(join(dir, "package.json"), stringify(manifest(widened)));
expect(await install(dir)).toContain("Saved lockfile");
for (const [name, literal] of Object.entries(declared(pinned))) {
expect(await installedVersion(dir, name)).toBe(literal.replace(/^npm:[^@]+@/, ""));
}
return dir;
}
// Every version of `name` resolved anywhere in bun.lock, including behind an alias.
async function lockedVersions(dir: string, name: string) {
const { packages } = await lock(dir);
const versions = Object.values(packages as Record<string, [string]>)
.map(([resolution]) => resolution)
.filter(resolution => resolution.startsWith(`${name}@`))
.map(resolution => resolution.slice(name.length + 1));
return [...new Set(versions)].sort();
}
// `expected` is a dependency map, or an object of groups when several groups are asserted at once.
async function expectInSync(dir: string, expected: Json) {
const groups: Json = isFields(expected) ? expected : { dependencies: expected };
const packageJson = await packageJsonOf(dir);
const root = (await lock(dir)).workspaces[""];
for (const group of GROUPS) {
if (!(group in groups)) continue;
expect(packageJson[group]).toStrictEqual(groups[group]);
expect(root[group]).toStrictEqual(groups[group]);
}
await install(dir, "--frozen-lockfile");
}
async function expectUnchanged(dir: string, before: { packageJson: string; lock: string }) {
expect(await packageJsonText(dir)).toBe(before.packageJson);
expect(await lockText(dir)).toBe(before.lock);
}
const snapshotFiles = async (dir: string) => ({ packageJson: await packageJsonText(dir), lock: await lockText(dir) });
const SIBLINGS_PINNED = { "no-deps": "1.0.0", "a-dep": "1.0.1" };
const SIBLINGS_WIDENED = { "no-deps": "^1.0.0", "a-dep": "^1.0.1" };
for (const flags of [[], ["--latest"]]) {
it.concurrent(`bun update ${["a-dep", ...flags].join(" ")} leaves a stale unnamed sibling alone`, async () => {
const dir = await stale(SIBLINGS_PINNED, SIBLINGS_WIDENED);
await update(dir, "a-dep", ...flags);
await expectInSync(dir, { "no-deps": "^1.0.0", "a-dep": "^1.0.10" });
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.10");
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
});
}
it.concurrent("bun update <name> keeps a dist-tag literal as written", async () => {
const dir = await setup({ "dep-with-tags": "pre-2" });
expect(await installedVersion(dir, "dep-with-tags")).toBe("2.0.1");
await update(dir, "dep-with-tags");
await expectInSync(dir, { "dep-with-tags": "pre-2" });
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["2.0.1"]);
});
for (const args of [[], ["dep-with-tags"]]) {
it.concurrent(
`bun update ${[...args, "--latest"].join(" ")} replaces a dist-tag literal with the latest version`,
async () => {
const dir = await setup({ "dep-with-tags": "pre-2" });
expect(await installedVersion(dir, "dep-with-tags")).toBe("2.0.1");
await update(dir, ...args, "--latest");
await expectInSync(dir, { "dep-with-tags": "^3.0.0" });
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["3.0.0"]);
expect(await installedVersion(dir, "dep-with-tags")).toBe("3.0.0");
},
);
}
it.concurrent("bun update --latest rewrites a dist-tag literal next to a range", async () => {
const dir = await setup({ "dep-with-tags": "pre-2", "no-deps": "~1.0.0" });
const { stdout } = await update(dir, "--latest");
await expectInSync(dir, { "dep-with-tags": "^3.0.0", "no-deps": "~2.0.0" });
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["3.0.0"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
expect(stdout).toContain("no-deps");
expect(stdout).toContain("dep-with-tags");
});
it.concurrent("bun update --latest replaces an aliased dist-tag behind its alias", async () => {
const dir = await setup({ tagged: "npm:dep-with-tags@pre-2" });
expect(await installedVersion(dir, "tagged")).toBe("2.0.1");
await update(dir, "--latest");
await expectInSync(dir, { tagged: "npm:dep-with-tags@^3.0.0" });
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["3.0.0"]);
expect(await installedVersion(dir, "tagged")).toBe("3.0.0");
});
it.concurrent("bun update --latest replaces `latest` literals even when the tag has not moved", async () => {
const dir = await setup({ "no-deps": "latest", aliased: "npm:a-dep@latest" });
const { stdout } = await update(dir, "--latest");
expect(stdout).not.toContain("->");
expect(stdout).not.toContain("→");
await expectInSync(dir, { "no-deps": "^2.0.0", aliased: "npm:a-dep@^1.0.10" });
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
expect(await installedVersion(dir, "aliased")).toBe("1.0.10");
});
it.concurrent("bun update keeps `latest` literals and an aliased dist-tag as written", async () => {
const dir = await setup({ "no-deps": "latest", tagged: "npm:dep-with-tags@pre-2" });
const before = await snapshotFiles(dir);
await update(dir);
await expectUnchanged(dir, before);
await expectInSync(dir, { "no-deps": "latest", tagged: "npm:dep-with-tags@pre-2" });
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
expect(await installedVersion(dir, "tagged")).toBe("2.0.1");
});
it.concurrent.each<[string, string, string[]]>([
["*", "2.0.0", []],
["1", "1.1.0", []],
["1.x", "1.1.0", []],
["~1", "1.1.0", []],
[">=1.0.0 <2", "1.1.0", []],
["1.0.0 - 1.0.1", "1.0.1", []],
["^1.0.0 || ^2.0.0", "2.0.0", []],
["npm:[email protected]", "1.1.0", []],
["*", "2.0.0", ["no-deps"]],
])(
"a plain update keeps the range %p as written and only moves bun.lock to %p (extra args: %p)",
async (literal, version, names) => {
const pin = literal.startsWith("npm:") ? "npm:[email protected]" : "1.0.0";
const dir = await stale({ "no-deps": pin }, { "no-deps": literal });
await update(dir, ...names);
await expectInSync(dir, { "no-deps": literal });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual([version]);
expect(await installedVersion(dir, "no-deps")).toBe(version);
},
);
it.concurrent("--latest still rewrites a non-caret range", async () => {
const dir = await setup({ "no-deps": "1.x" });
await update(dir, "--latest");
await expectInSync(dir, { "no-deps": "^2.0.0" });
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
});
it.concurrent("bun update <name>@<range> on a non-caret entry writes the caret form", async () => {
const dir = await setup({ "no-deps": "*" });
await update(dir, "no-deps@1");
await expectInSync(dir, { "no-deps": "^1.1.0" });
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
});
it.concurrent("bun update <real name> reaches a dependency declared behind an npm: alias", async () => {
const dir = await stale({ aliased: "npm:[email protected]" }, { aliased: "npm:no-deps@~1.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
await update(dir, "no-deps");
await expectInSync(dir, { aliased: "npm:no-deps@~1.0.1" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]);
expect(await installedVersion(dir, "aliased")).toBe("1.0.1");
});
it.concurrent(
"bun update <real name> moves the plain entry and the aliased entry, each within its own range",
async () => {
const dir = await stale(
{ "no-deps": "1.0.0", aliased: "npm:[email protected]" },
{ "no-deps": "^1.0.0", aliased: "npm:no-deps@~1.0.0" },
);
await update(dir, "no-deps");
await expectInSync(dir, { "no-deps": "^1.1.0", aliased: "npm:no-deps@~1.0.1" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1", "1.1.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
expect(await installedVersion(dir, "aliased")).toBe("1.0.1");
},
);
it.concurrent("bun update <real name> --latest moves an aliased entry to latest in its pin style", async () => {
const dir = await setup({ aliased: "npm:no-deps@~1.0.0" });
expect(await installedVersion(dir, "aliased")).toBe("1.0.1");
await update(dir, "no-deps", "--latest");
await expectInSync(dir, { aliased: "npm:no-deps@~2.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
expect(await installedVersion(dir, "aliased")).toBe("2.0.0");
});
for (const flag of ["--latest", "-L"]) {
it.concurrent(`bun update <name>@<version> ${flag} is an error and writes nothing`, async () => {
const dir = await setup({ "no-deps": "~1.0.0" });
const before = await snapshotFiles(dir);
const { stderr, exitCode } = await run(dir, "update", "no-deps@1", flag);
expect(stderr).toContain("error: --latest cannot be combined with a version");
expect(stderr).not.toContain("Saved lockfile");
await expectUnchanged(dir, before);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
expect(exitCode).not.toBe(0);
});
}
it.concurrent("bun update <name> --dry-run writes nothing", async () => {
const dir = await stale({ "no-deps": "1.0.0" }, { "no-deps": "^1.0.0" });
const before = await snapshotFiles(dir);
const { stderr } = await update(dir, "no-deps", "--dry-run");
expect(stderr).not.toContain("Saved lockfile");
await expectUnchanged(dir, before);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
});
it.concurrent("bun update leaves an =x.y.z pin untouched", async () => {
const dir = await setup({ "no-deps": "=1.0.0" });
const before = await snapshotFiles(dir);
await update(dir);
await expectUnchanged(dir, before);
await expectInSync(dir, { "no-deps": "=1.0.0" });
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
});
// `~1` stays in the preserved table above: `~1.x.y` would lower its ceiling, while these keep theirs.
it.concurrent.each([
["^1", "^1.1.0", "1.1.0"],
["^1.0", "^1.1.0", "1.1.0"],
["~1.0", "~1.0.1", "1.0.1"],
])("a plain update rewrites the short range %p to %p", async (literal, rewritten, version) => {
const dir = await stale({ "no-deps": "1.0.0" }, { "no-deps": literal });
await update(dir);
await expectInSync(dir, { "no-deps": rewritten });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual([version]);
expect(await installedVersion(dir, "no-deps")).toBe(version);
});
it.concurrent.each([
["1", "1.1.0", "^2.0.0"],
["1.0", "1.0.1", "~2.0.0"],
["=1.0.0", "1.0.0", "=2.0.0"],
])("--latest rewrites %p (locked at %p) to %p, keeping its width", async (literal, locked, rewritten) => {
const dir = await setup({ "no-deps": literal });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual([locked]);
await update(dir, "--latest");
await expectInSync(dir, { "no-deps": rewritten });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
});
// prereleases-2: 0.5.0 (latest) and 1.0.0-next.0..23; prereleases-3: 5.0.0-alpha.150..153.
it.concurrent("a plain update moves a caret range on a prerelease and leaves an exact prerelease alone", async () => {
const dir = await stale(
{ "prereleases-2": "1.0.0-next.0", "prereleases-3": "5.0.0-alpha.150" },
{ "prereleases-2": "^1.0.0-next.0", "prereleases-3": "5.0.0-alpha.150" },
);
await update(dir);
await expectInSync(dir, { "prereleases-2": "^1.0.0-next.23", "prereleases-3": "5.0.0-alpha.150" });
expect(await lockedVersions(dir, "prereleases-2")).toStrictEqual(["1.0.0-next.23"]);
expect(await lockedVersions(dir, "prereleases-3")).toStrictEqual(["5.0.0-alpha.150"]);
expect(await installedVersion(dir, "prereleases-2")).toBe("1.0.0-next.23");
expect(await installedVersion(dir, "prereleases-3")).toBe("5.0.0-alpha.150");
});
it.concurrent.each([
["dep-with-tags@pre-2", { "dep-with-tags": "pre-2", "no-deps": "^1.0.0" }, ["2.0.1"], ["1.0.0"]],
["no-deps@latest", { "dep-with-tags": "^1.0.0", "no-deps": "latest" }, ["1.0.1"], ["2.0.0"]],
])(
"bun update %p writes the dist-tag literal to package.json and bun.lock follows the tag",
async (request, expected, depWithTags, noDeps) => {
const dir = await stale(
{ "dep-with-tags": "1.0.1", "no-deps": "1.0.0" },
{ "dep-with-tags": "^1.0.0", "no-deps": "^1.0.0" },
);
await update(dir, request);
await expectInSync(dir, expected);
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(depWithTags);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(noDeps);
expect(await installedVersion(dir, "dep-with-tags")).toBe(depWithTags[0]);
expect(await installedVersion(dir, "no-deps")).toBe(noDeps[0]);
},
);
it.concurrent(
"install.exact + --latest writes exact versions behind an alias and in place of a dist-tag",
async () => {
const dir = await setup({ tagged: "npm:dep-with-tags@pre-2", "no-deps": "latest", "a-dep": "^1.0.1" });
expect(await installedVersion(dir, "tagged")).toBe("2.0.1");
await appendFile(join(dir, "bunfig.toml"), "exact = true\n");
await update(dir, "--latest");
await expectInSync(dir, { tagged: "npm:[email protected]", "no-deps": "2.0.0", "a-dep": "1.0.10" });
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["3.0.0"]);
expect(await installedVersion(dir, "tagged")).toBe("3.0.0");
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
},
);
it.concurrent("--exact on the command line writes exact versions like install.exact does", async () => {
const dir = await setup({ "no-deps": "^1.0.0", tagged: "npm:dep-with-tags@pre-2" });
await update(dir, "--latest", "--exact");
await expectInSync(dir, { "no-deps": "2.0.0", tagged: "npm:[email protected]" });
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
});
it.concurrent("-L is an alias of --latest, bare and named", async () => {
const bare = await setup({ "no-deps": "~1.0.0", "a-dep": "~1.0.1" });
await update(bare, "-L");
await expectInSync(bare, { "no-deps": "~2.0.0", "a-dep": "~1.0.10" });
const named = await setup({ "no-deps": "~1.0.0", "a-dep": "~1.0.1" });
await update(named, "no-deps", "-L");
await expectInSync(named, { "no-deps": "~2.0.0", "a-dep": "~1.0.1" });
const { stdout, exitCode } = await run(named, "update", "--help");
expect(stdout).toContain("-L, --latest");
expect(stdout).toContain("-d, --dev");
expect(exitCode).toBe(0);
});
it.concurrent("bun up is an alias of bun update", async () => {
const dir = await stale({ "no-deps": "1.0.0" }, { "no-deps": "^1.0.0" });
const up = await run(dir, "up");
expect(up.stderr).not.toContain("error:");
expect(up.exitCode).toBe(0);
await expectInSync(dir, { "no-deps": "^1.1.0" });
const upLatest = await run(dir, "up", "no-deps", "-L");
expect(upLatest.stderr).not.toContain("error:");
expect(upLatest.exitCode).toBe(0);
await expectInSync(dir, { "no-deps": "^2.0.0" });
const help = await run(dir, "up", "--help");
expect(help.stdout).toContain("bun update");
expect(help.exitCode).toBe(0);
});
it.concurrent("bun up wins over a package.json script named up; bun run up still runs the script", async () => {
const scripts = { up: "echo SCRIPT_RAN" };
const dir = await stale(
{ dependencies: { "no-deps": "1.0.0" }, scripts },
{ dependencies: { "no-deps": "^1.0.0" }, scripts },
);
const up = await run(dir, "up");
expect(up.stdout).not.toContain("SCRIPT_RAN");
expect(up.exitCode).toBe(0);
await expectInSync(dir, { "no-deps": "^1.1.0" });
const script = await run(dir, "run", "up");
expect(script.stdout).toContain("SCRIPT_RAN");
expect(script.exitCode).toBe(0);
});
// The entries are declared out of alphabetical order, so a rewrite of the file would show up as a re-sort.
it.concurrent.each([[["no-deps"]], [["one-range-dep"]], [["no-deps", "one-range-dep"]]])(
"bun update %p that changes nothing leaves package.json and bun.lock byte-identical",
async names => {
const dir = await setup({ "one-range-dep": "1.0.0", "no-deps": "^1.1.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
const before = await snapshotFiles(dir);
expect(Object.keys(JSON.parse(before.packageJson).dependencies)).toStrictEqual(["one-range-dep", "no-deps"]);
const { stdout } = await update(dir, ...names);
expect(stdout).not.toContain("->");
expect(stdout).not.toContain("→");
await expectUnchanged(dir, before);
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
},
);
// one-range-dep and one-range-dep-too both depend on no-deps ^1.0.0; the root's exact pin parks their edges on 1.0.0, and dropping it leaves them there.
describe("scope of an update run from a workspace member", () => {
const ROOT = { name: "root", workspaces: ["packages/*"] };
const PINNED_ROOT = { ...ROOT, dependencies: { "no-deps": "1.0.0" } };
const member = (name: string, dependencies: Json = {}) => ({ name, version: "1.0.0", dependencies });
const DEPENDENTS = ["one-range-dep", "one-range-dep-too"] as const;
const FILES = ["", "packages/a", "packages/b"] as const;
// `widenedA` replaces a's dependencies once its pins are locked, so a's own direct dependencies can be stale as well.
async function staleRepo(a: Json, b: Json, widenedA?: Json) {
const dir = await createDir({
"package.json": PINNED_ROOT,
"packages/a/package.json": member("a", a),
"packages/b/package.json": member("b", b),
});
await install(dir);
await writeFile(join(dir, "package.json"), stringify(ROOT));
if (widenedA) await writeFile(join(dir, "packages", "a", "package.json"), stringify(member("a", widenedA)));
expect(await install(dir)).toContain("Saved lockfile");
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const texts = () => Promise.all(FILES.map(rel => packageJsonText(dir, rel)));
return { dir, texts, packageJsons: await texts(), lockBefore: await lockText(dir) };
}
// The no-deps version each dependent's edge resolves to: its nested row when it lost the hoisting race, else the hoisted one.
async function noDepsEdges(dir: string) {
const { packages } = await lock(dir);
const versionOf = (key: string) => (packages[key] as [string] | undefined)?.[0].slice("no-deps@".length);
return Object.fromEntries(
DEPENDENTS.filter(dependent => dependent in packages).map(dependent => [
dependent,
versionOf(`${dependent}/no-deps`) ?? versionOf("no-deps"),
]),
);
}
const distinctTransitives = () => staleRepo({ "one-range-dep": "1.0.0" }, { "one-range-dep-too": "1.0.0" });
// Once a's edge moves, b's edge accepts the new version too and is re-pointed so bun.lock keeps one copy; only a package a cannot reach at all stays put (below).
it.concurrent.each([
["from packages/a", "packages/a", []],
["with --filter a", "", ["--filter", "a"]],
["from the root", "", []],
["with -r", "", ["-r"]],
["with --filter '*'", "", ["--filter", "*"]],
])("bun update no-deps %s re-points every edge that accepts the new version", async (_, cwd, flags) => {
const { dir, texts, packageJsons } = await distinctTransitives();
const { stderr, exitCode } = await runFrom(join(dir, cwd), dir, "update", "no-deps", ...flags);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
expect(await noDepsEdges(dir)).toStrictEqual({ "one-range-dep": "1.1.0", "one-range-dep-too": "1.1.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await texts()).toStrictEqual(packageJsons);
await install(dir, "--frozen-lockfile");
});
// a -> a-dep (stale at 1.0.1, no path to no-deps); b -> one-range-dep -> no-deps (stale at 1.0.0).
async function unreachableFromA() {
const repo = await staleRepo({ "a-dep": "1.0.1" }, { "one-range-dep": "1.0.0" }, { "a-dep": "^1.0.1" });
expect(await lockedVersions(repo.dir, "a-dep")).toStrictEqual(["1.0.1"]);
return repo;
}
it.concurrent.each([
[
"from packages/a",
"packages/a",
["no-deps"],
'error: "no-deps" is not a dependency of this workspace\n bun update -r no-deps\n bun update --filter b no-deps\n',
],
[
"with --filter a",
"",
["no-deps", "--filter", "a"],
'error: "no-deps" is not a dependency of the selected workspaces\n bun update -r no-deps\n bun update --filter b no-deps\n',
],
["as a pattern from packages/a", "packages/a", ["no-*"], 'error: no packages in bun.lock match "no-*"\n'],
])(
"bun update naming a package the workspace has no path to (%s) is an error that writes nothing",
async (_, cwd, args, expected) => {
const { dir, texts, packageJsons, lockBefore } = await unreachableFromA();
const { stderr, exitCode } = await runFrom(join(dir, cwd), dir, "update", ...args);
expect(stderr).toBe(expected);
expect(await lockText(dir)).toBe(lockBefore);
expect(await texts()).toStrictEqual(packageJsons);
expect(exitCode).toBe(1);
},
);
it.concurrent.each([
["from packages/b", "packages/b", []],
["from the root", "", []],
["with --filter b", "", ["--filter", "b"]],
])(
"bun update no-deps %s moves the transitive row b reaches and leaves stale a-dep alone",
async (_, cwd, flags) => {
const { dir, texts, packageJsons } = await unreachableFromA();
const { stderr, exitCode } = await runFrom(join(dir, cwd), dir, "update", "no-deps", ...flags);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
expect(await noDepsEdges(dir)).toStrictEqual({ "one-range-dep": "1.1.0" });
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
expect(await texts()).toStrictEqual(packageJsons);
await install(dir, "--frozen-lockfile");
},
);
// a-dep is a's own entry, so it moves only when a is selected; no-deps is a nested row, so it moves when b (or the root, which reaches every nested row) is selected.
it.concurrent.each([
["from packages/a", "packages/a", [], "1.0.10", "1.0.0"],
["with --filter a", "", ["--filter", "a"], "1.0.10", "1.0.0"],
["from packages/b", "packages/b", [], "1.0.1", "1.1.0"],
["from the root", "", [], "1.0.1", "1.1.0"],
["with -r", "", ["-r"], "1.0.10", "1.1.0"],
])("a bare update %s moves a-dep to %s and no-deps to %s", async (_, cwd, flags, aDep, noDeps) => {
const { dir, texts, packageJsons } = await unreachableFromA();
const [rootBefore, aBefore, bBefore] = packageJsons;
const { stderr, exitCode } = await runFrom(join(dir, cwd), dir, "update", ...flags);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual([aDep]);
expect(await noDepsEdges(dir)).toStrictEqual({ "one-range-dep": noDeps });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual([noDeps]);
const aDeps = { "a-dep": `^${aDep}` };
expect((await packageJsonOf(dir, "packages/a")).dependencies).toStrictEqual(aDeps);
expect((await lock(dir)).workspaces["packages/a"].dependencies).toStrictEqual(aDeps);
const [rootAfter, aAfter, bAfter] = await texts();
expect([rootAfter, bAfter, aAfter === aBefore]).toStrictEqual([rootBefore, bBefore, aDep === "1.0.1"]);
await install(dir, "--frozen-lockfile");
});
});
describe("patterns", () => {
const TRIO_PINNED = { "no-deps": "1.0.0", "a-dep": "1.0.1", "dep-with-tags": "1.0.0" };
const TRIO_WIDENED = { "no-deps": "^1.0.0", "a-dep": "^1.0.1", "dep-with-tags": "^1.0.0" };
it.concurrent("bun update '@types/*' --latest updates the matching packages and nothing else", async () => {
const dir = await stale(
{ "@types/no-deps": "1.0.0", "@types/is-number": "1.0.0", "no-deps": "1.0.0" },
{ "@types/no-deps": "^1.0.0", "@types/is-number": "^1.0.0", "no-deps": "^1.0.0" },
);
await update(dir, "@types/*", "--latest");
await expectInSync(dir, { "@types/no-deps": "^2.0.0", "@types/is-number": "^2.0.0", "no-deps": "^1.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "@types/no-deps")).toBe("2.0.0");
expect(await installedVersion(dir, "@types/is-number")).toBe("2.0.0");
});
it.concurrent("a name and a pattern that only matches that name update it once", async () => {
const dir = await stale({ "@types/no-deps": "1.0.0" }, { "@types/no-deps": "^1.0.0" });
const { stdout } = await update(dir, "@types/no-deps", "@types/*", "--latest");
expect(stdout.match(/^.*@types\/no-deps.*$/gm)).toStrictEqual(["^ @types/no-deps 1.0.0 -> 2.0.0"]);
await expectInSync(dir, { "@types/no-deps": "^2.0.0" });
expect(await lockedVersions(dir, "@types/no-deps")).toStrictEqual(["2.0.0"]);
expect(await installedVersion(dir, "@types/no-deps")).toBe("2.0.0");
});
it.concurrent("two patterns update the union within their ranges", async () => {
const dir = await stale(TRIO_PINNED, TRIO_WIDENED);
await update(dir, "a-*", "dep-*");
await expectInSync(dir, { "no-deps": "^1.0.0", "a-dep": "^1.0.10", "dep-with-tags": "^1.0.1" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["1.0.1"]);
});
it.concurrent("--dry-run with a pattern writes nothing", async () => {
const dir = await stale(TRIO_PINNED, TRIO_WIDENED);
const before = await snapshotFiles(dir);
const { stderr } = await update(dir, "a-*", "--dry-run");
expect(stderr).not.toContain("Saved lockfile");
await expectUnchanged(dir, before);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.1");
});
it.concurrent.each([
[[], { "no-deps": "^1.0.0", "a-dep": "^1.0.10", "dep-with-tags": "^1.0.1" }],
[["--latest"], { "no-deps": "^1.0.0", "a-dep": "^1.0.10", "dep-with-tags": "^3.0.0" }],
])("a negation pattern updates everything except the match (flags: %p)", async (flags, expected) => {
const dir = await stale(TRIO_PINNED, TRIO_WIDENED);
await update(dir, "!no-deps", ...flags);
await expectInSync(dir, expected);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
});
it.concurrent("a positive pattern minus a negation updates the matched set minus the exclusion", async () => {
const dir = await stale(TRIO_PINNED, TRIO_WIDENED);
await update(dir, "*-*", "!a-dep");
await expectInSync(dir, { "no-deps": "^1.1.0", "a-dep": "^1.0.1", "dep-with-tags": "^1.0.1" });
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
});
it.concurrent.each([
["zzz-*", 'error: no packages in bun.lock match "zzz-*"'],
["@types/*@2", "error: a version cannot be combined with a pattern: @types/*@2"],
])("bun update %p is an error that writes nothing", async (arg, message) => {
const dir = await setup({ "no-deps": "~1.0.0" });
const before = await snapshotFiles(dir);
const { stderr, exitCode } = await run(dir, "update", arg);
expect(stderr).toContain(message);
expect(stderr).not.toContain("unrecognised dependency format");
await expectUnchanged(dir, before);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
expect(exitCode).toBe(1);
});
});
describe("group selectors", () => {
const THREE_GROUPS_PINNED = {
dependencies: { "no-deps": "1.0.0" },
devDependencies: { "a-dep": "1.0.1" },
optionalDependencies: { "dep-with-tags": "1.0.0" },
};
const THREE_GROUPS_WIDENED = {
dependencies: { "no-deps": "^1.0.0" },
devDependencies: { "a-dep": "^1.0.1" },
optionalDependencies: { "dep-with-tags": "^1.0.0" },
};
it.concurrent.each(["--dev", "-D"])("%s updates only devDependencies", async flag => {
const dir = await stale(THREE_GROUPS_PINNED, THREE_GROUPS_WIDENED);
await update(dir, flag);
await expectInSync(dir, {
dependencies: { "no-deps": "^1.0.0" },
devDependencies: { "a-dep": "^1.0.10" },
optionalDependencies: { "dep-with-tags": "^1.0.0" },
});
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
expect(await installedVersion(dir, "a-dep")).toBe("1.0.10");
});
it.concurrent.each(["--prod", "-P", "--production", "-p"])(
"%s updates dependencies and optionalDependencies and keeps devDependencies installed",
async flag => {
const dir = await stale(THREE_GROUPS_PINNED, THREE_GROUPS_WIDENED);
await update(dir, flag);
await expectInSync(dir, {
dependencies: { "no-deps": "^1.1.0" },
devDependencies: { "a-dep": "^1.0.1" },
optionalDependencies: { "dep-with-tags": "^1.0.1" },
});
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.1");
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
expect(await installedVersion(dir, "dep-with-tags")).toBe("1.0.1");
},
);
it.concurrent("--dev composes with --latest", async () => {
const dir = await setup({
dependencies: { "dep-with-tags": "~1.0.0" },
devDependencies: { "no-deps": "~1.0.0" },
});
await update(dir, "--dev", "--latest");
await expectInSync(dir, {
dependencies: { "dep-with-tags": "~1.0.0" },
devDependencies: { "no-deps": "~2.0.0" },
});
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["1.0.1"]);
});
it.concurrent("--dev with a name declared in another group updates nothing", async () => {
const dir = await stale(
{ dependencies: { "no-deps": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } },
{ dependencies: { "no-deps": "^1.0.0" }, devDependencies: { "a-dep": "^1.0.1" } },
);
const before = await snapshotFiles(dir);
const { stderr, exitCode } = await run(dir, "update", "no-deps", "--dev");
expect(stderr).toContain('error: no dependencies in the selected groups match "no-deps"');
await expectUnchanged(dir, before);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
expect(exitCode).toBe(1);
});
});
// Nothing is stale after `install` (no-deps ^ -> 1.1.0, ~ -> 1.0.1); what varies is which package.json files get rewritten.
describe("bun update <name> -r / --filter", () => {
const FILES: Record<string, Json | string> = {
"package.json": { name: "root", workspaces: ["packages/*"], dependencies: { "no-deps": "^1.0.0" } },
"packages/api/package.json": {
name: "api",
version: "1.0.0",
dependencies: { "no-deps": "^1.0.0", "a-dep": "^1.0.1", aliased: "npm:no-deps@~1.0.0" },
},
"packages/web/package.json": '{"name":"web","peerDependencies":{"no-deps":"~1.0.0"}}',
"packages/pkg-a/package.json": {
name: "pkg-a",
devDependencies: { "no-deps": "^1.0.0" },
dependencies: { api: "workspace:*" },
},
"packages/pkg-b/package.json": '{"name":"pkg-b"}',
};
const MEMBERS = ["", "packages/api", "packages/web", "packages/pkg-a", "packages/pkg-b"] as const;
type Texts = Record<(typeof MEMBERS)[number], string>;
async function texts(dir: string): Promise<Texts> {
const out = {} as Texts;
for (const rel of MEMBERS) out[rel] = await packageJsonText(dir, rel);
return out;
}
async function fanOut() {
const dir = await createDir(FILES);
await install(dir);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1", "1.1.0"]);
return { dir, before: await texts(dir), lockBefore: await lockText(dir) };
}
const API_UPDATED = { "no-deps": "^1.1.0", "a-dep": "^1.0.1", aliased: "npm:no-deps@~1.0.1" };
it.concurrent("--filter rewrites the selected workspace only, reaching an alias by its real name", async () => {
const { dir, before } = await fanOut();
await update(dir, "no-deps", "--filter", "api");
expect((await packageJsonOf(dir, "packages/api")).dependencies).toStrictEqual(API_UPDATED);
const after = await texts(dir);
expect(after).toStrictEqual({ ...before, "packages/api": after["packages/api"] });
const { workspaces } = await lock(dir);
expect(workspaces["packages/api"].dependencies).toStrictEqual(API_UPDATED);
expect(workspaces[""].dependencies).toStrictEqual({ "no-deps": "^1.0.0" });
await install(dir, "--frozen-lockfile");
});
it.concurrent.each([[["-r"]], [["--filter", "*"]]])(
"%p rewrites every workspace declaring the name, in whichever group, and leaves the rest byte-identical",
async flags => {
const { dir, before } = await fanOut();
await update(dir, "no-deps", ...flags);
expect((await packageJsonOf(dir)).dependencies).toStrictEqual({ "no-deps": "^1.1.0" });
expect((await packageJsonOf(dir, "packages/api")).dependencies).toStrictEqual(API_UPDATED);
expect(await packageJsonOf(dir, "packages/web")).toStrictEqual({
name: "web",
peerDependencies: { "no-deps": "~1.0.1" },
});
expect(await packageJsonOf(dir, "packages/pkg-a")).toStrictEqual({
name: "pkg-a",
devDependencies: { "no-deps": "^1.1.0" },
dependencies: { api: "workspace:*" },
});
expect(await packageJsonText(dir, "packages/pkg-b")).toBe(before["packages/pkg-b"]);
const { workspaces } = await lock(dir);
expect(workspaces[""].dependencies).toStrictEqual({ "no-deps": "^1.1.0" });
expect(workspaces["packages/api"].dependencies).toStrictEqual(API_UPDATED);
expect(workspaces["packages/web"].peerDependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect(workspaces["packages/pkg-a"].devDependencies).toStrictEqual({ "no-deps": "^1.1.0" });
await install(dir, "--frozen-lockfile");
},
);
it.concurrent(
"--latest with two filters leaves unselected workspaces' ranges in package.json and bun.lock",
async () => {
const { dir, before } = await fanOut();
await update(dir, "no-deps", "--latest", "--filter", "api", "--filter", "pkg-a");
expect((await packageJsonOf(dir, "packages/api")).dependencies).toStrictEqual({
"no-deps": "^2.0.0",
"a-dep": "^1.0.1",
aliased: "npm:no-deps@~2.0.0",
});
expect((await packageJsonOf(dir, "packages/pkg-a")).devDependencies).toStrictEqual({ "no-deps": "^2.0.0" });
expect(await packageJsonText(dir)).toBe(before[""]);
expect(await packageJsonText(dir, "packages/web")).toBe(before["packages/web"]);
expect(await packageJsonText(dir, "packages/pkg-b")).toBe(before["packages/pkg-b"]);
const { workspaces } = await lock(dir);
expect(workspaces[""].dependencies).toStrictEqual({ "no-deps": "^1.0.0" });
expect(workspaces["packages/web"].peerDependencies).toStrictEqual({ "no-deps": "~1.0.0" });
// 1.0.1 was only ever placed by api's alias row; web's peer entry is satisfied by whatever is hoisted.
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0", "2.0.0"]);
await install(dir, "--frozen-lockfile");
},
);
it.concurrent("several names never add a name to a workspace that does not declare it", async () => {
const { dir, before } = await fanOut();
await update(dir, "no-deps", "a-dep", "--filter", "api", "--filter", "web");
expect((await packageJsonOf(dir, "packages/api")).dependencies).toStrictEqual({
...API_UPDATED,
"a-dep": "^1.0.10",
});
expect(await packageJsonOf(dir, "packages/web")).toStrictEqual({
name: "web",
peerDependencies: { "no-deps": "~1.0.1" },
});
const after = await texts(dir);
expect(after).toStrictEqual({
...before,
"packages/api": after["packages/api"],
"packages/web": after["packages/web"],
});
expect(JSON.stringify((await lock(dir)).workspaces["packages/web"])).not.toContain("a-dep");
await install(dir, "--frozen-lockfile");
});
it.concurrent.each([
[
["no-deps", "--filter", "pkg-b"],
'error: "no-deps" is not a dependency of the selected workspaces\n bun update -r no-deps\n bun update --filter root no-deps\n bun update --filter api no-deps\n bun update --filter pkg-a no-deps\n bun update --filter web no-deps\n',
],
[["is-number", "--filter", "api"], 'error: "is-number" is not in bun.lock\n bun add is-number\n'],
[["no-deps", "--filter", "nope"], 'error: No workspace packages matched the filter "nope"\n'],
])("bun update %p is an error that writes nothing", async (args, expected) => {
const { dir, before, lockBefore } = await fanOut();
const { stderr, exitCode } = await run(dir, "update", ...args);
expect(stderr).toBe(expected);
expect(await texts(dir)).toStrictEqual(before);
expect(await lockText(dir)).toBe(lockBefore);
expect(exitCode).toBe(1);
});
it.concurrent("a pattern with one unmatched filter warns exactly once", async () => {
const { dir } = await fanOut();
const { stderr, exitCode } = await run(dir, "update", "no-*", "--filter", "api", "--filter", "nope");
const warnings = stderr.split("\n").filter(line => line.includes("No workspace packages matched"));
expect(warnings).toStrictEqual(['warn: No workspace packages matched the filter "nope"']);
expect(exitCode).toBe(0);
});
it.concurrent("--dry-run writes nothing", async () => {
const { dir, before, lockBefore } = await fanOut();
const { stderr } = await update(dir, "no-deps", "--filter", "api", "--dry-run");
expect(stderr).not.toContain("Saved lockfile");
expect(await texts(dir)).toStrictEqual(before);
expect(await lockText(dir)).toBe(lockBefore);
});
it.concurrent("--filter decides the target, not the cwd", async () => {
const { dir, before } = await fanOut();
const { stderr, exitCode } = await runFrom(
join(dir, "packages", "web"),
dir,
"update",
"no-deps",
"--filter",
"api",
);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
expect((await packageJsonOf(dir, "packages/api")).dependencies).toStrictEqual(API_UPDATED);
expect(await packageJsonText(dir, "packages/web")).toBe(before["packages/web"]);
expect(await packageJsonText(dir)).toBe(before[""]);
});
// Like an unfiltered `bun update <name>`, a named update never rewrites a `catalog:` reference or the catalog entry behind it (only a bare update moves the catalog).
it.concurrent("a catalog reference keeps the member's literal and the root catalog entry", async () => {
const dir = await createDir({
"package.json": { name: "root", workspaces: { packages: ["packages/*"], catalog: { "no-deps": "^1.0.0" } } },
"packages/api/package.json": { name: "api", dependencies: { "no-deps": "catalog:" } },
"packages/web/package.json": '{"name":"web","dependencies":{"no-deps":"catalog:"}}',
});
await install(dir);
const [rootBefore, apiBefore, webBefore] = await Promise.all([
packageJsonText(dir),
packageJsonText(dir, "packages/api"),
packageJsonText(dir, "packages/web"),
]);
await update(dir, "no-deps", "--latest", "--filter", "api");
expect(await packageJsonText(dir, "packages/api")).toBe(apiBefore);
expect(await packageJsonText(dir)).toBe(rootBefore);
expect(await packageJsonText(dir, "packages/web")).toBe(webBefore);
expect((await lock(dir)).catalog).toStrictEqual({ "no-deps": "^1.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
await install(dir, "--frozen-lockfile");
});
// root -> app -> lib -> util; tool -> lib (devDependency); lone has no workspace edges.
it.concurrent("an unnamed update accepts relation selectors", async () => {
const dir = await createDir({
"package.json": {
name: "root",
workspaces: ["packages/*"],
dependencies: { app: "workspace:*", "dep-with-tags": "1.0.0" },
},
"packages/app/package.json": {
name: "app",
version: "1.0.0",
dependencies: { lib: "workspace:*", "is-number": "1.0.0" },
},
"packages/lib/package.json": {
name: "lib",
version: "1.0.0",
dependencies: { util: "workspace:*", "no-deps": "1.0.0" },
},
"packages/util/package.json": { name: "util", version: "1.0.0", dependencies: { "a-dep": "1.0.1" } },
"packages/tool/package.json": {
name: "tool",
version: "1.0.0",
devDependencies: { lib: "workspace:*" },
dependencies: { "@types/is-number": "1.0.0" },
},
"packages/lone/package.json": { name: "lone", version: "1.0.0", dependencies: { "@types/no-deps": "1.0.0" } },
});
await install(dir);
const untouched = ["", "packages/app", "packages/tool", "packages/lone"];
const before = await Promise.all(untouched.map(rel => packageJsonText(dir, rel)));
await update(dir, "--latest", "--filter", "lib...");
const libDeps = { util: "workspace:*", "no-deps": "2.0.0" };
const utilDeps = { "a-dep": "1.0.10" };
expect((await packageJsonOf(dir, "packages/lib")).dependencies).toStrictEqual(libDeps);
expect((await packageJsonOf(dir, "packages/util")).dependencies).toStrictEqual(utilDeps);
expect(await Promise.all(untouched.map(rel => packageJsonText(dir, rel)))).toStrictEqual(before);
const { workspaces } = await lock(dir);
expect(workspaces["packages/lib"].dependencies).toStrictEqual(libDeps);
expect(workspaces["packages/util"].dependencies).toStrictEqual(utilDeps);
await install(dir, "--frozen-lockfile");
});
// Installed once, then every node_modules is removed and a stale entry is planted in web's, so what an update links back is observable.
async function linkRepo(linker: "hoisted" | "isolated") {
const dir = await createDir({
"package.json": { name: "root", workspaces: ["packages/*"], dependencies: { "a-dep": "^1.0.1" } },
"packages/api/package.json": { name: "api", dependencies: { "no-deps": "^1.0.0" } },
"packages/web/package.json": { name: "web", dependencies: { "is-number": "1.0.0" } },
});
await install(dir, `--linker=${linker}`);
await rm(join(dir, "node_modules"), { recursive: true, force: true });
if (linker === "isolated") {
await rm(join(dir, "packages", "api", "node_modules"), { recursive: true, force: true });
await rm(join(dir, "packages", "web", "node_modules"), { recursive: true, force: true });
}
await mkdir(join(dir, "packages", "web", "node_modules", "stale"), { recursive: true });
await writeFile(join(dir, "packages", "web", "node_modules", "stale", "package.json"), '{"name":"stale"}');
return dir;
}
const installed = (dir: string, rels: string[]) => Promise.all(rels.map(rel => exists(join(dir, rel))));
const HOISTED_LINK_PATHS = [
"node_modules/api",
"node_modules/no-deps/package.json",
"node_modules/web",
"node_modules/is-number",
"node_modules/a-dep",
"packages/web/node_modules/stale/package.json",
];
it.concurrent("a named --filter update links only the selected workspace (hoisted)", async () => {
const dir = await linkRepo("hoisted");
await update(dir, "no-deps", "--filter", "api", "--linker=hoisted");
expect(await installed(dir, HOISTED_LINK_PATHS)).toStrictEqual([true, true, false, false, false, true]);
await install(dir, "--linker=hoisted");
expect(await installed(dir, ["node_modules/is-number", "node_modules/a-dep"])).toStrictEqual([true, true]);
});
it.concurrent("a named --filter update links only the selected workspace (isolated)", async () => {
const dir = await linkRepo("isolated");
await update(dir, "no-deps", "--filter", "api", "--linker=isolated");
expect(
await installed(dir, [
"packages/api/node_modules/no-deps/package.json",
"packages/web/node_modules/is-number",
"packages/web/node_modules/stale/package.json",
"node_modules/web",
]),
).toStrictEqual([true, false, true, false]);
await install(dir, "--linker=isolated");
expect(await installed(dir, ["packages/web/node_modules/is-number/package.json"])).toStrictEqual([true]);
});
it.concurrent("an unnamed --filter update links only the selected workspace", async () => {
const dir = await linkRepo("hoisted");
await update(dir, "--filter", "api", "--linker=hoisted");
expect(await installed(dir, HOISTED_LINK_PATHS)).toStrictEqual([true, true, false, false, false, true]);
});
it.concurrent("an unnamed --filter update links only the selected workspace (isolated)", async () => {
const dir = await linkRepo("isolated");
await update(dir, "--filter", "api", "--linker=isolated");
expect(
await installed(dir, [
"packages/api/node_modules/no-deps/package.json",
"packages/web/node_modules/is-number",
"packages/web/node_modules/stale/package.json",
"node_modules/web",
]),
).toStrictEqual([true, false, true, false]);
await install(dir, "--linker=isolated");
expect(await installed(dir, ["packages/web/node_modules/is-number/package.json"])).toStrictEqual([true]);
});
const TABBED = '{\n\t"name": "tabbed",\n\t"dependencies": {\n\t\t"no-deps": "~1.0.0"\n\t}\n}\n';
const SPACED = '{\n "name": "spaced",\n "devDependencies": {\n "no-deps": "^1.0.0"\n }\n}';
it.concurrent("a bare -r --latest keeps each member's indentation and trailing newline", async () => {
const dir = await createDir({
"package.json": { name: "root", workspaces: ["packages/*"], dependencies: { "a-dep": "~1.0.1" } },
"packages/tabbed/package.json": TABBED,
"packages/spaced/package.json": SPACED,
});
await install(dir);
await update(dir, "-r", "--latest");
expect(await packageJsonText(dir, "packages/tabbed")).toBe(TABBED.replace("~1.0.0", "~2.0.0"));
expect(await packageJsonText(dir, "packages/spaced")).toBe(SPACED.replace("^1.0.0", "^2.0.0"));
expect(await packageJsonText(dir)).toBe(
stringify({ name: "root", workspaces: ["packages/*"], dependencies: { "a-dep": "~1.0.10" } }),
);
const { workspaces } = await lock(dir);
expect(workspaces["packages/tabbed"].dependencies).toStrictEqual({ "no-deps": "~2.0.0" });
expect(workspaces["packages/spaced"].devDependencies).toStrictEqual({ "no-deps": "^2.0.0" });
expect(workspaces[""].dependencies).toStrictEqual({ "a-dep": "~1.0.10" });
await install(dir, "--frozen-lockfile");
});
// dep-with-tags 3.0.1 is published above its `latest` tag (3.0.0).
it.concurrent.each([[""], ["packages/pinned"]])(
"-r --latest run from %p does not downgrade a member pinned ahead of `latest` while moving the others",
async cwd => {
const dir = await createDir({
"package.json": { name: "root", workspaces: ["packages/*"] },
"packages/pinned/package.json": { name: "pinned", dependencies: { "dep-with-tags": "3.0.1" } },
"packages/stale/package.json": { name: "stale", dependencies: { "no-deps": "^1.0.0" } },
});
await install(dir);
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["3.0.1"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
const rootText = await packageJsonText(dir);
const pinnedText = await packageJsonText(dir, "packages/pinned");
const { stdout, stderr, exitCode } = await runFrom(join(dir, cwd), dir, "update", "-r", "--latest");
expect(stderr).not.toContain("error:");
expect(stdout).not.toContain("[email protected]");
expect(await packageJsonText(dir)).toBe(rootText);
expect(await packageJsonText(dir, "packages/pinned")).toBe(pinnedText);
expect((await packageJsonOf(dir, "packages/stale")).dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
const { workspaces } = await lock(dir);
expect(workspaces["packages/pinned"].dependencies).toStrictEqual({ "dep-with-tags": "3.0.1" });
expect(workspaces["packages/stale"].dependencies).toStrictEqual({ "no-deps": "^2.0.0" });
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["3.0.1"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
expect(await installedVersion(dir, "dep-with-tags")).toBe("3.0.1");
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
await install(dir, "--frozen-lockfile");
expect(exitCode).toBe(0);
},
);
it.concurrent.each([[["-r"]], [["--filter", "api"]]])(
"a bare update %p without a bun.lock is an error that writes nothing",
async flags => {
const dir = await createDir(FILES);
const before = await texts(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", ...flags);
expect(stderr).toContain("error: missing lockfile, nothing to update");
expect(stdout).not.toContain("installed");
expect(await texts(dir)).toStrictEqual(before);
expect(await installed(dir, ["bun.lock", "node_modules", "packages/api/node_modules"])).toStrictEqual([
false,
false,
false,
]);
expect(exitCode).toBe(1);
},
);
it.concurrent.each([[["-r"]], [["--filter", "*"]]])(
"a named update with %p still links every workspace and the root",
async flags => {
const dir = await linkRepo("hoisted");
await update(dir, "no-deps", ...flags, "--linker=hoisted");
expect(
await installed(dir, [
"node_modules/api",
"node_modules/web",
"node_modules/no-deps",
"node_modules/is-number",
"node_modules/a-dep",
]),
).toStrictEqual([true, true, true, true, true]);
},
);
});
// The global dir (<dir>/.global/install/global) is a sibling of the cwd project: a first `-g` install into a global dir nested under a project walks up to that project's package.json.
describe("--global", () => {
const PROJECT = { name: "project", dependencies: { "no-deps": "^1.0.0" } };
const GLOBAL_PINNED = { "no-deps": "1.0.0", "a-dep": "1.0.1" };
const GLOBAL_WIDENED = { "no-deps": "^1.0.0", "a-dep": "^1.0.1" };
async function globalRepo() {
const dir = await createDir({ "project/package.json": PROJECT });
const project = join(dir, "project");
const globalDir = join(dir, ".global", "install", "global");
const runGlobal = async (...args: string[]) => {
await using proc = spawn({
cmd: [bunExe(), ...args, "-g", `--config=${join(dir, "bunfig.toml")}`],
cwd: project,
env: { ...envFor(dir), BUN_INSTALL: join(dir, ".global") },
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 };
};
const added = await runGlobal("add", "[email protected]", "[email protected]");
expect(added.stderr).not.toContain("error:");
expect(added.exitCode).toBe(0);
const globalJson = await packageJsonOf(globalDir);
expect(globalJson.dependencies).toStrictEqual(GLOBAL_PINNED);
await writeFile(join(globalDir, "package.json"), stringify({ ...globalJson, dependencies: GLOBAL_WIDENED }));
const projectBefore = await packageJsonText(project);
return { project, globalDir, runGlobal, projectBefore };
}
async function expectGlobalInSync(globalDir: string, dependencies: Json) {
expect((await packageJsonOf(globalDir)).dependencies).toStrictEqual(dependencies);
expect((await lock(globalDir)).workspaces[""].dependencies).toStrictEqual(dependencies);
}
async function expectProjectUntouched(project: string, projectBefore: string) {
expect(await packageJsonText(project)).toBe(projectBefore);
expect(
await Promise.all([exists(join(project, "bun.lock")), exists(join(project, "node_modules"))]),
).toStrictEqual([false, false]);
}
it.concurrent.each([
[[], { "no-deps": "^1.1.0", "a-dep": "^1.0.10" }, "1.1.0", "1.0.10"],
[["no-deps"], { "no-deps": "^1.1.0", "a-dep": "^1.0.1" }, "1.1.0", "1.0.1"],
[["--latest"], { "no-deps": "^2.0.0", "a-dep": "^1.0.10" }, "2.0.0", "1.0.10"],
])("bun update -g %p rewrites the global package.json and bun.lock only", async (args, expected, noDeps, aDep) => {
const { project, globalDir, runGlobal, projectBefore } = await globalRepo();
const { stderr, exitCode } = await runGlobal("update", ...args);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
await expectGlobalInSync(globalDir, expected);
expect(await lockedVersions(globalDir, "no-deps")).toStrictEqual([noDeps]);
expect(await lockedVersions(globalDir, "a-dep")).toStrictEqual([aDep]);
expect(await installedVersion(globalDir, "no-deps")).toBe(noDeps);
expect(await installedVersion(globalDir, "a-dep")).toBe(aDep);
await expectProjectUntouched(project, projectBefore);
});
it.concurrent.each([
[["-r"], "--recursive"],
[["--filter", "*"], "--filter"],
])("bun update <name> %p -g is an error that writes nothing", async (flags, flag) => {
const { project, globalDir, runGlobal, projectBefore } = await globalRepo();
const globalBefore = await snapshotFiles(globalDir);
const { stderr, exitCode } = await runGlobal("update", "no-deps", ...flags);
expect(stderr).toContain(`error: ${flag} cannot be used with --global`);
await expectUnchanged(globalDir, globalBefore);
expect(await installedVersion(globalDir, "no-deps")).toBe("1.0.0");
await expectProjectUntouched(project, projectBefore);
expect(exitCode).toBe(1);
});
});
});