fabro/lib/apps/fabro-cli/tests/it/cmd/ps.rs
2026-07-23 17:59:34 -04:00

544 lines
17 KiB
Rust

use fabro_client::ServerTarget;
use fabro_config::{Storage, envfile};
use fabro_test::{fabro_snapshot, test_context};
use fabro_util::dev_token;
use httpmock::MockServer;
use serde_json::Value;
use super::support::{
local_dev_token, remote_run_summary_json, setup_seeded_completed_dry_run,
setup_seeded_created_dry_run,
};
use crate::support::{fatal_error_line, seed_dev_token_auth, unique_run_id};
const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
const TEST_SESSION_SECRET: &str =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn provision_local_server_auth(context: &fabro_test::TestContext, storage_dir: &std::path::Path) {
context.ensure_home_server_auth_methods();
let runtime_directory = Storage::new(storage_dir).runtime_directory();
let server_env_path = runtime_directory.env_path();
envfile::merge_env_file(&server_env_path, [
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
("SESSION_SECRET", TEST_SESSION_SECRET),
])
.expect("merging server auth into server.env");
dev_token::write_dev_token(&runtime_directory.dev_token_path(), TEST_DEV_TOKEN)
.expect("writing runtime dev-token");
}
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["ps", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
List workflow runs
Usage: fabro ps [OPTIONS]
Options:
--json Output as JSON [env: FABRO_JSON=]
--server <SERVER> Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=]
--before <BEFORE> Only include runs started before this date (YYYY-MM-DD prefix match)
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--workflow <WORKFLOW> Filter by workflow name (substring match)
--label <KEY=VALUE> Filter by label (KEY=VALUE, repeatable, AND semantics)
--orphans Include orphan directories (no matching durable run)
--verbose Enable verbose output [env: FABRO_VERBOSE=]
-a, --all Show all runs, not just running (like docker ps -a)
-q, --quiet Only display run IDs
--parent <RUN> Only display runs linked to this orchestration parent
-h, --help Print help
----- stderr -----
");
}
#[test]
fn ps_explicit_local_tcp_server_target_ignores_env_dev_token() {
let context = test_context!();
let storage_root = tempfile::tempdir_in("/tmp").unwrap();
let storage_dir = storage_root.path().join("storage");
std::fs::create_dir_all(&storage_dir).unwrap();
provision_local_server_auth(&context, &storage_dir);
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "start", "--bind", "127.0.0.1"])
.assert()
.success();
let status_output = context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let status_json: Value = serde_json::from_slice(&status_output).unwrap();
let bind = status_json["bind"]
.as_str()
.expect("bind should be present");
let output = context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.env("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)
.args(["ps", "-a", "--json", "--server", &format!("http://{bind}")])
.output()
.expect("ps should run");
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "stop"])
.assert()
.success();
assert!(
!output.status.success(),
"ps against an explicit local TCP target should ignore FABRO_DEV_TOKEN and require persisted auth:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.status.code(), Some(4));
assert_eq!(fatal_error_line(&output.stderr), "Authentication required.");
}
#[test]
fn ps_explicit_local_tcp_server_target_accepts_explicit_dev_token() {
let context = test_context!();
let storage_root = tempfile::tempdir_in("/tmp").unwrap();
let storage_dir = storage_root.path().join("storage");
std::fs::create_dir_all(&storage_dir).unwrap();
provision_local_server_auth(&context, &storage_dir);
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "start", "--bind", "127.0.0.1"])
.assert()
.success();
let status_output = context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "status", "--json"])
.assert()
.success()
.get_output()
.stdout
.clone();
let status_json: Value = serde_json::from_slice(&status_output).unwrap();
let bind = status_json["bind"]
.as_str()
.expect("bind should be present");
let token = local_dev_token(&storage_dir).expect("local dev token should exist");
let target = ServerTarget::http_url(format!("http://{bind}")).expect("bind should parse");
seed_dev_token_auth(&context.home_dir, &target, &token);
let output = context
.command()
.args(["ps", "-a", "--json", "--server", &format!("http://{bind}")])
.output()
.expect("ps should run");
context
.command()
.env("FABRO_STORAGE_DIR", &storage_dir)
.args(["server", "stop"])
.assert()
.success();
assert!(
output.status.success(),
"ps against local TCP target with explicit auth failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).unwrap();
assert!(runs.is_empty(), "new local TCP server should have no runs");
}
#[test]
fn ps_default_excludes_non_running_runs() {
let context = test_context!();
setup_seeded_completed_dry_run(&context);
let mut cmd = context.ps();
cmd.args(["--label", &context.test_case_label()]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
No running processes found. Use -a to show all runs (including archived).
");
}
#[test]
fn ps_all_json_lists_created_and_completed_runs() {
let context = test_context!();
setup_seeded_completed_dry_run(&context);
setup_seeded_created_dry_run(&context);
let output = context
.ps()
.args(["-a", "--json", "--label", &context.test_case_label()])
.output()
.expect("ps should run");
assert!(output.status.success(), "ps should succeed");
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
assert_eq!(runs.len(), 2, "expected submitted + completed runs");
assert!(
runs.iter()
.all(|run| run["workflow_name"].is_null() && run["workflow_graph_name"] == "Simple"),
"all runs should expose Simple as the graph name, not a workflow name: {runs:#?}"
);
assert!(
runs.iter()
.all(|run| run["labels"]["fabro_test_case"] == context.test_case_id()),
"all runs should be scoped to the current test case: {runs:#?}"
);
assert!(
runs.iter()
.all(|run| run["labels"]["fabro_test_run"] == context.test_run_id()),
"all runs should be scoped to the current test session: {runs:#?}"
);
assert!(
runs.iter().any(|run| run["status"]["kind"] == "submitted"),
"ps should include the created run: {runs:#?}"
);
assert!(
runs.iter().any(|run| {
run["status"]["kind"] == "succeeded" && run["status"]["reason"] == "completed"
}),
"ps should include the completed run: {runs:#?}"
);
}
#[test]
fn setup_seeded_run_helpers_preserve_handle_when_another_run_exists() {
let context = test_context!();
let created = setup_seeded_created_dry_run(&context);
let completed = setup_seeded_completed_dry_run(&context);
assert_ne!(created.run_id, completed.run_id);
assert_ne!(created.run_dir, completed.run_dir);
assert!(created.run_dir.exists(), "created run dir should exist");
assert!(completed.run_dir.exists(), "completed run dir should exist");
}
#[test]
fn ps_quiet_outputs_run_ids_only() {
let context = test_context!();
setup_seeded_completed_dry_run(&context);
setup_seeded_created_dry_run(&context);
let mut cmd = context.ps();
cmd.args(["-a", "--quiet", "--label", &context.test_case_label()]);
fabro_snapshot!(context.filters(), cmd, @r###"
success: true
exit_code: 0
----- stdout -----
[ULID]
[ULID]
----- stderr -----
"###);
}
#[test]
fn ps_filters_by_workflow_and_label() {
let context = test_context!();
let simple = context.temp_dir.join("simple.fabro");
let branching = context.temp_dir.join("branching.fabro");
context.write_temp(
"simple.fabro",
r#"digraph Simple {
start [shape=Mdiamond]
exit [shape=Msquare]
run [shape=parallelogram, script="true"]
start -> run -> exit
}
"#,
);
context.write_temp(
"branching.fabro",
r#"digraph Branching {
start [shape=Mdiamond]
exit [shape=Msquare]
run [shape=parallelogram, script="true"]
start -> run -> exit
}
"#,
);
context
.run_cmd()
.args(["--dry-run", "--auto-approve", "--label", "suite=alpha"])
.arg(&simple)
.assert()
.success();
context
.create_cmd()
.args(["--dry-run", "--auto-approve", "--label", "suite=beta"])
.arg(&branching)
.assert()
.success();
let output = context
.ps()
.args([
"-a",
"--json",
"--workflow",
"Simple",
"--label",
"suite=alpha",
"--label",
&context.test_case_label(),
])
.output()
.expect("ps should run");
assert!(output.status.success(), "ps should succeed");
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
assert_eq!(
runs.len(),
1,
"workflow+label filter should isolate one run"
);
let run = &runs[0];
assert!(run["workflow_name"].is_null());
assert_eq!(run["workflow_graph_name"], "Simple");
assert_eq!(run["status"]["kind"], "succeeded");
assert_eq!(run["status"]["reason"], "completed");
assert_eq!(run["labels"]["suite"], "alpha");
assert_eq!(run["labels"]["fabro_test_case"], context.test_case_id());
assert_eq!(run["labels"]["fabro_test_run"], context.test_run_id());
}
#[test]
fn ps_uses_configured_server_target_without_server_flag() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let mut summary = remote_run_summary_json(
&run_id,
"Remote Workflow",
"remote-workflow",
"Remote goal",
&serde_json::json!({
"kind": "succeeded",
"reason": "completed"
}),
"2026-04-05T12:00:00Z",
);
summary["labels"] = serde_json::json!({
"suite": "remote"
});
let mock = server.mock(|when, then| {
when.method("GET").path("/api/v1/runs");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [summary],
"meta": { "has_more": false }
})
.to_string(),
);
});
context.set_http_target(&server.base_url());
let output = context
.ps()
.args(["-a", "--json"])
.output()
.expect("ps should execute");
assert!(output.status.success(), "ps should succeed");
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
mock.assert();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0]["workflow_name"], "Remote Workflow");
assert_eq!(runs[0]["source_directory"], "/srv/repo");
}
#[test]
fn ps_parent_resolves_parent_and_filters_on_server() {
let context = test_context!();
let server = MockServer::start();
let child_id = unique_run_id();
let parent_id = unique_run_id();
let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id);
let mut summary = remote_run_summary_json(
&child_id,
"Child Workflow",
"child-workflow",
"Child goal",
&serde_json::json!({
"kind": "succeeded",
"reason": "completed"
}),
"2026-04-20T12:00:00Z",
);
summary["parent_id"] = serde_json::json!(parent_id);
let list_mock = server.mock(|when, then| {
when.method("GET")
.path("/api/v1/runs")
.query_param("parent_id", parent_id.as_str());
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [summary],
"meta": { "has_more": false }
})
.to_string(),
);
});
let output = context
.ps()
.args([
"-a",
"--json",
"--server",
&format!("{}/api/v1", server.base_url()),
"--parent",
"nightly-parent",
])
.output()
.expect("ps should execute");
assert!(output.status.success(), "ps should succeed");
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
resolve_mock.assert();
list_mock.assert();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0]["run_id"], child_id);
assert_eq!(runs[0]["parent_id"], parent_id);
}
#[test]
fn ps_table_adds_parent_column_for_unfiltered_child_runs() {
let context = test_context!();
let server = MockServer::start();
let child_id = unique_run_id();
let parent_id = unique_run_id();
let mut summary = remote_run_summary_json(
&child_id,
"Child Workflow",
"child-workflow",
"Child goal",
&serde_json::json!({
"kind": "succeeded",
"reason": "completed"
}),
"2026-04-20T12:00:00Z",
);
summary["parent_id"] = serde_json::json!(parent_id);
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!({
"data": [summary],
"meta": { "has_more": false }
})
.to_string(),
);
});
let output = context
.ps()
.args(["-a", "--server", &format!("{}/api/v1", server.base_url())])
.output()
.expect("ps should execute");
assert!(output.status.success(), "ps should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
list_mock.assert();
assert!(
stdout.contains("PARENT"),
"table should include parent column:\n{stdout}"
);
assert!(
stdout.contains(&child_id[..12]),
"table should include child run id:\n{stdout}"
);
assert!(
stdout.contains(&parent_id[..12]),
"table should include parent run id:\n{stdout}"
);
}
#[test]
fn ps_explicit_remote_target_ignores_broken_local_storage_settings() {
let context = test_context!();
let server = MockServer::start();
let run_id = unique_run_id();
let summary = remote_run_summary_json(
&run_id,
"Explicit Remote",
"explicit-remote",
"Remote goal",
&serde_json::json!({
"kind": "succeeded",
"reason": "completed"
}),
"2026-04-20T12:00:00Z",
);
let mock = server.mock(|when, then| {
when.method("GET").path("/api/v1/runs");
then.status(200)
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"data": [summary],
"meta": { "has_more": false }
})
.to_string(),
);
});
context.write_home(
".fabro/settings.toml",
"_version = 1\n\n[server.storage]\nroot = \"${FABRO_MISSING_STORAGE_ROOT}\"\n",
);
let output = context
.command()
.env_remove("FABRO_STORAGE_DIR")
.args([
"ps",
"-a",
"--json",
"--server",
&format!("{}/api/v1", server.base_url()),
])
.output()
.expect("ps should execute");
assert!(
output.status.success(),
"explicit remote ps should ignore broken local storage settings\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
mock.assert();
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0]["workflow_name"], "Explicit Remote");
}