use std::process::Command;
use serde_json::{json, Value};
use tempfile::TempDir;
mod common;
use common::TestHome;
fn bin(home: &TempDir) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_orchestratectl"));
c.env("ORCHESTRATECTL_HOME", home.path());
c.env("OCTL_TEST_SKIP_MATERIALIZE", "1");
c
}
fn run_ok(cmd: &mut Command) -> Value {
let out = cmd.output().expect("spawn");
assert!(
out.status.success(),
"exit={:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("stdout is valid JSON")
}
fn run_exit(cmd: &mut Command, want: i32) -> Value {
let out = cmd.output().expect("spawn");
let code = out.status.code().expect("exit code");
assert_eq!(
code,
want,
"want exit {want}, got {code}; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("stdout is valid JSON")
}
fn run_err(cmd: &mut Command, want: i32) -> Value {
let out = cmd.output().expect("spawn");
let code = out.status.code().expect("exit code");
assert_eq!(code, want, "want exit {want}, got {code}");
let stderr = String::from_utf8(out.stderr).expect("utf8");
let last = stderr.lines().last().expect("stderr has at least one line");
serde_json::from_str(last).expect("error envelope JSON")
}
fn create(home: &TempDir, kind: &str, title: &str) -> String {
let v = run_ok(bin(home).args([
"--output", "json", "run", "create", "--kind", kind, "--title", title,
]));
v["data"]["run_id"]
.as_str()
.expect("run_id is string")
.to_string()
}
fn event_create(home: &TempDir, run_id: &str, kind: &str, node_id: Option<&str>, data: Value) {
let keep = TempDir::new().unwrap();
let f = keep.path().join("data.json");
std::fs::write(&f, serde_json::to_vec(&data).unwrap()).unwrap();
let mut cmd = bin(home);
cmd.args([
"--output", "json", "event", "create", run_id, "--kind", kind,
]);
if let Some(n) = node_id {
cmd.args(["--node-id", n]);
}
cmd.args(["--from-file", f.to_str().unwrap()]);
run_ok(&mut cmd);
}
fn add_node(home: &TempDir, run_id: &str, node_id: &str) {
event_create(
home,
run_id,
"node.created",
Some(node_id),
json!({ "kind": "spinoff" }),
);
}
fn node_report(home: &TempDir, run_id: &str, node_id: &str, data: Value) {
let keep = TempDir::new().unwrap();
let f = keep.path().join("report.json");
std::fs::write(&f, serde_json::to_vec(&data).unwrap()).unwrap();
run_ok(bin(home).args([
"--output",
"json",
"node",
"report",
run_id,
node_id,
"--from-file",
f.to_str().unwrap(),
]));
}
fn settle_run(home: &TempDir, title: &str, status: &str, report: Value) -> String {
let run_id = create(home, "spinoff", title);
add_node(home, &run_id, "n-0001");
node_report(home, &run_id, "n-0001", report);
event_create(
home,
&run_id,
"run.status",
None,
json!({ "status": status }),
);
run_id
}
fn pending_run(home: &TempDir, title: &str) -> String {
create(home, "spinoff", title)
}
#[test]
fn all_happy_path_two_done_runs_exit_zero() {
let home = TestHome::new();
let a = settle_run(
&home,
"a",
"done",
json!({ "success": true, "summary": "did A", "via": "explicit-merge" }),
);
let b = settle_run(
&home,
"b",
"done",
json!({ "success": true, "summary": "did B", "via": "explicit-merge" }),
);
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &a, &b]));
assert_eq!(v["data"]["condition"], "all");
let runs = v["data"]["runs"].as_array().expect("runs array");
assert_eq!(runs.len(), 2);
for r in runs {
assert_eq!(r["status"], "done");
assert_eq!(r["merged"], true);
assert!(r["summary"].as_str().unwrap().starts_with("did "));
assert!(r.get("error").is_none(), "done run has no error: {r}");
}
assert!(v["data"]["waited_ms"].is_number());
}
#[test]
fn any_returns_when_one_of_two_is_terminal() {
let home = TestHome::new();
let done = settle_run(
&home,
"done",
"done",
json!({ "success": true, "summary": "ok" }),
);
let pending = pending_run(&home, "pending");
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &done, &pending, "--any"]));
assert_eq!(v["data"]["condition"], "any");
let runs = v["data"]["runs"].as_array().expect("runs array");
assert_eq!(runs.len(), 2);
let by_id = |id: &str| runs.iter().find(|r| r["run_id"] == id).unwrap();
assert_eq!(by_id(&done)["status"], "done");
assert_eq!(by_id(&pending)["status"], "pending");
}
#[test]
fn timeout_without_terminal_run_exits_two() {
let home = TestHome::new();
let pending = pending_run(&home, "pending");
let v = run_exit(
bin(&home).args([
"--output",
"json",
"run",
"wait",
&pending,
"--timeout",
"500ms",
]),
2,
);
assert_eq!(v["data"]["condition"], "all");
let waited = v["data"]["waited_ms"].as_u64().expect("waited_ms u64");
assert!(
(400..=2000).contains(&waited),
"waited_ms {waited} should be ~500ms (the timeout budget)"
);
assert_eq!(v["data"]["runs"][0]["status"], "pending");
}
#[test]
fn fail_on_error_with_failed_run_exits_three() {
let home = TestHome::new();
let failed = settle_run(
&home,
"failed",
"failed",
json!({ "success": false, "summary": "blew up" }),
);
let v = run_exit(
bin(&home).args([
"--output",
"json",
"run",
"wait",
&failed,
"--fail-on-error",
]),
3,
);
assert_eq!(v["data"]["runs"][0]["status"], "failed");
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &failed]));
assert_eq!(v["data"]["runs"][0]["status"], "failed");
}
#[test]
fn unknown_run_id_exits_one() {
let home = TestHome::new();
let ghost = "01arz3ndektsv4rrffq69g5fav";
let v = run_err(
bin(&home).args(["--output", "json", "run", "wait", ghost]),
1,
);
assert_eq!(v["error"]["code"], "unknown_run");
assert_eq!(v["error"]["invalid_value"], ghost);
}
#[test]
fn malformed_timeout_is_rejected_up_front() {
let home = TestHome::new();
let pending = pending_run(&home, "pending");
let v = run_err(
bin(&home).args([
"--output",
"json",
"run",
"wait",
&pending,
"--timeout",
"soon",
]),
1,
);
assert_eq!(v["error"]["code"], "invalid_arguments");
}
#[test]
fn all_and_any_are_mutually_exclusive() {
let home = TestHome::new();
let pending = pending_run(&home, "pending");
let out = bin(&home)
.args(["run", "wait", &pending, "--all", "--any"])
.output()
.expect("spawn");
assert_eq!(
out.status.code(),
Some(1),
"conflicting flags are a usage error"
);
}