Files
bun-src/test/cli/install/bun-update-transitive.test.ts
T

2468 lines
115 KiB
TypeScript
Raw Normal View History

2026-08-27 21:09:14 +00:00
import { file, write } from "bun";
import { afterAll, beforeAll, expect, test } from "bun:test";
import { exists } from "fs/promises";
import { VerdaccioRegistry, bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
// Registry: no-deps 1.0.0/1.0.1/1.1.0/2.0.0, a-dep 1.0.1..1.0.10, @types/no-deps 1.0.0/2.0.0, [email protected] -> no-deps ^1.0.0, [email protected] -> no-deps 1.0.0, dep-with-tags latest=3.0.0, pre-2=2.0.1, 3.0.1 published above latest.
const registry = new VerdaccioRegistry();
beforeAll(async () => {
await registry.start();
});
afterAll(() => {
registry.stop();
});
type Json = Record<string, any>;
type Linker = "hoisted" | "isolated";
type Layout = { text?: boolean; linker?: Linker };
const pkgJson = (dependencies: Json, extra: Json = {}) => ({ name: "foo", dependencies, ...extra });
const stringify = (json: Json) => JSON.stringify(json, null, 2) + "\n";
const linkerArgs = (layout: Layout) => ["--linker", layout.linker ?? "hoisted"];
async function runIn(dir: string, rel: string, ...args: string[]) {
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
cwd: join(dir, rel),
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") },
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 run = (dir: string, ...args: string[]) => runIn(dir, "", ...args);
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;
}
const frozen = (dir: string, layout: Layout = {}) => install(dir, "--frozen-lockfile", ...linkerArgs(layout));
async function setup(files: Record<string, Json | string>, layout: Layout = {}) {
const { packageDir } = await registry.createTestDir({
bunfigOpts: { saveTextLockfile: layout.text ?? true, linker: layout.linker ?? "hoisted" },
files: Object.fromEntries(
Object.entries(files).map(([path, json]) => [path, typeof json === "string" ? json : stringify(json)]),
),
});
await install(packageDir, ...linkerArgs(layout));
return packageDir;
}
async function reinstall(dir: string, packageJson: Json, layout: Layout = {}, rel = "") {
await write(join(dir, rel, "package.json"), stringify(packageJson));
expect(await install(dir, ...linkerArgs(layout))).toContain("Saved lockfile");
}
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;
// Every moved package, transitive, named or bare, is one `^ name old -> new` row (piped form) in the summary block, whether it was installed, planned (--dry-run) or only saved (--lockfile-only); a row carries ` (vX available)` when a newer version is out of range.
const movedRows = (stdout: string) =>
(stdout.match(/^\^ .+$/gm) ?? []).map(row => row.replace(/ \(v\S+ available\)$/, ""));
const movedRow = (name: string, from: string, to: string, available?: string) =>
`^ ${name} ${from} -> ${to}${available ? ` (v${available} available)` : ""}`;
const NO_DEPS_ROW = movedRow("no-deps", "1.0.0", "1.1.0");
const NO_DEPS_ROW_HINTED = movedRow("no-deps", "1.0.0", "1.1.0", "2.0.0");
const A_DEP_ROW = movedRow("a-dep", "1.0.1", "1.0.10");
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`;
const installed = (n: number) => `${plural(n, "package")} installed [duration]`;
const wouldUpdate = (n: number) => `${plural(n, "package")} would be updated [duration]`;
const noChanges = (installs: number, packages: number) =>
`Checked ${plural(installs, "install")} across ${plural(packages, "package")} (no changes) [duration]`;
const nothingToUpdate = (packages: number) => `Checked ${plural(packages, "package")}, nothing to update [duration]`;
const noneSelected = (dependencies: number, by: string) =>
`Checked ${dependencies} ${dependencies === 1 ? "dependency" : "dependencies"}, none ${by} (no changes) [duration]`;
const keptPatched = (name: string, version: string, available: string) =>
`kept ${name}@${version} (patched, v${available} available)`;
// The header's version and the `[12.00ms]` durations are the only unstable parts of update's stdout.
const normalize = (stdout: string) =>
stdout.replace(/^bun update v.*$/m, "bun update <version>").replace(/\[[\d.]+m?s\]/g, "[duration]");
const summary = (...lines: string[]) => ["bun update <version>", "", ...lines, ""].join("\n");
function expectSummary(stdout: string, ...lines: string[]) {
expect(normalize(stdout)).toBe(summary(...lines));
}
// Several rows in one real run: the rows are asserted as a list and the count line by itself.
function expectRowsAnd(stdout: string, rows: string[], countLine: string) {
expect(movedRows(stdout)).toStrictEqual(rows);
expect(normalize(stdout)).toEndWith(`\n\n${countLine}\n`);
expect(stdout).not.toMatch(/^installed /m);
}
function expectMoved(stdout: string, name: string, from: string, to: string) {
expect(movedRows(stdout)).toContain(movedRow(name, from, to));
expect(stdout).not.toMatch(/^installed /m);
}
function expectNoMoves(stdout: string) {
expect(movedRows(stdout)).toStrictEqual([]);
expect(stdout).not.toMatch(/^installed /m);
}
// A no-op prints what it checked and how long it took, and nothing else.
function expectNoChangesLine(stdout: string) {
expectNoMoves(stdout);
expect(normalize(stdout)).toMatch(/^Checked \d+ installs? across \d+ packages? \(no changes\) \[duration\]$/m);
expect(stdout).not.toContain(" done");
}
function expectDryRun(stdout: string, ...rows: string[]) {
expectSummary(stdout, ...rows, "", wouldUpdate(rows.length));
}
const NOT_IN_LOCKFILE = (name: string, lockfile = "bun.lock") =>
`error: "${name}" is not in ${lockfile}\n bun add ${name}\n`;
// `scope` is "the selected workspaces" once -r/--filter picked the scope, "this workspace" when the cwd did; one --filter hint per other workspace that reaches the name.
const notADependencyOf = (scope: string, name: string, ...workspaces: string[]) =>
[
`error: "${name}" is not a dependency of ${scope}`,
` bun update -r ${name}`,
...workspaces.map(workspace => ` bun update --filter ${workspace} ${name}`),
"",
].join("\n");
const NOT_A_DEPENDENCY_HERE = (name: string, ...workspaces: string[]) =>
notADependencyOf("this workspace", name, ...workspaces);
const NOT_A_DEPENDENCY_OF_SELECTION = (name: string, ...workspaces: string[]) =>
notADependencyOf("the selected workspaces", name, ...workspaces);
const notCheckedWarning = (server: Bun.Server, name: string, version: string, status: number) =>
`warn: ${name}@${version} was not checked for updates: GET ${server.url.origin}/${name} - ${status}\n`;
// The manifest-cache progress bar must never leak its `Resolving... ` fragment into a piped stderr.
function expectCleanStderr(stderr: string) {
expect(stderr).not.toContain("error:");
expect(stderr).not.toContain("Resolving...");
expect(stderr).not.toContain("warn:");
}
// Every version of `name` resolved anywhere in bun.lock, rows installed under an alias included.
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();
}
async function installedVersion(dir: string, ...segments: string[]) {
return (await file(join(dir, "node_modules", ...segments, "package.json")).json()).version;
}
const noDepsPath = (linker: Linker = "hoisted") =>
linker === "isolated" ? [".bun", "[email protected]", "node_modules", "no-deps"] : ["no-deps"];
// Dropping the root's exact [email protected] leaves one-range-dep's `^1.0.0` edge on 1.0.0, which `bun install` never moves.
async function stale(layout: Layout = {}) {
const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" }) }, layout);
const packageJson = pkgJson({ "one-range-dep": "1.0.0" });
await reinstall(dir, packageJson, layout);
const noDeps = noDepsPath(layout.linker);
if (layout.text ?? true) {
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
}
expect(await installedVersion(dir, ...noDeps)).toBe("1.0.0");
return { dir, packageJson, noDeps };
}
async function expectTransitiveBump(
{ dir, packageJson, noDeps }: Awaited<ReturnType<typeof stale>>,
layout: Layout,
...args: string[]
) {
const { stdout, stderr, exitCode } = await run(dir, "update", ...args, ...linkerArgs(layout));
expectCleanStderr(stderr);
expect(stderr).toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await installedVersion(dir, ...noDeps)).toBe("1.1.0");
if (layout.text ?? true) {
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "one-range-dep")).toStrictEqual(["1.0.0"]);
}
await frozen(dir, layout);
expect(exitCode).toBe(0);
return stdout;
}
type Groups = Record<string, "dependencies" | "devDependencies" | "optionalDependencies">;
function grouped(versions: Json, groups: Groups) {
const json: Json = { name: "foo" };
for (const [name, version] of Object.entries(versions)) (json[groups[name] ?? "dependencies"] ??= {})[name] = version;
return json;
}
// Exact pins widened to ranges after the install: both entries stay locked below the newest version their range allows.
async function staleSiblings(groups: Groups = {}) {
const dir = await setup({ "package.json": grouped({ "no-deps": "1.0.0", "a-dep": "1.0.1" }, groups) });
const packageJson = grouped({ "no-deps": "^1.0.0", "a-dep": "^1.0.1" }, groups);
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
return { dir, packageJson, groups };
}
// The named path prints the same rows as a bare update: the entry that moved gets a `^` row, the one that did not prints nothing and is not counted.
async function expectOnlyADepMoved({ dir, groups }: Awaited<ReturnType<typeof staleSiblings>>, ...args: string[]) {
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
expectSummary(stdout, A_DEP_ROW, "", installed(1));
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(grouped({ "no-deps": "^1.0.0", "a-dep": "^1.0.10" }, groups));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
await frozen(dir);
expect(exitCode).toBe(0);
}
async function expectRejected(dir: string, message: string, ...args: string[]) {
const packageJsonBefore = await packageJsonText(dir);
const lockBefore = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
expect(stderr).toContain(message);
expect(await packageJsonText(dir)).toBe(packageJsonBefore);
expect(await lockText(dir)).toBe(lockBefore);
expect(exitCode).toBe(1);
return { stdout, stderr };
}
async function expectNothingToUpdate(dir: string, line: string, ...args: string[]) {
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
expectSummary(stdout, line);
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(before);
expect(exitCode).toBe(0);
}
const ROOT = { name: "root", workspaces: ["packages/*"] };
const member = (name: string, dependencies: Json = {}) => ({ name, version: "1.0.0", dependencies });
test.concurrent.each<[string, Layout]>([
["text lockfile", { text: true }],
["binary lockfile", { text: false }],
])("`bun update` moves a transitive dependency within its dependent's range (%s)", async (_, layout) => {
const fixture = await stale(layout);
const stdout = await expectTransitiveBump(fixture, layout);
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1));
});
// The isolated linker re-links one-range-dep's store entry onto the moved no-deps, and a re-linked entry counts as installed like a fresh one.
test.concurrent(
"`bun update` moves a transitive dependency within its dependent's range (isolated linker)",
async () => {
const layout: Layout = { text: true, linker: "isolated" };
const fixture = await stale(layout);
const stdout = await expectTransitiveBump(fixture, layout);
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", "+ [email protected]", "", installed(2));
},
);
test.concurrent("`bun update --latest` still moves transitive dependencies only within their ranges", async () => {
const fixture = await stale();
const stdout = await expectTransitiveBump(fixture, {}, "--latest");
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1));
});
// The move is reported the same way whether the name was given or found, and naming it again is the same no-op as a bare rerun.
test.concurrent.each([
["no-deps", ["no-deps"]],
["--latest no-deps", ["--latest", "no-deps"]],
])("`bun update %s` reaches a package that is only a transitive dependency", async (_, args) => {
const fixture = await stale();
const stdout = await expectTransitiveBump(fixture, {}, ...args);
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1));
const again = await run(fixture.dir, "update", ...args);
expectSummary(again.stdout, noChanges(2, 3));
expectCleanStderr(again.stderr);
expect(again.stderr).not.toContain("Saved lockfile");
expect(again.exitCode).toBe(0);
});
// one-range-dep is named too but has nothing newer: no row, and it is not counted.
test.concurrent.each([
["a pattern", ["no-*"]],
["a bare `*`", ["*"]],
["a negated name", ["!one-range-dep"]],
["a pattern alongside a direct name", ["no-d*", "one-range-dep"]],
])("`bun update` with %s reaches a package that is only a transitive dependency", async (_, args) => {
const fixture = await stale();
const stdout = await expectTransitiveBump(fixture, {}, ...args);
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1));
});
test.concurrent(
"`bun update <name>` naming a package with nothing newer is the same no-op as a bare rerun",
async () => {
const { dir, packageJson } = await stale();
const before = await lock(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "one-range-dep");
expectSummary(stdout, noChanges(2, 3));
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lock(dir)).toStrictEqual(before);
expect(exitCode).toBe(0);
},
);
// Both errors name the lockfile that was actually loaded; the remedy is a bare command line, not a note.
test.concurrent.each<[string, Layout]>([
["bun.lock", { text: true }],
["bun.lockb", { text: false }],
])("`bun update <name>` and a pattern that match nothing in %s name that file", async (lockfile, layout) => {
const { dir, packageJson } = await stale(layout);
const before = await file(join(dir, lockfile)).bytes();
const named = await run(dir, "update", "does-not-exist", ...linkerArgs(layout));
expect(named.stderr).toContain(NOT_IN_LOCKFILE("does-not-exist", lockfile));
expect(named.stderr).not.toContain("note:");
expect(named.stderr).not.toContain("nothing to update");
expect(named.stdout).not.toContain("installed");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await file(join(dir, lockfile)).bytes()).toStrictEqual(before);
expect(named.exitCode).toBe(1);
const pattern = await run(dir, "update", "zzz-*", ...linkerArgs(layout));
expect(pattern.stderr).toContain(`error: no packages in ${lockfile} match "zzz-*"\n`);
expect(pattern.stderr).not.toContain("note:");
expect(await file(join(dir, lockfile)).bytes()).toStrictEqual(before);
expect(pattern.exitCode).toBe(1);
});
async function withoutLockfile(dependencies: Json) {
const { packageDir } = await registry.createTestDir({
bunfigOpts: { saveTextLockfile: true, linker: "hoisted" },
files: { "package.json": stringify(pkgJson(dependencies)) },
});
return packageDir;
}
test.concurrent("without a lockfile, `bun update <declared>` resolves and saves", async () => {
const dir = await withoutLockfile({ "no-deps": "^1.0.0" });
const { stderr, exitCode } = await run(dir, "update", "no-deps");
expect(stderr).not.toContain("error:");
expect(stderr).toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "^1.1.0" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("without a lockfile, `bun update <undeclared>` is rejected and writes no lockfile", async () => {
const dir = await withoutLockfile({ "no-deps": "^1.0.0" });
const { stderr, exitCode } = await run(dir, "update", "a-dep");
expect(stderr).toContain(NOT_IN_LOCKFILE("a-dep"));
expect(stderr).not.toContain("note:");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "^1.0.0" }));
expect(await file(join(dir, "bun.lock")).exists()).toBeFalse();
expect(exitCode).toBe(1);
});
test.concurrent("`bun update --lockfile-only` moves the transitive dependency in bun.lock only", async () => {
const { dir, packageJson, noDeps } = await stale();
const { stdout, stderr, exitCode } = await run(dir, "update", "--lockfile-only");
expect(movedRows(stdout)).toStrictEqual([NO_DEPS_ROW]);
expect(stdout).not.toContain("would be updated");
expect(stdout).toContain("Saved bun.lock (");
expectCleanStderr(stderr);
expect(stderr).toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await installedVersion(dir, ...noDeps)).toBe("1.0.0");
await frozen(dir);
expect(await installedVersion(dir, ...noDeps)).toBe("1.1.0");
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --frozen-lockfile` refuses to move the transitive dependency", async () => {
const { dir, noDeps } = await stale();
const packageJsonBefore = await packageJsonText(dir);
const lockBefore = await lockText(dir);
const { stderr, exitCode } = await run(dir, "update", "--frozen-lockfile");
expect(stderr).toContain("error: lockfile had changes, but lockfile is frozen");
expect(await packageJsonText(dir)).toBe(packageJsonBefore);
expect(await lockText(dir)).toBe(lockBefore);
expect(await installedVersion(dir, ...noDeps)).toBe("1.0.0");
expect(exitCode).toBe(1);
});
// peer-deps-fixed's peer edge is skipped by the plan and only follows the root's moved no-deps; a-dep is inserted ahead of no-deps between the install and the update, so the root's rows no longer line up with the ones bun.lock recorded.
test.concurrent("a moved direct dependency is still followed after its row index shifted", async () => {
const dir = await setup({ "package.json": pkgJson({ "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" }) });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
await write(
join(dir, "package.json"),
stringify(pkgJson({ "a-dep": "1.0.1", "no-deps": "^1.0.0", "peer-deps-fixed": "1.0.0" })),
);
const { stderr, exitCode } = await run(dir, "update");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(
pkgJson({ "a-dep": "1.0.1", "no-deps": "^1.1.0", "peer-deps-fixed": "1.0.0" }),
);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
expect((await lock(dir)).packages["peer-deps-fixed/no-deps"]).toBeUndefined();
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
await frozen(dir);
expect(exitCode).toBe(0);
});
// The plan is the same block a real run prints, with a "would be updated" count line in place of "installed"; nothing is installed and no lockfile dump follows.
test.concurrent.each([
["bare", []],
["--latest", ["--latest"]],
["no-deps", ["no-deps"]],
["a pattern", ["no-*"]],
])("`bun update --dry-run` (%s) prints the plan as summary rows and writes nothing", async (_, args) => {
const { dir, packageJson, noDeps } = await stale();
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "--dry-run", ...args);
expectDryRun(stdout, NO_DEPS_ROW_HINTED);
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(before);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await installedVersion(dir, ...noDeps)).toBe("1.0.0");
expect(exitCode).toBe(0);
});
// Packages counted: the root, one-range-dep and no-deps.
test.concurrent.each([
["bare", []],
["one-range-dep", ["one-range-dep"]],
["--prod", ["--prod"]],
])("`bun update --dry-run` (%s) says so when nothing would move", async (_, args) => {
const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0" }) });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "--dry-run", ...args);
expectSummary(stdout, nothingToUpdate(3));
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(before);
expect(exitCode).toBe(0);
});
test.concurrent(
"`bun update --dry-run` lists and counts direct dependencies together with the transitive plan",
async () => {
const dir = await setup({
"package.json": pkgJson({ "a-dep": "1.0.1", "one-range-dep": "1.0.0", "no-deps": "1.0.0" }),
});
const packageJson = pkgJson({ "a-dep": "^1.0.1", "one-range-dep": "1.0.0" });
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "--dry-run");
expectDryRun(stdout, A_DEP_ROW, NO_DEPS_ROW_HINTED);
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(before);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.1");
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
expect(exitCode).toBe(0);
},
);
test.concurrent("a direct dependency's declared range is left alone when only its dependency moves", async () => {
const { dir, packageJson } = await stale();
const { stderr, exitCode } = await run(dir, "update");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(packageJson.dependencies);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(exitCode).toBe(0);
});
test.concurrent("a transitive dependency pinned exactly by its dependent stays put", async () => {
const dir = await setup({ "package.json": pkgJson({ "one-fixed-dep": "1.0.0" }) });
const before = await lock(dir);
const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, noChanges(2, 3));
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await lock(dir)).toStrictEqual(before);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(exitCode).toBe(0);
});
// The root's [email protected] dedupes both dependents onto 1.0.0 before it is dropped; the update forks only the `^1.0.0` edge.
test.concurrent("dependents with different ranges are resolved independently", async () => {
const dependents = { "one-fixed-dep": "1.0.0", "one-range-dep": "1.0.0" };
const dir = await setup({ "package.json": pkgJson({ "no-deps": "1.0.0", ...dependents }) });
const packageJson = pkgJson(dependents);
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1));
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]);
const { packages } = await lock(dir);
expect([packages["no-deps"][0], packages["one-range-dep/no-deps"][0]]).toStrictEqual([
"[email protected]",
"[email protected]",
]);
await frozen(dir);
expect(exitCode).toBe(0);
});
// The root's exact 1.0.0 takes the root slot and pushes one-range-dep's 1.1.0 into a nested folder; widening the root keeps 1.0.0 locked, so the update collapses both rows onto 1.1.0.
test.concurrent("hoisted: a bare update removes the nested copy whose row it collapsed", async () => {
const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0" }) });
await reinstall(dir, pkgJson({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" }));
await reinstall(dir, pkgJson({ "one-range-dep": "1.0.0", "no-deps": "^1.0.0" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
expect(await installedVersion(dir, "one-range-dep", "node_modules", "no-deps")).toBe("1.1.0");
const nested = join(dir, "node_modules", "one-range-dep", "node_modules", "no-deps");
const { stdout, stderr, exitCode } = await run(dir, "update");
expect(movedRows(stdout)).toStrictEqual([NO_DEPS_ROW]);
expect(stdout).not.toContain("+ no-deps@");
expectCleanStderr(stderr);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(Object.keys((await lock(dir)).packages).sort()).toStrictEqual(["no-deps", "one-range-dep"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
expect(await exists(nested)).toBeFalse();
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("an override holds a transitive dependency back", async () => {
const { dir } = await stale();
const packageJson = pkgJson({ "one-range-dep": "1.0.0" }, { overrides: { "no-deps": "1.0.0" } });
await reinstall(dir, packageJson);
const before = await lock(dir);
const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, noChanges(2, 3));
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await lock(dir)).toStrictEqual(before);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(exitCode).toBe(0);
});
const NO_DEPS_PATCH = `diff --git a/patched.txt b/patched.txt
new file mode 100644
index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e728b8dad
--- /dev/null
+++ b/patched.txt
@@ -0,0 +1 @@
+hello world
`;
const PATCHED = { patchedDependencies: { "[email protected]": "patches/[email protected]" } };
// The `stale()` recipe with the transitive [email protected] patched; the kept row sits in the summary block and names the version the patch is holding back.
test.concurrent.each<[string, string[], string]>([
["bare", [], noChanges(2, 3)],
["no-deps", ["no-deps"], noChanges(2, 3)],
["bare --dry-run", ["--dry-run"], nothingToUpdate(3)],
])("a patched transitive dependency is left alone (%s)", async (_, args, countLine) => {
const dir = await setup({
"package.json": pkgJson({ "one-range-dep": "1.0.0", "no-deps": "1.0.0" }, PATCHED),
"patches/[email protected]": NO_DEPS_PATCH,
});
const packageJson = pkgJson({ "one-range-dep": "1.0.0" }, PATCHED);
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const patched = file(join(dir, "node_modules", "no-deps", "patched.txt"));
expect(await patched.exists()).toBeTrue();
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
expectSummary(stdout, keptPatched("no-deps", "1.0.0", "1.1.0"), "", countLine);
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockText(dir)).toBe(before);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await patched.exists()).toBeTrue();
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent.each<[string, string[], string]>([
["bare", [], noChanges(1, 2)],
["no-deps", ["no-deps"], noChanges(1, 2)],
["bare --dry-run", ["--dry-run"], nothingToUpdate(2)],
["no-deps --dry-run", ["no-deps", "--dry-run"], nothingToUpdate(2)],
])("a patched dependency declared in package.json is held within its range (%s)", async (_, args, countLine) => {
const dir = await setup({
"package.json": pkgJson({ "no-deps": "1.0.0" }, PATCHED),
"patches/[email protected]": NO_DEPS_PATCH,
});
const packageJson = pkgJson({ "no-deps": "^1.0.0" }, PATCHED);
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const patched = file(join(dir, "node_modules", "no-deps", "patched.txt"));
expect(await patched.exists()).toBeTrue();
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
expectSummary(stdout, keptPatched("no-deps", "1.0.0", "1.1.0"), "", countLine);
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockText(dir)).toBe(before);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await patched.exists()).toBeTrue();
await frozen(dir);
expect(exitCode).toBe(0);
});
// --latest moves past the patch on purpose; the orphaned patchedDependencies entry is pointed out on stderr.
test.concurrent(
"`bun update <name> --latest` moves a patched dependency and warns that its patch no longer applies",
async () => {
const dir = await setup({
"package.json": pkgJson({ "no-deps": "1.0.0" }, PATCHED),
"patches/[email protected]": NO_DEPS_PATCH,
});
await reinstall(dir, pkgJson({ "no-deps": "^1.0.0" }, PATCHED));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const { stdout, stderr, exitCode } = await run(dir, "update", "no-deps", "--latest");
expectSummary(stdout, movedRow("no-deps", "1.0.0", "2.0.0"), "", installed(1));
expect(stderr).toContain("warn: patches/[email protected] no longer applies (no-deps is now 2.0.0)\n");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "^2.0.0" }, PATCHED));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
expect(await file(join(dir, "node_modules", "no-deps", "patched.txt")).exists()).toBeFalse();
expect(exitCode).toBe(0);
},
);
// bun.lock still records the `no-deps: 1.0.0` override; package.json is edited without an install in between, so the update sees the new overrides first.
async function overriddenThenEdited(overrides?: Json) {
const dependencies = { "one-range-dep": "1.0.0" };
const dir = await setup({ "package.json": pkgJson(dependencies, { overrides: { "no-deps": "1.0.0" } }) });
expect((await lock(dir)).overrides).toStrictEqual({ "no-deps": "1.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const packageJson = pkgJson(dependencies, overrides ? { overrides } : {});
await write(join(dir, "package.json"), stringify(packageJson));
return { dir, packageJson };
}
test.concurrent.each<[string, Json | undefined, string]>([
["removed", undefined, "1.1.0"],
["widened", { "no-deps": "~1.0.0" }, "1.0.1"],
])(
"`bun update` re-resolves a transitive dependency whose override was %s since bun.lock was written",
async (_, overrides, to) => {
const { dir, packageJson } = await overriddenThenEdited(overrides);
const { stdout, stderr, exitCode } = await run(dir, "update");
expect(movedRows(stdout)).toStrictEqual([movedRow("no-deps", "1.0.0", to)]);
expect(stdout).not.toContain("+ no-deps@");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect((await lock(dir)).overrides).toStrictEqual(overrides);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual([to]);
expect(await installedVersion(dir, "no-deps")).toBe(to);
await frozen(dir);
expect(exitCode).toBe(0);
},
);
test.concurrent(
"the summary reports the target an override added since bun.lock was written actually allows",
async () => {
const { dir } = await stale();
const packageJson = pkgJson({ "one-range-dep": "1.0.0" }, { overrides: { "no-deps": "1.0.1" } });
await write(join(dir, "package.json"), stringify(packageJson));
const { stdout, stderr, exitCode } = await run(dir, "update");
expect(movedRows(stdout)).toStrictEqual([movedRow("no-deps", "1.0.0", "1.0.1")]);
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect((await lock(dir)).overrides).toStrictEqual({ "no-deps": "1.0.1" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
await frozen(dir);
expect(exitCode).toBe(0);
},
);
test.concurrent("a lockfile that already resolves the newest allowed versions is left alone", async () => {
const dir = await setup({ "package.json": pkgJson({ "one-range-dep": "1.0.0" }) });
const before = await lock(dir);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, noChanges(2, 3));
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await lock(dir)).toStrictEqual(before);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --latest` run twice is a no-op that does not claim to have saved bun.lock", async () => {
const { dir } = await stale();
const first = await run(dir, "update", "--latest");
expectSummary(first.stdout, NO_DEPS_ROW_HINTED, "", installed(1));
expect(first.stderr).toContain("Saved lockfile");
expect(first.exitCode).toBe(0);
const before = await lockText(dir);
const second = await run(dir, "update", "--latest");
expectSummary(second.stdout, noChanges(2, 3));
expectCleanStderr(second.stderr);
expect(second.stderr).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(before);
expect(second.exitCode).toBe(0);
});
test.concurrent("without a lockfile `bun update` resolves everything fresh", async () => {
const { packageDir } = await registry.createTestDir({
bunfigOpts: { saveTextLockfile: true, linker: "hoisted" },
files: { "package.json": stringify(pkgJson({ "one-range-dep": "1.0.0" })) },
});
const { stdout, stderr, exitCode } = await run(packageDir, "update");
expectNoMoves(stdout);
expect(stderr).not.toContain("error:");
expect(stderr).toContain("Saved lockfile");
expect(await lockedVersions(packageDir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(exitCode).toBe(0);
});
// The `stale()` recipe applied inside pkg1; pkg2, when present, pins [email protected] too and is widened to `pkg2Range`.
async function staleMemberTransitive(pkg2Range?: string) {
const dir = await setup({
"package.json": ROOT,
"packages/pkg1/package.json": member("pkg1", { "one-range-dep": "1.0.0", "no-deps": "1.0.0" }),
...(pkg2Range ? { "packages/pkg2/package.json": member("pkg2", { "no-deps": "1.0.0" }) } : {}),
});
if (pkg2Range) {
await write(join(dir, "packages/pkg2/package.json"), stringify(member("pkg2", { "no-deps": pkg2Range })));
}
const pkg1 = member("pkg1", { "one-range-dep": "1.0.0" });
await reinstall(dir, pkg1, {}, "packages/pkg1");
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
return { dir, pkg1 };
}
test.concurrent("in a workspace, `bun update` from the root moves a member's transitive dependency", async () => {
const { dir, pkg1 } = await staleMemberTransitive();
const { stdout, stderr, exitCode } = await run(dir, "update");
expectMoved(stdout, "no-deps", "1.0.0", "1.1.0");
expect(stdout).not.toContain("+ no-deps@");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(ROOT);
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent.each([
["the root", ""],
["the member", "packages/pkg1"],
])("`bun update <name>` run from %s still moves a member's transitive dependency", async (_, cwd) => {
const { dir, pkg1 } = await staleMemberTransitive();
const { stderr, exitCode } = await runIn(dir, cwd, "update", "no-deps");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(ROOT);
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
const MEMBER_FILES = ["", "packages/pkg1", "packages/pkg2"];
// The root's exact no-deps pin parks every member's transitive `^1.0.0` edge on 1.0.0; dropping it (and widening pkg1 to `pkg1After`) leaves everything where it was locked.
async function staleMemberEdges(pkg1: Json, pkg2: Json, pkg1After: Json = pkg1) {
const dir = await setup({
"package.json": { ...ROOT, dependencies: { "no-deps": "1.0.0" } },
"packages/pkg1/package.json": member("pkg1", pkg1),
"packages/pkg2/package.json": member("pkg2", pkg2),
});
await write(join(dir, "packages/pkg1/package.json"), stringify(member("pkg1", pkg1After)));
await reinstall(dir, ROOT);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const texts = () => Promise.all(MEMBER_FILES.map(rel => packageJsonText(dir, rel)));
return { dir, texts, textsBefore: 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, ...dependents: string[]) {
const { packages } = await lock(dir);
const versionOf = (key: string) => (packages[key] as [string] | undefined)?.[0].slice("no-deps@".length);
return Object.fromEntries(
dependents.map(dependent => [dependent, versionOf(`${dependent}/no-deps`) ?? versionOf("no-deps")]),
);
}
// one-range-dep and one-range-dep-too both depend on no-deps ^1.0.0; pkg1 reaches no-deps only through the former, pkg2 only through the latter.
const distinctEdges = () => staleMemberEdges({ "one-range-dep": "1.0.0" }, { "one-range-dep-too": "1.0.0" });
// Only pkg1 is planned from pkg1, but once its no-deps moves, pkg2's compatible edge is re-pointed too so bun.lock keeps one copy.
test.concurrent.each([
["from packages/pkg1", "packages/pkg1", []],
["with --filter pkg1", "", ["--filter", "pkg1"]],
["from the root", "", []],
["with -r", "", ["-r"]],
])(
"in a workspace, a bare `bun update` %s re-points every compatible edge onto the one moved no-deps copy",
async (_, cwd, args) => {
const { dir, texts, textsBefore } = await distinctEdges();
const { stdout, stderr, exitCode } = await runIn(dir, cwd, "update", ...args);
expectRowsAnd(stdout, [NO_DEPS_ROW], installed(1));
expectCleanStderr(stderr);
expect(await noDepsEdges(dir, "one-range-dep", "one-range-dep-too")).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(textsBefore);
await frozen(dir);
expect(exitCode).toBe(0);
},
);
// pkg1's a-dep is parked on 1.0.1 by the widening; no-deps is reachable only through pkg2, so nothing pkg1 moves can drag it along.
const disjointEdges = () =>
staleMemberEdges({ "a-dep": "1.0.1" }, { "one-range-dep-too": "1.0.0" }, { "a-dep": "^1.0.1" });
const PKG1_A_DEP_MOVED = stringify(member("pkg1", { "a-dep": "^1.0.10" }));
test.concurrent.each([
["from packages/pkg1", "packages/pkg1", []],
["with --filter pkg1", "", ["--filter", "pkg1"]],
])(
"in a workspace, a bare `bun update` %s leaves alone the stale rows only another workspace reaches",
async (_, cwd, args) => {
const { dir, texts, textsBefore } = await disjointEdges();
const [rootBefore, , pkg2Before] = textsBefore;
const { stdout, stderr, exitCode } = await runIn(dir, cwd, "update", ...args);
expectRowsAnd(stdout, [A_DEP_ROW], installed(1));
expectCleanStderr(stderr);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await texts()).toStrictEqual([rootBefore, PKG1_A_DEP_MOVED, pkg2Before]);
await frozen(dir);
expect(exitCode).toBe(0);
},
);
// Every workspace's transitive rows are planned from the root; a member's own entries only move with -r, and then they get rows like everything else.
test.concurrent.each<[string, string[], boolean]>([
["from the root", [], false],
["with -r", ["-r"], true],
])(
"in a workspace, a bare `bun update` %s moves the transitive rows only a member reaches",
async (_, args, movesMemberEntries) => {
const { dir, texts, textsBefore } = await disjointEdges();
const [rootBefore, pkg1Before, pkg2Before] = textsBefore;
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
if (movesMemberEntries) {
expectRowsAnd(stdout, [A_DEP_ROW, NO_DEPS_ROW], installed(2));
} else {
expectRowsAnd(stdout, [NO_DEPS_ROW], installed(1));
}
expectCleanStderr(stderr);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual([movesMemberEntries ? "1.0.10" : "1.0.1"]);
expect(await texts()).toStrictEqual([rootBefore, movesMemberEntries ? PKG1_A_DEP_MOVED : pkg1Before, pkg2Before]);
await frozen(dir);
expect(exitCode).toBe(0);
},
);
test.concurrent.each<[string, string, string[], string]>([
["from packages/pkg1", "packages/pkg1", [], NOT_A_DEPENDENCY_HERE("no-deps", "pkg2")],
["with --filter pkg1", "", ["--filter", "pkg1"], NOT_A_DEPENDENCY_OF_SELECTION("no-deps", "pkg2")],
])(
"in a workspace, `bun update <name>` %s naming a package only another workspace reaches is an error",
async (_, cwd, args, message) => {
const { dir, texts, textsBefore, lockBefore } = await disjointEdges();
const { stderr, exitCode } = await runIn(dir, cwd, "update", ...args, "no-deps");
expect(stderr).toContain(message);
expect(stderr).not.toContain("note:");
expect(await lockText(dir)).toBe(lockBefore);
expect(await texts()).toStrictEqual(textsBefore);
expect(exitCode).toBe(1);
},
);
// -r plans pkg1's own a-dep entry too and lists it once; --filter pkg1 plans nothing but that entry.
test.concurrent.each<[string, string[], string[]]>([
["-r", ["-r"], [A_DEP_ROW, NO_DEPS_ROW_HINTED]],
["--filter pkg1", ["--filter", "pkg1"], [A_DEP_ROW]],
["-r --latest", ["-r", "--latest"], [A_DEP_ROW, NO_DEPS_ROW_HINTED]],
])("in a workspace, `bun update %s --dry-run` prints one row per move and writes nothing", async (_, args, rows) => {
const { dir, texts, textsBefore, lockBefore } = await disjointEdges();
const { stdout, stderr, exitCode } = await run(dir, "update", "--dry-run", ...args);
expectDryRun(stdout, ...rows);
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(lockBefore);
expect(await texts()).toStrictEqual(textsBefore);
expect(exitCode).toBe(0);
});
test.concurrent.each([
["bare", []],
["named", ["no-deps"]],
])("in a workspace, `bun update --filter` matching no workspace is an error (%s)", async (_, args) => {
const { dir, texts, textsBefore, lockBefore } = await disjointEdges();
const { stdout, stderr, exitCode } = await run(dir, "update", "--filter", "zzz", ...args);
expect(stderr).toContain('error: No workspace packages matched the filter "zzz"\n');
expect(stderr).not.toContain("warn:");
expect(stderr).not.toContain("Saved lockfile");
expect(stdout).not.toContain("Checked");
expect(await lockText(dir)).toBe(lockBefore);
expect(await texts()).toStrictEqual(textsBefore);
expect(exitCode).toBe(1);
});
test.concurrent.each([
["from packages/pkg2", "packages/pkg2", []],
["with --filter pkg2", "", ["--filter", "pkg2"]],
])(
"in a workspace, `bun update <name>` %s moves a package that workspace reaches transitively",
async (_, cwd, args) => {
const { dir, texts, textsBefore } = await disjointEdges();
const { stderr, exitCode } = await runIn(dir, cwd, "update", ...args, "no-deps");
expect(stderr).not.toContain("error:");
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
expect(await texts()).toStrictEqual(textsBefore);
await frozen(dir);
expect(exitCode).toBe(0);
},
);
// dep-with-tags has 3.0.1 published above its `latest` (3.0.0); prereleases-1 has 1.0.0-future.7 above 1.0.0-future.4.
test.concurrent.each([
["dep-with-tags", "3.0.1"],
["prereleases-1", "1.0.0-future.7"],
])("`bun update --latest` does not downgrade %s from %s, which is ahead of `latest`", async (name, version) => {
const packageJson = pkgJson({ [name]: version });
const dir = await setup({ "package.json": packageJson });
expect(await installedVersion(dir, name)).toBe(version);
const { stderr, exitCode } = await run(dir, "update", "--latest");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, name)).toStrictEqual([version]);
expect(await lockText(dir)).not.toContain('"latest"');
expect(await installedVersion(dir, name)).toBe(version);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent(
"`bun update --latest` holds back only the entry it rewrote; a member's own dist-tag entry still follows its tag",
async () => {
const dir = await setup({
"package.json": { ...ROOT, dependencies: { "dep-with-tags": "3.0.1" } },
"packages/pkg1/package.json": member("pkg1", { "dep-with-tags": "pre-2" }),
});
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["2.0.1", "3.0.1"]);
const rootText = await packageJsonText(dir);
const pkg1Text = await packageJsonText(dir, "packages/pkg1");
const { stderr, exitCode } = await run(dir, "update", "--latest");
expect(stderr).not.toContain("error:");
expect(await packageJsonText(dir)).toBe(rootText);
expect(await packageJsonText(dir, "packages/pkg1")).toBe(pkg1Text);
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["2.0.1", "3.0.1"]);
expect((await lock(dir)).workspaces["packages/pkg1"].dependencies).toStrictEqual({ "dep-with-tags": "pre-2" });
expect(await installedVersion(dir, "dep-with-tags")).toBe("3.0.1");
expect(await lockText(dir)).toContain('"[email protected]"');
await frozen(dir);
expect(exitCode).toBe(0);
},
);
test.concurrent("`bun up --help` prints the update help", async () => {
const { packageDir } = await registry.createTestDir();
const { stdout, exitCode } = await run(packageDir, "up", "--help");
expect(stdout).toContain("bun update");
expect(stdout).toContain("Alias: bun up");
expect(stdout).toContain("-L, --latest");
expect(stdout).toContain("--no-optional");
expect(stdout).toContain("Only update dependencies and optionalDependencies");
expect(stdout).toContain("bun update --prod");
expect(stdout).not.toContain("--transitive");
expect(stdout).not.toContain("Don't install devDependencies");
expect(stdout).toContain("-p, --production");
expect(exitCode).toBe(0);
});
test.concurrent("`bun up` is `bun update`", async () => {
const { dir, packageJson } = await stale();
const { stdout, stderr, exitCode } = await run(dir, "up");
expectMoved(stdout, "no-deps", "1.0.0", "1.1.0");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(exitCode).toBe(0);
});
test.concurrent("`-L` is `--latest`", async () => {
const dir = await setup({ "package.json": pkgJson({ "no-deps": "~1.0.0" }) });
const { stderr, exitCode } = await run(dir, "update", "-L");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "~2.0.0" }));
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --silent` prints nothing at all but still moves the transitive dependency", async () => {
const { dir, packageJson, noDeps } = await stale();
const { stdout, stderr, exitCode } = await run(dir, "update", "--silent");
expect(stdout).toBe("");
expect(stderr).toBe("");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await installedVersion(dir, ...noDeps)).toBe("1.1.0");
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --silent --dry-run` prints nothing and writes nothing", async () => {
const { dir, noDeps } = await stale();
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "--silent", "--dry-run");
expect(stdout).toBe("");
expect(stderr).toBe("");
expect(await lockText(dir)).toBe(before);
expect(await installedVersion(dir, ...noDeps)).toBe("1.0.0");
expect(exitCode).toBe(0);
});
test.concurrent.each<[string, Layout]>([
["hoisted", {}],
["isolated", { linker: "isolated" }],
])("`bun update --no-save` moves the transitive dependency in node_modules only (%s)", async (_, layout) => {
const { dir, noDeps } = await stale(layout);
const packageJsonBefore = await packageJsonText(dir);
const lockBefore = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "--no-save", ...linkerArgs(layout));
expectMoved(stdout, "no-deps", "1.0.0", "1.1.0");
expect(stderr).not.toContain("error:");
expect(stderr).not.toContain("Saved lockfile");
expect(await packageJsonText(dir)).toBe(packageJsonBefore);
expect(await lockText(dir)).toBe(lockBefore);
expect(await installedVersion(dir, ...noDeps)).toBe("1.1.0");
expect(exitCode).toBe(0);
});
async function expectNoop(dir: string, ...args: string[]) {
const packageJson = await packageJsonOf(dir);
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
expectNoChangesLine(stdout);
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockText(dir)).toBe(before);
await frozen(dir);
expect(exitCode).toBe(0);
}
// hoist-lockfile-{1,2,3} depend on hoist-lockfile-shared (1.0.1 / 1.0.2 / 2.0.1 / 2.0.2) as `*` / `^1.0.1` / `>=1.0.1`.
const HOIST_DEPENDENTS = { "hoist-lockfile-1": "1.0.0", "hoist-lockfile-2": "1.0.0", "hoist-lockfile-3": "1.0.0" };
async function staleShared() {
const dir = await setup({ "package.json": pkgJson({ ...HOIST_DEPENDENTS, "hoist-lockfile-shared": "1.0.1" }) });
const packageJson = pkgJson(HOIST_DEPENDENTS);
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.1"]);
return { dir, packageJson };
}
test.concurrent.each([
["bare", []],
["named", ["hoist-lockfile-shared"]],
["named with an ignored @version", ["[email protected]"]],
])("every dependent's range on a shared package is re-resolved on its own (%s)", async (_, args) => {
const { dir, packageJson } = await staleShared();
const { stderr, exitCode } = await run(dir, "update", ...args);
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(HOIST_DEPENDENTS);
expect(await lockedVersions(dir, "hoist-lockfile-shared")).toStrictEqual(["1.0.2", "2.0.2"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("the plan counts packages, not the edges that move onto them", async () => {
const { dir } = await staleShared();
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "--dry-run");
expectDryRun(
stdout,
movedRow("hoist-lockfile-shared", "1.0.1", "1.0.2", "2.0.2"),
movedRow("hoist-lockfile-shared", "1.0.1", "2.0.2"),
);
expectCleanStderr(stderr);
expect(await lockText(dir)).toBe(before);
expect(exitCode).toBe(0);
});
// [email protected] declares peer `no-deps: ^1.0.0`; the root's exact [email protected] is its only provider.
test.concurrent.each([
["bare", []],
["--latest peer-deps-fixed", ["--latest", "peer-deps-fixed"]],
["no-deps", ["no-deps"]],
])("a peer edge keeps following the root's pinned provider instead of forking (%s)", async (_, args) => {
const dir = await setup({ "package.json": pkgJson({ "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" }) });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
await expectNoop(dir, ...args);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
});
// [email protected] declares peer `no-deps: ^1.0.0`; the root's exact [email protected] provided it, and dropping that entry leaves the auto-installed peer parked on 1.0.0.
async function staleAutoInstalledPeer() {
const dir = await setup({ "package.json": pkgJson({ "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" }) });
const packageJson = pkgJson({ "peer-deps-fixed": "1.0.0" });
await reinstall(dir, packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
return { dir, packageJson };
}
test.concurrent.each<[string, string[], boolean]>([
["bare", [], true],
["--latest peer-deps-fixed", ["--latest", "peer-deps-fixed"], true],
["no-deps", ["no-deps"], false],
])("an auto-installed peer that nothing else depends on is re-resolved (%s)", async (_, args, planned) => {
const { dir, packageJson } = await staleAutoInstalledPeer();
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
if (planned) expectMoved(stdout, "no-deps", "1.0.0", "1.1.0");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "peer-deps-fixed")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
await frozen(dir);
expect(exitCode).toBe(0);
});
// [email protected] has nothing but a `no-deps: *` peer, which the install auto-installs at latest.
test.concurrent("a package with only peer dependencies is a clean no-op", async () => {
const dir = await setup({ "package.json": pkgJson({ "peer-deps": "1.0.0" }) });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
await expectNoop(dir);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
});
// [email protected] and [email protected] pin each other; [email protected] ships its own [email protected].
test.concurrent.each([
["bare", []],
["no-deps", ["no-deps"]],
["dep-loop-exit", ["dep-loop-exit"]],
])("a dependency cycle and a bundled dependency are left alone (%s)", async (_, args) => {
const dir = await setup({ "package.json": pkgJson({ "dep-loop-entry": "1.0.0", "bundled-1": "1.0.0" }) });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
await expectNoop(dir, ...args);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "dep-loop-entry")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "dep-loop-exit")).toStrictEqual(["1.0.0"]);
});
test.concurrent.each([
["bare", []],
["no-deps", ["no-deps"]],
])("a bundled edge is not re-resolved even when its range would allow it (%s)", async (_, args) => {
const dir = await setup({ "package.json": pkgJson({ "bundled-1": "1.0.0" }) });
const pinned = await lockText(dir);
expect(pinned).toContain('"bundled-1/no-deps": ["[email protected]"');
expect(pinned.split('{ "dependencies": { "no-deps": "1.0.0" } }')).toHaveLength(2);
const widened = pinned.replace(
'{ "dependencies": { "no-deps": "1.0.0" } }',
'{ "dependencies": { "no-deps": "^1.0.0" } }',
);
await write(join(dir, "bun.lock"), widened);
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
expectNoMoves(stdout);
expect(stderr).not.toContain("error:");
expect(await lockText(dir)).toBe(widened);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(exitCode).toBe(0);
});
// pkg1 and pkg2 both install [email protected] exactly, then widen to the given ranges, which keep 1.0.0 locked for both.
async function staleMembers(pkg1Range: string, pkg2Range: string) {
const dir = await setup({
"package.json": ROOT,
"packages/pkg1/package.json": member("pkg1", { "no-deps": "1.0.0" }),
"packages/pkg2/package.json": member("pkg2", { "no-deps": "1.0.0" }),
});
await write(join(dir, "packages/pkg2/package.json"), stringify(member("pkg2", { "no-deps": pkg2Range })));
await reinstall(dir, member("pkg1", { "no-deps": pkg1Range }), {}, "packages/pkg1");
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
return { dir, pkg2Text: await packageJsonText(dir, "packages/pkg2") };
}
test.concurrent("in a workspace, `bun update` from one member also re-points a sibling's identical range", async () => {
const { dir } = await staleMembers("~1.0.0", "~1.0.0");
const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(ROOT);
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "~1.0.1" }));
expect(await packageJsonOf(dir, "packages/pkg2")).toStrictEqual(member("pkg2", { "no-deps": "~1.0.0" }));
const { workspaces } = await lock(dir);
expect(workspaces["packages/pkg1"].dependencies).toStrictEqual({ "no-deps": "~1.0.1" });
expect(workspaces["packages/pkg2"].dependencies).toStrictEqual({ "no-deps": "~1.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.1");
await frozen(dir);
expect(exitCode).toBe(0);
});
type Manifests = Record<string, Record<string, { dependencies?: Record<string, string> }>>;
type Tags = Record<string, Record<string, string>>;
// Serves one manifest per name from memory; verdaccio has no parent whose newer version keeps a range on the same child, and its dist-tags cannot move mid-test. `tags` is read per request, so a test can move a tag after installing.
type RegistryKnobs = { times?: Record<string, Record<string, string>>; status?: Record<string, number> };
async function serveRegistry(manifests: Manifests, tags: Tags = {}, knobs: RegistryKnobs = {}) {
const tarballs = new Map<string, Uint8Array>();
for (const [name, versions] of Object.entries(manifests)) {
for (const [version, extra] of Object.entries(versions)) {
const archive = new Bun.Archive(
{ "package/package.json": JSON.stringify({ name, version, ...extra }) },
{ compress: "gzip" },
);
tarballs.set(`/${name}-${version}.tgz`, await archive.bytes());
}
}
return Bun.serve({
port: 0,
fetch(request) {
const { origin, pathname } = new URL(request.url);
const tarball = tarballs.get(pathname);
if (tarball) return new Response(tarball);
const name = pathname.slice(1);
const entry = manifests[name];
const status = knobs.status?.[name];
if (status) return new Response("registry says no", { status });
if (!entry) return new Response("not found", { status: 404 });
const versions: Json = {};
for (const [version, extra] of Object.entries(entry)) {
versions[version] = { name, version, dist: { tarball: `${origin}/${name}-${version}.tgz` }, ...extra };
}
const latest = Object.keys(entry).sort(Bun.semver.order).at(-1);
const time = knobs.times?.[name];
return Response.json({ name, versions, "dist-tags": { latest, ...tags[name] }, ...(time ? { time } : {}) });
},
});
}
// Installs `pinned` against the in-memory registry, then re-installs `packageJson`, which drops the pins that parked the transitive edges.
async function setupServed(server: Bun.Server, prefix: string, pinned: Json, packageJson: Json = pinned) {
const dir = await installServed(server, prefix, pinned);
if (packageJson !== pinned) await reinstall(dir, packageJson);
return dir;
}
const servedBunfig = (server: Bun.Server, dir: string, extra: Json = {}) =>
write(
join(dir, "bunfig.toml"),
Bun.TOML.stringify({
install: {
cache: join(dir, ".bun-cache"),
registry: server.url.href,
saveTextLockfile: true,
linker: "hoisted",
...extra,
},
}),
);
async function installServed(server: Bun.Server, prefix: string, packageJson: Json, ...args: string[]) {
const dir = String(tempDir(prefix, { "package.json": stringify(packageJson) }));
await servedBunfig(server, dir);
await install(dir, ...args);
return dir;
}
const freshInstallLock = async (server: Bun.Server, prefix: string, packageJson: Json, ...args: string[]) =>
lock(await installServed(server, prefix, packageJson, ...args));
test.concurrent("`bun update <name>` leaves the named package's own dependencies where they are", async () => {
using server = await serveRegistry({
parent: { "1.0.0": { dependencies: { leaf: "^1.0.0" } }, "1.1.0": { dependencies: { leaf: "^1.0.0" } } },
leaf: { "1.0.0": {}, "1.1.0": {} },
});
const dir = await setupServed(
server,
"update-named-children-",
pkgJson({ parent: "1.0.0", leaf: "1.0.0" }),
pkgJson({ parent: "^1.0.0" }),
);
expect(await lockedVersions(dir, "parent")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
const named = await run(dir, "update", "parent");
expect(named.stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ parent: "^1.1.0" }));
expect(await lockedVersions(dir, "parent")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "parent")).toBe("1.1.0");
expect(await installedVersion(dir, "leaf")).toBe("1.0.0");
await frozen(dir);
expect(named.exitCode).toBe(0);
const bare = await run(dir, "update");
expectMoved(bare.stdout, "leaf", "1.0.0", "1.1.0");
expect(bare.stdout).not.toContain("+ leaf@");
expect(bare.stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ parent: "^1.1.0" }));
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.1.0"]);
expect(await installedVersion(dir, "leaf")).toBe("1.1.0");
await frozen(dir);
expect(bare.exitCode).toBe(0);
});
// parent and other are already at their newest; the leaf under each is parked one release behind by the dropped root pins.
const STALE_CHILDREN: Manifests = {
parent: { "1.0.0": { dependencies: { leaf: "^1.0.0" } } },
leaf: { "1.0.0": {}, "1.1.0": {} },
other: { "1.0.0": { dependencies: { "other-leaf": "^1.0.0" } } },
"other-leaf": { "1.0.0": {}, "1.1.0": {} },
};
async function staleChildren(server: Bun.Server) {
const packageJson = pkgJson({ parent: "^1.0.0", other: "^1.0.0" });
const dir = await setupServed(
server,
"update-named-children-",
pkgJson({ ...packageJson.dependencies, leaf: "1.0.0", "other-leaf": "1.0.0" }),
packageJson,
);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "other-leaf")).toStrictEqual(["1.0.0"]);
return { dir, packageJson };
}
test.concurrent("`bun update <name>` whose package does not move keeps its dependencies locked", async () => {
using server = await serveRegistry(STALE_CHILDREN);
const { dir, packageJson } = await staleChildren(server);
const before = await lockText(dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "parent");
expectSummary(stdout, noChanges(4, 5));
expectCleanStderr(stderr);
expect(stderr).not.toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockText(dir)).toBe(before);
expect(await installedVersion(dir, "leaf")).toBe("1.0.0");
expect(exitCode).toBe(0);
});
test.concurrent(
"`bun update <name> --latest` re-resolves only the named package's own dependencies, in range",
async () => {
using server = await serveRegistry(STALE_CHILDREN);
const { dir, packageJson } = await staleChildren(server);
const { stdout, stderr, exitCode } = await run(dir, "update", "parent", "--latest");
expectSummary(stdout, movedRow("leaf", "1.0.0", "1.1.0"), "", installed(1));
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "parent")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "other-leaf")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "leaf")).toBe("1.1.0");
expect(await installedVersion(dir, "other-leaf")).toBe("1.0.0");
await frozen(dir);
expect(exitCode).toBe(0);
},
);
const TAGGED_FREE: Manifests = {
parent: { "1.0.0": { dependencies: { leaf: "^1.0.0" } } },
leaf: { "1.0.0": {}, "1.1.0": {} },
};
const LEAF_1_1_0_FATAL_SCANNER = `export const scanner = {
version: "1",
scan: async ({ packages }) => {
console.log("scanned: " + packages.map(p => p.name + "@" + p.version).join(" "));
return packages
.filter(p => p.name === "leaf" && p.version === "1.1.0")
.map(() => ({ package: "leaf", description: "[email protected] is bad", level: "fatal", url: "https://example.com/leaf" }));
},
};
`;
async function withLeafScanner(server: Bun.Server, dir: string) {
await write(join(dir, "scanner.ts"), LEAF_1_1_0_FATAL_SCANNER);
await servedBunfig(server, dir, { security: { scanner: "./scanner.ts" } });
return lockText(dir);
}
test.concurrent(
"`bun update <name>` for a transitive-only name runs the security scanner on what it re-resolved",
async () => {
using server = await serveRegistry(TAGGED_FREE);
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(
server,
"update-named-scan-",
pkgJson({ ...packageJson.dependencies, leaf: "1.0.0" }),
packageJson,
);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
const before = await withLeafScanner(server, dir);
const { stdout, exitCode } = await run(dir, "update", "leaf");
expect(stdout).toContain("scanned: [email protected]");
expect(stdout).not.toContain("parent@");
expect(stdout).toContain("FATAL: leaf");
expect(stdout).toContain("Installation aborted due to fatal security advisories");
expect(await lockText(dir)).toBe(before);
expect(await installedVersion(dir, "leaf")).toBe("1.0.0");
expect(exitCode).toBe(1);
},
);
// The request binds to the root's exact [email protected], which stays put; the copy that moves is parent's nested one.
test.concurrent(
"`bun update <name>` also scans the nested copy it re-resolved when the root's own row stays put",
async () => {
using server = await serveRegistry(TAGGED_FREE);
const dir = await installServed(server, "update-named-scan-nested-", pkgJson({ parent: "^1.0.0", leaf: "1.0.0" }));
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
const before = await withLeafScanner(server, dir);
const { stdout, exitCode } = await run(dir, "update", "leaf");
const scanned = stdout.match(/^scanned: .*$/m)?.[0] ?? "";
expect(scanned).toContain("[email protected]");
expect(scanned).toContain("[email protected]");
expect(stdout).toContain("FATAL: leaf");
expect(await lockText(dir)).toBe(before);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "leaf")).toBe("1.0.0");
expect(exitCode).toBe(1);
},
);
test.concurrent("`bun update <name>` for a transitive-only name that the scanner accepts installs it", async () => {
using server = await serveRegistry({ ...TAGGED_FREE, leaf: { "1.0.0": {}, "1.0.1": {} } });
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(
server,
"update-named-scan-clean-",
pkgJson({ ...packageJson.dependencies, leaf: "1.0.0" }),
packageJson,
);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
await withLeafScanner(server, dir);
const { stdout, stderr, exitCode } = await run(dir, "update", "leaf");
expect(stdout).toContain("scanned: [email protected]");
expect(stdout).not.toContain("FATAL:");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.1"]);
expect(await installedVersion(dir, "leaf")).toBe("1.0.1");
await frozen(dir);
expect(exitCode).toBe(0);
});
const TAGGED: Manifests = {
parent: { "1.0.0": { dependencies: { leaf: "stable" } } },
leaf: { "1.0.0": {}, "1.1.0": {} },
};
// `stable` is moved after the install; `bun install` keeps the locked version, so only `bun update` can follow it.
async function movedTag(server: Bun.Server, tags: Tags, from: string, to: string) {
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(server, "update-moved-tag-", packageJson);
expect(await lockedVersions(dir, "leaf")).toStrictEqual([from]);
expect(await lockText(dir)).toContain('"leaf": "stable"');
tags.leaf.stable = to;
await install(dir);
expect(await lockedVersions(dir, "leaf")).toStrictEqual([from]);
return { dir, packageJson };
}
test.concurrent.each([
["bare", "1.0.0", "1.1.0", []],
["--latest", "1.0.0", "1.1.0", ["--latest"]],
["bare, tag moved backwards", "1.1.0", "1.0.0", []],
])("`bun update` follows a transitive dist-tag edge to wherever its tag points now (%s)", async (_, from, to, args) => {
const tags: Tags = { leaf: { stable: from } };
using server = await serveRegistry(TAGGED, tags);
const { dir, packageJson } = await movedTag(server, tags, from, to);
const { stdout, stderr, exitCode } = await run(dir, "update", ...args);
expectMoved(stdout, "leaf", from, to);
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "leaf")).toStrictEqual([to]);
expect(await lockText(dir)).toContain('"leaf": "stable"');
expect(await installedVersion(dir, "leaf")).toBe(to);
await frozen(dir);
expect(exitCode).toBe(0);
});
const DAY_MS = 24 * 60 * 60 * 1000;
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS).toISOString();
const THREE_DAYS_SECONDS = String((3 * DAY_MS) / 1000);
// leaf is reached through a range, tagged through its `stable` tag; both have a newest release published yesterday.
const AGED: Manifests = {
parent: { "1.0.0": { dependencies: { leaf: "^1.0.0", tagged: "stable" } } },
leaf: { "1.0.0": {}, "1.1.0": {}, "1.2.0": {} },
tagged: { "1.0.0": {}, "1.1.0": {}, "1.2.0": {} },
};
const AGED_TIMES = {
leaf: { "1.0.0": daysAgo(30), "1.1.0": daysAgo(20), "1.2.0": daysAgo(1) },
tagged: { "1.0.0": daysAgo(30), "1.1.0": daysAgo(20), "1.2.0": daysAgo(1) },
};
test.concurrent(
"`bun update --minimum-release-age` stops range and dist-tag edges at the newest old enough release",
async () => {
const tags: Tags = { tagged: { stable: "1.0.0" } };
using server = await serveRegistry(AGED, tags, { times: AGED_TIMES });
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(
server,
"update-min-age-",
pkgJson({ ...packageJson.dependencies, leaf: "1.0.0" }),
packageJson,
);
tags.tagged.stable = "1.2.0";
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "tagged")).toStrictEqual(["1.0.0"]);
const { stdout, stderr, exitCode } = await run(dir, "update", "--minimum-release-age", THREE_DAYS_SECONDS);
expectMoved(stdout, "leaf", "1.0.0", "1.1.0");
expectMoved(stdout, "tagged", "1.0.0", "1.1.0");
expect(stdout).not.toContain("1.2.0");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "tagged")).toStrictEqual(["1.1.0"]);
expect(await installedVersion(dir, "leaf")).toBe("1.1.0");
expect(await installedVersion(dir, "tagged")).toBe("1.1.0");
await frozen(dir);
expect(await lock(dir)).toStrictEqual(
await freshInstallLock(server, "update-min-age-fresh-", packageJson, "--minimum-release-age", THREE_DAYS_SECONDS),
);
expect(exitCode).toBe(0);
},
);
// Mirrors verdaccio's dep-with-tags: 3.0.1 is published above `latest` (3.0.0), which `bun install` prefers whenever the range allows it.
const ABOVE_LATEST: Manifests = {
parent: { "1.0.0": { dependencies: { leaf: ">=1.0.0" } } },
leaf: { "1.0.0": {}, "1.0.1": {}, "2.0.0": {}, "2.0.1": {}, "3.0.0": {}, "3.0.1": {} },
};
const ABOVE_LATEST_TAGS: Tags = { leaf: { latest: "3.0.0" } };
test.concurrent(
"a bare `bun update` moves a transitive range edge to `latest`, as a fresh `bun install` would",
async () => {
using server = await serveRegistry(ABOVE_LATEST, ABOVE_LATEST_TAGS);
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(
server,
"update-below-latest-",
pkgJson({ ...packageJson.dependencies, leaf: "2.0.1" }),
packageJson,
);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["2.0.1"]);
const { stdout, stderr, exitCode } = await run(dir, "update");
expectMoved(stdout, "leaf", "2.0.1", "3.0.0");
expect(stdout).not.toContain("3.0.1");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.0"]);
expect(await installedVersion(dir, "leaf")).toBe("3.0.0");
await frozen(dir);
expect(await lock(dir)).toStrictEqual(await freshInstallLock(server, "update-below-latest-fresh-", packageJson));
expect(exitCode).toBe(0);
},
);
test.concurrent(
"a transitive range edge already on `latest` is a no-op even though a newer release exists",
async () => {
using server = await serveRegistry(ABOVE_LATEST, ABOVE_LATEST_TAGS);
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(server, "update-at-latest-", packageJson);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.0"]);
await expectNoop(dir);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.0"]);
expect(await lock(dir)).toStrictEqual(await freshInstallLock(server, "update-at-latest-fresh-", packageJson));
},
);
test.concurrent("a transitive range edge locked ahead of `latest` is not downgraded", async () => {
using server = await serveRegistry(ABOVE_LATEST, ABOVE_LATEST_TAGS);
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(
server,
"update-ahead-of-latest-",
pkgJson({ ...packageJson.dependencies, leaf: "3.0.1" }),
packageJson,
);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.1"]);
await expectNoop(dir);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["3.0.1"]);
expect(await installedVersion(dir, "leaf")).toBe("3.0.1");
expect(
await lockedVersions(await installServed(server, "update-ahead-of-latest-fresh-", packageJson), "leaf"),
).toStrictEqual(["3.0.0"]);
});
// `pre` is parked on `from` by a dropped root pin; parent's range on it is `range`.
test.concurrent.each<[string, string, string[], string, string]>([
[
"the newest prerelease it allows",
"^1.0.0-future.4",
["1.0.0-future.4", "1.0.0-future.7"],
"1.0.0-future.4",
"1.0.0-future.7",
],
[
"the release over a prerelease that also satisfies it",
"^1.0.0-rc.1",
["1.0.0-rc.1", "1.0.0-rc.2", "1.0.0"],
"1.0.0-rc.1",
"1.0.0",
],
[
"a prerelease whose tag is longer than 8 bytes",
"^1.0.0-future.4",
["1.0.0-future.4", "1.0.0-future.10"],
"1.0.0-future.4",
"1.0.0-future.10",
],
])("a transitive prerelease range moves to %s", async (_, range, versions, from, to) => {
using server = await serveRegistry({
parent: { "1.0.0": { dependencies: { pre: range } } },
pre: Object.fromEntries(versions.map(version => [version, {}])),
});
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(
server,
"update-prerelease-",
pkgJson({ ...packageJson.dependencies, pre: from }),
packageJson,
);
expect(await lockedVersions(dir, "pre")).toStrictEqual([from]);
const { stdout, stderr, exitCode } = await run(dir, "update");
expectMoved(stdout, "pre", from, to);
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "pre")).toStrictEqual([to]);
expect((await lock(dir)).packages.pre[0]).toBe(`pre@${to}`);
expect(await installedVersion(dir, "pre")).toBe(to);
await frozen(dir);
expect(await installedVersion(dir, "pre")).toBe(to);
expect(exitCode).toBe(0);
});
// A package whose manifest could not be fetched is reported once, as a stderr warning carrying the registry's answer; stdout keeps the summary block only.
function expectWarnings(stderr: string, ...warnings: string[]) {
expect(
stderr
.split("\n")
.filter(line => line.startsWith("warn:"))
.map(line => `${line}\n`),
).toStrictEqual(warnings);
expect(stderr).not.toContain("error:");
expect(stderr).not.toContain("Resolving...");
}
// Only leaf is stale; its manifest stops being served between the install and the update.
test.concurrent.each([404, 500])(
"a %d for a transitive dependency's manifest during a bare `bun update` changes nothing",
async status => {
const knobs: RegistryKnobs = { status: {} };
using server = await serveRegistry(TAGGED_FREE, {}, knobs);
const packageJson = pkgJson({ parent: "^1.0.0" });
const dir = await setupServed(
server,
"update-manifest-failure-",
pkgJson({ ...packageJson.dependencies, leaf: "1.0.0" }),
packageJson,
);
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
const before = await lockText(dir);
knobs.status!.leaf = status;
const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, noChanges(2, 3));
expectWarnings(stderr, notCheckedWarning(server, "leaf", "1.0.0", status));
expect(stderr).not.toContain("Saved lockfile");
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockText(dir)).toBe(before);
expect(await installedVersion(dir, "leaf")).toBe("1.0.0");
expect(exitCode).toBe(0);
},
);
test.concurrent("a manifest that could not be fetched is warned about while the rest still moves", async () => {
const knobs: RegistryKnobs = { status: {} };
using server = await serveRegistry(STALE_CHILDREN, {}, knobs);
const { dir, packageJson } = await staleChildren(server);
knobs.status!.leaf = 404;
const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, movedRow("other-leaf", "1.0.0", "1.1.0"), "", installed(1));
expectWarnings(stderr, notCheckedWarning(server, "leaf", "1.0.0", 404));
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "other-leaf")).toStrictEqual(["1.1.0"]);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("several unfetchable manifests are warned about once each, sorted", async () => {
const knobs: RegistryKnobs = { status: {} };
using server = await serveRegistry(STALE_CHILDREN, {}, knobs);
const { dir, packageJson } = await staleChildren(server);
const before = await lockText(dir);
knobs.status!.leaf = 500;
knobs.status!["other-leaf"] = 500;
const { stdout, stderr, exitCode } = await run(dir, "update");
expectSummary(stdout, noChanges(4, 5));
expectWarnings(
stderr,
notCheckedWarning(server, "leaf", "1.0.0", 500),
notCheckedWarning(server, "other-leaf", "1.0.0", 500),
);
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(before);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --dry-run` warns about the manifests it could not fetch and prints the plan", async () => {
const knobs: RegistryKnobs = { status: {} };
using server = await serveRegistry(STALE_CHILDREN, {}, knobs);
const { dir, packageJson } = await staleChildren(server);
const before = await lockText(dir);
knobs.status!.leaf = 404;
const { stdout, stderr, exitCode } = await run(dir, "update", "--dry-run");
expectDryRun(stdout, movedRow("other-leaf", "1.0.0", "1.1.0"));
expectWarnings(stderr, notCheckedWarning(server, "leaf", "1.0.0", 404));
expect(stderr).not.toContain("Saved lockfile");
expect(await lockText(dir)).toBe(before);
expect(await packageJsonOf(dir)).toStrictEqual(packageJson);
expect(await lockedVersions(dir, "other-leaf")).toStrictEqual(["1.0.0"]);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --silent` swallows the unfetchable-manifest warning too", async () => {
const knobs: RegistryKnobs = { status: {} };
using server = await serveRegistry(STALE_CHILDREN, {}, knobs);
const { dir } = await staleChildren(server);
knobs.status!.leaf = 500;
const { stdout, stderr, exitCode } = await run(dir, "update", "--silent");
expect(stdout).toBe("");
expect(stderr).toBe("");
expect(await lockedVersions(dir, "leaf")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "other-leaf")).toStrictEqual(["1.1.0"]);
expect(exitCode).toBe(0);
});
// `bun outdated` and `bun update -i` exist to answer for the direct dependencies, so for them a manifest that does not arrive is reported and fails the command (a registry that is down must not read as "nothing to update"); only an optional dependency's is a warning.
const errorLines = (stderr: string) => stderr.split("\n").filter(line => line.startsWith("error:"));
const manifestFailure = (server: Bun.Server, status: number) => `GET ${server.url.origin}/leaf - ${status}`;
// [email protected] installed while 1.1.0 exists, with the manifest cache off so every later command asks the registry again.
async function staleDirectLeaf(server: Bun.Server, groups: Groups = {}) {
const dir = await installServed(server, "direct-manifest-failure-", grouped({ leaf: "1.0.0" }, groups));
await servedBunfig(server, dir, { cache: false });
return dir;
}
// Nothing but the header: no table, and no "up to date" either.
const expectHeaderOnly = (stdout: string, command: string) =>
expect(stdout).toMatch(new RegExp(`^bun ${command} v[^\\n]*\\n$`));
test.concurrent.each<[number, string[]]>([
[502, []],
[404, []],
[502, ["-r"]],
])("a %d for a direct dependency's manifest fails `bun outdated` (extra args: %p)", async (status, args) => {
const knobs: RegistryKnobs = { status: {} };
using server = await serveRegistry(TAGGED_FREE, {}, knobs);
const dir = await staleDirectLeaf(server);
const healthy = await run(dir, "outdated", ...args);
expect(healthy.stdout).toMatch(/\| leaf\s+\| 1\.0\.0\s+\| 1\.0\.0\s+\| 1\.1\.0\s+\|/);
expectCleanStderr(healthy.stderr);
expect(healthy.exitCode).toBe(0);
knobs.status!.leaf = status;
const { stdout, stderr, exitCode } = await run(dir, "outdated", ...args);
expectHeaderOnly(stdout, "outdated");
expect(errorLines(stderr)).toStrictEqual([`error: ${manifestFailure(server, status)}`]);
expect(exitCode).toBe(1);
});
test.concurrent("a registry that does not answer at all fails `bun outdated`", async () => {
using server = await serveRegistry(TAGGED_FREE);
const dir = await staleDirectLeaf(server);
server.stop(true);
const { stdout, stderr, exitCode } = await run(dir, "outdated");
expectHeaderOnly(stdout, "outdated");
expect(errorLines(stderr)).toStrictEqual([expect.stringMatching(/^error: \w+ downloading package manifest leaf$/)]);
expect(exitCode).toBe(1);
});
test.concurrent(
"an optional dependency whose manifest cannot be fetched is only warned about by `bun outdated`",
async () => {
const knobs: RegistryKnobs = { status: {} };
using server = await serveRegistry(TAGGED_FREE, {}, knobs);
const dir = await staleDirectLeaf(server, { leaf: "optionalDependencies" });
knobs.status!.leaf = 502;
const { stdout, stderr, exitCode } = await run(dir, "outdated");
expectHeaderOnly(stdout, "outdated");
expectWarnings(stderr, `warn: ${manifestFailure(server, 502)}\n`);
expect(exitCode).toBe(0);
},
);
test.concurrent(
"a 502 for a direct dependency's manifest fails `bun update -i` instead of offering nothing",
async () => {
const knobs: RegistryKnobs = { status: {} };
using server = await serveRegistry(TAGGED_FREE, {}, knobs);
const dir = await staleDirectLeaf(server);
const before = await lockText(dir);
const healthy = await runInteractive(dir, "", "--dry-run");
expect(healthy.stdout).toContain("leaf");
expect(healthy.exitCode).toBe(0);
knobs.status!.leaf = 502;
const { stdout, stderr, exitCode } = await runInteractive(dir, "", "--dry-run");
expectHeaderOnly(stdout, "update --interactive");
expect(errorLines(stderr)).toStrictEqual([`error: ${manifestFailure(server, 502)}`]);
expect(await lockText(dir)).toBe(before);
expect(exitCode).toBe(1);
},
);
test.concurrent("`bun update <name>` from a member leaves a sibling's own entry alone but lets it follow", async () => {
const { dir, pkg2Text } = await staleMembers("~1.0.0", "^1.0.0");
const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "~1.0.1" }));
expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text);
expect((await lock(dir)).workspaces["packages/pkg2"].dependencies).toStrictEqual({ "no-deps": "^1.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent(
"`bun update <name>` from a member: a sibling whose range rejects the picked version stays put",
async () => {
const { dir, pkg1 } = await staleMemberTransitive("~1.0.0");
const pkg2Text = await packageJsonText(dir, "packages/pkg2");
const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(pkg1);
expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]);
await frozen(dir);
expect(exitCode).toBe(0);
},
);
test.concurrent("`bun update <name>` from the root does not re-resolve a member's own entry", async () => {
const root = { ...ROOT, dependencies: { "no-deps": "^2.0.0" } };
const dir = await setup({
"package.json": root,
"packages/pkg1/package.json": member("pkg1", { "no-deps": "1.0.0" }),
});
await reinstall(dir, member("pkg1", { "no-deps": "^1.0.0" }), {}, "packages/pkg1");
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "2.0.0"]);
const rootText = await packageJsonText(dir);
const pkg1Text = await packageJsonText(dir, "packages/pkg1");
const fromRoot = await run(dir, "update", "no-deps");
expect(fromRoot.stderr).not.toContain("error:");
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "2.0.0"]);
expect(await packageJsonText(dir)).toBe(rootText);
expect(await packageJsonText(dir, "packages/pkg1")).toBe(pkg1Text);
expect(fromRoot.exitCode).toBe(0);
const fromMember = await runIn(dir, "packages/pkg1", "update", "no-deps");
expect(fromMember.stderr).not.toContain("error:");
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0", "2.0.0"]);
expect(await packageJsonText(dir)).toBe(rootText);
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "^1.1.0" }));
await frozen(dir);
expect(fromMember.exitCode).toBe(0);
});
test.concurrent("`bun update <name>` for a name only other workspaces depend on is an error", async () => {
const dir = await setup({
"package.json": { ...ROOT, dependencies: { "no-deps": "1.0.0" } },
"packages/pkg1/package.json": member("pkg1"),
});
await reinstall(dir, { ...ROOT, dependencies: { "no-deps": "^1.0.0" } });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const rootText = await packageJsonText(dir);
const lockBefore = await lockText(dir);
const { stderr, exitCode } = await runIn(dir, "packages/pkg1", "update", "no-deps");
expect(stderr).toContain(NOT_A_DEPENDENCY_HERE("no-deps", "root"));
expect(stderr).not.toContain("note:");
expect(await lockText(dir)).toBe(lockBefore);
expect(await packageJsonText(dir)).toBe(rootText);
expect(exitCode).toBe(1);
});
async function staleScoped() {
const dir = await setup({ "package.json": pkgJson({ "no-deps": "1.0.0", "@types/no-deps": "^1.0.0" }) });
await reinstall(dir, pkgJson({ "no-deps": "^1.0.0", "@types/no-deps": "^1.0.0" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "@types/no-deps")).toStrictEqual(["1.0.0"]);
return dir;
}
test.concurrent("a scoped glob selects only the matching names", async () => {
const dir = await staleScoped();
const { stdout, stderr, exitCode } = await run(dir, "update", "--latest", "@types/*");
expectSummary(stdout, movedRow("@types/no-deps", "1.0.0", "2.0.0"), "", installed(1));
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "^1.0.0", "@types/no-deps": "^2.0.0" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "@types/no-deps")).toStrictEqual(["2.0.0"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("a bare `*` names everything, scoped names included", async () => {
const dir = await staleScoped();
const { stdout, stderr, exitCode } = await run(dir, "update", "--latest", "*");
expectRowsAnd(
stdout,
[movedRow("@types/no-deps", "1.0.0", "2.0.0"), movedRow("no-deps", "1.0.0", "2.0.0")],
installed(2),
);
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "no-deps": "^2.0.0", "@types/no-deps": "^2.0.0" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent.each([
["a negated pattern updates everything else", "!no-deps"],
["an unscoped glob", "a-*"],
])("%s", async (_, pattern) => {
await expectOnlyADepMoved(await staleSiblings(), pattern);
});
test.concurrent("a version cannot be combined with a pattern", async () => {
const dir = await staleScoped();
await expectRejected(dir, "a version cannot be combined with a pattern: @types/*@2", "@types/*@2");
});
// The one dependency row was checked; the no-op says which patterns left nothing selected.
test.concurrent.each([
["a negated name", ["!no-deps"], 'match "!no-deps"'],
["every match negated", ["*", "!*"], 'match "*" "!*"'],
])("excluding every package is a no-op that shows its work (%s)", async (_, args, by) => {
const dir = await setup({ "package.json": pkgJson({ "no-deps": "^1.0.0" }) });
await expectNothingToUpdate(dir, noneSelected(1, by), ...args);
});
test.concurrent.each([
["a pattern", ["no-*"], '"no-*"'],
["a group selector", ["--dev"], "--dev"],
])(
"without a lockfile, `bun update` with %s names the missing file and the command that creates it",
async (_, args, subject) => {
const dir = await withoutLockfile({ "no-deps": "^1.0.0" });
const { stderr, exitCode } = await run(dir, "update", ...args);
expect(stderr).toContain(`error: no bun.lock to match ${subject} against\n bun install\n`);
expect(stderr).not.toContain("missing lockfile");
expect(await file(join(dir, "bun.lock")).exists()).toBeFalse();
expect(exitCode).toBe(1);
},
);
test.concurrent("several names in one command are matched independently, aliases through their real name", async () => {
const dir = await setup({
"package.json": pkgJson({ "a-dep": "1.0.1", aliased: "npm:[email protected]", "one-range-dep": "1.0.0" }),
});
await reinstall(dir, pkgJson({ "a-dep": "^1.0.1", aliased: "npm:no-deps@~1.0.0", "one-range-dep": "1.0.0" }));
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const { stdout, stderr, exitCode } = await run(dir, "update", "a-dep", "no-deps");
expect(movedRows(stdout)).toContain(A_DEP_ROW);
expect(movedRows(stdout)).toContain(NO_DEPS_ROW);
expect(stdout).not.toMatch(/^installed /m);
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(
pkgJson({ "a-dep": "^1.0.10", aliased: "npm:no-deps@~1.0.1", "one-range-dep": "1.0.0" }),
);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1", "1.1.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
const DEV_A_DEP: Groups = { "a-dep": "devDependencies" };
test.concurrent.each(["--dev", "-D", "-d", "--development"])(
"`bun update %s` only touches devDependencies",
async flag => {
await expectOnlyADepMoved(await staleSiblings(DEV_A_DEP), flag);
},
);
test.concurrent.each(["--prod", "-P", "--production", "-p"])(
"`bun update %s` only touches dependencies and still installs devDependencies",
async flag => {
const { dir } = await staleSiblings(DEV_A_DEP);
const { stdout, stderr, exitCode } = await run(dir, "update", flag);
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1));
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(grouped({ "no-deps": "^1.1.0", "a-dep": "^1.0.1" }, DEV_A_DEP));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
expect(await installedVersion(dir, "a-dep")).toBe("1.0.1");
await frozen(dir);
expect(exitCode).toBe(0);
},
);
test.concurrent("`bun update --prod` includes optionalDependencies", async () => {
const groups: Groups = { "a-dep": "optionalDependencies" };
const { dir } = await staleSiblings(groups);
const { stdout, stderr, exitCode } = await run(dir, "update", "--prod");
expectRowsAnd(stdout, [A_DEP_ROW, NO_DEPS_ROW], installed(2));
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(grouped({ "no-deps": "^1.1.0", "a-dep": "^1.0.10" }, groups));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --no-optional` leaves optionalDependencies alone", async () => {
const groups: Groups = { "a-dep": "optionalDependencies" };
const { dir } = await staleSiblings(groups);
const { stdout, stderr, exitCode } = await run(dir, "update", "--no-optional");
expectSummary(stdout, NO_DEPS_ROW_HINTED, "", installed(1));
expectCleanStderr(stderr);
expect(await packageJsonOf(dir)).toStrictEqual(grouped({ "no-deps": "^1.1.0", "a-dep": "^1.0.1" }, groups));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.1.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("a selector with a name outside the selected groups is an error", async () => {
const { dir } = await staleSiblings(DEV_A_DEP);
await expectRejected(dir, 'no dependencies in the selected groups match "no-deps"', "--dev", "no-deps");
});
test.concurrent("a selector combined with `--latest` only rewrites the selected groups", async () => {
await expectOnlyADepMoved(await staleSiblings(DEV_A_DEP), "--dev", "--latest");
});
test.concurrent.each([
["--dev", ["--dev"], "selected by --dev"],
["--dev --no-optional", ["--dev", "--no-optional"], "selected by --dev --no-optional"],
])("a selector matching nothing (%s) is a no-op that shows its work", async (_, args, by) => {
const dir = await setup({ "package.json": pkgJson({ "no-deps": "^1.0.0" }) });
await expectNothingToUpdate(dir, noneSelected(1, by), ...args);
});
test.concurrent("a version cannot be combined with a selector", async () => {
const { dir } = await staleSiblings(DEV_A_DEP);
await expectRejected(
dir,
"a version cannot be combined with --dev, --prod or --no-optional: a-dep@1",
"--dev",
"a-dep@1",
);
});
test.concurrent.each([
["a name", "a-dep"],
["a pattern", "a-*"],
])("`bun update -D` with %s that hits moves only that entry", async (_, arg) => {
await expectOnlyADepMoved(await staleSiblings(DEV_A_DEP), "-D", arg);
});
async function staleAlias() {
const dir = await setup({ "package.json": pkgJson({ aliased: "npm:[email protected]", "a-dep": "1.0.1" }) });
await reinstall(dir, pkgJson({ aliased: "npm:no-deps@~1.0.0", "a-dep": "^1.0.1" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
return dir;
}
test.concurrent("a pattern matches an aliased entry through its alias", async () => {
const dir = await staleAlias();
const { stderr, exitCode } = await run(dir, "update", "alias*");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ aliased: "npm:no-deps@~1.0.1", "a-dep": "^1.0.1" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.1"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("a negated pattern excludes an aliased entry through its alias", async () => {
const dir = await staleAlias();
const { stderr, exitCode } = await run(dir, "update", "!aliased");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ aliased: "npm:no-deps@~1.0.0", "a-dep": "^1.0.10" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("a pattern with an unparsable lockfile is an error", async () => {
const dir = await setup({ "package.json": pkgJson({ "no-deps": "^1.0.0" }) });
await write(join(dir, "bun.lock"), "this is not a lockfile\n");
const packageJsonBefore = await packageJsonText(dir);
const { stderr, exitCode } = await run(dir, "update", "no-*");
expect(stderr).toContain("error: failed to parse lockfile:");
expect(await packageJsonText(dir)).toBe(packageJsonBefore);
expect(await lockText(dir)).toBe("this is not a lockfile\n");
expect(exitCode).toBe(1);
});
const withPeer = (aDep: string, noDeps: string) => ({
name: "foo",
dependencies: { "a-dep": aDep },
peerDependencies: { "no-deps": noDeps },
});
async function stalePeerEntry() {
const dir = await setup({ "package.json": withPeer("1.0.1", "1.0.0") });
await reinstall(dir, withPeer("^1.0.1", "^1.0.0"));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
return dir;
}
// A root peerDependencies entry is never re-resolved by `bun update`; a pattern still counts it as a match, a group selector does not.
test.concurrent("a pattern matches a peerDependencies entry", async () => {
const dir = await stalePeerEntry();
const packageJsonBefore = await packageJsonText(dir);
const { stderr, exitCode } = await run(dir, "update", "no-*");
expect(stderr).not.toContain("error:");
expect(await packageJsonText(dir)).toBe(packageJsonBefore);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --prod` does not match a peerDependencies entry", async () => {
const dir = await stalePeerEntry();
await expectRejected(dir, 'error: no dependencies in the selected groups match "no-*"', "--prod", "no-*");
});
test.concurrent("`bun update --prod` leaves a stale peerDependencies entry alone", async () => {
const dir = await stalePeerEntry();
const { stderr, exitCode } = await run(dir, "update", "--prod");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(withPeer("^1.0.10", "^1.0.0"));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --dev` with only a stale peerDependencies entry has nothing to update", async () => {
const dir = await stalePeerEntry();
const packageJsonBefore = await packageJsonText(dir);
await expectNothingToUpdate(dir, noneSelected(2, "selected by --dev"), "--dev");
expect(await packageJsonText(dir)).toBe(packageJsonBefore);
});
// pkg1 has a stale entry in every group; pkg2 only in dependencies; the root has none.
const PKG1_GROUPS = (noDeps: string, types: string, aDep: string) => ({
name: "pkg1",
version: "1.0.0",
dependencies: { "no-deps": noDeps, "@types/no-deps": types },
devDependencies: { "a-dep": aDep },
});
const PKG2_GROUPS = (depWithTags: string, types: string) => ({
name: "pkg2",
version: "1.0.0",
dependencies: { "dep-with-tags": depWithTags, "@types/no-deps": types },
});
async function staleMemberGroups() {
const dir = await setup({
"package.json": ROOT,
"packages/pkg1/package.json": PKG1_GROUPS("1.0.0", "1.0.0", "1.0.1"),
"packages/pkg2/package.json": PKG2_GROUPS("1.0.0", "1.0.0"),
});
await write(join(dir, "packages/pkg2/package.json"), stringify(PKG2_GROUPS("^1.0.0", "^1.0.0")));
await reinstall(dir, PKG1_GROUPS("^1.0.0", "^1.0.0", "^1.0.1"), {}, "packages/pkg1");
const locked = async () => ({
"no-deps": await lockedVersions(dir, "no-deps"),
"@types/no-deps": await lockedVersions(dir, "@types/no-deps"),
"a-dep": await lockedVersions(dir, "a-dep"),
"dep-with-tags": await lockedVersions(dir, "dep-with-tags"),
});
const stale = {
"no-deps": ["1.0.0"],
"@types/no-deps": ["1.0.0"],
"a-dep": ["1.0.1"],
"dep-with-tags": ["1.0.0"],
};
expect(await locked()).toStrictEqual(stale);
const texts = () => Promise.all(MEMBER_FILES.map(rel => packageJsonText(dir, rel)));
return { dir, locked, stale, texts, textsBefore: await texts() };
}
test.concurrent("`bun update --dev -r` rewrites every workspace's devDependencies and nothing else", async () => {
const { dir, locked, stale, texts, textsBefore } = await staleMemberGroups();
const [rootBefore, , pkg2Before] = textsBefore;
const { stdout, stderr, exitCode } = await run(dir, "update", "--dev", "-r");
expectRowsAnd(stdout, [A_DEP_ROW], installed(1));
expectCleanStderr(stderr);
expect(await texts()).toStrictEqual([rootBefore, stringify(PKG1_GROUPS("^1.0.0", "^1.0.0", "^1.0.10")), pkg2Before]);
expect(await locked()).toStrictEqual({ ...stale, "a-dep": ["1.0.10"] });
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update --prod --filter pkg1` rewrites only pkg1's dependencies", async () => {
const { dir, locked, stale, texts, textsBefore } = await staleMemberGroups();
const [rootBefore, , pkg2Before] = textsBefore;
const { stdout, stderr, exitCode } = await run(dir, "update", "--prod", "--filter", "pkg1");
expectRowsAnd(stdout, [NO_DEPS_ROW], installed(1));
expectCleanStderr(stderr);
expect(await texts()).toStrictEqual([rootBefore, stringify(PKG1_GROUPS("^1.1.0", "^1.0.0", "^1.0.1")), pkg2Before]);
expect(await locked()).toStrictEqual({ ...stale, "no-deps": ["1.1.0"] });
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update '@types/*' --filter pkg1 --latest` rewrites only pkg1's matching entry", async () => {
const { dir, locked, stale, texts, textsBefore } = await staleMemberGroups();
const [rootBefore, , pkg2Before] = textsBefore;
const { stdout, stderr, exitCode } = await run(dir, "update", "@types/*", "--filter", "pkg1", "--latest");
expectMoved(stdout, "@types/no-deps", "1.0.0", "2.0.0");
expectCleanStderr(stderr);
expect(await texts()).toStrictEqual([rootBefore, stringify(PKG1_GROUPS("^1.0.0", "^2.0.0", "^1.0.1")), pkg2Before]);
expect(await locked()).toStrictEqual({ ...stale, "@types/no-deps": ["1.0.0", "2.0.0"] });
const { workspaces } = await lock(dir);
expect(workspaces["packages/pkg2"].dependencies["@types/no-deps"]).toBe("^1.0.0");
await frozen(dir);
expect(exitCode).toBe(0);
});
// The root's only rows are its two workspace links, which are not dependencies to check; pkg2 has two.
test.concurrent.each<[string, string[], number]>([
["from the root without -r", ["--dev"], 0],
["with --filter pkg2", ["--dev", "--filter", "pkg2"], 2],
])("`bun update --dev` %s selects no entries and says what it checked", async (_, args, checked) => {
const { dir, texts, textsBefore } = await staleMemberGroups();
await expectNothingToUpdate(dir, noneSelected(checked, "selected by --dev"), ...args);
expect(await texts()).toStrictEqual(textsBefore);
});
// pkg1's exact pin is widened in package.json only, so the update itself is the first thing to resolve the new range.
async function widenedPkg1() {
const dir = await setup({
"package.json": ROOT,
"packages/pkg1/package.json": member("pkg1", { "no-deps": "1.0.0" }),
"packages/pkg2/package.json": member("pkg2", { "no-deps": "1.0.0" }),
});
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
await write(join(dir, "packages/pkg1/package.json"), stringify(member("pkg1", { "no-deps": "^1.0.0" })));
return { dir, rootText: await packageJsonText(dir), pkg2Text: await packageJsonText(dir, "packages/pkg2") };
}
test.concurrent.each([
["from the member", "packages/pkg1", []],
["with -r from the root", "", ["-r"]],
])("`bun update <name>` %s re-resolves a range widened since the last install", async (_, cwd, args) => {
const { dir, rootText, pkg2Text } = await widenedPkg1();
const { stdout, stderr, exitCode } = await runIn(dir, cwd, "update", ...args, "no-deps");
// Two installs: 1.1.0 takes the hoisted slot and pkg2's 1.0.0 is re-installed nested; the one row is the move itself.
expectRowsAnd(stdout, [NO_DEPS_ROW], installed(2));
expectCleanStderr(stderr);
expect(await installedVersion(dir, "no-deps")).toBe("1.1.0");
expect((await file(join(dir, "packages/pkg2/node_modules/no-deps/package.json")).json()).version).toBe("1.0.0");
expect(await exists(join(dir, "packages/pkg1/node_modules/no-deps"))).toBeFalse();
expect(await packageJsonText(dir)).toBe(rootText);
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "^1.1.0" }));
expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text);
const { workspaces } = await lock(dir);
expect(workspaces["packages/pkg1"].dependencies).toStrictEqual({ "no-deps": "^1.1.0" });
expect(workspaces["packages/pkg2"].dependencies).toStrictEqual({ "no-deps": "1.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0", "1.1.0"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update <name> --filter <other>` records a widened range without re-resolving it", async () => {
const { dir, rootText, pkg2Text } = await widenedPkg1();
const { stderr, exitCode } = await run(dir, "update", "--filter", "pkg2", "no-deps");
expect(stderr).not.toContain("error:");
expect(await packageJsonText(dir)).toBe(rootText);
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "^1.0.0" }));
expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text);
const { workspaces } = await lock(dir);
expect(workspaces["packages/pkg1"].dependencies).toStrictEqual({ "no-deps": "^1.0.0" });
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
test.concurrent(
"`bun update <name>` from the root still rejects a name only members declare after one was widened",
async () => {
const { dir, rootText, pkg2Text } = await widenedPkg1();
const lockBefore = await lockText(dir);
const { stderr, exitCode } = await run(dir, "update", "no-deps");
expect(stderr).toContain(NOT_A_DEPENDENCY_HERE("no-deps", "pkg1", "pkg2"));
expect(stderr).not.toContain("note:");
expect(await packageJsonText(dir)).toBe(rootText);
expect(await packageJsonOf(dir, "packages/pkg1")).toStrictEqual(member("pkg1", { "no-deps": "^1.0.0" }));
expect(await packageJsonText(dir, "packages/pkg2")).toBe(pkg2Text);
expect(await lockText(dir)).toBe(lockBefore);
expect(exitCode).toBe(1);
},
);
// `a` selects every offered row and `\r` confirms; EOF confirms too, so "" answers an empty picker.
async function runInteractive(dir: string, keys: string, ...args: string[]) {
await using proc = Bun.spawn({
cmd: [bunExe(), "update", "-i", ...args],
cwd: dir,
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") },
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
proc.stdin.write(keys);
proc.stdin.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
test.concurrent("`bun update -i --dev` only offers and updates devDependencies", async () => {
const { dir } = await staleSiblings(DEV_A_DEP);
const { stderr, exitCode } = await runInteractive(dir, "a\r", "--dev");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(grouped({ "no-deps": "^1.0.0", "a-dep": "^1.0.10" }, DEV_A_DEP));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
await frozen(dir);
expect(exitCode).toBe(0);
});
// `-i --dry-run` answers with the same rows and count line as `bun update --dry-run`; the picker itself is drawn above them.
function expectInteractiveDryRun(stdout: string, ...rows: string[]) {
expect(movedRows(stdout)).toStrictEqual(rows);
expect(normalize(stdout)).toContain(`\n${wouldUpdate(rows.length)}\n`);
expect(stdout).not.toContain("Would update");
}
test.concurrent("`bun update -i --prod --dry-run` lists only dependencies", async () => {
const { dir } = await staleSiblings(DEV_A_DEP);
const before = await lockText(dir);
const { stdout, exitCode } = await runInteractive(dir, "a\r", "--prod", "--dry-run");
expectInteractiveDryRun(stdout, NO_DEPS_ROW);
expect(stdout).not.toContain("a-dep");
expect(await lockText(dir)).toBe(before);
expect(exitCode).toBe(0);
});
test.concurrent("`bun update -i --no-optional --dry-run` skips optionalDependencies", async () => {
const { dir } = await staleSiblings({ "a-dep": "optionalDependencies" });
const { stdout, exitCode } = await runInteractive(dir, "a\r", "--no-optional", "--dry-run");
expectInteractiveDryRun(stdout, NO_DEPS_ROW);
expect(stdout).not.toContain("a-dep");
expect(exitCode).toBe(0);
});
test.concurrent(
"`bun update -i` with a selector that matches nothing prints the same no-op line as `bun update`",
async () => {
const dir = await setup({ "package.json": pkgJson({ "no-deps": "^1.0.0" }) });
const before = await lockText(dir);
const { stdout, exitCode } = await runInteractive(dir, "", "--dev");
expect(normalize(stdout)).toContain(`\n${noneSelected(1, "selected by --dev")}\n`);
expect(stdout).not.toContain("No packages to update");
expect(stdout).not.toContain("no-deps");
expect(await lockText(dir)).toBe(before);
expect(exitCode).toBe(0);
},
);
test.concurrent("`bun update -i` without a selector still offers every group", async () => {
const { dir } = await staleSiblings(DEV_A_DEP);
const { stdout, exitCode } = await runInteractive(dir, "a\r", "--dry-run");
expectInteractiveDryRun(stdout, A_DEP_ROW, NO_DEPS_ROW);
expect(exitCode).toBe(0);
});
// Rows are offered sorted by name: a-dep, then no-deps; one-range-dep's edge keeps [email protected] in the picture as a transitive row too.
async function staleSiblingsWithTransitive() {
const dir = await setup({
"package.json": pkgJson({ "no-deps": "1.0.0", "a-dep": "1.0.1", "one-range-dep": "1.0.0" }),
});
await reinstall(dir, pkgJson({ "no-deps": "^1.0.0", "a-dep": "^1.0.1", "one-range-dep": "1.0.0" }));
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.1"]);
return dir;
}
test.concurrent("`bun update -i` installs only the selected entries, as `bun update <selected>`", async () => {
const dir = await staleSiblingsWithTransitive();
const { stderr, exitCode } = await runInteractive(dir, " \r");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(
pkgJson({ "no-deps": "^1.0.0", "a-dep": "^1.0.10", "one-range-dep": "1.0.0" }),
);
expect(await lockedVersions(dir, "a-dep")).toStrictEqual(["1.0.10"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
expect(await installedVersion(dir, "no-deps")).toBe("1.0.0");
await frozen(dir);
expect(exitCode).toBe(0);
});
// Offered sorted by name: dep-with-tags (in range 1.0.1, latest 3.0.0), then no-deps (in range 1.1.0, latest 2.0.0).
test.concurrent("`bun update -i --latest` honours an entry toggled back to its in-range target", async () => {
const dir = await setup({ "package.json": pkgJson({ "dep-with-tags": "1.0.0", "no-deps": "1.0.0" }) });
await reinstall(dir, pkgJson({ "dep-with-tags": "^1.0.0", "no-deps": "^1.0.0" }));
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["1.0.0"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["1.0.0"]);
const { stderr, exitCode } = await runInteractive(dir, "lj \r", "--latest");
expect(stderr).not.toContain("error:");
expect(await packageJsonOf(dir)).toStrictEqual(pkgJson({ "dep-with-tags": "^1.0.1", "no-deps": "^2.0.0" }));
expect(await lockedVersions(dir, "dep-with-tags")).toStrictEqual(["1.0.1"]);
expect(await lockedVersions(dir, "no-deps")).toStrictEqual(["2.0.0"]);
expect(await installedVersion(dir, "dep-with-tags")).toBe("1.0.1");
expect(await installedVersion(dir, "no-deps")).toBe("2.0.0");
await frozen(dir);
expect(exitCode).toBe(0);
});