975 lines
32 KiB
TypeScript
975 lines
32 KiB
TypeScript
import { dlopen, FFIType } from "bun:ffi";
|
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
import { closeSync, createReadStream } from "fs";
|
|
import { bunEnv, bunExe, isMusl, isWindows, tempDir, VerdaccioRegistry } from "harness";
|
|
import { join } from "path";
|
|
|
|
let registry: VerdaccioRegistry;
|
|
let registryUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
registry = new VerdaccioRegistry();
|
|
registryUrl = registry.registryUrl();
|
|
await registry.start();
|
|
});
|
|
|
|
afterAll(() => {
|
|
registry.stop();
|
|
});
|
|
|
|
function bunfig() {
|
|
return `[install]\ncache = false\nregistry = "${registryUrl}"\n`;
|
|
}
|
|
|
|
async function install(dir: string) {
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: String(dir),
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
if (exitCode !== 0) {
|
|
throw new Error(`bun install failed (exit ${exitCode})\nstderr: ${stderr}\nstdout: ${stdout}`);
|
|
}
|
|
return { stdout, stderr };
|
|
}
|
|
|
|
async function updateInteractive(
|
|
dir: string,
|
|
{ args = ["--latest"], input = "a\n", cwd }: { args?: string[]; input?: string; cwd?: string } = {},
|
|
) {
|
|
await using proc = Bun.spawn({
|
|
cmd: [bunExe(), "update", "-i", ...args],
|
|
cwd: cwd ?? String(dir),
|
|
env: bunEnv,
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
proc.stdin.write(input);
|
|
proc.stdin.end();
|
|
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
|
|
return { stdout, stderr, exitCode };
|
|
}
|
|
|
|
// Each test owns its own tempDir and subprocesses; the registry is read-only after beforeAll.
|
|
describe.concurrent("bun update --interactive", () => {
|
|
it("should render the outdated-package table with aligned name/version columns", async () => {
|
|
await using dir = tempDir("update-interactive-alignment", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"a-dep": "1.0.1",
|
|
"dep-with-tags": "1.0.0",
|
|
"no-deps": "1.0.0",
|
|
"normal-dep-and-dev-dep": "1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"], input: "\n" });
|
|
|
|
// Exact spacing so a regression in name_padding / current_padding /
|
|
// target_padding fails here instead of slipping through a \s+ regex.
|
|
// Covers: short/medium/long names (5..22 chars), short/long latest
|
|
// versions (2.0.0 vs 1.0.10), and a name wider than the header.
|
|
expect(stdout).toContain("dependencies Current Target Latest");
|
|
expect(stdout).toContain("a-dep 1.0.1 1.0.1 1.0.10");
|
|
expect(stdout).toContain("dep-with-tags 1.0.0 1.0.0 3.0.0");
|
|
expect(stdout).toContain("no-deps 1.0.0 1.0.0 2.0.0");
|
|
expect(stdout).toContain("normal-dep-and-dev-dep 1.0.0 1.0.0 1.0.2");
|
|
expect(stdout).toContain("No packages selected");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it("should list workspace and catalog dependencies with --filter", async () => {
|
|
await using dir = tempDir("update-interactive-workspace-catalog-render", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
catalog: { "no-deps": "1.0.0" },
|
|
workspaces: ["packages/*"],
|
|
}),
|
|
"packages/pkg1/package.json": JSON.stringify({
|
|
name: "pkg1",
|
|
dependencies: { "no-deps": "catalog:" },
|
|
}),
|
|
"packages/pkg2/package.json": JSON.stringify({
|
|
name: "pkg2",
|
|
dependencies: { "a-dep": "1.0.1" },
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, {
|
|
args: ["--filter=*", "--latest", "--dry-run"],
|
|
input: "\n",
|
|
});
|
|
|
|
expect(stdout).toContain("Select packages to update");
|
|
// Catalog-backed dep (pkg1) and direct dep (pkg2) both resolve to a row.
|
|
expect(stdout).toContain("a-dep 1.0.1 1.0.1 1.0.10");
|
|
expect(stdout).toContain("no-deps 1.0.0 1.0.0 2.0.0");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it("should render mixed dependency types under separate section headers", async () => {
|
|
await using dir = tempDir("update-interactive-mixed-sections", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
devDependencies: { "a-dep": "1.0.1" },
|
|
peerDependencies: { "dep-with-tags": "1.0.0" },
|
|
optionalDependencies: { "normal-dep-and-dev-dep": "1.0.0" },
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"], input: "\n" });
|
|
|
|
// name_padding accounts for the " dev"/" peer"/" optional" suffix so every
|
|
// section's rows line up on the same Current column.
|
|
expect(stdout).toContain("dependencies Current Target Latest");
|
|
expect(stdout).toContain("no-deps 1.0.0 1.0.0 2.0.0");
|
|
expect(stdout).toContain("devDependencies Current Target Latest");
|
|
expect(stdout).toContain("a-dep dev 1.0.1 1.0.1 1.0.10");
|
|
expect(stdout).toContain("peerDependencies Current Target Latest");
|
|
expect(stdout).toContain("dep-with-tags peer 1.0.0 1.0.0 3.0.0");
|
|
expect(stdout).toContain("optionalDependencies Current Target Latest");
|
|
expect(stdout).toContain("normal-dep-and-dev-dep optional 1.0.0 1.0.0 1.0.2");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
// The header's help-text budget is `terminal_width - 30`; on a tty narrower
|
|
// than 30 columns that usize subtraction overflows (panics on overflow-checks
|
|
// builds). Exercise the render path through a 20-column pty.
|
|
it.skipIf(isWindows)("should render on a terminal narrower than the header prefix", async () => {
|
|
const openptyDecl = {
|
|
args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr],
|
|
returns: FFIType.i32,
|
|
} as const;
|
|
const lib =
|
|
process.platform === "darwin"
|
|
? dlopen("libc.dylib", { openpty: openptyDecl })
|
|
: isMusl
|
|
? dlopen(process.arch === "arm64" ? "libc.musl-aarch64.so.1" : "libc.musl-x86_64.so.1", {
|
|
openpty: openptyDecl,
|
|
})
|
|
: dlopen("libutil.so.1", { openpty: openptyDecl });
|
|
|
|
const masterBuf = new Int32Array(1);
|
|
const slaveBuf = new Int32Array(1);
|
|
// struct winsize { u16 ws_row; u16 ws_col; u16 ws_xpixel; u16 ws_ypixel; }
|
|
const winsize = new Uint16Array([24, 20, 0, 0]);
|
|
expect(lib.symbols.openpty(masterBuf, slaveBuf, null, null, winsize)).toBe(0);
|
|
const master = masterBuf[0];
|
|
const slave = slaveBuf[0];
|
|
let slaveOpen = true;
|
|
|
|
try {
|
|
await using dir = tempDir("update-interactive-narrow-tty", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: { "no-deps": "1.0.0" },
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
|
|
await using update = Bun.spawn({
|
|
cmd: [bunExe(), "update", "--interactive", "--dry-run"],
|
|
cwd: dir,
|
|
env: bunEnv,
|
|
stdin: "pipe",
|
|
stdout: slave,
|
|
stderr: "pipe",
|
|
});
|
|
// Drop the parent's slave handle so master reads see EOF on child exit.
|
|
closeSync(slave);
|
|
slaveOpen = false;
|
|
|
|
// Drain the master concurrently so a crashing debug build's backtrace
|
|
// doesn't block on a full pty buffer.
|
|
let ptyOutput = "";
|
|
const ptyStream = createReadStream("", { fd: master, autoClose: false });
|
|
const drained = new Promise<void>(resolve => {
|
|
ptyStream.on("data", chunk => (ptyOutput += chunk.toString("utf8")));
|
|
ptyStream.on("error", () => resolve());
|
|
ptyStream.on("end", () => resolve());
|
|
ptyStream.on("close", () => resolve());
|
|
});
|
|
|
|
update.stdin.write("\r");
|
|
update.stdin.end();
|
|
|
|
const [stderr, exitCode] = await Promise.all([update.stderr.text(), update.exited]);
|
|
await drained;
|
|
|
|
if (exitCode !== 0) {
|
|
console.error("stderr:", stderr);
|
|
}
|
|
|
|
// The header proves we reached process_multi_select where width is used.
|
|
expect(ptyOutput).toContain("Select packages to update");
|
|
expect(exitCode).toBe(0);
|
|
} finally {
|
|
if (slaveOpen) {
|
|
try {
|
|
closeSync(slave);
|
|
} catch {}
|
|
}
|
|
try {
|
|
closeSync(master);
|
|
} catch {}
|
|
}
|
|
});
|
|
|
|
it("should update packages when 'a' (select all) is used", async () => {
|
|
await using dir = tempDir("update-interactive-select-all", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, stderr, exitCode } = await updateInteractive(dir);
|
|
|
|
expect(stderr + stdout).toContain("Installing updates...");
|
|
expect(stderr + stdout).toContain("Saved lockfile");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.dependencies["no-deps"]).toBe("2.0.0");
|
|
});
|
|
|
|
it("should handle workspace updates with recursive flag", async () => {
|
|
await using dir = tempDir("update-interactive-workspace-recursive", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
private: true,
|
|
workspaces: ["packages/*"],
|
|
}),
|
|
"packages/app/package.json": JSON.stringify({
|
|
name: "@test/app",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, stderr, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stderr + stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const appPackageJson = await Bun.file(join(dir, "packages/app/package.json")).json();
|
|
expect(appPackageJson.dependencies["no-deps"]).toBe("2.0.0");
|
|
});
|
|
|
|
it("should handle catalog updates correctly", async () => {
|
|
await using dir = tempDir("update-interactive-catalog-actual", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: ["packages/*"],
|
|
catalog: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
"packages/app/package.json": JSON.stringify({
|
|
name: "@test/app",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "catalog:",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const rootPackageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(rootPackageJson.catalog["no-deps"]).toBe("2.0.0");
|
|
|
|
const appPackageJson = await Bun.file(join(dir, "packages/app/package.json")).json();
|
|
expect(appPackageJson.dependencies["no-deps"]).toBe("catalog:");
|
|
});
|
|
|
|
it("should work correctly when run from inside a workspace directory", async () => {
|
|
await using dir = tempDir("update-interactive-from-workspace", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: ["packages/*"],
|
|
}),
|
|
"packages/app1/package.json": JSON.stringify({
|
|
name: "@test/app1",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
"packages/app2/package.json": JSON.stringify({
|
|
name: "@test/app2",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"dep-with-tags": "1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, {
|
|
args: ["-r", "--latest"],
|
|
cwd: join(dir, "packages/app1"),
|
|
});
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const app1Json = await Bun.file(join(dir, "packages/app1/package.json")).json();
|
|
const app2Json = await Bun.file(join(dir, "packages/app2/package.json")).json();
|
|
|
|
expect(app1Json.dependencies["no-deps"]).toBe("2.0.0");
|
|
expect(app2Json.dependencies["dep-with-tags"]).toBe("3.0.0");
|
|
});
|
|
|
|
it("should handle catalog updates in workspaces.catalogs object", async () => {
|
|
await using dir = tempDir("update-interactive-workspaces-catalogs", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: {
|
|
packages: ["packages/*"],
|
|
catalogs: {
|
|
"tools": {
|
|
"no-deps": "^1.0.0",
|
|
"dep-with-tags": "~1.0.0",
|
|
},
|
|
"frameworks": {
|
|
"a-dep": ">=1.0.5",
|
|
"normal-dep-and-dev-dep": "^1.0.0",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
"packages/app/package.json": JSON.stringify({
|
|
name: "@test/app",
|
|
dependencies: {
|
|
"no-deps": "catalog:tools",
|
|
"dep-with-tags": "catalog:tools",
|
|
"a-dep": "catalog:frameworks",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.workspaces.catalogs).toEqual({
|
|
tools: { "no-deps": "^2.0.0", "dep-with-tags": "~3.0.0" },
|
|
// a-dep >=1.0.5 already resolves to latest (1.0.10) so it is not listed
|
|
// as outdated; normal-dep-and-dev-dep is not referenced by any workspace.
|
|
frameworks: { "a-dep": ">=1.0.5", "normal-dep-and-dev-dep": "^1.0.0" },
|
|
});
|
|
|
|
const appJson = await Bun.file(join(dir, "packages/app/package.json")).json();
|
|
expect(appJson.dependencies).toEqual({
|
|
"no-deps": "catalog:tools",
|
|
"dep-with-tags": "catalog:tools",
|
|
"a-dep": "catalog:frameworks",
|
|
});
|
|
});
|
|
|
|
it("should handle selecting specific packages in interactive mode", async () => {
|
|
await using dir = tempDir("update-interactive-selective", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
"dep-with-tags": "1.0.0",
|
|
"a-dep": "1.0.5",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
// space to toggle first row, arrow down, enter
|
|
const { stdout, exitCode } = await updateInteractive(dir, { input: " \u001b[B\n" });
|
|
|
|
expect(stdout).toContain("Selected 1 package to update");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
let updatedCount = 0;
|
|
if (packageJson.dependencies["no-deps"] !== "1.0.0") updatedCount++;
|
|
if (packageJson.dependencies["dep-with-tags"] !== "1.0.0") updatedCount++;
|
|
if (packageJson.dependencies["a-dep"] !== "1.0.5") updatedCount++;
|
|
expect(updatedCount).toBe(1);
|
|
});
|
|
|
|
it("should handle empty catalog definitions gracefully", async () => {
|
|
await using dir = tempDir("update-interactive-empty-catalog", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: ["packages/*"],
|
|
catalog: {},
|
|
}),
|
|
"packages/app/package.json": JSON.stringify({
|
|
name: "@test/app",
|
|
dependencies: {
|
|
"no-deps": "^1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const appJson = await Bun.file(join(dir, "packages/app/package.json")).json();
|
|
expect(appJson.dependencies["no-deps"]).toBe("^2.0.0");
|
|
|
|
const rootJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(Object.keys(rootJson.catalog)).toHaveLength(0);
|
|
});
|
|
|
|
it("should handle cancellation (Ctrl+C) gracefully", async () => {
|
|
await using dir = tempDir("update-interactive-cancel", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { input: "\u0003" });
|
|
|
|
expect(stdout).toContain("Cancelled");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.dependencies["no-deps"]).toBe("1.0.0");
|
|
});
|
|
|
|
it("should preserve version prefixes on direct dependencies", async () => {
|
|
await using dir = tempDir("update-interactive-direct-prefixes", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
"dep-with-tags": "^1.0.0",
|
|
"a-dep": "~1.0.5",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir);
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.dependencies).toEqual({
|
|
"no-deps": "2.0.0",
|
|
"dep-with-tags": "^3.0.0",
|
|
// ~1.0.5 already resolves to latest (1.0.10) so it is not listed as outdated.
|
|
"a-dep": "~1.0.5",
|
|
});
|
|
});
|
|
|
|
it("should update catalog in workspaces object (not workspaces.catalogs)", async () => {
|
|
await using dir = tempDir("update-interactive-workspaces-catalog", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: {
|
|
packages: ["packages/*"],
|
|
catalog: {
|
|
"no-deps": "^1.0.0",
|
|
"dep-with-tags": "~1.0.0",
|
|
},
|
|
},
|
|
}),
|
|
"packages/app/package.json": JSON.stringify({
|
|
name: "@test/app",
|
|
dependencies: {
|
|
"no-deps": "catalog:",
|
|
"dep-with-tags": "catalog:",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.workspaces.catalog).toEqual({ "no-deps": "^2.0.0", "dep-with-tags": "~3.0.0" });
|
|
});
|
|
|
|
it("should preserve version prefixes on catalog entries, including scoped names", async () => {
|
|
await using dir = tempDir("update-interactive-catalog-prefixes", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: ["packages/*"],
|
|
catalog: {
|
|
"@types/no-deps": "^1.0.0",
|
|
"no-deps": ">=1.0.0 <1.1.0",
|
|
"dep-with-tags": "~1.0.0",
|
|
},
|
|
}),
|
|
"packages/app/package.json": JSON.stringify({
|
|
name: "@test/app",
|
|
dependencies: {
|
|
"@types/no-deps": "catalog:",
|
|
"no-deps": "catalog:",
|
|
"dep-with-tags": "catalog:",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.catalog).toEqual({
|
|
"@types/no-deps": "^2.0.0",
|
|
"no-deps": ">=2.0.0",
|
|
"dep-with-tags": "~3.0.0",
|
|
});
|
|
});
|
|
|
|
it("should handle catalog updates when running from root with filter", async () => {
|
|
await using dir = tempDir("update-interactive-filter-catalog", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: ["packages/*"],
|
|
catalog: {
|
|
"no-deps": "^1.0.0",
|
|
"dep-with-tags": "~1.0.0",
|
|
},
|
|
}),
|
|
"packages/app1/package.json": JSON.stringify({
|
|
name: "@test/app1",
|
|
dependencies: {
|
|
"no-deps": "catalog:",
|
|
},
|
|
}),
|
|
"packages/app2/package.json": JSON.stringify({
|
|
name: "@test/app2",
|
|
dependencies: {
|
|
"dep-with-tags": "catalog:",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["--filter=@test/app2", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.catalog["dep-with-tags"]).toBe("~3.0.0");
|
|
// app1 was filtered out, so its catalog entry is untouched.
|
|
expect(packageJson.catalog["no-deps"]).toBe("^1.0.0");
|
|
});
|
|
|
|
it("should handle multiple catalog definitions with same package", async () => {
|
|
await using dir = tempDir("update-interactive-multi-catalog", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: {
|
|
packages: ["packages/*"],
|
|
catalogs: {
|
|
"dev": {
|
|
"no-deps": "^1.0.0",
|
|
},
|
|
"prod": {
|
|
"no-deps": "~1.0.0",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
"packages/app/package.json": JSON.stringify({
|
|
name: "@test/app",
|
|
dependencies: {
|
|
"no-deps": "catalog:prod",
|
|
},
|
|
devDependencies: {
|
|
"no-deps": "catalog:dev",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.workspaces.catalogs.dev).toEqual({ "no-deps": "^2.0.0" });
|
|
// group_catalog_dependencies currently keys the interactive list by package
|
|
// name alone, so the catalog:prod reference is deduped with catalog:dev and
|
|
// only dev's entry is rewritten. When that is addressed this becomes "~2.0.0".
|
|
expect(packageJson.workspaces.catalogs.prod).toEqual({ "no-deps": expect.stringMatching(/^~[12]\.0\.0$/) });
|
|
});
|
|
|
|
it("should handle version ranges with multiple conditions", async () => {
|
|
await using dir = tempDir("update-interactive-complex-ranges", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
workspaces: ["packages/*"],
|
|
catalog: {
|
|
"no-deps": "^1.0.0 || ^2.0.0",
|
|
"dep-with-tags": ">=1.0.0 <3.0.0",
|
|
},
|
|
}),
|
|
"packages/app/package.json": JSON.stringify({
|
|
name: "@test/app",
|
|
dependencies: {
|
|
"no-deps": "catalog:",
|
|
"dep-with-tags": "catalog:",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.catalog).toEqual({
|
|
"no-deps": "^1.0.0 || ^2.0.0",
|
|
"dep-with-tags": ">=3.0.0",
|
|
});
|
|
});
|
|
|
|
it("should handle keyboard navigation correctly", async () => {
|
|
await using dir = tempDir("update-interactive-navigation", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
"dep-with-tags": "1.0.0",
|
|
"a-dep": "1.0.5",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
// n (select none), i (invert selection = select all), enter
|
|
const { stdout, exitCode } = await updateInteractive(dir, { input: "ni\n" });
|
|
|
|
expect(stdout).toContain("Selected 3 packages to update");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it("comprehensive interactive update test with all scenarios", async () => {
|
|
await using dir = tempDir("update-interactive-comprehensive", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root-project",
|
|
version: "1.0.0",
|
|
private: true,
|
|
workspaces: ["packages/*"],
|
|
catalog: {
|
|
"no-deps": "^1.0.0",
|
|
"dep-with-tags": "~1.0.0",
|
|
},
|
|
dependencies: {
|
|
"a-dep": "^1.0.5",
|
|
},
|
|
devDependencies: {
|
|
"normal-dep-and-dev-dep": "^1.0.0",
|
|
},
|
|
}),
|
|
"packages/app1/package.json": JSON.stringify({
|
|
name: "@test/app1",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "catalog:",
|
|
"dep-with-tags": "catalog:",
|
|
"a-dep": "^1.0.5",
|
|
},
|
|
devDependencies: {
|
|
"normal-dep-and-dev-dep": "^1.0.0",
|
|
},
|
|
}),
|
|
"packages/app2/package.json": JSON.stringify({
|
|
name: "@test/app2",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "catalog:",
|
|
"a-dep": "^1.0.5",
|
|
},
|
|
devDependencies: {
|
|
"dep-with-tags": "^1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const rootPackageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(rootPackageJson.catalog).toEqual({ "no-deps": "^2.0.0", "dep-with-tags": "~3.0.0" });
|
|
// a-dep ^1.0.5 and normal-dep-and-dev-dep ^1.0.0 already resolve to their
|
|
// latest versions so they are not listed as outdated.
|
|
expect(rootPackageJson.dependencies["a-dep"]).toBe("^1.0.5");
|
|
expect(rootPackageJson.devDependencies["normal-dep-and-dev-dep"]).toBe("^1.0.0");
|
|
|
|
const app1Json = await Bun.file(join(dir, "packages/app1/package.json")).json();
|
|
expect(app1Json.dependencies).toEqual({
|
|
"no-deps": "catalog:",
|
|
"dep-with-tags": "catalog:",
|
|
"a-dep": "^1.0.5",
|
|
});
|
|
expect(app1Json.devDependencies["normal-dep-and-dev-dep"]).toBe("^1.0.0");
|
|
|
|
const app2Json = await Bun.file(join(dir, "packages/app2/package.json")).json();
|
|
expect(app2Json.dependencies).toEqual({ "no-deps": "catalog:", "a-dep": "^1.0.5" });
|
|
expect(app2Json.devDependencies["dep-with-tags"]).toBe("^3.0.0");
|
|
|
|
const lockfileExists = await Bun.file(join(dir, "bun.lock")).exists();
|
|
expect(lockfileExists).toBe(true);
|
|
|
|
// bun install again should make no further changes.
|
|
await using verifyInstall = Bun.spawn({
|
|
cmd: [bunExe(), "install"],
|
|
cwd: dir,
|
|
env: bunEnv,
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
const [verifyStdout, verifyStderr, verifyExitCode] = await Promise.all([
|
|
verifyInstall.stdout.text(),
|
|
verifyInstall.stderr.text(),
|
|
verifyInstall.exited,
|
|
]);
|
|
expect(verifyStdout + verifyStderr).not.toContain("Installing");
|
|
expect(verifyExitCode).toBe(0);
|
|
});
|
|
|
|
it("interactive update with workspace filters", async () => {
|
|
await using dir = tempDir("update-interactive-filter", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "root",
|
|
version: "1.0.0",
|
|
private: true,
|
|
workspaces: ["packages/*"],
|
|
catalog: {
|
|
"no-deps": "^1.0.0",
|
|
},
|
|
}),
|
|
"packages/frontend/package.json": JSON.stringify({
|
|
name: "@test/frontend",
|
|
dependencies: {
|
|
"no-deps": "catalog:",
|
|
"a-dep": "^1.0.5",
|
|
},
|
|
}),
|
|
"packages/backend/package.json": JSON.stringify({
|
|
name: "@test/backend",
|
|
dependencies: {
|
|
"dep-with-tags": "^1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["--filter=@test/frontend", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const rootJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(rootJson.catalog["no-deps"]).toBe("^2.0.0");
|
|
|
|
const frontendJson = await Bun.file(join(dir, "packages/frontend/package.json")).json();
|
|
expect(frontendJson.dependencies["a-dep"]).toBe("^1.0.5");
|
|
|
|
const backendJson = await Bun.file(join(dir, "packages/backend/package.json")).json();
|
|
expect(backendJson.dependencies["dep-with-tags"]).toBe("^1.0.0");
|
|
});
|
|
|
|
it("should not modify package.json in dry-run mode", async () => {
|
|
await using dir = tempDir("update-interactive-dry-run", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"no-deps": "1.0.0",
|
|
"dep-with-tags": "1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const originalContent = await Bun.file(join(dir, "package.json")).text();
|
|
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"] });
|
|
|
|
expect(stdout).toContain("Selected 2 packages to update");
|
|
expect(stdout).toContain("would be updated");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const afterContent = await Bun.file(join(dir, "package.json")).text();
|
|
expect(afterContent).toBe(originalContent);
|
|
});
|
|
|
|
it("should preserve npm: alias prefix when updating packages", async () => {
|
|
await using dir = tempDir("update-interactive-npm-alias", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"my-alias": "npm:[email protected]",
|
|
"@my/alias": "npm:@types/no-deps@^1.0.0",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir);
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const packageJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(packageJson.dependencies["my-alias"]).toBe("npm:[email protected]");
|
|
expect(packageJson.dependencies["@my/alias"]).toBe("npm:@types/no-deps@^2.0.0");
|
|
});
|
|
|
|
it("interactive update with mixed dependency types", async () => {
|
|
await using dir = tempDir("update-interactive-mixed", {
|
|
"bunfig.toml": bunfig(),
|
|
"package.json": JSON.stringify({
|
|
name: "test-project",
|
|
version: "1.0.0",
|
|
workspaces: ["packages/*"],
|
|
catalog: {
|
|
"a-dep": "^1.0.5",
|
|
},
|
|
dependencies: {
|
|
"no-deps": "^1.0.0",
|
|
},
|
|
devDependencies: {
|
|
"dep-with-tags": "~1.0.0",
|
|
},
|
|
peerDependencies: {
|
|
"a-dep": ">=1.0.5",
|
|
},
|
|
optionalDependencies: {
|
|
"normal-dep-and-dev-dep": "^1.0.0",
|
|
},
|
|
}),
|
|
"packages/workspace1/package.json": JSON.stringify({
|
|
name: "@test/workspace1",
|
|
dependencies: {
|
|
"a-dep": "catalog:",
|
|
"@test/workspace2": "workspace:*",
|
|
},
|
|
devDependencies: {
|
|
"no-deps": "^1.0.0",
|
|
},
|
|
}),
|
|
"packages/workspace2/package.json": JSON.stringify({
|
|
name: "@test/workspace2",
|
|
version: "1.0.0",
|
|
dependencies: {
|
|
"a-dep": "catalog:",
|
|
},
|
|
}),
|
|
});
|
|
|
|
await install(dir);
|
|
const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] });
|
|
|
|
expect(stdout).toContain("Installing updates...");
|
|
expect(exitCode).toBe(0);
|
|
|
|
const rootJson = await Bun.file(join(dir, "package.json")).json();
|
|
expect(rootJson.catalog["a-dep"]).toBe("^1.0.5");
|
|
expect(rootJson.dependencies["no-deps"]).toBe("^2.0.0");
|
|
expect(rootJson.devDependencies["dep-with-tags"]).toBe("~3.0.0");
|
|
expect(rootJson.peerDependencies["a-dep"]).toBe(">=1.0.5");
|
|
expect(rootJson.optionalDependencies["normal-dep-and-dev-dep"]).toBe("^1.0.0");
|
|
|
|
const ws1Json = await Bun.file(join(dir, "packages/workspace1/package.json")).json();
|
|
expect(ws1Json.dependencies).toEqual({ "a-dep": "catalog:", "@test/workspace2": "workspace:*" });
|
|
expect(ws1Json.devDependencies["no-deps"]).toBe("^2.0.0");
|
|
|
|
const ws2Json = await Bun.file(join(dir, "packages/workspace2/package.json")).json();
|
|
expect(ws2Json.dependencies["a-dep"]).toBe("catalog:");
|
|
});
|
|
});
|