mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
346 lines
10 KiB
Rust
346 lines
10 KiB
Rust
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] <RUNS>...
|
||
|
||
Arguments:
|
||
<RUNS>... Run IDs or workflow names to remove
|
||
|
||
Options:
|
||
--json Output as JSON [env: FABRO_JSON=]
|
||
--server <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();
|
||
}
|