From 13a501ec1d6b7c3ace66bef025ecb8c664e1279e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 17:42:37 -0400 Subject: [PATCH] test(cli): add archive and unarchive command integration tests Adds the CLI-layer integration coverage the archived-run plan called for in its Unit 6 test scenarios but never landed: help snapshots, required-arg handling, happy paths (including ps/ps -a visibility switching), precondition errors (archive on active runs, unarchive on not-archived runs), unknown-id errors, idempotent no-ops, JSON output shape, and mixed-batch per-id error aggregation. 15 new tests across archive.rs and unarchive.rs mirror rm.rs's fabro_snapshot style. --- lib/crates/fabro-cli/tests/it/cmd/archive.rs | 200 +++++++++++++++++ lib/crates/fabro-cli/tests/it/cmd/mod.rs | 2 + .../fabro-cli/tests/it/cmd/unarchive.rs | 206 ++++++++++++++++++ 3 files changed, 408 insertions(+) create mode 100644 lib/crates/fabro-cli/tests/it/cmd/archive.rs create mode 100644 lib/crates/fabro-cli/tests/it/cmd/unarchive.rs diff --git a/lib/crates/fabro-cli/tests/it/cmd/archive.rs b/lib/crates/fabro-cli/tests/it/cmd/archive.rs new file mode 100644 index 000000000..fee73fc38 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/archive.rs @@ -0,0 +1,200 @@ +use fabro_test::{fabro_snapshot, test_context}; +use serde_json::Value; + +use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run}; +use crate::support::unique_run_id; + +fn ulid_filter() -> (String, String) { + ( + r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), + "[ULID]".to_string(), + ) +} + +#[test] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["archive", "--help"]); + fabro_snapshot!(context.filters(), cmd, @r" + success: true + exit_code: 0 + ----- stdout ----- + Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings + + Usage: fabro archive [OPTIONS] ... + + Arguments: + ... Run IDs or workflow names to archive + + Options: + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --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 ----- + "); +} + +#[test] +fn archive_requires_at_least_one_id() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["archive"]); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 2 + ----- stdout ----- + ----- stderr ----- + error: the following required arguments were not provided: + ... + + Usage: fabro archive --no-upgrade-check ... + + For more information, try '--help'. + "); +} + +#[test] +fn archive_succeeded_run_hides_it_from_default_ps() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + let mut filters = context.filters(); + filters.push(ulid_filter()); + + let mut cmd = context.command(); + cmd.args(["archive", &run.run_id]); + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + [ULID] + "); + + // Default `ps` filters out archived runs. + let mut ps = context.ps(); + ps.args(["--json", "--label", &context.test_case_label()]); + fabro_snapshot!(context.filters(), ps, @r#" + success: true + exit_code: 0 + ----- stdout ----- + [] + ----- stderr ----- + "#); + + // `ps -a` surfaces it with status `archived`. + let output = context + .ps() + .args(["-a", "--json", "--label", &context.test_case_label()]) + .output() + .expect("ps -a should execute"); + assert!(output.status.success()); + let runs: Vec = serde_json::from_slice(&output.stdout).expect("ps JSON should parse"); + assert_eq!(runs.len(), 1, "ps -a should show the archived run"); + assert_eq!(runs[0]["status"], "archived"); + assert_eq!(runs[0]["run_id"], run.run_id); +} + +#[test] +fn archive_running_run_rejects_with_must_be_terminal_message() { + // A `create`d run is in `submitted` — not yet terminal. + let context = test_context!(); + let run = setup_created_fast_dry_run(&context); + let output = context + .command() + .args(["archive", &run.run_id]) + .output() + .expect("archive should execute"); + assert!(!output.status.success(), "archive on submitted must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("must be terminal"), + "expected 'must be terminal' in stderr, got: {stderr}" + ); +} + +#[test] +fn archive_already_archived_is_idempotent() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + + let first = context + .command() + .args(["archive", &run.run_id]) + .output() + .expect("archive should execute"); + assert!(first.status.success(), "first archive should succeed"); + + let second = context + .command() + .args(["archive", &run.run_id]) + .output() + .expect("archive should execute"); + assert!( + second.status.success(), + "second archive on already-archived should succeed\nstderr:\n{}", + String::from_utf8_lossy(&second.stderr) + ); +} + +#[test] +fn archive_unknown_id_renders_clean_error() { + let context = test_context!(); + let fake_id = unique_run_id(); + let output = context + .command() + .args(["archive", &fake_id]) + .output() + .expect("archive should execute"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&fake_id) || stderr.contains("No run found"), + "expected unknown-id error in stderr, got: {stderr}" + ); +} + +#[test] +fn archive_json_output_shape() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + + let output = context + .command() + .args(["--json", "archive", &run.run_id]) + .output() + .expect("archive --json should execute"); + assert!(output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).expect("archive JSON should parse"); + assert_eq!( + value["archived"], + Value::Array(vec![Value::String(run.run_id.clone())]) + ); + assert_eq!(value["errors"], Value::Array(vec![])); +} + +#[test] +fn archive_mixed_batch_aggregates_errors() { + let context = test_context!(); + let good = setup_completed_fast_dry_run(&context); + let bad = unique_run_id(); + + let output = context + .command() + .args(["--json", "archive", &good.run_id, &bad]) + .output() + .expect("archive should execute"); + assert!(!output.status.success(), "mixed batch should exit non-zero"); + let value: Value = serde_json::from_slice(&output.stdout).expect("archive JSON should parse"); + assert_eq!( + value["archived"], + Value::Array(vec![Value::String(good.run_id.clone())]) + ); + let errors = value["errors"].as_array().expect("errors should be array"); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0]["identifier"], bad); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index eb3f0cc6b..e747216c1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -1,3 +1,4 @@ +mod archive; mod artifact_cp; mod artifact_list; mod attach; @@ -62,6 +63,7 @@ mod system_prune; #[cfg(debug_assertions)] mod test_panic; mod top_level; +mod unarchive; mod uninstall; mod upgrade; mod validate; diff --git a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs new file mode 100644 index 000000000..d65438bdc --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs @@ -0,0 +1,206 @@ +use fabro_test::{fabro_snapshot, test_context}; +use serde_json::Value; + +use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run}; +use crate::support::unique_run_id; + +fn ulid_filter() -> (String, String) { + ( + r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), + "[ULID]".to_string(), + ) +} + +#[test] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["unarchive", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Restore archived runs to their prior terminal status + + Usage: fabro unarchive [OPTIONS] ... + + Arguments: + ... Run IDs or workflow names to unarchive + + Options: + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --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 ----- + "); +} + +#[test] +fn unarchive_requires_at_least_one_id() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["unarchive"]); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 2 + ----- stdout ----- + ----- stderr ----- + error: the following required arguments were not provided: + ... + + Usage: fabro unarchive --no-upgrade-check ... + + For more information, try '--help'. + "); +} + +#[test] +fn unarchive_archived_run_restores_prior_terminal_status() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + + // Archive first. + let archive = context + .command() + .args(["archive", &run.run_id]) + .output() + .expect("archive should execute"); + assert!(archive.status.success()); + + // Unarchive. + let mut filters = context.filters(); + filters.push(ulid_filter()); + let mut cmd = context.command(); + cmd.args(["unarchive", &run.run_id]); + fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + [ULID] + "); + + // `ps -a` now shows it as succeeded. + let output = context + .ps() + .args(["-a", "--json", "--label", &context.test_case_label()]) + .output() + .expect("ps -a should execute"); + assert!(output.status.success()); + let runs: Vec = serde_json::from_slice(&output.stdout).expect("ps JSON should parse"); + assert_eq!(runs.len(), 1, "ps -a should show the unarchived run"); + assert_eq!(runs[0]["status"], "succeeded"); +} + +#[test] +fn unarchive_on_non_archived_terminal_is_idempotent() { + // Unarchiving a succeeded (not-archived) run returns success with no event. + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + + let output = context + .command() + .args(["unarchive", &run.run_id]) + .output() + .expect("unarchive should execute"); + assert!( + output.status.success(), + "unarchive on already-succeeded should succeed\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn unarchive_on_active_run_rejects_with_not_archived_message() { + let context = test_context!(); + let run = setup_created_fast_dry_run(&context); + + let output = context + .command() + .args(["unarchive", &run.run_id]) + .output() + .expect("unarchive should execute"); + assert!(!output.status.success(), "unarchive on submitted must fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("is not archived"), + "expected 'is not archived' in stderr, got: {stderr}" + ); +} + +#[test] +fn unarchive_unknown_id_renders_clean_error() { + let context = test_context!(); + let fake_id = unique_run_id(); + let output = context + .command() + .args(["unarchive", &fake_id]) + .output() + .expect("unarchive should execute"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&fake_id) || stderr.contains("No run found"), + "expected unknown-id error in stderr, got: {stderr}" + ); +} + +#[test] +fn unarchive_json_output_shape() { + let context = test_context!(); + let run = setup_completed_fast_dry_run(&context); + context + .command() + .args(["archive", &run.run_id]) + .output() + .expect("archive should execute"); + + let output = context + .command() + .args(["--json", "unarchive", &run.run_id]) + .output() + .expect("unarchive --json should execute"); + assert!(output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).expect("unarchive JSON should parse"); + assert_eq!( + value["unarchived"], + Value::Array(vec![Value::String(run.run_id.clone())]) + ); + assert_eq!(value["errors"], Value::Array(vec![])); +} + +#[test] +fn unarchive_mixed_batch_aggregates_errors() { + let context = test_context!(); + let archived_run = setup_completed_fast_dry_run(&context); + context + .command() + .args(["archive", &archived_run.run_id]) + .output() + .expect("archive should execute"); + let active_run = setup_created_fast_dry_run(&context); + + let output = context + .command() + .args([ + "--json", + "unarchive", + &archived_run.run_id, + &active_run.run_id, + ]) + .output() + .expect("unarchive should execute"); + assert!(!output.status.success(), "mixed batch should exit non-zero"); + let value: Value = serde_json::from_slice(&output.stdout).expect("unarchive JSON should parse"); + assert_eq!( + value["unarchived"], + Value::Array(vec![Value::String(archived_run.run_id.clone())]) + ); + let errors = value["errors"].as_array().expect("errors should be array"); + assert_eq!(errors.len(), 1); + assert_eq!(errors[0]["identifier"], active_run.run_id); +}