mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-22 00:31:12 +00:00
Replace the slow CLI integration tests that waited on worker shutdown grace periods with focused coverage that still checks the important behavior. The attach JSON test now finishes the gated run cleanly, the rm force test uses a mocked server contract, and the Ctrl-C cancel path is covered at the attach layer instead of through a full live run. Add a cooperative subprocess cancel control message so cancel and delete can abort pending interviews without relying only on the 5 second hard kill fallback.
312 lines
9.1 KiB
Rust
312 lines
9.1 KiB
Rust
use fabro_test::{fabro_snapshot, test_context};
|
|
use httpmock::MockServer;
|
|
use serde_json::Value;
|
|
|
|
use crate::support::unique_run_id;
|
|
|
|
use super::support::{
|
|
setup_completed_fast_dry_run, setup_created_fast_dry_run, setup_local_sandbox_run,
|
|
};
|
|
|
|
#[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_completed_fast_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: 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_created_fast_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 -f to force)
|
|
error: some runs could not be removed
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn rm_force_deletes_submitted_run() {
|
|
let context = test_context!();
|
|
let run = setup_created_fast_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", "--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]{12}\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 list_mock = server.mock(|when, then| {
|
|
when.method("GET").path("/api/v1/runs");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!([
|
|
{
|
|
"run_id": run_id,
|
|
"workflow_name": "Active Workflow",
|
|
"workflow_slug": "active-workflow",
|
|
"goal": "Active goal",
|
|
"labels": {},
|
|
"host_repo_path": null,
|
|
"start_time": "2026-04-05T12:00:00Z",
|
|
"status": "running",
|
|
"status_reason": null,
|
|
"duration_ms": 123,
|
|
"total_usd_micros": null
|
|
}
|
|
])
|
|
.to_string(),
|
|
);
|
|
});
|
|
let delete_mock = server.mock(|when, then| {
|
|
when.method("DELETE").path(format!("/api/v1/runs/{run_id}"));
|
|
then.status(204);
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!("[server]\ntarget = \"{}/api/v1\"\n", 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", "--force", &run_id]);
|
|
fabro_snapshot!(filters, cmd, @"
|
|
success: true
|
|
exit_code: 0
|
|
----- stdout -----
|
|
----- stderr -----
|
|
[ULID]
|
|
");
|
|
list_mock.assert();
|
|
delete_mock.assert();
|
|
}
|
|
|
|
#[test]
|
|
fn rm_partial_failure_reports_which_identifiers_failed() {
|
|
let context = test_context!();
|
|
let run = setup_completed_fast_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, "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)
|
|
error: 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_completed_fast_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 list_mock = server.mock(|when, then| {
|
|
when.method("GET").path("/api/v1/runs");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!([
|
|
{
|
|
"run_id": run_id,
|
|
"workflow_name": "Remote Workflow",
|
|
"workflow_slug": "remote-workflow",
|
|
"goal": "Remote goal",
|
|
"labels": {},
|
|
"host_repo_path": null,
|
|
"start_time": "2026-04-05T12:00:00Z",
|
|
"status": "succeeded",
|
|
"status_reason": null,
|
|
"duration_ms": 123,
|
|
"total_usd_micros": null
|
|
}
|
|
])
|
|
.to_string(),
|
|
);
|
|
});
|
|
let delete_mock = server.mock(|when, then| {
|
|
when.method("DELETE").path(format!("/api/v1/runs/{run_id}"));
|
|
then.status(204);
|
|
});
|
|
context.write_home(
|
|
".fabro/settings.toml",
|
|
format!("[server]\ntarget = \"{}/api/v1\"\n", 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)
|
|
);
|
|
list_mock.assert();
|
|
delete_mock.assert();
|
|
}
|