use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use serde_json::Value; use super::support::{ remote_run_summary_json, setup_local_sandbox_run, setup_seeded_completed_dry_run, setup_seeded_created_dry_run, }; use crate::support::unique_run_id; #[test] fn help() { let context = test_context!(); let mut cmd = context.command(); cmd.args(["rm", "--help"]); fabro_snapshot!(context.filters(), cmd, @" success: true exit_code: 0 ----- stdout ----- Remove one or more workflow runs Usage: fabro rm [OPTIONS] ... Arguments: ... Run IDs or workflow names to remove 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=] -f, --force Force removal of active runs --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 rm_deletes_completed_run() { let context = test_context!(); let run = setup_seeded_completed_dry_run(&context); let mut filters = context.filters(); filters.push(( r"\b[0-9A-HJKMNP-TV-Z]{26}\b".to_string(), "[ULID]".to_string(), )); let mut cmd = context.command(); cmd.args(["rm", &run.run_id]); fabro_snapshot!(filters, cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- [ULID] "); assert!(!run.run_dir.exists(), "run directory should be deleted"); let mut ps = context.ps(); ps.args(["-a", "--json", "--label", &context.test_case_label()]); fabro_snapshot!(context.filters(), ps, @r###" success: true exit_code: 0 ----- stdout ----- [] ----- stderr ----- "###); } #[test] fn rm_rejects_submitted_run_without_force() { let context = test_context!(); let run = setup_seeded_created_dry_run(&context); let mut filters = context.filters(); filters.push(( r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), "[ULID]".to_string(), )); let mut cmd = context.command(); cmd.args(["rm", &run.run_id]); fabro_snapshot!(filters, cmd, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- cannot remove active run [ULID] (status: submitted, use force=true or --force to force) × some runs could not be removed "); } #[test] fn rm_force_deletes_submitted_run() { let context = test_context!(); let run = setup_seeded_created_dry_run(&context); let mut filters = context.filters(); filters.push(( r"\b[0-9A-HJKMNP-TV-Z]{26}\b".to_string(), "[ULID]".to_string(), )); let mut cmd = context.command(); cmd.args(["rm", "--force", &run.run_id]); fabro_snapshot!(filters, cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- [ULID] "); assert!(!run.run_dir.exists(), "run directory should be deleted"); let mut ps = context.ps(); ps.args(["-a", "--json", "--label", &context.test_case_label()]); fabro_snapshot!(context.filters(), ps, @r###" success: true exit_code: 0 ----- stdout ----- [] ----- stderr ----- "###); } #[test] fn rm_force_deletes_run_without_sandbox_json_when_store_has_sandbox() { let context = test_context!(); let setup = setup_local_sandbox_run(&context); let mut filters = context.filters(); filters.push(( r"\b[0-9A-HJKMNP-TV-Z]{26}\b".to_string(), "[ULID]".to_string(), )); let mut cmd = context.command(); cmd.args(["rm", "--force", &setup.run.run_id]); fabro_snapshot!(filters, cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- [ULID] "); assert!( !setup.run.run_dir.exists(), "run directory should be deleted" ); } #[test] fn rm_force_removes_active_run() { let context = test_context!(); let run_id = unique_run_id(); let server = MockServer::start(); let delete_mock = server.mock(|when, then| { when.method("DELETE") .path(format!("/api/v1/runs/{run_id}")) .query_param("force", "true"); then.status(204); }); context.set_http_target(&server.base_url()); let mut filters = context.filters(); filters.push(( r"\b[0-9A-HJKMNP-TV-Z]{26}\b".to_string(), "[ULID]".to_string(), )); let mut cmd = context.command(); cmd.args(["rm", "--force", &run_id]); fabro_snapshot!(filters, cmd, @" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- [ULID] "); delete_mock.assert(); } #[test] fn rm_without_force_uses_resolve_then_surfaces_server_conflict() { let context = test_context!(); let run_id = unique_run_id(); let server = MockServer::start(); let resolve_mock = server.mock(|when, then| { when.method("GET") .path("/api/v1/runs/resolve") .query_param("selector", &run_id); then.status(200) .header("Content-Type", "application/json") .body( remote_run_summary_json( &run_id, "Active Workflow", "active-workflow", "Active goal", &serde_json::json!({ "kind": "running" }), "2026-04-05T12:00:00Z", ) .to_string(), ); }); let delete_mock = server.mock(|when, then| { when.method("DELETE").path(format!("/api/v1/runs/{run_id}")); then.status(409) .header("Content-Type", "application/json") .body( serde_json::json!({ "errors": [{ "status": "409", "title": "Conflict", "detail": format!( "cannot remove active run {} (status: running, use force=true or --force to force)", &run_id[..12], ), }] }) .to_string(), ); }); context.set_http_target(&server.base_url()); let mut filters = context.filters(); filters.push(( r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(), "[ULID]".to_string(), )); let mut cmd = context.command(); cmd.args(["rm", &run_id]); fabro_snapshot!(filters, cmd, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- cannot remove active run [ULID] (status: running, use force=true or --force to force) × some runs could not be removed "); resolve_mock.assert(); delete_mock.assert(); } #[test] fn rm_partial_failure_reports_which_identifiers_failed() { let context = test_context!(); let run = setup_seeded_completed_dry_run(&context); let mut filters = context.filters(); filters.push(( r"\b[0-9A-HJKMNP-TV-Z]{26}\b".to_string(), "[ULID]".to_string(), )); let mut cmd = context.command(); cmd.args(["rm", &run.run_id, "does-not-exist"]); fabro_snapshot!(filters, cmd, @" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- [ULID] error: does-not-exist: No run found matching 'does-not-exist' (tried run ID prefix and workflow name) × some runs could not be removed "); assert!( !run.run_dir.exists(), "existing run should still be removed" ); } #[test] fn rm_partial_failure_json_includes_removed_and_errors() { let context = test_context!(); let run = setup_seeded_completed_dry_run(&context); let output = context .command() .args(["--json", "rm", &run.run_id, "does-not-exist"]) .output() .expect("command should run"); assert!(!output.status.success()); let value: Value = serde_json::from_slice(&output.stdout).expect("rm JSON should parse"); assert_eq!( value["removed"], Value::Array(vec![Value::String(run.run_id.clone())]) ); assert_eq!(value["errors"][0]["identifier"], "does-not-exist"); assert!( value["errors"][0]["error"] .as_str() .is_some_and(|error| error.contains("does-not-exist")) ); assert!( !run.run_dir.exists(), "existing run should still be removed" ); } #[test] fn rm_uses_configured_server_target_without_local_run_dir() { let context = test_context!(); let run_id = unique_run_id(); let server = MockServer::start(); let resolve_mock = server.mock(|when, then| { when.method("GET") .path("/api/v1/runs/resolve") .query_param("selector", &run_id); then.status(200) .header("Content-Type", "application/json") .body( remote_run_summary_json( &run_id, "Remote Workflow", "remote-workflow", "Remote goal", &serde_json::json!({ "kind": "succeeded", "reason": "completed" }), "2026-04-05T12:00:00Z", ) .to_string(), ); }); let delete_mock = server.mock(|when, then| { when.method("DELETE").path(format!("/api/v1/runs/{run_id}")); then.status(204); }); context.set_http_target(&server.base_url()); let output = context .command() .args(["rm", &run_id]) .output() .expect("rm should execute"); assert!( output.status.success(), "rm failed:\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); resolve_mock.assert(); delete_mock.assert(); }