use std::time::Duration;
use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::{json, Value};
use tempfile::TempDir;
const ENVELOPE_SCHEMA: u64 = 1;
fn bin(home: &TempDir) -> Command {
let mut c = Command::cargo_bin("orchestratectl").expect("binary builds");
c.env("ORCHESTRATECTL_HOME", home.path());
c.env("OCTL_TEST_SKIP_MATERIALIZE", "1");
c.env_remove("ORCHESTRATECTL_LOG");
c.env_remove("NO_COLOR");
c.env_remove("CLICOLOR");
c.timeout(Duration::from_secs(30));
c
}
fn assert_success_envelope(stdout: &str) {
if let Ok(v) = serde_json::from_str::<Value>(stdout) {
if v.is_object() {
assert_eq!(v["schema_version"], json!(ENVELOPE_SCHEMA), "envelope: {v}");
assert!(
v.get("data").is_some(),
"success envelope missing data: {v}"
);
assert!(
v.get("error").is_none(),
"success envelope carries error: {v}"
);
}
}
}
fn assert_error_envelope(stderr: &str) {
if let Ok(v) = serde_json::from_str::<Value>(stderr) {
if v.is_object() {
assert_eq!(v["schema_version"], json!(ENVELOPE_SCHEMA), "envelope: {v}");
let err = v.get("error").expect("error envelope missing error");
assert!(
err.get("code").and_then(Value::as_str).is_some(),
"err.code"
);
assert!(
err.get("message").and_then(Value::as_str).is_some(),
"err.message"
);
assert!(v.get("data").is_none(), "error envelope carries data: {v}");
}
}
}
fn ok_stdout(cmd: &mut Command) -> String {
let out = cmd
.assert()
.success()
.stderr(predicate::str::is_empty())
.get_output()
.stdout
.clone();
let s = String::from_utf8(out).expect("stdout is utf8");
assert_success_envelope(&s);
s
}
fn err_stderr(cmd: &mut Command, code: i32) -> String {
let out = cmd
.assert()
.failure()
.code(code)
.stdout(predicate::str::is_empty())
.get_output()
.stderr
.clone();
let s = String::from_utf8(out)
.expect("stderr is utf8")
.trim_end()
.to_string();
assert_error_envelope(&s);
s
}
fn data_value(stdout: &str) -> Value {
serde_json::from_str(stdout).expect("stdout is a JSON envelope")
}
fn redactions(home: &TempDir, run_id: Option<&str>) -> Vec<(String, &'static str)> {
let mut v = vec![(regex::escape(&home.path().to_string_lossy()), "[HOME]")];
if let Some(r) = run_id {
v.push((regex::escape(r), "[RUN_ID]"));
}
v
}
fn find_ulids(rendered: &str) -> Vec<String> {
let re = regex::Regex::new(r"(?i)\b[0-9a-hjkmnp-tv-z]{26}\b").expect("valid ulid regex");
let mut out = Vec::new();
for m in re.find_iter(rendered) {
let s = m.as_str().to_string();
if !out.contains(&s) {
out.push(s);
}
}
out
}
fn minted_redactions(home: &TempDir, rendered: &str) -> Vec<(String, &'static str)> {
let mut v = redactions(home, None);
for u in find_ulids(rendered) {
v.push((regex::escape(&u), "[RUN_ID]"));
}
v
}
fn snapshot(name: &str, value: &str, dynamic: &[(String, &'static str)]) {
let mut settings = insta::Settings::clone_current();
for (pattern, replacement) in dynamic {
settings.add_filter(pattern.as_str(), *replacement);
}
settings.add_filter(r"(?i)\b[0-9a-hjkmnp-tv-z]{26}\b", "[ULID]");
settings.add_filter(r"\b[0-9a-f]{40}\b", "[COMMIT]");
settings.add_filter(
r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2}| UTC)",
"[TS]",
);
settings.add_filter(r"(?i)pid \d+", "pid [PID]");
settings.bind(|| insta::assert_snapshot!(name, value));
}
fn create_run(home: &TempDir) -> String {
let stdout = ok_stdout(bin(home).args([
"--output", "json", "run", "create", "--kind", "spinoff", "--title", "seed-run",
]));
data_value(&stdout)["data"]["run_id"]
.as_str()
.expect("run_id present")
.to_string()
}
fn write_json(home: &TempDir, name: &str, v: Value) -> String {
let p = home.path().join(name);
std::fs::write(&p, serde_json::to_vec(&v).unwrap()).unwrap();
p.to_str().unwrap().to_string()
}
fn event_create(home: &TempDir, run_id: &str, kind: &str, node_id: Option<&str>, data: Value) {
let file = write_json(home, &format!("ev-{kind}.json"), data);
let mut args = vec![
"--output".into(),
"json".into(),
"event".into(),
"create".into(),
run_id.into(),
"--kind".into(),
kind.into(),
"--from-file".into(),
file,
];
if let Some(n) = node_id {
args.push("--node-id".into());
args.push(n.into());
}
ok_stdout(bin(home).args(&args));
}
fn seed_node(home: &TempDir, run_id: &str) {
event_create(
home,
run_id,
"node.created",
Some("n-0001"),
json!({
"kind": "spinoff",
"branch": "wt/seed",
"worktree_path": "/tmp/seed-wt",
"tmux_window": "seed-win",
"agent_pid": 4242
}),
);
}
fn seed_discussion(home: &TempDir, run_id: &str) {
event_create(
home,
run_id,
"discussion.opened",
None,
json!({
"discussion_id": "d-pqrstuvwxy",
"node_id": "n-0001",
"topic": "seed topic",
"severity": "discuss"
}),
);
}
fn seed_spinoff(home: &TempDir, run_id: &str) {
event_create(
home,
run_id,
"spinoff.proposed",
None,
json!({
"proposal_id": "s-spinaaaaaa",
"node_id": "n-0001",
"proposed_title": "seed proposal",
"proposed_kind": "spinoff",
"rationale": "follow-up"
}),
);
}
#[test]
fn version_envelopes() {
let home = TempDir::new().unwrap();
let red = redactions(&home, None);
for (fmt, name) in [
("text", "version_text"),
("json", "version_json"),
("jsonl", "version_jsonl"),
] {
let out = ok_stdout(bin(&home).args(["--output", fmt, "version"]));
snapshot(name, &out, &red);
}
}
#[test]
fn global_arg_error_envelopes() {
let home = TempDir::new().unwrap();
snapshot(
"global_unknown_flag_error",
&err_stderr(bin(&home).args(["--frobnicate", "version"]), 1),
&[],
);
snapshot(
"global_invalid_output_error",
&err_stderr(bin(&home).args(["--output", "yaml", "version"]), 1),
&[],
);
}
#[test]
fn skill_envelopes() {
let home = TempDir::new().unwrap();
let red = redactions(&home, None);
for (fmt, name) in [
("text", "skill_list_text"),
("json", "skill_list_json"),
("jsonl", "skill_list_jsonl"),
] {
let out = ok_stdout(bin(&home).args(["--output", fmt, "skill", "list"]));
snapshot(name, &out, &red);
}
snapshot(
"skill_show_unknown_error",
&err_stderr(bin(&home).args(["skill", "show", "no-such-skill"]), 1),
&[],
);
}
#[test]
fn run_create_envelopes() {
let home = TempDir::new().unwrap();
for (fmt, name) in [
("text", "run_create_text"),
("json", "run_create_json"),
("jsonl", "run_create_jsonl"),
] {
let out = ok_stdout(bin(&home).args([
"--output",
fmt,
"run",
"create",
"--kind",
"spinoff",
"--title",
"envelope demo",
]));
let red = minted_redactions(&home, &out);
snapshot(name, &out, &red);
}
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"run",
"create",
"--kind",
"code",
"--title",
"dry",
"--dry-run",
]));
let red = minted_redactions(&home, &out);
snapshot("run_create_dry_run_json", &out, &red);
ok_stdout(bin(&home).args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--title",
"idem",
"--idempotency-key",
"snap-key",
]));
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--title",
"idem",
"--idempotency-key",
"snap-key",
]));
let red = minted_redactions(&home, &out);
snapshot("run_create_idempotent_replay_json", &out, &red);
}
#[test]
fn run_show_envelopes() {
let home = TempDir::new().unwrap();
let run_id = create_run(&home);
let red = redactions(&home, Some(&run_id));
for (fmt, name) in [
("text", "run_show_text"),
("json", "run_show_json"),
("jsonl", "run_show_jsonl"),
] {
let out = ok_stdout(bin(&home).args(["--output", fmt, "run", "show", &run_id]));
snapshot(name, &out, &red);
}
}
#[test]
fn run_error_envelopes() {
let home = TempDir::new().unwrap();
let run_id = create_run(&home);
snapshot(
"run_create_unknown_kind_error",
&err_stderr(
bin(&home).args(["run", "create", "--kind", "bogus", "--title", "x"]),
1,
),
&[],
);
snapshot(
"run_show_not_found_error",
&err_stderr(
bin(&home).args(["run", "show", "01jzabsent0000000000000000"]),
1,
),
&[],
);
let pid_file = home
.path()
.join("runs")
.join(&run_id)
.join("supervisor.pid");
std::fs::write(&pid_file, std::process::id().to_string()).unwrap();
snapshot(
"run_reattach_supervisor_alive_error",
&err_stderr(bin(&home).args(["run", "reattach", &run_id]), 2),
&redactions(&home, Some(&run_id)),
);
}
#[test]
fn event_envelopes() {
let home = TempDir::new().unwrap();
let run_id = create_run(&home);
seed_node(&home, &run_id);
let red = redactions(&home, Some(&run_id));
let nc = write_json(&home, "ev-extra.json", json!({"kind": "spinoff"}));
for (i, (fmt, name)) in [
("json", "event_create_json"),
("jsonl", "event_create_jsonl"),
("text", "event_create_text"),
]
.into_iter()
.enumerate()
{
let node = format!("n-100{i}");
let out = ok_stdout(bin(&home).args([
"--output",
fmt,
"event",
"create",
&run_id,
"--kind",
"node.created",
"--node-id",
&node,
"--from-file",
&nc,
]));
snapshot(name, &out, &red);
}
let st = write_json(&home, "ev-status.json", json!({"status": "running"}));
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"event",
"create",
&run_id,
"--kind",
"node.status",
"--node-id",
"n-0001",
"--from-file",
&st,
"--dry-run",
]));
snapshot("event_create_dry_run_json", &out, &red);
snapshot(
"event_tail_unsupported_format_error",
&err_stderr(
bin(&home).args(["--output", "json", "event", "tail", &run_id]),
1,
),
&red,
);
let e = write_json(&home, "ev-bad.json", json!({}));
snapshot(
"event_create_unknown_kind_error",
&err_stderr(
bin(&home).args([
"event",
"create",
&run_id,
"--kind",
"bogus.kind",
"--from-file",
&e,
]),
1,
),
&red,
);
}
#[test]
fn event_tail_envelopes() {
let home = TempDir::new().unwrap();
let run_id = create_run(&home);
seed_node(&home, &run_id);
let red = redactions(&home, Some(&run_id));
for (fmt, name) in [("jsonl", "event_tail_jsonl"), ("text", "event_tail_text")] {
let out = ok_stdout(bin(&home).args(["--output", fmt, "event", "tail", &run_id]));
snapshot(name, &out, &red);
}
}
#[test]
fn node_envelopes() {
let home = TempDir::new().unwrap();
let run_id = create_run(&home);
seed_node(&home, &run_id);
let red = redactions(&home, Some(&run_id));
for (fmt, name) in [
("text", "node_list_text"),
("json", "node_list_json"),
("jsonl", "node_list_jsonl"),
] {
let out = ok_stdout(bin(&home).args(["--output", fmt, "node", "list", &run_id]));
snapshot(name, &out, &red);
}
let out = ok_stdout(bin(&home).args(["--output", "json", "node", "show", &run_id, "n-0001"]));
snapshot("node_show_json", &out, &red);
let out = ok_stdout(bin(&home).args(["--output", "text", "node", "show", &run_id, "n-0001"]));
snapshot("node_show_text", &out, &red);
let rep = write_json(
&home,
"report.json",
json!({"success": true, "summary": "ok"}),
);
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"node",
"report",
&run_id,
"n-0001",
"--from-file",
&rep,
"--dry-run",
]));
snapshot("node_report_dry_run_json", &out, &red);
snapshot(
"node_show_not_found_error",
&err_stderr(bin(&home).args(["node", "show", &run_id, "n-9999"]), 1),
&red,
);
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"node",
"report",
&run_id,
"n-0001",
"--from-file",
&rep,
]));
snapshot("node_report_json", &out, &red);
}
#[test]
fn discussion_envelopes() {
let home = TempDir::new().unwrap();
let run_id = create_run(&home);
seed_node(&home, &run_id);
seed_discussion(&home, &run_id);
let red = redactions(&home, Some(&run_id));
for (fmt, name) in [
("text", "discussion_list_text"),
("json", "discussion_list_json"),
("jsonl", "discussion_list_jsonl"),
] {
let out = ok_stdout(bin(&home).args(["--output", fmt, "discussion", "list", &run_id]));
snapshot(name, &out, &red);
}
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"discussion",
"show",
&run_id,
"d-pqrstuvwxy",
]));
snapshot("discussion_show_json", &out, &red);
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"discussion",
"resolve",
&run_id,
"d-pqrstuvwxy",
"--choice",
"keep",
"--dry-run",
]));
snapshot("discussion_resolve_dry_run_json", &out, &red);
snapshot(
"discussion_show_not_found_error",
&err_stderr(
bin(&home).args(["discussion", "show", &run_id, "d-nopereport"]),
1,
),
&red,
);
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"discussion",
"resolve",
&run_id,
"d-pqrstuvwxy",
"--choice",
"keep",
]));
snapshot("discussion_resolve_json", &out, &red);
}
#[test]
fn spinoff_envelopes() {
let home = TempDir::new().unwrap();
let run_id = create_run(&home);
seed_node(&home, &run_id);
seed_spinoff(&home, &run_id);
let red = redactions(&home, Some(&run_id));
for (fmt, name) in [
("text", "spinoff_list_text"),
("json", "spinoff_list_json"),
("jsonl", "spinoff_list_jsonl"),
] {
let out = ok_stdout(bin(&home).args(["--output", fmt, "spinoff", "list", &run_id]));
snapshot(name, &out, &red);
}
snapshot(
"spinoff_reject_empty_reason_error",
&err_stderr(
bin(&home).args([
"spinoff",
"reject",
&run_id,
"s-spinaaaaaa",
"--reason",
" ",
]),
1,
),
&red,
);
let out = ok_stdout(bin(&home).args([
"--output",
"json",
"spinoff",
"reject",
&run_id,
"s-spinaaaaaa",
"--reason",
"not now",
]));
snapshot("spinoff_reject_json", &out, &red);
}