From b3226c84b53430d5a835618ea2e622c53de79fcd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 8 Apr 2026 16:47:33 -0400 Subject: [PATCH] test(cli): add integration tests for `fabro uninstall` 7 IT tests in cmd/uninstall.rs covering: - help snapshot - not-installed detection (plain + JSON) - dry-run preview without deleting - --yes removes ~/.fabro/ - --json inventory output (dry-run + execute) Also fixes the "not installed" check to use marker files (settings.toml, certs/, storage/) instead of directory existence, since the CLI's logging startup may auto-create the directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-cli/src/commands/uninstall.rs | 12 +- lib/crates/fabro-cli/tests/it/cmd/mod.rs | 1 + .../fabro-cli/tests/it/cmd/uninstall.rs | 166 ++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 lib/crates/fabro-cli/tests/it/cmd/uninstall.rs diff --git a/lib/crates/fabro-cli/src/commands/uninstall.rs b/lib/crates/fabro-cli/src/commands/uninstall.rs index 37886b4eb..4e34f6e4e 100644 --- a/lib/crates/fabro-cli/src/commands/uninstall.rs +++ b/lib/crates/fabro-cli/src/commands/uninstall.rs @@ -31,7 +31,7 @@ pub(crate) async fn run_uninstall(args: &UninstallArgs, globals: &GlobalArgs) -> let home = Home::from_env(); let home_root = home.root().to_path_buf(); - if !home_root.exists() { + if !looks_like_fabro_home(&home_root) { if globals.json { print_json_pretty(&serde_json::json!({ "status": "not_installed" }))?; } else { @@ -372,6 +372,16 @@ fn execute_uninstall(inventory: &Inventory, json: bool) -> Result<()> { Ok(()) } +/// Returns true if the directory exists and contains Fabro artifacts +/// (settings.toml, certs/, or storage/). An empty directory auto-created +/// by the CLI's logging setup is not considered an installation. +fn looks_like_fabro_home(path: &Path) -> bool { + path.exists() + && (path.join("settings.toml").exists() + || path.join("certs").exists() + || path.join("storage").exists()) +} + fn validate_safe_to_delete(path: &Path) -> Result<()> { let root = Path::new("/"); anyhow::ensure!(path != root, "path is the filesystem root"); diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 0bbe54302..aad21bc9a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -60,6 +60,7 @@ mod system_info; mod system_prune; mod test_panic; mod top_level; +mod uninstall; mod upgrade; mod validate; mod wait; diff --git a/lib/crates/fabro-cli/tests/it/cmd/uninstall.rs b/lib/crates/fabro-cli/tests/it/cmd/uninstall.rs new file mode 100644 index 000000000..0817fa928 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/uninstall.rs @@ -0,0 +1,166 @@ +use std::fs; + +use fabro_test::{fabro_snapshot, test_context}; +use serde_json::Value; + +#[test] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["uninstall", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Uninstall Fabro from this machine + + Usage: fabro uninstall [OPTIONS] + + Options: + --json Output as JSON [env: FABRO_JSON=] + --yes Skip confirmation prompt + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} + +fn command_with_no_fabro_home(context: &fabro_test::TestContext) -> assert_cmd::Command { + let mut cmd = context.command(); + cmd.env( + "FABRO_HOME", + context.temp_dir.join("nonexistent-fabro-home"), + ); + cmd +} + +#[test] +fn not_installed_prints_message() { + let context = test_context!(); + let mut cmd = command_with_no_fabro_home(&context); + cmd.arg("uninstall"); + let output = cmd.output().expect("command should run"); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "expected exit 0.\nstdout: {stdout}\nstderr: {stderr}" + ); + assert!( + stderr.contains("Fabro is not installed."), + "expected 'Fabro is not installed.' in stderr, got: {stderr}" + ); +} + +#[test] +fn dry_run_shows_preview_without_deleting() { + let context = test_context!(); + let fabro_home = context.home_dir.join(".fabro"); + fs::create_dir_all(fabro_home.join("certs")).unwrap(); + fs::write(fabro_home.join("settings.toml"), "# fabro settings\n").unwrap(); + + let mut cmd = context.command(); + cmd.arg("uninstall"); + let output = cmd.output().expect("command should run"); + + assert!(output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("Pass --yes to confirm."), + "expected dry-run hint in stderr, got: {stderr}" + ); + assert!(fabro_home.exists(), "dry run should not delete ~/.fabro"); +} + +#[test] +fn yes_removes_fabro_home() { + let context = test_context!(); + let fabro_home = context.home_dir.join(".fabro"); + fs::create_dir_all(fabro_home.join("certs")).unwrap(); + fs::write(fabro_home.join("settings.toml"), "# fabro settings\n").unwrap(); + + let mut cmd = context.command(); + cmd.args(["uninstall", "--yes"]); + let output = cmd.output().expect("command should run"); + + assert!( + output.status.success(), + "expected exit 0, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !fabro_home.exists(), + "~/.fabro should be removed after --yes" + ); +} + +#[test] +fn dry_run_json_outputs_inventory() { + let context = test_context!(); + let fabro_home = context.home_dir.join(".fabro"); + fs::create_dir_all(fabro_home.join("certs")).unwrap(); + fs::write(fabro_home.join("settings.toml"), "# fabro settings\n").unwrap(); + + let output = context + .command() + .args(["--json", "uninstall"]) + .output() + .expect("command should run"); + + assert!(output.status.success()); + let value: Value = + serde_json::from_slice(&output.stdout).expect("uninstall --json should parse"); + + assert!(value["home_exists"].as_bool().unwrap_or(false)); + assert!(value["home_root"].as_str().is_some()); + assert!(value["home_size"].is_number()); + assert!(value.get("server_running").is_some()); + assert!(value.get("shell_configs").is_some()); +} + +#[test] +fn yes_json_outputs_result() { + let context = test_context!(); + let fabro_home = context.home_dir.join(".fabro"); + fs::create_dir_all(fabro_home.join("certs")).unwrap(); + fs::write(fabro_home.join("settings.toml"), "# fabro settings\n").unwrap(); + + let output = context + .command() + .args(["--json", "uninstall", "--yes"]) + .output() + .expect("command should run"); + + assert!(output.status.success()); + let value: Value = + serde_json::from_slice(&output.stdout).expect("uninstall --yes --json should parse"); + + assert_eq!(value["status"].as_str(), Some("completed")); + assert_eq!(value["home_removed"].as_bool(), Some(true)); + assert!( + !fabro_home.exists(), + "~/.fabro should be removed after --yes --json" + ); +} + +#[test] +fn not_installed_json() { + let context = test_context!(); + let mut cmd = command_with_no_fabro_home(&context); + cmd.args(["--json", "uninstall"]); + let output = cmd.output().expect("command should run"); + + let stderr = String::from_utf8(output.stderr).unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + output.status.success(), + "expected exit 0.\nstdout: {stdout}\nstderr: {stderr}" + ); + let value: Value = serde_json::from_str(&stdout).expect("uninstall --json should parse"); + + assert_eq!(value["status"].as_str(), Some("not_installed")); +}