diff --git a/test/cli/update_interactive_formatting.test.ts b/test/cli/update_interactive_formatting.test.ts index 89467165a534..068ca3ea052b 100644 --- a/test/cli/update_interactive_formatting.test.ts +++ b/test/cli/update_interactive_formatting.test.ts @@ -17,213 +17,120 @@ afterAll(() => { registry.stop(); }); -describe("bun update --interactive", () => { - it("should handle package names of unusual lengths", async () => { - await using dir = tempDir("update-interactive-test", { +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 column header and align package names of varying lengths", async () => { + await using dir = tempDir("update-interactive-name-lengths", { + "bunfig.toml": bunfig(), "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" }, + "a-dep": "1.0.1", + "no-deps": "1.0.0", + "normal-dep-and-dev-dep": "1.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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"], input: "\n" }); - // 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"); + // Widest name is 22 chars; every row pads to the same Current column. + expect(stdout).toContain("Current Target Latest"); + expect(stdout).toContain("a-dep 1.0.1"); + expect(stdout).toContain("no-deps 1.0.0"); + expect(stdout).toContain("normal-dep-and-dev-dep 1.0.0"); + expect(stdout).toContain("No packages selected"); + expect(exitCode).toBe(0); }); - it("should handle version strings of unusual lengths", async () => { - await using dir = tempDir("update-interactive-versions-test", { + it("should render version strings of varying lengths in aligned columns", async () => { + await using dir = tempDir("update-interactive-version-lengths", { + "bunfig.toml": bunfig(), "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", - }, + "no-deps": "1.0.0", + "a-dep": "1.0.1", + "dep-with-tags": "1.0.0", }, }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"], input: "\n" }); - // 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"); + // Exact spacing so a regression in name_padding / current_padding / + // target_padding fails here instead of slipping through a \s+ regex. + expect(stdout).toContain("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(exitCode).toBe(0); }); - it("should truncate extremely long package names", async () => { - const extremelyLongPackageName = "a".repeat(100); - await using dir = tempDir("update-interactive-truncate-test", { + it("should render packages that exceed the default column width without wrapping", async () => { + await using dir = tempDir("update-interactive-long-name", { + "bunfig.toml": bunfig(), "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", - }, + "normal-dep-and-dev-dep": "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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"], input: "\n" }); - // 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"); + // The name column grows to fit; Current/Target/Latest stay on the same line. + expect(stdout).toContain("normal-dep-and-dev-dep 1.0.0 1.0.0 1.0.2"); + expect(exitCode).toBe(0); }); - it("should show workspace column with --filter", async () => { - await using dir = tempDir("update-interactive-workspace-col-test", { + it("should list packages from multiple workspaces with --filter", async () => { + await using dir = tempDir("update-interactive-workspace-col", { + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -231,133 +138,81 @@ describe("bun update --interactive", () => { }), "packages/pkg1/package.json": JSON.stringify({ name: "pkg1", - dependencies: { - "dep1": "1.0.0", - }, + dependencies: { "no-deps": "1.0.0" }, }), "packages/pkg2/package.json": JSON.stringify({ name: "pkg2", - dependencies: { - "dep2": "1.0.0", - }, + dependencies: { "a-dep": "1.0.1" }, }), }); - // 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", + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { + args: ["--filter=*", "--latest", "--dry-run"], + input: "\n", }); - const stderr = await new Response(result.stderr).text(); - - // Should not crash with workspace column - expect(stderr).not.toContain("panic"); - expect(stderr).not.toContain("segfault"); + expect(stdout).toContain("Select packages to update"); + expect(stdout).toContain("no-deps"); + expect(stdout).toContain("a-dep"); + expect(exitCode).toBe(0); }); - it("should handle catalog dependencies in interactive update", async () => { - await using dir = tempDir("update-interactive-catalog-test", { + it("should render catalog dependencies in the interactive table", async () => { + await using dir = tempDir("update-interactive-catalog-render", { + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", - catalog: { - "shared-dep": "1.0.0", - }, + catalog: { "no-deps": "1.0.0" }, workspaces: ["packages/*"], }), "packages/pkg1/package.json": JSON.stringify({ name: "pkg1", - dependencies: { - "shared-dep": "catalog:", - }, + dependencies: { "no-deps": "catalog:" }, }), "packages/pkg2/package.json": JSON.stringify({ name: "pkg2", - dependencies: { - "shared-dep": "catalog:", - }, + dependencies: { "no-deps": "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", + await install(dir); + const { stdout, stderr, exitCode } = await updateInteractive(dir, { + args: ["--filter=*", "--latest", "--dry-run"], + input: "\n", }); - 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"); + expect(stderr).not.toContain("failed to resolve"); + expect(stdout).toContain("Select packages to update"); + expect(stdout).toContain("no-deps"); + expect(exitCode).toBe(0); }); - it("should handle mixed dependency types with various name lengths", async () => { - await using dir = tempDir("update-interactive-mixed-test", { + 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: { - "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" }, - }, + 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" }, }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"], input: "\n" }); - // 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"); + expect(stdout).toContain("devDependencies"); + expect(stdout).toContain("peerDependencies"); + expect(stdout).toContain("optionalDependencies"); + expect(stdout).toContain("no-deps"); + expect(stdout).toContain("a-dep dev"); + expect(stdout).toContain("dep-with-tags peer"); + expect(stdout).toContain("normal-dep-and-dev-dep optional"); + expect(exitCode).toBe(0); }); // The header's help-text budget is `terminal_width - 30`; on a tty narrower @@ -388,10 +243,7 @@ describe("bun update --interactive", () => { try { await using dir = tempDir("update-interactive-narrow-tty", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -399,19 +251,7 @@ registry = "${registryUrl}" }), }); - await using install = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: dir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [installOut, installErr, installCode] = await Promise.all([ - install.stdout.text(), - install.stderr.text(), - install.exited, - ]); - expect({ stdout: installOut, stderr: installErr, exitCode: installCode }).toMatchObject({ exitCode: 0 }); + await install(dir); await using update = Bun.spawn({ cmd: [bunExe(), "update", "--interactive", "--dry-run"], @@ -463,79 +303,30 @@ registry = "${registryUrl}" it("should update packages when 'a' (select all) is used", async () => { await using dir = tempDir("update-interactive-select-all", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", dependencies: { - "no-deps": "1.0.0", // Old version + "no-deps": "1.0.0", }, }), }); - // 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", - }); + await install(dir); + const { stdout, stderr, exitCode } = await updateInteractive(dir); - // 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(stderr + stdout).toContain("Installing updates..."); + expect(stderr + stdout).toContain("Saved lockfile"); 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 () => { await using dir = tempDir("update-interactive-workspace-recursive", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -546,58 +337,30 @@ registry = "${registryUrl}" name: "@test/app", version: "1.0.0", dependencies: { - "no-deps": "1.0.0", // Old version in workspace + "no-deps": "1.0.0", }, }), }); - // 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(); + await install(dir); + const { stdout, stderr, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); + expect(stderr + stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-catalog-actual", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", workspaces: ["packages/*"], catalog: { - "no-deps": "1.0.0", // Old version in catalog + "no-deps": "1.0.0", }, }), "packages/app/package.json": JSON.stringify({ @@ -609,53 +372,23 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, stderr, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); + expect(stderr + stdout).not.toContain("catalog: failed to resolve"); + expect(stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-from-workspace", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -677,42 +410,17 @@ registry = "${registryUrl}" }), }); - // First install from root - await using install = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: dir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", + await install(dir); + const { stdout, stderr, exitCode } = await updateInteractive(dir, { + args: ["-r", "--latest"], + cwd: join(dir, "packages/app1"), }); - 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; + const combined = stdout + stderr; - // Should not fail with FileNotFound - expect(exitCode).toBe(0); expect(combined).not.toContain("FileNotFound"); expect(combined).not.toContain("Failed to update"); + expect(exitCode).toBe(0); - // 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(); @@ -722,10 +430,7 @@ registry = "${registryUrl}" it("should handle basic interactive update with select all", async () => { await using dir = tempDir("update-interactive-basic", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -735,44 +440,19 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir); - const exitCode = await update.exited; + expect(stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-semver-prefixes", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -793,37 +473,13 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); - const exitCode = await update.exited; + expect(stdout).toContain("Installing updates..."); 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(/^>=/); @@ -831,10 +487,7 @@ registry = "${registryUrl}" it("should handle catalog updates in workspaces.catalogs object", async () => { await using dir = tempDir("update-interactive-workspaces-catalogs", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -861,50 +514,20 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); + expect(stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-mixed-deps", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -930,51 +553,23 @@ registry = "${registryUrl}" }), }); - // Install first - await using install = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: dir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - expect(await install.exited).toBe(0); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); - // 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(stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-selective", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -986,41 +581,14 @@ registry = "${registryUrl}" }), }); - // 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(); + 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); - 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++; @@ -1030,10 +598,7 @@ registry = "${registryUrl}" it("should handle empty catalog definitions gracefully", async () => { await using dir = tempDir("update-interactive-empty-catalog", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -1048,48 +613,22 @@ registry = "${registryUrl}" }), }); - // 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", - }); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); - // Send 'a' to select all, then newline to confirm - update.stdin.write("a\n"); - update.stdin.end(); - - const exitCode = await update.exited; + expect(stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-cancel", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -1099,47 +638,19 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { input: "\u0003" }); + expect(stdout).toContain("Cancelled"); 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 () => { await using dir = tempDir("update-interactive-prerelease", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -1151,47 +662,20 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir); - const exitCode = await update.exited; + expect(stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-workspaces-catalog", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -1212,37 +696,12 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); + expect(stdout).toContain("Installing updates..."); 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(/^~/); @@ -1250,10 +709,7 @@ registry = "${registryUrl}" it("should handle scoped packages in catalogs correctly", async () => { await using dir = tempDir("update-interactive-scoped-catalog", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -1274,34 +730,12 @@ registry = "${registryUrl}" }), }); - // Install first - await using install = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: dir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - expect(await install.exited).toBe(0); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); - // 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(stdout).toContain("Installing updates..."); 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(/^~/); @@ -1310,10 +744,7 @@ registry = "${registryUrl}" it("should handle catalog updates when running from root with filter", async () => { await using dir = tempDir("update-interactive-filter-catalog", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -1337,47 +768,21 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["--filter=@test/app2", "--latest"] }); + expect(stdout).toContain("Installing updates..."); 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 + 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": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -1404,48 +809,20 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); + expect(stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-complex-ranges", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -1464,46 +841,20 @@ registry = "${registryUrl}" }), }); - // Install first - await using install = Bun.spawn({ - cmd: [bunExe(), "install"], - cwd: dir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - expect(await install.exited).toBe(0); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); - // 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(stdout).toContain("Installing updates..."); 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 () => { await using dir = tempDir("update-interactive-dry-run", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -1514,37 +865,12 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"] }); + expect(stdout).toContain("Selected"); 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"); @@ -1552,10 +878,7 @@ registry = "${registryUrl}" it("should handle keyboard navigation correctly", async () => { await using dir = tempDir("update-interactive-navigation", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -1567,59 +890,26 @@ registry = "${registryUrl}" }), }); - // 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(); + 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); - expect(output).toContain("Selected 3 packages to update"); }); - // Comprehensive tests from separate file it("comprehensive interactive update test with all scenarios", async () => { await using dir = tempDir("update-interactive-comprehensive", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, - // Root package.json with catalog definitions and dependencies + "bunfig.toml": bunfig(), "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", }, @@ -1627,105 +917,61 @@ registry = "${registryUrl}" "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) + "no-deps": "catalog:", + "dep-with-tags": "catalog:", + "a-dep": "^1.0.5", }, devDependencies: { - "normal-dep-and-dev-dep": "^1.0.0", // Dev dependency + "normal-dep-and-dev-dep": "^1.0.0", }, }), - // 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 + "no-deps": "catalog:", + "a-dep": "^1.0.5", }, devDependencies: { - "dep-with-tags": "^1.0.0", // Different from catalog - should update independently + "dep-with-tags": "^1.0.0", }, }), }); - // 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(); + await install(dir); + const { stdout, stderr, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); 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"); + expect(stdout).toContain("Installing updates..."); + expect(exitCode).toBe(0); - // 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 + expect(app1Json.dependencies["no-deps"]).toBe("catalog:"); + expect(app1Json.dependencies["dep-with-tags"]).toBe("catalog:"); + expect(app1Json.dependencies["a-dep"]).toMatch(/^\^/); + expect(app1Json.devDependencies["normal-dep-and-dev-dep"]).toMatch(/^\^/); - // 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(app2Json.dependencies["no-deps"]).toBe("catalog:"); + expect(app2Json.dependencies["a-dep"]).toMatch(/^\^/); + expect(app2Json.devDependencies["dep-with-tags"]).toMatch(/^\^/); + + const lockfileExists = await Bun.file(join(dir, "bun.lock")).exists(); expect(lockfileExists).toBe(true); - // Run bun install again to verify no changes are needed + // bun install again should make no further changes. await using verifyInstall = Bun.spawn({ cmd: [bunExe(), "install"], cwd: dir, @@ -1733,25 +979,18 @@ registry = "${registryUrl}" 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; - + 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); - - // 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 () => { await using dir = tempDir("update-interactive-filter", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -1776,51 +1015,25 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["--filter=@test/frontend", "--latest"] }); - const exitCode = await update.exited; + expect(stdout).toContain("Installing updates..."); 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 () => { - await using dir = tempDir("update-interactive-workspaces-catalogs", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + await using dir = tempDir("update-interactive-workspaces-catalogs-2", { + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "root", version: "1.0.0", @@ -1847,59 +1060,19 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); + expect(stdout).toContain("Installing updates..."); 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"]).not.toBe("^1.0.0"); + expect(packageJson.workspaces.catalogs.shared["dep-with-tags"]).not.toBe("~1.0.0"); 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"); @@ -1907,11 +1080,8 @@ registry = "${registryUrl}" }); it("interactive update dry run mode", async () => { - await using dir = tempDir("update-interactive-dry-run", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + await using dir = tempDir("update-interactive-dry-run-2", { + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -1922,43 +1092,17 @@ registry = "${registryUrl}" }), }); - // 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 + await install(dir); 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(); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["--latest", "--dry-run"] }); + expect(stdout).toContain("Dry run"); 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"); @@ -1966,10 +1110,7 @@ registry = "${registryUrl}" it("should preserve npm: alias prefix when updating packages", async () => { await using dir = tempDir("update-interactive-npm-alias", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -1980,28 +1121,10 @@ registry = "${registryUrl}" }), }); - 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", - }); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir); - update.stdin.write("a\n"); - update.stdin.end(); - - const exitCode = await update.exited; + expect(stdout).toContain("Installing updates..."); expect(exitCode).toBe(0); const packageJson = await Bun.file(join(dir, "package.json")).json(); @@ -2011,10 +1134,7 @@ registry = "${registryUrl}" it("interactive update with mixed dependency types", async () => { await using dir = tempDir("update-interactive-mixed", { - "bunfig.toml": `[install] -cache = false -registry = "${registryUrl}" -`, + "bunfig.toml": bunfig(), "package.json": JSON.stringify({ name: "test-project", version: "1.0.0", @@ -2054,47 +1174,25 @@ registry = "${registryUrl}" }), }); - // 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(); + await install(dir); + const { stdout, exitCode } = await updateInteractive(dir, { args: ["-r", "--latest"] }); - const exitCode = await update.exited; + expect(stdout).toContain("Installing updates..."); 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 + expect(rootJson.catalog["a-dep"]).toMatch(/^\^/); + expect(rootJson.dependencies["no-deps"]).toMatch(/^\^/); + expect(rootJson.devDependencies["dep-with-tags"]).toMatch(/^~/); + expect(rootJson.peerDependencies["a-dep"]).toMatch(/^>=/); + expect(rootJson.optionalDependencies["normal-dep-and-dev-dep"]).toMatch(/^\^/); - // 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 + expect(ws1Json.dependencies["a-dep"]).toBe("catalog:"); + expect(ws1Json.dependencies["@test/workspace2"]).toBe("workspace:*"); + expect(ws1Json.devDependencies["no-deps"]).toMatch(/^\^/); const ws2Json = await Bun.file(join(dir, "packages/workspace2/package.json")).json(); - expect(ws2Json.dependencies["a-dep"]).toBe("catalog:"); // Catalog ref preserved + expect(ws2Json.dependencies["a-dep"]).toBe("catalog:"); }); });