use std::path::PathBuf;
use std::process::Command;
fn fixture() -> String {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join(".claude/skills/dev-cycle/fsmp-definition.yaml")
.to_string_lossy()
.into_owned()
}
struct Env {
home: PathBuf,
}
impl Env {
fn new(name: &str) -> Env {
let home = std::env::temp_dir().join(format!("fsmp-it-{name}"));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
Env { home }
}
fn run(&self, args: &[&str]) -> Out {
let out = Command::new(env!("CARGO_BIN_EXE_fsmp"))
.env("FSMP_HOME", &self.home)
.args(args)
.output()
.expect("failed to run fsmp");
Out {
code: out.status.code().unwrap_or(-1),
text: format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
),
}
}
}
struct Out {
code: i32,
text: String,
}
impl Out {
fn ok(self) -> Out {
assert_eq!(
self.code, 0,
"expected success, got {}:\n{}",
self.code, self.text
);
self
}
fn fail(self) -> Out {
assert_ne!(
self.code, 0,
"expected failure, got success:\n{}",
self.text
);
self
}
fn has(self, needle: &str) -> Out {
assert!(
self.text.contains(needle),
"missing {needle:?} in:\n{}",
self.text
);
self
}
fn lacks(self, needle: &str) -> Out {
assert!(
!self.text.contains(needle),
"unexpected {needle:?} in:\n{}",
self.text
);
self
}
}
fn to_awaiting_review(name: &str, bar: &str) -> Env {
let e = Env::new(name);
let f = fixture();
e.run(&[
"new",
"--def",
&f,
"--id",
"m",
"--set",
&format!("bar={bar}"),
])
.ok()
.has("state: triage");
e.run(&["do", "brief_ready", "--id", "m"])
.ok()
.has("state: implementing");
e.run(&[
"do",
"pr_opened",
"--id",
"m",
"--data",
"pr_url=https://x/1",
])
.ok()
.has("state: awaiting_review");
e
}
#[test]
fn happy_path_two_clean_initials_reaches_merged() {
let e = to_awaiting_review("happy", "2");
e.run(&["do", "verdict_clean", "--id", "m"])
.ok()
.has("1 of 2");
e.run(&["do", "converge", "--id", "m"])
.fail()
.has("currently 1");
e.run(&["do", "next_round", "--id", "m"])
.ok()
.has("Round 2");
e.run(&["do", "verdict_clean", "--id", "m"])
.ok()
.has("2 of 2");
e.run(&["do", "converge", "--id", "m"])
.ok()
.has("state: verifying");
e.run(&["do", "verification_passed", "--id", "m"])
.ok()
.has("state: presenting");
e.run(&["do", "operator_merged", "--id", "m"])
.ok()
.has("state: merged")
.has("terminal");
e.run(&["do", "operator_merged", "--id", "m"])
.fail()
.has("terminal");
}
#[test]
fn clean_notes_cannot_skip_the_implementer_response() {
let e = to_awaiting_review("cleannotes", "2");
e.run(&["do", "verdict_clean_notes", "--id", "m"])
.ok()
.has("state: awaiting_impl_response")
.has("NOT optional");
e.run(&["do", "converge", "--id", "m"])
.fail()
.has("not a valid transition");
e.run(&["do", "impl_responded", "--id", "m"])
.ok()
.has("state: awaiting_reassessment");
e.run(&["do", "reviewer_satisfied", "--id", "m"])
.ok()
.has("1 of 2");
}
#[test]
fn converge_is_gated_on_the_counter_not_on_round_count() {
let e = to_awaiting_review("counter", "1"); e.run(&["do", "verdict_changes", "--id", "m"])
.ok()
.has("state: awaiting_impl_response");
e.run(&["do", "impl_responded", "--id", "m"]).ok();
e.run(&["do", "reviewer_satisfied", "--id", "m"])
.ok()
.has("0 of 1");
e.run(&["do", "converge", "--id", "m"])
.fail()
.has("currently 0");
e.run(&["do", "next_round", "--id", "m"])
.ok()
.has("Round 2");
e.run(&["do", "verdict_clean", "--id", "m"])
.ok()
.has("1 of 1");
e.run(&["do", "converge", "--id", "m"])
.ok()
.has("state: verifying");
}
fn to_verifying(name: &str, extra_sets: &[&str]) -> Env {
let e = Env::new(name);
let f = fixture();
let mut args = vec!["new", "--def", &f, "--id", "m", "--set", "bar=1"];
for s in extra_sets {
args.push("--set");
args.push(s);
}
e.run(&args).ok();
e.run(&["do", "brief_ready", "--id", "m"]).ok();
e.run(&[
"do",
"pr_opened",
"--id",
"m",
"--data",
"pr_url=https://x/1",
])
.ok();
e.run(&["do", "verdict_clean", "--id", "m"]).ok();
e.run(&["do", "converge", "--id", "m"])
.ok()
.has("state: verifying");
e
}
#[test]
fn presenting_is_reachable_only_through_verifying() {
let e = to_awaiting_review("onlyverify", "1");
e.run(&["do", "verdict_clean", "--id", "m"]).ok();
e.run(&["do", "verification_passed", "--id", "m"])
.fail()
.has("not a valid transition");
e.run(&["do", "converge", "--id", "m"])
.ok()
.has("state: verifying");
e.run(&["do", "operator_merged", "--id", "m"])
.fail()
.has("not a valid transition");
}
#[test]
fn verification_failure_loops_through_fix_review_and_reverifies() {
let e = to_verifying("verifyfail", &[]);
e.run(&["do", "verification_failed", "--id", "m"])
.fail()
.has("requires data: findings_url");
e.run(&[
"do",
"verification_failed",
"--id",
"m",
"--data",
"findings_url=https://x/1#issuecomment-9",
])
.ok()
.has("state: fixing")
.has("https://x/1#issuecomment-9");
e.run(&["do", "fix_pushed", "--id", "m"])
.ok()
.has("state: awaiting_fix_review")
.has("FRESH reviewer");
e.run(&["do", "fix_changes", "--id", "m"])
.ok()
.has("state: fixing");
e.run(&["do", "fix_pushed", "--id", "m"]).ok();
e.run(&["do", "fix_satisfied", "--id", "m"])
.ok()
.has("state: verifying");
e.run(&["show", "--id", "m", "--json"])
.ok()
.has("\"clean_initial_count\": 1");
e.run(&["do", "verification_passed", "--id", "m"])
.ok()
.has("state: presenting");
}
#[test]
fn waive_is_blocked_unless_capstone_was_disabled_at_new() {
let e = to_verifying("waiveblocked", &[]);
e.run(&["do", "verification_waived", "--id", "m"])
.fail()
.has("capstone=true");
e.run(&["show", "--id", "m"])
.ok()
.has("Blocked from here")
.has("verification_waived");
let e = to_verifying("waiveopen", &["capstone=false"]);
e.run(&["do", "verification_waived", "--id", "m"])
.ok()
.has("state: presenting");
}
#[test]
fn verification_failed_is_blocked_at_the_round_ceiling() {
let e = to_verifying("verifyceiling", &["round_ceiling=1"]);
e.run(&[
"do",
"verification_failed",
"--id",
"m",
"--data",
"findings_url=https://x/1#c",
])
.fail()
.has("round ceiling 1 reached");
e.run(&["do", "escalate", "--id", "m"])
.ok()
.has("state: escalated");
}
#[test]
fn pr_opened_requires_the_pr_url_and_interpolates_it() {
let e = Env::new("requires");
let f = fixture();
e.run(&["new", "--def", &f, "--id", "m", "--set", "bar=2"])
.ok();
e.run(&["do", "brief_ready", "--id", "m"]).ok();
e.run(&["do", "pr_opened", "--id", "m"])
.fail()
.has("requires data: pr_url")
.has("--data pr_url=");
e.run(&[
"do",
"pr_opened",
"--id",
"m",
"--data",
"pr_url=https://x/9",
])
.ok()
.has("https://x/9");
}
#[test]
fn unknown_transition_is_rejected_with_the_valid_list() {
let e = Env::new("unknown");
let f = fixture();
e.run(&["new", "--def", &f, "--id", "m"]).ok();
e.run(&["do", "teleport", "--id", "m"])
.fail()
.has("not a valid transition")
.has("brief_ready"); }
#[test]
fn escalate_reaches_a_terminal_state() {
let e = Env::new("escalate");
let f = fixture();
e.run(&["new", "--def", &f, "--id", "m"]).ok();
e.run(&["do", "escalate", "--id", "m"])
.ok()
.has("state: escalated")
.has("terminal");
e.run(&["do", "escalate", "--id", "m"])
.fail()
.has("terminal");
}
#[test]
fn round_complete_lists_converge_as_blocked_with_a_reason() {
let e = to_awaiting_review("blockedlist", "2");
e.run(&["do", "verdict_clean", "--id", "m"]).ok();
e.run(&["show", "--id", "m"])
.ok()
.has("Blocked from here")
.has("converge")
.has("Valid transitions")
.has("next_round");
}
#[test]
fn json_view_exposes_state_and_transition_partitions() {
let e = Env::new("json");
let f = fixture();
e.run(&["new", "--def", &f, "--id", "m", "--json"])
.ok()
.has("\"state\": \"triage\"")
.has("\"valid\"")
.has("\"blocked\"")
.has("\"guidance\"");
}
#[test]
fn log_records_the_full_transition_history() {
let e = to_awaiting_review("log", "2");
e.run(&["log", "--id", "m"])
.ok()
.has("new")
.has("brief_ready")
.has("pr_opened")
.has("pr_url=https://x/1");
}
#[test]
fn hostile_instance_ids_are_rejected_before_touching_disk() {
let e = Env::new("hostileid");
let f = fixture();
for id in ["../pwned", "a/b", "/abs", "", ".", "..", ".hidden"] {
e.run(&["new", "--def", &f, "--id", id])
.fail()
.has("invalid instance id");
}
assert!(
!e.home.parent().unwrap().join("pwned").exists(),
"traversal id escaped FSMP_HOME"
);
e.run(&["show", "--id", "../pwned"])
.fail()
.has("invalid instance id");
}
#[test]
fn a_tampered_snapshot_errors_cleanly_instead_of_panicking() {
let e = to_awaiting_review("tampered", "2");
let path = e.home.join("state/m/instance.json");
let json = std::fs::read_to_string(&path).unwrap();
let json = json.replace("\"current\": \"awaiting_review\"", "\"current\": \"ghost\"");
std::fs::write(&path, json).unwrap();
for args in [
vec!["show", "--id", "m"],
vec!["do", "verdict_clean", "--id", "m"],
vec!["log", "--id", "m"],
] {
e.run(&args)
.fail()
.has("corrupt")
.has("ghost")
.lacks("panicked");
}
}
#[test]
fn a_fresh_instance_does_not_leak_state_between_ids() {
let e = Env::new("isolation");
let f = fixture();
e.run(&["new", "--def", &f, "--id", "a", "--set", "bar=2"])
.ok();
e.run(&["do", "brief_ready", "--id", "a"]).ok();
e.run(&["new", "--def", &f, "--id", "b", "--set", "bar=2"])
.ok()
.has("state: triage");
e.run(&["show", "--id", "b"])
.ok()
.has("state: triage")
.lacks("state: implementing");
e.run(&["new", "--def", &f, "--id", "a"])
.fail()
.has("already exists");
}