mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-27 01:21:25 +00:00
Use the canonical nested Run DTO directly and reject the old flat run summary JSON shape. Update store, server, CLI, and fixtures to read and produce canonical fields.
431 lines
14 KiB
Rust
431 lines
14 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
|
|
-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"] == "Simple"),
|
|
"all runs should belong to the Simple workflow: {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_eq!(run["workflow_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_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");
|
|
}
|