use devflow_core::gates::{GateResponse, Gates};
use devflow_core::mode::Mode;
use devflow_core::stage::Stage;
use devflow_core::state::{AgentKind, State};
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
fn devflow_bin() -> &'static str {
env!("CARGO_BIN_EXE_devflow")
}
fn git(root: &Path, args: &[&str]) {
let output = devflow_core::test_support::git_command(root)
.args(args)
.output()
.expect("spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn init_repo(root: &Path, phase: u32) {
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "devflow@example.com"]);
git(root, &["config", "user.name", "DevFlow Tests"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
git(root, &["checkout", "-q", "-b", "develop"]);
std::fs::write(root.join("README.md"), "base\n").unwrap();
git(root, &["add", "README.md"]);
git(root, &["commit", "-q", "-m", "base"]);
let branch = format!("feature/phase-{phase:02}");
git(root, &["checkout", "-q", "-b", &branch]);
std::fs::write(root.join("work.txt"), "agent work\n").unwrap();
git(root, &["add", "work.txt"]);
git(root, &["commit", "-q", "-m", "agent work"]);
}
fn wait_for(mut predicate: impl FnMut() -> bool, timeout_secs: u64, what: &str) {
let start = Instant::now();
while !predicate() {
assert!(
start.elapsed() < Duration::from_secs(timeout_secs),
"timed out after {timeout_secs}s waiting for: {what}"
);
std::thread::sleep(Duration::from_millis(20));
}
}
fn e2e_child_timeout() -> Duration {
let secs: u64 = std::env::var("DEVFLOW_E2E_CHILD_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(90);
Duration::from_secs(secs)
}
fn wait_for_child_exit(
child: &mut std::process::Child,
root: &Path,
phase: u32,
deadline: Duration,
) -> std::process::ExitStatus {
let start = Instant::now();
loop {
if let Some(status) = child.try_wait().expect("try_wait on devflow advance child") {
return status;
}
if start.elapsed() >= deadline {
let pid = child.id();
let lock_present = devflow_core::lock::holder(root, phase).is_some();
let gate_present = Gates::gate_path(root, phase, Stage::Code).exists();
let response_present = Gates::response_path(root, phase, Stage::Code).exists();
let _ = child.wait();
panic!(
"devflow advance (pid {pid}) did not exit within {deadline:?}; \
on disk: lock_present={lock_present} gate_present={gate_present} \
response_present={response_present}"
);
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn stop_ends_a_gated_phase_through_its_own_abort_path_with_no_signal_sent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 95;
init_repo(root, phase);
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
devflow_core::workflow::save_state(&state).unwrap();
let mut child = Command::new(devflow_bin())
.args(["advance", "--phase", &phase.to_string()])
.arg(root)
.env("DEVFLOW_GATE_TIMEOUT_SECS", "15")
.spawn()
.expect("spawn devflow advance");
wait_for(
|| devflow_core::lock::holder(root, phase).is_some(),
10,
"the child to acquire .devflow/lock-95",
);
let gate_path = Gates::gate_path(root, phase, Stage::Code);
wait_for(
|| gate_path.exists(),
10,
"the child to write the Code gate",
);
assert!(
devflow_core::lock::holder(root, phase).is_some(),
"lock must still be held once the gate is written — proof a live poller is genuinely \
blocking on it"
);
let output = Command::new(devflow_bin())
.args(["stop", "--phase", &phase.to_string(), "--root"])
.arg(root)
.output()
.expect("run devflow stop");
assert!(
output.status.success(),
"devflow stop failed\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let status = wait_for_child_exit(&mut child, root, phase, e2e_child_timeout());
assert!(
status.success(),
"devflow advance must exit cleanly once its own abort() path runs, got {status:?}"
);
assert!(
devflow_core::lock::holder(root, phase).is_none(),
"LockGuard's Drop must have released the per-phase lock — proof the process unwound \
cleanly through its own code, not by being terminated from outside"
);
let events = std::fs::read_to_string(devflow_core::events::events_path(root)).unwrap();
assert!(
events
.lines()
.any(|line| line.contains("\"workflow_aborted\"")),
"the target process must have run its own abort() path, recording workflow_aborted — \
the whole claim this plan rests on\nevents:\n{events}"
);
}
#[test]
fn stop_marks_state_stopped_and_records_reason() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 96;
Gates::write_gate(root, phase, Stage::Ship, "approve merge?").unwrap();
let state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
devflow_core::workflow::save_state(&state).unwrap();
let output = Command::new(devflow_bin())
.args(["stop", "--phase", &phase.to_string(), "--root"])
.arg(root)
.output()
.expect("run devflow stop");
assert!(
output.status.success(),
"devflow stop failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let reloaded = devflow_core::workflow::load_state(root, phase).unwrap();
assert!(reloaded.stopped, "stop must set stopped=true");
assert!(
reloaded
.stop_reason
.as_deref()
.is_some_and(|r| r.contains("devflow stop")),
"stop_reason must name devflow stop as the cause, got {:?}",
reloaded.stop_reason
);
}
#[test]
fn stop_preserves_pre_existing_stop_reason() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 97;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stopped = true;
state.stop_reason = Some("stopped after plan completed (--until plan)".to_string());
devflow_core::workflow::save_state(&state).unwrap();
let output = Command::new(devflow_bin())
.args(["stop", "--phase", &phase.to_string(), "--root"])
.arg(root)
.output()
.expect("run devflow stop");
assert!(output.status.success());
let reloaded = devflow_core::workflow::load_state(root, phase).unwrap();
assert!(reloaded.stopped);
let reason = reloaded.stop_reason.expect("stop_reason must be present");
assert!(
reason.contains("stopped after plan completed"),
"the earlier reason must be preserved, got: {reason}"
);
assert!(
reason.contains("devflow stop"),
"the new reason must also be recorded, got: {reason}"
);
}
#[test]
fn stop_leaves_stop_until_unchanged() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 98;
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stop_until = Some(Stage::Plan);
devflow_core::workflow::save_state(&state).unwrap();
let output = Command::new(devflow_bin())
.args(["stop", "--phase", &phase.to_string(), "--root"])
.arg(root)
.output()
.expect("run devflow stop");
assert!(output.status.success());
let reloaded = devflow_core::workflow::load_state(root, phase).unwrap();
assert_eq!(reloaded.stop_until, Some(Stage::Plan));
}
#[test]
fn stop_help_documents_phase_flag() {
let output = Command::new(devflow_bin())
.args(["stop", "--help"])
.output()
.expect("run devflow stop --help");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--phase"),
"--help missing --phase:\n{stdout}"
);
}
#[test]
fn stop_is_idempotent_against_an_already_answered_gate() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 99;
Gates::write_gate(root, phase, Stage::Ship, "approve merge?").unwrap();
let state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
devflow_core::workflow::save_state(&state).unwrap();
let run_stop = || {
Command::new(devflow_bin())
.args(["stop", "--phase", &phase.to_string(), "--root"])
.arg(root)
.output()
.expect("run devflow stop")
};
let first = run_stop();
assert!(
first.status.success(),
"first stop failed: {}",
String::from_utf8_lossy(&first.stderr)
);
let response_path = Gates::response_path(root, phase, Stage::Ship);
let first_bytes =
std::fs::read(&response_path).expect("response file must exist after first stop");
let second = run_stop();
assert!(
second.status.success(),
"second stop failed: {}",
String::from_utf8_lossy(&second.stderr)
);
let second_bytes = std::fs::read(&response_path).expect("response file must still exist");
assert_eq!(
first_bytes, second_bytes,
"the second stop must not modify the response file the first one wrote"
);
}
#[test]
fn stop_against_a_hand_written_response_is_a_success_no_op() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 100;
Gates::write_gate(root, phase, Stage::Ship, "approve merge?").unwrap();
Gates::respond(
root,
phase,
Stage::Ship,
&GateResponse {
approved: false,
note: Some("hand-written rejection".into()),
responded_by: Some("human".into()),
},
)
.unwrap();
let response_path = Gates::response_path(root, phase, Stage::Ship);
let before = std::fs::read(&response_path).unwrap();
let output = Command::new(devflow_bin())
.args(["stop", "--phase", &phase.to_string(), "--root"])
.arg(root)
.output()
.expect("run devflow stop");
assert!(
output.status.success(),
"devflow stop failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let after = std::fs::read(&response_path).unwrap();
assert_eq!(
before, after,
"stop must not clobber an existing hand-written response"
);
}
#[test]
fn stop_against_a_root_with_no_state_is_a_success() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 101;
let output = Command::new(devflow_bin())
.args(["stop", "--phase", &phase.to_string(), "--root"])
.arg(root)
.output()
.expect("run devflow stop");
assert!(
output.status.success(),
"stop against a root with no state must succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn stop_then_cleanup_composes_refuse_then_force() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let phase = 102;
git(root, &["init", "-q"]);
git(root, &["config", "user.email", "devflow@example.com"]);
git(root, &["config", "user.name", "DevFlow Tests"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "core.hooksPath", "/dev/null"]);
git(root, &["checkout", "-q", "-b", "develop"]);
std::fs::write(root.join("README.md"), "base\n").unwrap();
git(root, &["add", "README.md"]);
git(root, &["commit", "-q", "-m", "base"]);
let branch = format!("feature/phase-{phase:02}");
git(root, &["checkout", "-q", "-b", &branch]);
std::fs::write(root.join("work.txt"), "agent work\n").unwrap();
git(root, &["add", "work.txt"]);
git(root, &["commit", "-q", "-m", "agent work"]);
git(root, &["checkout", "-q", "develop"]);
let wt_path = root.join(".worktrees").join(format!("phase-{phase:02}"));
devflow_core::worktree::add(root, &wt_path, &branch, &branch, false).unwrap();
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.worktree_path = Some(wt_path.clone());
devflow_core::workflow::save_state(&state).unwrap();
let stopped = Command::new(devflow_bin())
.args(["stop", "--phase", &phase.to_string(), "--root"])
.arg(root)
.output()
.expect("run devflow stop");
assert!(
stopped.status.success(),
"devflow stop failed: {}",
String::from_utf8_lossy(&stopped.stderr)
);
let stopped_state = devflow_core::workflow::load_state(root, phase).unwrap();
assert!(
stopped_state.stopped,
"stop must have marked the phase stopped before the composition check runs"
);
let refused = Command::new(devflow_bin())
.args(["cleanup"])
.arg(root)
.output()
.expect("run devflow cleanup");
assert!(
refused.status.success(),
"cleanup on a stopped phase must not error, only skip: {}",
String::from_utf8_lossy(&refused.stderr)
);
assert!(
wt_path.is_dir(),
"cleanup without --force must not remove a stop-marked phase's worktree"
);
let forced = Command::new(devflow_bin())
.args(["cleanup", "--force"])
.arg(root)
.output()
.expect("run devflow cleanup --force");
assert!(
forced.status.success(),
"cleanup --force must succeed: {}",
String::from_utf8_lossy(&forced.stderr)
);
assert!(
!wt_path.is_dir(),
"cleanup --force must remove the worktree after stop"
);
}