mod support;
use std::fs;
use serde_json::Value;
use support::{FakeAgent, World, wait_until, wait_until_slow};
const FLOW_PASSING_TEST: &str = "stages:
- { name: build, kind: agent, verdict: exit }
- { name: test, kind: exec, cmd: [\"true\"] }
- { name: merge, kind: merge }
";
const FLOW_FAILING_TEST: &str = "stages:
- { name: build, kind: agent, verdict: exit }
- { name: test, kind: exec, cmd: [\"false\"] }
- { name: merge, kind: merge }
";
const FLOW_AGENT_ONLY: &str = "stages:\n - { name: build, kind: agent, verdict: exit }\n";
fn configure(world: &World, stages: &str, script_body: &str) {
let flow_directory = world.root().join(".agents/sloop/flows");
fs::create_dir_all(&flow_directory).expect("create flow directory");
fs::write(flow_directory.join("default.yaml"), stages).expect("write flow");
let script = world.root().join("fake-agent.sh");
fs::write(&script, format!("#!/bin/sh\nset -eu\n{script_body}")).expect("write fake agent");
fs::write(
world.root().join(".agents/sloop/config.yaml"),
format!(
"version: 1\nscheduler:\n max_parallel_tasks: 1\nagent:\n default_target: fake\n \
targets:\n fake:\n cmd: [\"sh\", {}, \"{{prompt}}\"]\n",
serde_json::to_string(&script.to_string_lossy()).expect("serialize script path"),
),
)
.expect("write config");
}
fn committing_agent() -> String {
"git -c user.name=agent -c user.email=agent@example.invalid commit --quiet --allow-empty \
-m work\nexit 0\n"
.to_owned()
}
fn post(world: &World, name: &str) -> String {
let ticket = world.write_ticket(name, "# Show history\nwork\n");
let output = world.sloop(&["post", ticket.to_str().unwrap(), "--manual"]);
assert!(
output.status.success(),
"post failed: {}",
String::from_utf8_lossy(&output.stderr)
);
World::json_stdout(&output)["data"]["ticket"]["id"]
.as_str()
.expect("ticket id")
.to_owned()
}
fn status(world: &World) -> Value {
let output = world.sloop(&["status"]);
assert!(output.status.success());
World::json_stdout(&output)["data"].clone()
}
fn show(world: &World, reference: &str) -> Value {
let output = world.sloop(&["show", reference]);
assert!(
output.status.success(),
"show {reference} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
World::json_stdout(&output)["data"]["value"].clone()
}
fn show_text(world: &World, reference: &str) -> String {
let output = world.sloop_plain(&["show", reference]);
assert!(
output.status.success(),
"show {reference} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stage<'a>(stages: &'a Value, name: &str) -> &'a Value {
stages
.as_array()
.expect("stages array")
.iter()
.find(|entry| entry["stage"] == name)
.unwrap_or_else(|| panic!("no stage `{name}` in {stages}"))
}
#[test]
fn a_merged_run_shows_every_stage_passed_on_the_ticket_and_the_run() {
let world = World::configured();
configure(&world, FLOW_PASSING_TEST, &committing_agent());
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "merged-history.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until_slow("the run merges", || {
status(&world)["tickets"]["merged"] == 1
});
let shown = show(&world, &ticket);
let runs = shown["runs"].as_array().expect("runs array");
assert_eq!(runs.len(), 1, "{runs:?}");
assert_eq!(runs[0]["alias"], world.run_alias(1));
assert_eq!(runs[0]["state"], "merged");
assert!(runs[0]["started_at_ms"].is_i64(), "{}", runs[0]);
assert!(runs[0]["finished_at_ms"].is_i64(), "{}", runs[0]);
for name in ["build", "test", "merge"] {
assert_eq!(stage(&runs[0]["stages"], name)["state"], "passed");
}
assert_eq!(runs[0]["reason"], Value::Null);
let run = show(&world, &world.run_alias(1));
assert_eq!(run["state"], "merged");
assert_eq!(run["reason"], Value::Null);
assert!(run["claimed_at_ms"].is_i64(), "{run}");
assert!(run["started_at_ms"].is_i64(), "{run}");
assert!(run["finished_at_ms"].is_i64(), "{run}");
let stages = &run["stages"];
assert_eq!(stages.as_array().expect("stages").len(), 3);
for name in ["build", "test", "merge"] {
let row = stage(stages, name);
assert_eq!(row["state"], "passed", "{row}");
assert_eq!(row["exit_code"], 0, "{row}");
assert_eq!(row["attempts"], 1, "{row}");
assert!(row["started_at_ms"].is_i64(), "{row}");
assert!(row["finished_at_ms"].is_i64(), "{row}");
}
let text = show_text(&world, &world.run_alias(1));
assert!(text.contains("stages:"), "{text}");
assert!(text.contains("build passed"), "{text}");
assert!(text.contains("agent exit: 0"), "{text}");
assert!(text.contains("timeline: claimed "), "{text}");
}
#[test]
fn an_agent_that_succeeds_before_a_failing_stage_reports_that_stage_as_the_reason() {
let world = World::configured();
configure(&world, FLOW_FAILING_TEST, &committing_agent());
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "failing-stage.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until_slow("the run lands in needs_review", || {
status(&world)["tickets"]["needs_review"] == 1
});
let run = show(&world, &world.run_alias(1));
assert_eq!(run["state"], "needs_review");
assert_eq!(run["exit_code"], 0);
assert_eq!(run["agent_exit_code"], 0);
let reason = run["reason"].as_str().expect("a derived reason");
assert!(reason.contains("`test`"), "{reason}");
assert!(reason.contains("failed"), "{reason}");
assert!(reason.contains("exit 1"), "{reason}");
assert!(reason.contains("agent completed with commits"), "{reason}");
assert_eq!(stage(&run["stages"], "build")["state"], "passed");
let failed = stage(&run["stages"], "test");
assert_eq!(failed["state"], "failed");
assert_eq!(failed["exit_code"], 1);
assert_eq!(failed["verdict_source"], "exit_code");
assert_eq!(stage(&run["stages"], "merge")["state"], "pending");
let text = show_text(&world, &world.run_alias(1));
assert!(text.contains("agent exit: 0"), "{text}");
assert!(
!text.contains("\nexit: 0"),
"bare `exit:` must be gone: {text}"
);
assert!(text.contains("test failed"), "{text}");
assert!(text.contains("exit 1"), "{text}");
let shown = show(&world, &ticket);
let runs = shown["runs"].as_array().expect("runs array");
assert_eq!(runs[0]["state"], "needs_review");
assert_eq!(stage(&runs[0]["stages"], "test")["state"], "failed");
let ticket_text = show_text(&world, &ticket);
assert!(ticket_text.contains("runs:"), "{ticket_text}");
assert!(ticket_text.contains("test:FAIL"), "{ticket_text}");
}
#[test]
fn a_ticket_with_several_runs_lists_them_newest_first() {
let world = World::configured();
configure(&world, FLOW_FAILING_TEST, "exit 0\n");
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "two-runs.md");
for attempt in 1..=2 {
if attempt > 1 {
assert!(world.sloop(&["retry", &ticket]).status.success());
}
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until_slow("the attempt fails", || {
status(&world)["tickets"]["failed"] == 1
});
}
let runs = show(&world, &ticket)["runs"].as_array().cloned().unwrap();
assert_eq!(runs.len(), 2, "{runs:?}");
assert_eq!(runs[0]["attempt"], 2);
assert_eq!(runs[1]["attempt"], 1);
assert_eq!(runs[0]["alias"], world.run_alias(2));
assert_eq!(runs[1]["alias"], world.run_alias(1));
}
#[test]
fn a_ticket_that_has_never_run_reports_no_runs() {
let world = World::configured();
configure(&world, FLOW_AGENT_ONLY, "exit 0\n");
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "never-run.md");
assert_eq!(show(&world, &ticket)["runs"], Value::Array(Vec::new()));
assert!(show_text(&world, &ticket).contains("runs: none"));
}
#[test]
fn project_show_is_unchanged() {
let world = World::configured();
configure(&world, FLOW_AGENT_ONLY, "exit 0\n");
world.commit_all("initial");
world.start_daemon();
post(&world, "project-untouched.md");
let project = show(&world, "default");
assert!(project["tickets"].is_array(), "{project}");
assert_eq!(project["runs"], Value::Null);
}
#[test]
fn bare_show_is_a_dashboard_and_patterns_filter_before_limiting() {
let world = World::configured();
configure(&world, FLOW_AGENT_ONLY, "exit 0\n");
world.commit_all("initial");
world.start_daemon();
let first = post(&world, "audit.md");
let second = post(&world, "audit-helper.md");
let third = post(&world, "other-audit.md");
let bare = world.sloop(&[]);
let shown = world.sloop(&["show"]);
assert!(bare.status.success());
assert!(shown.status.success());
let bare_data = &World::json_stdout(&bare)["data"];
let shown_data = &World::json_stdout(&shown)["data"];
assert_eq!(bare_data, shown_data);
assert_eq!(shown_data["kind"], "dashboard");
assert_eq!(shown_data["recent"][0]["id"], third);
let status_alias = world.sloop_plain(&["status"]);
let status_new = world.sloop_plain(&["show"]);
assert_eq!(status_alias.stdout, status_new.stdout);
assert_eq!(
String::from_utf8_lossy(&status_alias.stderr),
"note: 'sloop status' is now 'sloop show'; this alias will be removed in a future release\n"
);
let list_alias = world.sloop_plain(&["list", "-2"]);
let list_new = world.sloop_plain(&["show", ".*", "-2"]);
assert_eq!(list_alias.stdout, list_new.stdout);
assert_eq!(
String::from_utf8_lossy(&list_alias.stderr),
"note: 'sloop list' is now 'sloop show'; this alias will be removed in a future release\n"
);
let exact = World::json_stdout(&world.sloop(&["show", "audit"]));
assert_eq!(exact["data"]["kind"], "ticket");
assert_eq!(exact["data"]["value"]["id"], first);
let partial = World::json_stdout(&world.sloop(&["show", "udi"]));
assert_eq!(partial["data"]["kind"], "matches");
assert_eq!(partial["data"]["tickets"].as_array().unwrap().len(), 3);
let limited = World::json_stdout(&world.sloop(&["show", "audit", "-2"]));
assert_eq!(limited["data"]["kind"], "ticket");
let limited = World::json_stdout(&world.sloop(&["show", "udi", "-2"]));
let ids = limited["data"]["tickets"]
.as_array()
.unwrap()
.iter()
.map(|ticket| ticket["id"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(ids, [third.as_str(), second.as_str()]);
let invalid = world.sloop(&["show", "["]);
assert_eq!(invalid.status.code(), Some(2));
assert!(
String::from_utf8_lossy(&invalid.stderr).contains("invalid ticket pattern `[`"),
"{}",
String::from_utf8_lossy(&invalid.stderr)
);
}
#[test]
fn show_follow_streams_a_ticket_until_it_settles_and_quiet_is_silent() {
let world = World::configured();
world.configure_fake_agent(
FakeAgent::new()
.block_until_released("follow")
.commit("work")
.exit(0),
);
world.commit_all("initial");
world.start_daemon();
let ticket = post(&world, "follow-ticket.md");
assert!(world.sloop(&["run", &ticket]).status.success());
wait_until("the followed run starts", || {
world.fake_agent_reached("follow")
});
let streaming = world.spawn_sloop(&["show", &ticket, "--follow"]);
let quiet = world.spawn_sloop(&["show", &ticket, "--follow", "--quiet"]);
world.release("follow");
let streaming = streaming
.wait_with_output()
.expect("wait for streaming show");
let quiet = quiet.wait_with_output().expect("wait for quiet show");
assert!(streaming.status.success(), "{:?}", streaming.status);
assert!(quiet.status.success(), "{:?}", quiet.status);
assert!(quiet.stdout.is_empty(), "quiet output: {:?}", quiet.stdout);
let events = String::from_utf8_lossy(&streaming.stdout);
assert!(
events.lines().any(|line| {
serde_json::from_str::<Value>(line)
.is_ok_and(|event| event["kind"] == "run_finished" && event["ticket"] == ticket)
}),
"streamed events: {events}"
);
let watch_alias = world.sloop(&["watch", &ticket]);
let watch_new = world.sloop(&["show", &ticket, "--follow"]);
assert_eq!(watch_alias.status.code(), watch_new.status.code());
assert_eq!(watch_alias.stdout, watch_new.stdout);
assert!(
String::from_utf8_lossy(&watch_alias.stderr)
.starts_with("note: 'sloop watch' is now 'sloop show --follow'")
);
let wait_alias = world.sloop(&["wait", &ticket, "--timeout", "5"]);
let wait_new = world.sloop(&["show", &ticket, "--follow", "--quiet"]);
assert_eq!(wait_alias.status.code(), wait_new.status.code());
assert_eq!(wait_alias.stdout, wait_new.stdout);
assert!(
String::from_utf8_lossy(&wait_alias.stderr)
.starts_with("note: 'sloop wait' is now 'sloop show --follow --quiet'")
);
}