Files
bun.sh/test/cli/update_interactive_formatting.test.ts
Michael H d76fad3618 fix update interactive to keep npm aliases (#23903)
### What does this PR do?

fixes #23901

### How did you verify your code works?

with a test

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-11-03 02:12:24 -08:00

1998 lines
60 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles, 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();
});
describe("bun update --interactive", () => {
it("should handle package names of unusual lengths", async () => {
const dir = tempDirWithFiles("update-interactive-test", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"a": "1.0.0",
"really-long-package-name-that-causes-formatting-issues": "1.0.0",
"@org/extremely-long-scoped-package-name-that-will-test-formatting": "1.0.0",
"short": "1.0.0",
"another-package-with-a-very-long-name-to-test-column-alignment": "1.0.0",
},
devDependencies: {
"dev-package": "1.0.0",
"super-long-dev-package-name-that-should-not-break-formatting": "1.0.0",
},
peerDependencies: {
"peer-package": "1.0.0",
"extremely-long-peer-dependency-name-for-testing-column-alignment": "1.0.0",
},
optionalDependencies: {
"optional-package": "1.0.0",
"very-long-optional-dependency-name-that-tests-formatting": "1.0.0",
},
}),
"bun.lockb": JSON.stringify({
"lockfileVersion": 3,
"packages": {
"a": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"really-long-package-name-that-causes-formatting-issues": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"@org/extremely-long-scoped-package-name-that-will-test-formatting": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"short": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"another-package-with-a-very-long-name-to-test-column-alignment": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"dev-package": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"super-long-dev-package-name-that-should-not-break-formatting": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"peer-package": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"extremely-long-peer-dependency-name-for-testing-column-alignment": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"optional-package": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"very-long-optional-dependency-name-that-tests-formatting": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
},
}),
});
// Mock outdated packages by creating fake manifests
const manifestsDir = join(dir, ".bun", "manifests");
await Bun.write(
join(manifestsDir, "a.json"),
JSON.stringify({
name: "a",
"dist-tags": { latest: "2.0.0" },
versions: {
"1.0.0": { version: "1.0.0" },
"2.0.0": { version: "2.0.0" },
},
}),
);
// Test that the command doesn't crash with unusual package name lengths
const result = await Bun.spawn({
cmd: [bunExe(), "update", "--interactive", "--dry-run"],
cwd: dir,
env: bunEnv,
stdin: "inherit",
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(result.stdout).text();
const stderr = await new Response(result.stderr).text();
// The command might fail due to missing manifests, but it shouldn't crash
// due to formatting issues
expect(stderr).not.toContain("panic");
expect(stderr).not.toContain("segfault");
expect(stderr).not.toContain("underflow");
expect(stderr).not.toContain("overflow");
});
it("should handle version strings of unusual lengths", async () => {
const dir = tempDirWithFiles("update-interactive-versions-test", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"package-with-long-version": "1.0.0-alpha.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20",
"package-with-short-version": "1.0.0",
"package-with-prerelease": "1.0.0-beta.1+build.1234567890.abcdef",
},
}),
"bun.lockb": JSON.stringify({
"lockfileVersion": 3,
"packages": {
"package-with-long-version": {
"integrity": "sha512-fake",
"version": "1.0.0-alpha.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20",
},
"package-with-short-version": {
"integrity": "sha512-fake",
"version": "1.0.0",
},
"package-with-prerelease": {
"integrity": "sha512-fake",
"version": "1.0.0-beta.1+build.1234567890.abcdef",
},
},
}),
});
// Test that the command doesn't crash with unusual version string lengths
const result = await Bun.spawn({
cmd: [bunExe(), "update", "--interactive", "--dry-run"],
cwd: dir,
env: bunEnv,
stdin: "inherit",
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(result.stdout).text();
const stderr = await new Response(result.stderr).text();
// The command might fail due to missing manifests, but it shouldn't crash
// due to formatting issues
expect(stderr).not.toContain("panic");
expect(stderr).not.toContain("segfault");
expect(stderr).not.toContain("underflow");
expect(stderr).not.toContain("overflow");
});
it("should truncate extremely long package names", async () => {
const extremelyLongPackageName = "a".repeat(100);
const dir = tempDirWithFiles("update-interactive-truncate-test", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
[extremelyLongPackageName]: "1.0.0",
},
}),
"bun.lockb": JSON.stringify({
"lockfileVersion": 3,
"packages": {
[extremelyLongPackageName]: {
"integrity": "sha512-fake",
"version": "1.0.0",
},
},
}),
});
// Test that extremely long package names are handled gracefully
const result = await Bun.spawn({
cmd: [bunExe(), "update", "--interactive", "--dry-run"],
cwd: dir,
env: bunEnv,
stdin: "inherit",
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(result.stdout).text();
const stderr = await new Response(result.stderr).text();
// The command might fail due to missing manifests, but it shouldn't crash
// due to formatting issues
expect(stderr).not.toContain("panic");
expect(stderr).not.toContain("segfault");
expect(stderr).not.toContain("underflow");
expect(stderr).not.toContain("overflow");
});
it("should show workspace column with --filter", async () => {
const dir = tempDirWithFiles("update-interactive-workspace-col-test", {
"package.json": JSON.stringify({
name: "root",
version: "1.0.0",
workspaces: ["packages/*"],
}),
"packages/pkg1/package.json": JSON.stringify({
name: "pkg1",
dependencies: {
"dep1": "1.0.0",
},
}),
"packages/pkg2/package.json": JSON.stringify({
name: "pkg2",
dependencies: {
"dep2": "1.0.0",
},
}),
});
// Test with --filter should include workspace column
const result = await Bun.spawn({
cmd: [bunExe(), "update", "--interactive", "--filter=*", "--dry-run"],
cwd: dir,
env: bunEnv,
stdin: "inherit",
stdout: "pipe",
stderr: "pipe",
});
const stderr = await new Response(result.stderr).text();
// Should not crash with workspace column
expect(stderr).not.toContain("panic");
expect(stderr).not.toContain("segfault");
});
it("should handle catalog dependencies in interactive update", async () => {
const dir = tempDirWithFiles("update-interactive-catalog-test", {
"package.json": JSON.stringify({
name: "root",
version: "1.0.0",
catalog: {
"shared-dep": "1.0.0",
},
workspaces: ["packages/*"],
}),
"packages/pkg1/package.json": JSON.stringify({
name: "pkg1",
dependencies: {
"shared-dep": "catalog:",
},
}),
"packages/pkg2/package.json": JSON.stringify({
name: "pkg2",
dependencies: {
"shared-dep": "catalog:",
},
}),
});
// Test interactive update with catalog dependencies
const result = await Bun.spawn({
cmd: [bunExe(), "update", "--interactive", "--filter=*", "--dry-run"],
cwd: dir,
env: bunEnv,
stdin: "inherit",
stdout: "pipe",
stderr: "pipe",
});
const stderr = await new Response(result.stderr).text();
// Should not crash with catalog dependencies
expect(stderr).not.toContain("panic");
expect(stderr).not.toContain("segfault");
expect(stderr).not.toContain("catalog: failed to resolve");
});
it("should handle mixed dependency types with various name lengths", async () => {
const dir = tempDirWithFiles("update-interactive-mixed-test", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"a": "1.0.0",
"really-long-dependency-name": "1.0.0",
},
devDependencies: {
"b": "1.0.0",
"super-long-dev-dependency-name": "1.0.0",
},
peerDependencies: {
"c": "1.0.0",
"extremely-long-peer-dependency-name": "1.0.0",
},
optionalDependencies: {
"d": "1.0.0",
"very-long-optional-dependency-name": "1.0.0",
},
}),
"bun.lockb": JSON.stringify({
"lockfileVersion": 3,
"packages": {
"a": { "integrity": "sha512-fake", "version": "1.0.0" },
"really-long-dependency-name": { "integrity": "sha512-fake", "version": "1.0.0" },
"b": { "integrity": "sha512-fake", "version": "1.0.0" },
"super-long-dev-dependency-name": { "integrity": "sha512-fake", "version": "1.0.0" },
"c": { "integrity": "sha512-fake", "version": "1.0.0" },
"extremely-long-peer-dependency-name": { "integrity": "sha512-fake", "version": "1.0.0" },
"d": { "integrity": "sha512-fake", "version": "1.0.0" },
"very-long-optional-dependency-name": { "integrity": "sha512-fake", "version": "1.0.0" },
},
}),
});
// Test that mixed dependency types with various name lengths don't cause crashes
const result = await Bun.spawn({
cmd: [bunExe(), "update", "--interactive", "--dry-run"],
cwd: dir,
env: bunEnv,
stdin: "inherit",
stdout: "pipe",
stderr: "pipe",
});
const stdout = await new Response(result.stdout).text();
const stderr = await new Response(result.stderr).text();
// The command might fail due to missing manifests, but it shouldn't crash
// due to formatting issues
expect(stderr).not.toContain("panic");
expect(stderr).not.toContain("segfault");
expect(stderr).not.toContain("underflow");
expect(stderr).not.toContain("overflow");
});
it("should update packages when 'a' (select all) is used", async () => {
const dir = tempDirWithFiles("update-interactive-select-all", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0", // Old version
},
}),
});
// First install to get lockfile
const install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Test interactive update with 'a' to select all
const update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then Enter to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const stdout = await new Response(update.stdout).text();
const stderr = await new Response(update.stderr).text();
const output = stdout + stderr;
if (exitCode !== 0) {
console.error("Update failed with exit code:", exitCode);
console.error("Stdout:", stdout);
console.error("Stderr:", stderr);
}
// Should complete successfully
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
// Check if package.json was updated
const packageJson = await Bun.file(join(dir, "package.json")).json();
// no-deps should be updated from 1.0.0 to 2.0.0
expect(packageJson.dependencies["no-deps"]).toBe("2.0.0");
// Check that the output shows the package was installed/updated
expect(output).toContain("Installing updates...");
// todo: Should show the installed package in the summary
// expect(output).toContain("installed no-deps@");
// Should save the lockfile
expect(output).toContain("Saved lockfile");
});
it("should handle workspace updates with recursive flag", async () => {
const dir = tempDirWithFiles("update-interactive-workspace-recursive", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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", // Old version in workspace
},
}),
});
// First install
const install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Test interactive update with recursive flag
const update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Select all packages
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const stderr = await new Response(update.stderr).text();
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
// Check if workspace package was updated
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 () => {
const dir = tempDirWithFiles("update-interactive-catalog-actual", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "root",
version: "1.0.0",
workspaces: ["packages/*"],
catalog: {
"no-deps": "1.0.0", // Old version in catalog
},
}),
"packages/app/package.json": JSON.stringify({
name: "@test/app",
version: "1.0.0",
dependencies: {
"no-deps": "catalog:",
},
}),
});
// First install
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const stdout = await new Response(update.stdout).text();
const stderr = await new Response(update.stderr).text();
expect(exitCode).toBe(0);
expect(stdout + stderr).not.toContain("panic");
expect(stdout + stderr).not.toContain("catalog: failed to resolve");
// Check if catalog was updated in root package.json
const rootPackageJson = await Bun.file(join(dir, "package.json")).json();
expect(rootPackageJson.catalog["no-deps"]).toBe("2.0.0");
// App package.json should still have catalog reference
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 () => {
const dir = tempDirWithFiles("update-interactive-from-workspace", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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",
},
}),
});
// First install from root
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update from inside workspace
const workspaceDir = join(dir, "packages/app1");
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: workspaceDir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
const stderr = await new Response(update.stderr).text();
const combined = output + stderr;
// Should not fail with FileNotFound
expect(exitCode).toBe(0);
expect(combined).not.toContain("FileNotFound");
expect(combined).not.toContain("Failed to update");
// Check that both workspace packages were updated
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 basic interactive update with select all", async () => {
const dir = tempDirWithFiles("update-interactive-basic", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Check if package was updated
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.dependencies["no-deps"]).toBe("2.0.0");
});
it("should preserve version prefixes for all semver range types in catalogs", async () => {
const dir = tempDirWithFiles("update-interactive-semver-prefixes", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "root",
version: "1.0.0",
workspaces: ["packages/*"],
catalog: {
"no-deps": "^1.0.0",
"dep-with-tags": "~1.0.0",
"a-dep": ">=1.0.5",
},
}),
"packages/app/package.json": JSON.stringify({
name: "@test/app",
dependencies: {
"no-deps": "catalog:",
"dep-with-tags": "catalog:",
"a-dep": "catalog:",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Check if prefixes were preserved
const packageJson = await Bun.file(join(dir, "package.json")).json();
// All prefixes should be preserved (versions may or may not change)
expect(packageJson.catalog["no-deps"]).toMatch(/^\^/);
expect(packageJson.catalog["dep-with-tags"]).toMatch(/^~/);
expect(packageJson.catalog["a-dep"]).toMatch(/^>=/);
});
it("should handle catalog updates in workspaces.catalogs object", async () => {
const dir = tempDirWithFiles("update-interactive-workspaces-catalogs", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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",
"a-dep": "catalog:frameworks",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
expect(output).toContain("Installing updates...");
// Check if catalogs were updated correctly
const packageJson = await Bun.file(join(dir, "package.json")).json();
// Prefixes should be preserved
expect(packageJson.workspaces.catalogs.tools["no-deps"]).toMatch(/^\^/);
expect(packageJson.workspaces.catalogs.tools["dep-with-tags"]).toMatch(/^~/);
});
it("should handle mixed workspace and catalog dependencies", async () => {
const dir = tempDirWithFiles("update-interactive-mixed-deps", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "root",
version: "1.0.0",
workspaces: ["packages/*"],
catalog: {
"no-deps": "^1.0.0",
},
}),
"packages/lib/package.json": JSON.stringify({
name: "@test/lib",
version: "1.0.0",
dependencies: {
"a-dep": "^1.0.5",
},
}),
"packages/app/package.json": JSON.stringify({
name: "@test/app",
dependencies: {
"@test/lib": "workspace:*",
"no-deps": "catalog:",
"dep-with-tags": "~1.0.0",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Check updates were applied correctly
const appJson = await Bun.file(join(dir, "packages/app/package.json")).json();
const libJson = await Bun.file(join(dir, "packages/lib/package.json")).json();
// Workspace dependency should remain unchanged
expect(appJson.dependencies["@test/lib"]).toBe("workspace:*");
// Regular dependencies should be updated with prefix preserved
expect(appJson.dependencies["dep-with-tags"]).toMatch(/^~/);
expect(libJson.dependencies["a-dep"]).toMatch(/^\^/);
});
it("should handle selecting specific packages in interactive mode", async () => {
const dir = tempDirWithFiles("update-interactive-selective", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update that selects only first package (space toggles, arrow down, enter)
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send keyboard navigation: space to toggle, arrow down, enter to confirm
update.stdin.write(" \u001b[B\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
expect(output).toContain("Selected 1 package to update");
// Check only one package was updated
const packageJson = await Bun.file(join(dir, "package.json")).json();
// Since we toggled only the first package, check that only one was updated
// The actual package updated depends on the order, so we check that exactly one changed
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 () => {
const dir = tempDirWithFiles("update-interactive-empty-catalog", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Check workspace package was updated normally
const appJson = await Bun.file(join(dir, "packages/app/package.json")).json();
expect(appJson.dependencies["no-deps"]).toBe("^2.0.0");
// Root catalog should remain empty
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 () => {
const dir = tempDirWithFiles("update-interactive-cancel", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update and send Ctrl+C
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send Ctrl+C to cancel
update.stdin.write("\u0003");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
expect(output).toContain("Cancelled");
// Check package.json was not modified
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.dependencies["no-deps"]).toBe("1.0.0");
});
it("should handle packages with pre-release versions correctly", async () => {
const dir = tempDirWithFiles("update-interactive-prerelease", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Check version prefixes are preserved
const packageJson = await Bun.file(join(dir, "package.json")).json();
// Prefixes should be preserved
expect(packageJson.dependencies["dep-with-tags"]).toMatch(/^\^/);
expect(packageJson.dependencies["a-dep"]).toMatch(/^~/);
});
it("should update catalog in workspaces object (not workspaces.catalogs)", async () => {
const dir = tempDirWithFiles("update-interactive-workspaces-catalog", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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:",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
expect(output).toContain("Installing updates...");
// Check catalog was updated with preserved prefixes
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.workspaces.catalog["no-deps"]).toBe("^2.0.0");
expect(packageJson.workspaces.catalog["dep-with-tags"]).toMatch(/^~/);
});
it("should handle scoped packages in catalogs correctly", async () => {
const dir = tempDirWithFiles("update-interactive-scoped-catalog", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "root",
version: "1.0.0",
workspaces: ["packages/*"],
catalog: {
"@scoped/has-bin-entry": "^1.0.0",
"no-deps": "~1.0.0",
"dep-with-tags": ">=1.0.0",
},
}),
"packages/app/package.json": JSON.stringify({
name: "@test/app",
dependencies: {
"@scoped/has-bin-entry": "catalog:",
"no-deps": "catalog:",
"dep-with-tags": "catalog:",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Check scoped packages were updated with preserved prefixes
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.catalog["@scoped/has-bin-entry"]).toMatch(/^\^/);
expect(packageJson.catalog["no-deps"]).toMatch(/^~/);
expect(packageJson.catalog["dep-with-tags"]).toMatch(/^>=/);
});
it("should handle catalog updates when running from root with filter", async () => {
const dir = tempDirWithFiles("update-interactive-filter-catalog", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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:",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with filter
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--filter=@test/app2", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
// Check catalog was updated
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.catalog["dep-with-tags"]).toMatch(/^~/);
//todo: actually check the catalog was updated
});
it("should handle multiple catalog definitions with same package", async () => {
const dir = tempDirWithFiles("update-interactive-multi-catalog", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
// Check both catalogs were updated with preserved prefixes
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.workspaces.catalogs.dev["no-deps"]).toBe("^2.0.0");
expect(packageJson.workspaces.catalogs.prod["no-deps"]).toMatch(/^~/);
//todo: actually check the catalog was updated
});
it("should handle version ranges with multiple conditions", async () => {
const dir = tempDirWithFiles("update-interactive-complex-ranges", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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:",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with piped input
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Check complex ranges are handled (they might be simplified)
const packageJson = await Bun.file(join(dir, "package.json")).json();
// Complex ranges might be simplified to latest version
expect(packageJson.catalog["no-deps"]).toBeDefined();
expect(packageJson.catalog["dep-with-tags"]).toBeDefined();
});
it("should handle dry-run mode correctly", async () => {
const dir = tempDirWithFiles("update-interactive-dry-run", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
"dep-with-tags": "1.0.0",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with dry-run
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest", "--dry-run"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
expect(output).toContain("Selected");
// Check packages were NOT updated (dry-run)
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.dependencies["no-deps"]).toBe("1.0.0");
expect(packageJson.dependencies["dep-with-tags"]).toBe("1.0.0");
});
it("should handle keyboard navigation correctly", async () => {
const dir = tempDirWithFiles("update-interactive-navigation", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update with keyboard navigation:
// - n (select none)
// - i (invert selection)
// - Enter (confirm)
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send keyboard navigation commands
update.stdin.write("ni\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
expect(output).toContain("Selected 3 packages to update");
});
// Comprehensive tests from separate file
it("comprehensive interactive update test with all scenarios", async () => {
const dir = tempDirWithFiles("update-interactive-comprehensive", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
// Root package.json with catalog definitions and dependencies
"package.json": JSON.stringify({
name: "root-project",
version: "1.0.0",
private: true,
workspaces: ["packages/*"],
// Catalog with old versions that can be updated
catalog: {
"no-deps": "^1.0.0",
"dep-with-tags": "~1.0.0",
},
// Some root dependencies
dependencies: {
"a-dep": "^1.0.5",
},
devDependencies: {
"normal-dep-and-dev-dep": "^1.0.0",
},
}),
// Workspace 1: Uses catalog references and has its own dependencies
"packages/app1/package.json": JSON.stringify({
name: "@test/app1",
version: "1.0.0",
dependencies: {
"no-deps": "catalog:", // References catalog
"dep-with-tags": "catalog:", // References catalog
"a-dep": "^1.0.5", // Regular dependency (same as root)
},
devDependencies: {
"normal-dep-and-dev-dep": "^1.0.0", // Dev dependency
},
}),
// Workspace 2: Different dependencies to test workspace-specific updates
"packages/app2/package.json": JSON.stringify({
name: "@test/app2",
version: "1.0.0",
dependencies: {
"no-deps": "catalog:", // References catalog
"a-dep": "^1.0.5", // Regular dependency
},
devDependencies: {
"dep-with-tags": "^1.0.0", // Different from catalog - should update independently
},
}),
});
// First install to establish the lockfile
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const installExitCode = await install.exited;
if (installExitCode !== 0) {
const stderr = await new Response(install.stderr).text();
console.error("Install failed:", stderr);
}
expect(installExitCode).toBe(0);
// Run interactive update and select all packages
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
// Send 'a' to select all, then newline to confirm
update.stdin.write("a\n");
update.stdin.end();
const updateExitCode = await update.exited;
const stdout = await new Response(update.stdout).text();
const stderr = await new Response(update.stderr).text();
const combined = stdout + stderr;
// Should complete successfully
expect(updateExitCode).toBe(0);
expect(combined).not.toContain("panic");
expect(combined).not.toContain("FileNotFound");
expect(combined).not.toContain("Failed to update");
// Verify catalog definitions were updated in root package.json
const rootPackageJson = await Bun.file(join(dir, "package.json")).json();
// Catalog should be updated while preserving prefixes
expect(rootPackageJson.catalog["no-deps"]).toBe("^2.0.0");
expect(rootPackageJson.catalog["dep-with-tags"]).toMatch(/^~/);
// Root dependencies should be updated
expect(rootPackageJson.dependencies["a-dep"]).toMatch(/^\^/);
expect(rootPackageJson.devDependencies["normal-dep-and-dev-dep"]).toMatch(/^\^/);
// App1 should have catalog references preserved but regular deps updated
const app1Json = await Bun.file(join(dir, "packages/app1/package.json")).json();
expect(app1Json.dependencies["no-deps"]).toBe("catalog:"); // Catalog ref preserved
expect(app1Json.dependencies["dep-with-tags"]).toBe("catalog:"); // Catalog ref preserved
expect(app1Json.dependencies["a-dep"]).toMatch(/^\^/); // Regular dep updated
expect(app1Json.devDependencies["normal-dep-and-dev-dep"]).toMatch(/^\^/); // Dev dep updated
// App2 should have catalog references preserved and independent deps updated
const app2Json = await Bun.file(join(dir, "packages/app2/package.json")).json();
expect(app2Json.dependencies["no-deps"]).toBe("catalog:"); // Catalog ref preserved
expect(app2Json.dependencies["a-dep"]).toMatch(/^\^/); // Regular dep updated
expect(app2Json.devDependencies["dep-with-tags"]).toMatch(/^\^/); // Independent dep updated
// Verify lockfile exists and is valid
console.log("Checking lockfile...");
const lockfilePath = join(dir, "bun.lock");
const lockfileExists = await Bun.file(lockfilePath).exists();
expect(lockfileExists).toBe(true);
// Run bun install again to verify no changes are needed
await using verifyInstall = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const verifyExitCode = await verifyInstall.exited;
const verifyStdout = await new Response(verifyInstall.stdout).text();
const verifyStderr = await new Response(verifyInstall.stderr).text();
const verifyCombined = verifyStdout + verifyStderr;
expect(verifyExitCode).toBe(0);
// Should indicate no changes are needed - just check that no new packages are being installed
expect(verifyCombined).not.toContain("Installing");
// "Saved lockfile" is fine even when no changes, so don't check for it
});
it("interactive update with workspace filters", async () => {
const dir = tempDirWithFiles("update-interactive-filter", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Update only frontend workspace
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--filter=@test/frontend", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Verify catalog was updated (even with filter)
const rootJson = await Bun.file(join(dir, "package.json")).json();
expect(rootJson.catalog["no-deps"]).toBe("^2.0.0");
// Verify frontend was updated
const frontendJson = await Bun.file(join(dir, "packages/frontend/package.json")).json();
expect(frontendJson.dependencies["a-dep"]).toMatch(/^\^/);
// Verify backend was not updated (should still be old version)
const backendJson = await Bun.file(join(dir, "packages/backend/package.json")).json();
expect(backendJson.dependencies["dep-with-tags"]).toBe("^1.0.0");
});
it("interactive update with workspaces.catalogs structure", async () => {
const dir = tempDirWithFiles("update-interactive-workspaces-catalogs", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "root",
version: "1.0.0",
workspaces: {
packages: ["packages/*"],
catalogs: {
"shared": {
"no-deps": "^1.0.0",
"dep-with-tags": "~1.0.0",
},
"tools": {
"a-dep": ">=1.0.5",
},
},
},
}),
"packages/app/package.json": JSON.stringify({
name: "@test/app",
dependencies: {
"no-deps": "catalog:shared",
"dep-with-tags": "catalog:shared",
"a-dep": "catalog:tools",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
expect(output).toContain("Installing updates..."); // Should show install message
// Verify workspaces.catalogs were updated with preserved prefixes AND new versions
const packageJson = await Bun.file(join(dir, "package.json")).json();
// Check that versions actually changed from original static values
expect(packageJson.workspaces.catalogs.shared["no-deps"]).not.toBe("^1.0.0"); // Should be newer
expect(packageJson.workspaces.catalogs.shared["dep-with-tags"]).not.toBe("~1.0.0"); // Should be newer
// For a-dep, check if it changed or at least verify it has the right prefix
// (Some versions might not change if already satisfied)
const aDep = packageJson.workspaces.catalogs.tools["a-dep"];
if (aDep !== ">=1.0.5") {
// Version changed - verify it starts with >=
expect(aDep).toMatch(/^>=/);
} else {
// Version didn't change - that's ok if the constraint was already satisfied
expect(aDep).toBe(">=1.0.5");
}
// Check that prefixes are preserved
expect(packageJson.workspaces.catalogs.shared["no-deps"]).toMatch(/^\^/);
expect(packageJson.workspaces.catalogs.shared["dep-with-tags"]).toMatch(/^~/);
expect(packageJson.workspaces.catalogs.tools["a-dep"]).toMatch(/^>=/);
// App package should still have catalog references (unchanged)
const appJson = await Bun.file(join(dir, "packages/app/package.json")).json();
expect(appJson.dependencies["no-deps"]).toBe("catalog:shared");
expect(appJson.dependencies["dep-with-tags"]).toBe("catalog:shared");
expect(appJson.dependencies["a-dep"]).toBe("catalog:tools");
});
it("interactive update dry run mode", async () => {
const dir = tempDirWithFiles("update-interactive-dry-run", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"no-deps": "1.0.0",
"dep-with-tags": "1.0.0",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Store original package.json content
const originalContent = await Bun.file(join(dir, "package.json")).text();
// Run interactive update with dry-run
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest", "--dry-run"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
const output = await new Response(update.stdout).text();
expect(exitCode).toBe(0);
expect(output).toContain("Dry run");
// Verify package.json was NOT modified
const afterContent = await Bun.file(join(dir, "package.json")).text();
expect(afterContent).toBe(originalContent);
// Parse and verify versions are still old
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.dependencies["no-deps"]).toBe("1.0.0");
expect(packageJson.dependencies["dep-with-tags"]).toBe("1.0.0");
});
it("should preserve npm: alias prefix when updating packages", async () => {
const dir = tempDirWithFiles("update-interactive-npm-alias", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"my-alias": "npm:no-deps@1.0.0",
"@my/alias": "npm:@types/no-deps@^1.0.0",
},
}),
});
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
const packageJson = await Bun.file(join(dir, "package.json")).json();
expect(packageJson.dependencies["my-alias"]).toBe("npm:no-deps@2.0.0");
expect(packageJson.dependencies["@my/alias"]).toBe("npm:@types/no-deps@^2.0.0");
});
it("interactive update with mixed dependency types", async () => {
const dir = tempDirWithFiles("update-interactive-mixed", {
"bunfig.toml": `[install]
cache = false
registry = "${registryUrl}"
`,
"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:",
},
}),
});
// Install first
await using install = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: dir,
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
expect(await install.exited).toBe(0);
// Run interactive update
await using update = Bun.spawn({
cmd: [bunExe(), "update", "-i", "-r", "--latest"],
cwd: dir,
env: bunEnv,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
update.stdin.write("a\n");
update.stdin.end();
const exitCode = await update.exited;
expect(exitCode).toBe(0);
// Verify all dependency types were handled correctly
const rootJson = await Bun.file(join(dir, "package.json")).json();
expect(rootJson.catalog["a-dep"]).toMatch(/^\^/); // Catalog updated
expect(rootJson.dependencies["no-deps"]).toMatch(/^\^/); // Regular dep updated
expect(rootJson.devDependencies["dep-with-tags"]).toMatch(/^~/); // Dev dep updated with prefix preserved
expect(rootJson.peerDependencies["a-dep"]).toMatch(/^>=/); // Peer dep updated with prefix preserved
expect(rootJson.optionalDependencies["normal-dep-and-dev-dep"]).toMatch(/^\^/); // Optional dep updated
// Verify workspace dependencies
const ws1Json = await Bun.file(join(dir, "packages/workspace1/package.json")).json();
expect(ws1Json.dependencies["a-dep"]).toBe("catalog:"); // Catalog ref preserved
expect(ws1Json.dependencies["@test/workspace2"]).toBe("workspace:*"); // Workspace ref preserved
expect(ws1Json.devDependencies["no-deps"]).toMatch(/^\^/); // Regular dep updated
const ws2Json = await Bun.file(join(dir, "packages/workspace2/package.json")).json();
expect(ws2Json.dependencies["a-dep"]).toBe("catalog:"); // Catalog ref preserved
});
});