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 Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] --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 Filter by workflow name (substring match) --label 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 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 = 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 = 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 = 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 = 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 = 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 = serde_json::from_slice(&output.stdout).expect("ps JSON should parse"); assert_eq!(runs.len(), 1); assert_eq!(runs[0]["workflow_name"], "Explicit Remote"); }