use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
struct Scratch(PathBuf);
impl Scratch {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("pma-test-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
fn project(&self, name: &str, todo: &str) -> PathBuf {
let dir = self.0.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("TODO.md"), todo).unwrap();
dir
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn pma(args: &[&std::ffi::OsStr]) -> Output {
Command::new(env!("CARGO_BIN_EXE_pma"))
.args(args)
.output()
.unwrap()
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn clean_file_exits_zero_silently() {
let s = Scratch::new("clean");
let dir = s.project("a", "# TODO\n\n## High\n\n- [ ] thing\n");
let out = pma(&["lint".as_ref(), dir.as_os_str()]);
assert!(out.status.success());
assert_eq!(stdout(&out), "");
assert!(out.stderr.is_empty());
}
#[test]
fn warnings_alone_exit_zero() {
let s = Scratch::new("warn");
let dir = s.project("a", "# TODO\n\n## Low\n\n- [x] finished #42\n");
let out = pma(&["lint".as_ref(), dir.as_os_str()]);
assert!(out.status.success());
let todo = dir.join("TODO.md");
assert_eq!(
stdout(&out),
format!(
"{}:5: warning: `#42` is text; write `gh:42` to link the issue\n",
todo.display()
)
);
}
#[test]
fn prune_lists_then_removes_finished_items() {
let s = Scratch::new("prune");
let text = "# TODO\n\n## High\n\n- [x] shipped\n notes\n- [ ] open\n\n## Done\n\n- [x] old\n- [ ] stray\n";
let a = s.project("a", text);
let bad = s.project("bad", "## High\n\n- [x] kept\n");
let todo = a.join("TODO.md");
let out = pma(&["prune".as_ref(), a.as_os_str(), bad.as_os_str()]);
assert!(out.status.success());
assert_eq!(
stdout(&out),
format!(
"{0}:9: remove `## Done`, lines 9-12\n\
{0}:12: remove open item with `## Done`: `- [ ] stray`\n\
{0}:5: remove `shipped`\n\
{1}: skipped: lint errors; see `pma lint`\n\
2 removals; run `pma prune --apply` to make them\n",
todo.display(),
bad.join("TODO.md").display()
)
);
assert_eq!(fs::read_to_string(&todo).unwrap(), text);
let out = pma(&["prune".as_ref(), "--apply".as_ref(), a.as_os_str()]);
assert!(out.status.success());
assert!(
stdout(&out).ends_with("2 removals made; TODO.md edits are uncommitted\n"),
"{}",
stdout(&out)
);
assert_eq!(
fs::read_to_string(&todo).unwrap(),
"# TODO\n\n## High\n\n- [ ] open\n"
);
let out = pma(&[
"prune".as_ref(),
a.as_os_str(),
s.0.join("none").as_os_str(),
]);
assert_eq!(out.status.code(), Some(1));
assert!(
stdout(&out).ends_with("nothing to prune\n"),
"{}",
stdout(&out)
);
}
#[test]
fn errors_in_any_file_exit_one_and_every_file_is_checked() {
let s = Scratch::new("errors");
let bad = s.project("bad", "## High\n");
let good = s.project("good", "# TODO\n");
let missing = s.0.join("missing");
fs::create_dir_all(&missing).unwrap();
let out = pma(&[
"lint".as_ref(),
bad.as_os_str(),
good.as_os_str(),
missing.as_os_str(),
]);
assert_eq!(out.status.code(), Some(1));
let text = stdout(&out);
assert!(
text.contains(&format!("{}:1: error:", bad.join("TODO.md").display())),
"{text}"
);
assert!(
text.contains(&format!("{}: error:", missing.join("TODO.md").display())),
"{text}"
);
assert!(!text.contains(&good.display().to_string()), "{text}");
assert_eq!(
String::from_utf8_lossy(&out.stderr),
"2 errors, 0 warnings\n"
);
}
const DAY: i64 = 86_400;
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
fn git(dir: &std::path::Path, args: &[&str], date: Option<i64>) {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
.args(args);
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1");
if let Some(d) = date {
cmd.env("GIT_AUTHOR_DATE", format!("@{d} +0000"))
.env("GIT_COMMITTER_DATE", format!("@{d} +0000"));
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn pma_in(home: &std::path::Path, args: &[&str]) -> (String, String, bool) {
let out = Command::new(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", home)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
fn ok(home: &std::path::Path, args: &[&str]) -> String {
let (out, err, success) = pma_in(home, args);
assert!(success, "pma {args:?} failed\nstdout: {out}\nstderr: {err}");
out
}
#[test]
fn scan_rank_and_explain_a_real_repo() {
let s = Scratch::new("stage2");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
fs::create_dir_all(root.join("beta")).unwrap();
git(&root.join("beta"), &["init", "-q"], None);
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n### core\n\n- [ ] old high\n\n## Low\n\n- [ ] old low\n",
)
.unwrap();
fs::write(alpha.join("lib.rs"), "\n").unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], Some(now() - 100 * DAY));
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n### core\n\n- [ ] old high\n\n## Low\n\n- [ ] old low #later\n",
)
.unwrap();
git(&alpha, &["commit", "-qam", "retag"], Some(now() - 50 * DAY));
git(&alpha, &["branch", "pma/stray"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n### core\n\n- [ ] old high\n- [ ] new high\n\n## Low\n\n- [ ] old low #later\n",
)
.unwrap();
let (_, err, success) = pma_in(&home, &["scan"]);
assert!(!success && err.contains("pma root add"), "{err}");
let (_, err, success) = pma_in(&home, &["matrix"]);
assert!(!success && err.contains("pma scan"), "{err}");
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["tier", "alpha", "1"]);
assert_eq!(ok(&home, &["tier", "alpha"]), "1\n");
let (_, err, success) = pma_in(&home, &["tier", "alpha", "6"]);
assert!(!success && err.contains("1 to 5"), "{err}");
let scanned = ok(&home, &["scan", "--offline"]);
assert_eq!(
scanned.split(" in ").next(),
Some("scanned 2 projects"),
"{scanned}"
);
assert!(
scanned.ends_with(": 1 with TODO.md, 3 open items\n"),
"{scanned}"
);
let matrix = ok(&home, &["matrix", "--all"]);
let expected = "\
Q1 Do right away: 0
Q2 Schedule for later: 2
alpha:7 T1 high open 100d core old high
alpha:8 T1 high open 0d core new high
Q3 Delegate or avoid: 0
Q4 Remove: 3
alpha T1 medium open 0d resolve local changes: 1 changed file, 1 leftover pma branch
alpha:12 T1 low open 100d old low
alpha T1 low open 70d review project: no code commits in 100 days
";
let (head, body) = matrix.split_once("\n\n").unwrap();
assert_eq!(
head,
"last scan just now; 1 tiered projects, 1 untiered (pma tier <project> <1-5>)"
);
assert_eq!(body, expected);
let status = ok(&home, &["status", "--explain"]);
assert!(status.contains("\nalpha 1 0."), "{status}");
assert!(
status.contains(" ci n/a w=3 unknown: offline"),
"{status}"
);
assert!(status.contains("open: 2 high, 1 low"), "{status}");
ok(&home, &["config", "tiers.1", "0.3"]);
assert_eq!(ok(&home, &["config", "tiers.1"]), "0.3\n");
assert!(ok(&home, &["config"]).contains("tiers.1 = 0.3 (set)\n"));
assert!(ok(&home, &["matrix", "-q", "q1"]).ends_with("Q1 Do right away: 0\n"));
let (_, err, success) = pma_in(&home, &["config", "tiers.1", "much"]);
assert!(
!success && err.contains("tiers.1: expected a number"),
"{err}"
);
ok(&home, &["config", "tiers.1", "--reset"]);
assert!(ok(&home, &["matrix", "-q", "q2"]).contains("Q2 Schedule for later: 2\n"));
assert!(ok(&home, &["matrix"]).contains("1 untiered"));
ok(&home, &["config", "default_tier", "5"]);
let matrix = ok(&home, &["matrix"]);
assert!(matrix.contains("2 tiered projects, 0 untiered"), "{matrix}");
ok(&home, &["config", "default_tier", "--reset"]);
let stale = ok(&home, &["stale", "-n", "2"]);
assert!(
stale.contains("alpha:7 T1 high open 100d old high"),
"{stale}"
);
assert!(stale.trim_end().ends_with("and 1 more"), "{stale}");
ok(&home, &["scan", "--offline", "alpha"]);
assert_eq!(ok(&home, &["tier", "alpha"]), "1\n");
}
const FAKE_CLAUDE: &str = r#"#!/bin/sh
case "$2" in
*"reviewer's feedback"*) echo hi > hello.txt ;;
*"add greeting"*) echo hi > hello.txt ;;
*"second task"*) echo two > second.txt ;;
*"third task"*) echo three > third.txt ;;
esac
if git push -q origin HEAD:refs/heads/agent 2>/dev/null; then echo pushed; else echo blocked; fi >> "$PUSH_LOG"
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
const PLAIN_WORKER: &str = r#"#!/bin/sh
cd "$3" || exit 1
echo hi > hello.txt
echo "wrote hello.txt in $3"
"#;
const STRAYING_CLAUDE: &str = r#"#!/bin/sh
mkdir -p .github/workflows
echo "on: push" > .github/workflows/ci.yml
echo bump >> Cargo.lock
git mv Makefile build.mk
git rm -q notes.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"updated Cargo.lock","total_cost_usd":0.1}'
"#;
struct Env {
home: PathBuf,
bin: PathBuf,
push_log: PathBuf,
}
impl Env {
fn command(&self, args: &[&str]) -> Command {
let path = format!(
"{}:{}",
self.bin.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_pma"));
cmd.env("PMA_HOME", &self.home)
.env("PATH", path)
.env("PUSH_LOG", &self.push_log)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "u")
.env("GIT_AUTHOR_EMAIL", "u@example.com")
.env("GIT_COMMITTER_NAME", "u")
.env("GIT_COMMITTER_EMAIL", "u@example.com")
.args(args);
cmd
}
fn run(&self, args: &[&str]) -> (String, String, bool) {
let out = self.command(args).output().unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
fn ok(&self, args: &[&str]) -> String {
let (out, err, success) = self.run(args);
assert!(success, "pma {args:?} failed\nstdout: {out}\nstderr: {err}");
out
}
}
fn git_out(dir: &std::path::Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn dispatch_env(s: &Scratch, scripts: &[(&str, &str)]) -> (Env, PathBuf, PathBuf) {
use std::os::unix::fs::PermissionsExt;
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
for (name, script) in scripts {
fs::write(bin.join(name), script).unwrap();
fs::set_permissions(bin.join(name), fs::Permissions::from_mode(0o755)).unwrap();
}
let env = Env {
home: s.0.join("home"),
bin,
push_log: s.0.join("push.log"),
};
let seed = s.0.join("seed");
fs::create_dir_all(&seed).unwrap();
git(&seed, &["init", "-q", "-b", "main"], None);
fs::write(
seed.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] add greeting #agent\n say hello in hello.txt\n- [ ] second task #agent gh:7\n- [ ] third task #agent\n\n## Low\n\n- [ ] guarded task #manual\n",
)
.unwrap();
fs::write(seed.join("Makefile"), "test:\n\ttest -f hello.txt\n").unwrap();
git(&seed, &["add", "."], None);
git(&seed, &["commit", "-qm", "init"], None);
let origin = s.0.join("origin.git");
git(&s.0, &["clone", "-q", "--bare", "seed", "origin.git"], None);
let root = s.0.join("root");
fs::create_dir_all(&root).unwrap();
git(&root, &["clone", "-q", "../origin.git", "alpha"], None);
let alpha = root.join("alpha");
env.ok(&["root", "add", root.to_str().unwrap()]);
env.ok(&["tier", "alpha", "1"]);
env.ok(&["scan", "--offline"]);
(env, origin, alpha)
}
#[test]
fn dispatch_review_rework_and_ship() {
let s = Scratch::new("stage3");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["config", "publish", "push"]);
let out = env.ok(&["dispatch", "--auto", "-n", "2"]);
assert!(
out.contains("#1 alpha: add greeting\n#2 alpha: second task\n"),
"{out}"
);
assert!(
out.ends_with("2 ready, 0 failed, $0.20 spent; see `pma review`\n"),
"{out}"
);
assert_eq!(
fs::read_to_string(&env.push_log).unwrap(),
"blocked\nblocked\n"
);
let (_, err, success) = env.run(&["dispatch", "alpha:5"]);
assert!(!success && err.contains("already has a run"), "{err}");
let list = env.ok(&["review"]);
assert!(
list.contains("#1 ready alpha verify ok $0.10"),
"{list}"
);
assert!(
list.contains("#2 ready alpha verify FAILED $0.10"),
"{list}"
);
let detail = env.ok(&["review", "1"]);
assert!(detail.starts_with("#1 ready alpha Q2\n"), "{detail}");
assert!(
detail.contains("accept: every recorded gate is clean"),
"{detail}"
);
assert!(list.contains("clean"), "{list}");
let failed = env.ok(&["review", "2"]);
assert!(
failed.contains("read this run because:\n - verify failed at the head"),
"{failed}"
);
assert!(
detail.contains("`make test`: base FAILED, head passed"),
"{detail}"
);
assert!(detail.contains("+hi"), "{detail}");
assert!(detail.contains("2 of 5 (v1)"), "{detail}");
assert!(failed.contains("4 of 5 (v1)"), "{failed}");
let (out, _, _) = env.run(&["review", "2", "--approve"]);
assert_eq!(out, "");
let out = env.ok(&["review", "2", "--rework", "tests need hello.txt"]);
assert!(out.contains("verify ok $0.20"), "{out}");
let detail = env.ok(&["review", "2", "--minutes", "7"]);
let attempts = detail
.split_once("attempts:\n")
.unwrap_or_default()
.1
.lines()
.take(2)
.collect::<Vec<_>>();
assert!(attempts[0].contains("verify FAILED"), "{detail}");
assert!(attempts[1].contains("verify passed"), "{detail}");
assert!(
detail.contains("reviewed") && detail.contains("7m00s"),
"{detail}"
);
let detail = env.ok(&["review", "2", "--minutes", "3"]);
assert!(detail.contains("10m00s"), "{detail}");
assert!(!env.ok(&["review", "1"]).contains("attempts:"));
env.ok(&["review", "1", "--approve"]);
env.ok(&["review", "2", "--approve"]);
env.ok(&["review", "2", "--approve"]);
let (_, err, success) = env.run(&["review", "9", "--approve"]);
assert!(!success && err.contains("no run #9"), "{err}");
let local = fs::read_to_string(alpha.join("TODO.md")).unwrap().replace(
"- [ ] third task #agent\n",
"- [ ] third task #agent\n- [ ] local only #agent\n",
);
fs::write(alpha.join("TODO.md"), local).unwrap();
env.ok(&["scan", "--offline"]);
let (_, err, success) = env.run(&["dispatch", "alpha:9"]);
assert!(
!success && err.contains("commit and push it first"),
"{err}"
);
assert!(!env.home.join("worktrees/alpha/local-only").exists());
env.ok(&["dispatch", "alpha:8"]);
env.ok(&["review", "3", "--reject"]);
assert!(!env.home.join("worktrees/alpha/third-task").exists());
assert_eq!(git_out(&alpha, &["branch", "--list", "pma/third-task"]), "");
let out = env.ok(&["ship"]);
assert!(out.contains("#1 alpha: pushed "), "{out}");
assert!(out.contains("#2 alpha: pushed "), "{out}");
let log = git_out(&origin, &["log", "--format=%B|", "main"]);
assert!(
log.starts_with("second task\n\nCloses #7\n|\nadd greeting\n|"),
"{log:?}"
);
assert_eq!(
git_out(&origin, &["show", "main:TODO.md"]),
"# TODO\n\n## High\n\n- [x] add greeting #agent\n say hello in hello.txt\n- [x] second task #agent gh:7\n- [ ] third task #agent\n\n## Low\n\n- [ ] guarded task #manual\n"
);
assert_eq!(git_out(&origin, &["show", "main:second.txt"]), "two\n");
assert_eq!(git_out(&origin, &["branch", "--list", "agent"]), "");
assert!(!env.home.join("worktrees/alpha/add-greeting").exists());
assert_eq!(git_out(&alpha, &["branch", "--list", "pma/*"]), "");
assert_eq!(env.ok(&["review"]), "no runs to review\n");
assert_eq!(env.ok(&["ship"]), "nothing approved\n");
let (_, err, success) = env.run(&["dispatch", "alpha:8"]);
assert!(
!success && err.contains("2 attempts on `third task` were used without an accepted result"),
"{err}"
);
env.ok(&["config", "batch_budget", "0.05"]);
let out = env.ok(&["dispatch", "alpha:8", "--retry"]);
assert!(
out.contains("not started: batch budget $0.05 reached"),
"{out}"
);
assert!(
out.ends_with("0 ready, 1 failed, $0.00 spent; see `pma review`\n"),
"{out}"
);
let report = env.ok(&["report"]);
assert!(
report.contains("67% of 3 decided runs accepted"),
"{report}"
);
let b: Vec<&str> = report
.lines()
.find(|l| l.starts_with("B "))
.unwrap()
.split_whitespace()
.collect();
assert_eq!(
&b[..8],
["B", "4", "1", "2", "2", "0", "4", "$0.40+1?"],
"{report}"
);
assert!(report.contains("10m00s"), "review time: {report}");
assert!(
env.ok(&["report", "--by", "project"])
.contains("by project"),
"{report}"
);
let (_, err, ok) = env.run(&["report", "--by", "model"]);
assert!(!ok && err.contains("unknown dimension `model`"), "{err}");
env.ok(&["config", "batch_budget", "5.0"]);
env.ok(&["review", "4", "--reject"]);
env.ok(&["dispatch", "alpha:8"]);
env.ok(&["review", "5", "--reject"]);
}
const FAKE_GH_PR: &str = r#"#!/bin/sh
case "$1 $2" in
"pr create")
if [ -e "$PMA_HOME/pr-create-fails" ]; then echo "HTTP 502" >&2; exit 1; fi
echo "https://github.com/me/alpha/pull/1" ;;
"pr list") if [ -e "$PMA_HOME/pr-list" ]; then cat "$PMA_HOME/pr-list"; fi ;;
"pr view") cat "$PMA_HOME/pr-state" ;;
*) echo "fake gh: unexpected $*" >&2; exit 1 ;;
esac
"#;
#[test]
fn a_pull_request_holds_its_task_until_merged_or_closed() {
let s = Scratch::new("pr");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE), ("gh", FAKE_GH_PR)]);
let pr_state = env.home.join("pr-state");
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
let out = env.ok(&["ship"]);
assert_eq!(out, "#1 alpha: https://github.com/me/alpha/pull/1\n");
assert_eq!(
git_out(&origin, &["branch", "--list", "pma/*"]),
" pma/add-greeting\n"
);
fs::write(&pr_state, "OPEN\n").unwrap();
assert!(env.ok(&["review"]).contains("#1 pr-open alpha"));
let out = env.ok(&["dispatch", "--auto", "-n", "1"]);
assert!(out.contains("#2 alpha: second task"), "{out}");
env.ok(&["review", "2", "--reject"]);
let (_, err, success) = env.run(&["dispatch", "alpha:5"]);
assert!(!success && err.contains("already has a run"), "{err}");
let (_, err, success) = env.run(&["review", "1", "--reject"]);
assert!(
!success && err.contains("open pull request; merge or close it"),
"{err}"
);
fs::write(&pr_state, "CLOSED\n").unwrap();
let out = env.ok(&["review"]);
assert!(
out.starts_with("#1 alpha: pull request closed without merging: https://"),
"{out}"
);
let out = env.ok(&["dispatch", "alpha:5"]);
assert!(out.contains("#3 alpha: add greeting"), "{out}");
assert!(env.ok(&["review", "3"]).contains("pma/add-greeting-2 in"));
env.ok(&["review", "3", "--approve"]);
env.ok(&["ship"]);
fs::write(&pr_state, "MERGED\n").unwrap();
let out = env.ok(&["review"]);
assert_eq!(
out,
"#3 alpha: pull request merged: https://github.com/me/alpha/pull/1\nno runs to review\n"
);
assert!(!alpha.join("hello.txt").exists(), "the clone is untouched");
}
#[test]
fn ship_resumes_after_a_partial_failure() {
let s = Scratch::new("resume");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE), ("gh", FAKE_GH_PR)]);
env.ok(&["config", "publish", "push"]);
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
let worktree = env.home.join("worktrees/alpha/add-greeting");
git(
&alpha,
&["worktree", "lock", worktree.to_str().unwrap()],
None,
);
let out = env.ok(&["ship"]);
assert!(
out.starts_with("#1 alpha: pushed ") && out.contains("; warning: worktree not removed: "),
"{out}"
);
assert_eq!(
env.ok(&["review"]),
"no runs to review\n",
"shipped all the same"
);
rusqlite::Connection::open(env.home.join("projects.db"))
.unwrap()
.execute("UPDATE runs SET state = 'approved' WHERE id = 1", [])
.unwrap();
git(
&alpha,
&["worktree", "unlock", worktree.to_str().unwrap()],
None,
);
let out = env.ok(&["ship"]);
assert!(out.starts_with("#1 alpha: already pushed "), "{out}");
assert!(!worktree.exists());
assert_eq!(
git_out(&origin, &["log", "--format=%s", "main"]),
"add greeting\ninit\n",
"pushed once"
);
env.ok(&["config", "publish", "pr"]);
env.ok(&["dispatch", "alpha:7"]);
env.ok(&["review", "2", "--approve"]);
fs::write(env.home.join("pr-create-fails"), "").unwrap();
let (out, _, success) = env.run(&["ship"]);
assert!(!success && out.contains("gh pr create: HTTP 502"), "{out}");
assert!(env.ok(&["review"]).contains("#2 approved"));
fs::remove_file(env.home.join("pr-create-fails")).unwrap();
fs::write(
env.home.join("pr-list"),
"https://github.com/me/alpha/pull/9\n",
)
.unwrap();
assert_eq!(
env.ok(&["ship"]),
"#2 alpha: https://github.com/me/alpha/pull/9\n"
);
}
#[test]
fn auto_dispatch_passes_over_refused_tasks_and_names_the_cause() {
let s = Scratch::new("refused");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let todo = fs::read_to_string(alpha.join("TODO.md")).unwrap();
fs::write(
alpha.join("TODO.md"),
todo.replace(
"- [ ] add greeting",
"- [ ] local only #agent\n- [ ] add greeting",
),
)
.unwrap();
let other = s.0.join("other");
git(
&s.0,
&["clone", "-q", origin.to_str().unwrap(), "other"],
None,
);
let ticked = fs::read_to_string(other.join("TODO.md"))
.unwrap()
.replace("- [ ] third task", "- [x] third task");
fs::write(other.join("TODO.md"), ticked).unwrap();
git(&other, &["commit", "-qam", "tick"], None);
git(&other, &["push", "-q"], None);
env.ok(&["scan", "--offline"]);
let (out, err, success) = env.run(&["dispatch", "--auto", "-n", "1"]);
assert!(success, "{err}");
assert!(
err.contains("warning: alpha: `local only` is not in TODO.md on origin/main; commit and push it first"),
"{err}"
);
assert!(out.starts_with("#1 alpha: add greeting\n"), "{out}");
let (_, err, success) = env.run(&["dispatch", "alpha:9"]);
assert!(
!success && err.contains("`third task` is already done on origin/main; pull the clone"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:13"]);
assert!(
!success && err.contains("`guarded task` is class D and is not dispatched"),
"{err}"
);
assert!(!env.home.join("worktrees/alpha/guarded-task").exists());
assert_eq!(
git_out(&alpha, &["branch", "--list", "pma/guarded-task"]),
""
);
}
#[test]
fn the_scope_check_reads_the_whole_change_not_the_agents_report() {
let s = Scratch::new("scope");
let (env, _, alpha) = dispatch_env(&s, &[("claude", STRAYING_CLAUDE)]);
env.ok(&["config", "projects.alpha.verify", "none"]);
fs::write(alpha.join("Cargo.lock"), "lock\n").unwrap();
fs::write(alpha.join("notes.txt"), "notes\n").unwrap();
git(&alpha, &["add", "Cargo.lock", "notes.txt"], None);
git(&alpha, &["commit", "-qm", "lock"], None);
git(&alpha, &["push", "-q"], None);
env.ok(&["scan", "--offline"]);
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("B, any path"), "{detail}");
assert!(
detail.contains("1 of 5 files OUTSIDE: .github/workflows/ci.yml"),
"{detail}"
);
assert!(detail.contains("updated Cargo.lock"), "{detail}");
assert!(
detail.contains(" - outside class B: .github/workflows/ci.yml"),
"{detail}"
);
assert!(detail.contains(" - no verify command"), "{detail}");
assert!(env.ok(&["review"]).contains("2 to read"), "{detail}");
}
#[test]
fn a_worker_without_json_output_or_an_allowlist_runs_the_same_path() {
let s = Scratch::new("adapter");
let (env, origin, _) = dispatch_env(&s, &[("plain", PLAIN_WORKER)]);
env.ok(&["config", "publish", "push"]);
let listed = env.ok(&["agent"]);
assert!(listed.contains("* claude"), "{listed}");
assert!(listed.contains("cost,allowlist"), "{listed}");
env.ok(&["agent", "set", "plain", "command", "plain"]);
env.ok(&[
"agent",
"set",
"plain",
"args",
r#"["--task","{prompt}","{dir}"]"#,
]);
env.ok(&["config", "agent", "plain"]);
let listed = env.ok(&["agent"]);
assert!(listed.contains("* plain"), "{listed}");
assert!(listed.contains("text-tail"), "{listed}");
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("plain, cost not reported"), "{detail}");
assert!(detail.contains("wrote hello.txt in"), "{detail}");
assert!(
detail.contains("`make test`: base FAILED, head passed"),
"{detail}"
);
assert!(
detail.contains("accept: every recorded gate is clean"),
"{detail}"
);
env.ok(&["review", "1", "--approve"]);
assert!(env.ok(&["ship"]).contains("#1 alpha: pushed "), "shipped");
assert_eq!(git_out(&origin, &["show", "main:hello.txt"]), "hi\n");
let report = env.ok(&["report", "--by", "agent"]);
assert!(report.contains("plain"), "{report}");
assert!(report.contains("$0.00+1?"), "{report}");
env.ok(&["agent", "rm", "plain"]);
env.ok(&["dispatch", "alpha:7"]);
let detail = env.ok(&["review", "2"]);
assert!(detail.contains("unknown agent `plain`"), "{detail}");
let (_, err, ok) = env.run(&["agent", "set", "x", "parse", "codex-json"]);
assert!(!ok && err.contains("unknown parser `codex-json`"), "{err}");
let (_, err, ok) = env.run(&["agent", "set", "x", "args", "not json"]);
assert!(!ok && err.contains("JSON array"), "{err}");
}
const MODEL_CLAUDE: &str = r#"#!/bin/sh
model=unset
while [ $# -gt 0 ]; do
case "$1" in --model) model="$2" ;; esac
shift
done
echo hi > hello.txt
printf '{"type":"result","subtype":"success","is_error":false,"result":"ran as %s","total_cost_usd":0.1}\n' "$model"
"#;
const CAMPAIGN_WORKER: &str = r#"#!/bin/sh
if [ "$(basename "$(dirname "$PWD")")" = "beta" ] && [ ! -e "$PMA_HOME/fixed" ]; then
printf '{"type":"result","subtype":"error","is_error":true,"result":"gave up","total_cost_usd":0.1}
'
exit 0
fi
mkdir -p .github/workflows
echo "on: push" > .github/workflows/ci.yml
printf '{"type":"result","subtype":"success","is_error":false,"result":"added the workflow","total_cost_usd":0.1}
'
"#;
#[test]
fn a_campaign_applies_one_definition_across_repositories() {
let s = Scratch::new("campaign");
let (env, _, _) = dispatch_env(&s, &[("claude", CAMPAIGN_WORKER)]);
let root = s.0.join("root");
for name in ["beta", "gamma"] {
git(&root, &["clone", "-q", "../origin.git", name], None);
env.ok(&["tier", name, "3"]);
}
env.ok(&["scan", "--offline"]);
for name in ["alpha", "beta", "gamma"] {
env.ok(&["config", &format!("projects.{name}.verify"), "true"]);
}
assert!(env.ok(&["campaign"]).contains("no campaigns"), "empty");
env.ok(&[
"campaign",
"add",
"workflows",
"add a workflow",
"--projects",
"alpha,beta,gamma",
"--class",
"A-",
"--describe",
"Create .github/workflows/ci.yml that runs on push.",
]);
let listed = env.ok(&["campaign"]);
assert!(
listed.contains("class A-") && listed.contains("0/3 dispatched"),
"{listed}"
);
let out = env.ok(&["campaign", "run", "workflows"]);
assert!(out.contains("2 ready, 1 failed"), "{out}");
let show = env.ok(&["campaign", "show", "workflows"]);
assert!(
show.starts_with(
"workflows: add a workflow
"
),
"{show}"
);
assert!(show.contains("failed"), "{show}");
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("A-, within .github/**"), "{detail}");
assert!(detail.contains("1 files, all permitted"), "{detail}");
assert!(detail.contains("base passed, head passed"), "{detail}");
assert!(detail.contains("every recorded gate is clean"), "{detail}");
let ready: Vec<String> = env
.ok(&["review"])
.lines()
.filter(|l| l.contains(" ready "))
.map(|l| l.split_whitespace().next().unwrap()[1..].to_string())
.collect();
assert_eq!(ready.len(), 2, "two runs to approve");
let mut args = vec!["review"];
args.extend(ready.iter().map(String::as_str));
args.push("--approve");
let approved = env.ok(&args);
assert_eq!(approved.lines().count(), 2, "{approved}");
let out = env.ok(&["campaign", "run", "workflows"]);
assert!(
out.contains("beta: #2 is failed; reject or rework it"),
"{out}"
);
assert!(
out.contains("every member of `workflows` has a run"),
"{out}"
);
fs::write(env.home.join("fixed"), "").unwrap();
env.ok(&["review", "2", "--reject"]);
let out = env.ok(&["campaign", "run", "workflows"]);
assert!(out.contains("1 ready, 0 failed"), "{out}");
assert_eq!(out.matches(": add a workflow").count(), 1, "{out}");
let show = env.ok(&["campaign", "show", "workflows"]);
assert_eq!(show.matches("approved").count(), 2, "{show}");
assert_eq!(show.matches("ready").count(), 1, "{show}");
env.ok(&["review", "3", "--approve"]);
let out = env.ok(&["campaign", "run", "workflows"]);
assert!(
out.contains("every member of `workflows` has a run"),
"{out}"
);
}
#[test]
fn an_approval_is_evidence_about_a_tree_not_a_state() {
let s = Scratch::new("evidence");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["config", "publish", "push"]);
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
let worktree = env.home.join("worktrees/alpha/add-greeting");
fs::write(worktree.join("sneaked.txt"), "later\n").unwrap();
let (out, err, success) = env.run(&["ship"]);
assert!(!success, "{out}{err}");
assert!(
out.contains("the worktree changed after it was approved"),
"{out}"
);
assert_eq!(git_out(&origin, &["log", "--format=%s", "main"]), "init\n");
fs::remove_file(worktree.join("sneaked.txt")).unwrap();
env.ok(&["review", "1", "--approve"]);
assert!(env.ok(&["ship"]).contains("#1 alpha: pushed "), "shipped");
env.ok(&["dispatch", "alpha:7"]);
env.ok(&["review", "2", "--approve"]);
env.ok(&["review", "2", "--rework", "add hello.txt too"]);
let detail = env.ok(&["review", "2"]);
assert!(!detail.contains("approved by"), "{detail}");
env.ok(&["review", "2", "--approve"]);
let other = s.0.join("other");
git(
&s.0,
&["clone", "-q", origin.to_str().unwrap(), "other"],
None,
);
fs::write(other.join("Makefile"), "test:\n\ttest -f never.txt\n").unwrap();
git(&other, &["commit", "-qam", "stricter test"], None);
git(&other, &["push", "-q"], None);
let (out, err, success) = env.run(&["ship"]);
assert!(!success, "{out}{err}");
assert!(
out.contains("`make test` failed on the integrated tree"),
"{out}"
);
assert_eq!(
git_out(&origin, &["log", "--format=%s", "main"]),
"stricter test\nadd greeting\ninit\n",
"nothing of the run reached the branch"
);
let _ = alpha;
}
#[test]
fn a_batch_approval_refuses_a_list_with_anything_to_read_in_it() {
let s = Scratch::new("batch");
let (env, _, _) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["dispatch", "--auto", "-n", "3"]);
let list = env.ok(&["review"]);
assert!(list.contains("clean"), "{list}");
let (_, err, success) = env.run(&["review", "1", "2", "--approve"]);
assert!(!success && err.contains("#2 is not clean"), "{err}");
assert!(err.contains("verify failed at the head"), "{err}");
assert_eq!(
env.ok(&["review"]).matches("ready").count(),
3,
"nothing was approved"
);
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[{"name":"draft-only","match":{},"approval":"propose"}]}"#,
)
.unwrap();
env.ok(&["route", "propose", doc.to_str().unwrap()]);
env.ok(&["route", "activate", "1"]);
env.ok(&["review", "1", "--reject"]);
env.ok(&["dispatch", "alpha:5", "--retry"]);
let (_, err, success) = env.run(&["review", "4", "--approve"]);
assert!(!success && err.contains("which is `propose`"), "{err}");
}
const ESCALATING_CLAUDE: &str = r#"#!/bin/sh
model=unset
while [ $# -gt 0 ]; do
case "$1" in --model) model="$2" ;; esac
shift
done
if [ "$model" = "opus" ]; then echo hi > hello.txt; fi
printf '{"type":"result","subtype":"success","is_error":false,"result":"ran as %s","total_cost_usd":0.1}
' "$model"
"#;
#[test]
fn a_failed_check_escalates_once_and_records_both_attempts() {
let s = Scratch::new("escalate");
let (env, _, _) = dispatch_env(&s, &[("claude", ESCALATING_CLAUDE)]);
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[{"name":"try-twice","match":{},"model":"haiku",
"escalate":{"model":"opus","attempts":1},"approval":"each"}]}"#,
)
.unwrap();
env.ok(&["route", "propose", doc.to_str().unwrap()]);
env.ok(&["route", "activate", "1"]);
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("base FAILED, head passed"), "{detail}");
let attempts = detail.split_once("attempts:\n").expect("two attempts").1;
let lines: Vec<&str> = attempts.lines().take(2).collect();
assert!(
lines[0].contains("haiku") && lines[0].contains("verify FAILED"),
"{detail}"
);
assert!(
lines[1].contains("opus") && lines[1].contains("verify passed"),
"{detail}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:5"]);
assert!(!success && err.contains("already has a run"), "{err}");
assert!(detail.contains("claude haiku, $0.20"), "{detail}");
}
#[test]
fn a_policy_is_a_draft_until_activated_and_replays_exactly() {
let s = Scratch::new("route");
let (env, _, _) = dispatch_env(&s, &[("claude", MODEL_CLAUDE)]);
assert!(env.ok(&["route"]).contains("no routing policy"), "empty");
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[
{"name":"specified","match":{"class":"B","complexity":"1-3"},
"model":"haiku","approval":"batch"},
{"name":"rest","match":{},"model":"opus","approval":"each"}
]}"#,
)
.unwrap();
let out = env.ok(&["route", "propose", doc.to_str().unwrap(), "--by", "me"]);
assert!(out.contains("revision 1 stored as a draft"), "{out}");
assert!(env.ok(&["route"]).contains("draft"), "still a draft");
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("ran as unset"), "{detail}");
assert!(!detail.contains("revision 1"), "{detail}");
env.ok(&["review", "1", "--reject"]);
env.ok(&["route", "activate", "1", "--shadow", "--by", "me"]);
env.ok(&["dispatch", "alpha:5", "--retry"]);
let detail = env.ok(&["review", "2"]);
assert!(detail.contains("specified in revision 1"), "{detail}");
assert!(detail.contains("approval batch"), "{detail}");
assert!(detail.contains("ran as unset"), "shadow applies nothing");
env.ok(&["review", "2", "--reject"]);
env.ok(&["route", "activate", "1", "--by", "me"]);
assert!(env.ok(&["route"]).contains("active, by me"), "activated");
env.ok(&["dispatch", "alpha:5", "--retry"]);
let detail = env.ok(&["review", "3"]);
assert!(detail.contains("ran as haiku"), "{detail}");
let replay = env.ok(&["route", "replay", "1"]);
assert!(
replay.contains("3 runs replayed, 2 routed differently"),
"{replay}"
);
assert!(replay.contains("#1"), "dispatched before it: {replay}");
assert!(replay.contains("#2"), "shadowed, so not applied: {replay}");
assert!(!replay.contains("#3"), "applied, so unchanged: {replay}");
assert!(replay.contains("Cost is not projected"), "{replay}");
let other = s.0.join("other.json");
fs::write(
&other,
r#"{"route":[
{"name":"specified","match":{"class":"B","complexity":"1-1"},
"model":"haiku","approval":"batch"},
{"name":"rest","match":{},"model":"opus","approval":"each"}
]}"#,
)
.unwrap();
let replay = env.ok(&["route", "replay", other.to_str().unwrap()]);
assert!(
replay.contains("3 runs replayed, 3 routed differently"),
"{replay}"
);
assert!(replay.contains("rest claude/opus"), "{replay}");
let narrow = s.0.join("narrow.json");
fs::write(
&narrow,
r#"{"route":[{"name":"only-a","match":{"class":"A"},"approval":"each"}]}"#,
)
.unwrap();
env.ok(&["route", "propose", narrow.to_str().unwrap()]);
env.ok(&["route", "activate", "2"]);
env.ok(&["review", "3", "--reject"]);
let (_, err, success) = env.run(&["dispatch", "alpha:5", "--retry"]);
assert!(
!success && err.contains("matches no route in policy revision 2"),
"{err}"
);
let bad = s.0.join("bad.json");
fs::write(&bad, r#"{"route":[{"match":{},"approval":"whenever"}]}"#).unwrap();
let (_, err, success) = env.run(&["route", "propose", bad.to_str().unwrap()]);
assert!(
!success && err.contains("unknown approval `whenever`"),
"{err}"
);
assert_eq!(env.ok(&["route"]).lines().count(), 2, "still two revisions");
}
const SLOW_CLAUDE: &str = r#"#!/bin/sh
n=0
while [ ! -e "$PMA_HOME/release" ] && [ $n -lt 1200 ]; do sleep 0.05; n=$((n + 1)); done
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
#[test]
fn a_second_session_leaves_running_runs_alone() {
let s = Scratch::new("session");
let (env, _, _) = dispatch_env(&s, &[("claude", SLOW_CLAUDE)]);
struct Release(PathBuf);
impl Drop for Release {
fn drop(&mut self) {
let _ = fs::write(&self.0, "");
}
}
let release = Release(env.home.join("release"));
let mut dispatch = env
.command(&["dispatch", "alpha:5"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let started = std::time::Instant::now();
let mut last = String::new();
while !last.contains("#1 running") {
if dispatch.try_wait().unwrap().is_some() || started.elapsed().as_secs() >= 30 {
let _ = dispatch.kill();
let out = dispatch.wait_with_output().unwrap();
panic!(
"the run never started; review: {last}\ndispatch: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
std::thread::sleep(std::time::Duration::from_millis(50));
last = env.ok(&["review"]);
}
assert!(
env.ok(&["review"]).contains("#1 running"),
"review does not fail a live run"
);
for args in [
&["dispatch", "alpha:6"][..],
&["ship"],
&["review", "1", "--reject"],
] {
let (_, err, success) = env.run(args);
assert!(
!success && err.contains("another pma session (pid "),
"{args:?}: {err}"
);
}
drop(release);
let out = dispatch.wait_with_output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success() && stdout.contains("1 ready, 0 failed"),
"{stdout}"
);
assert!(env.ok(&["review"]).contains("#1 ready"));
env.ok(&["review", "1", "--reject"]);
}
const FAKE_GH: &str = r#"#!/usr/bin/env python3
import json, os, sys
state = os.environ["GH_STATE"]
issues = json.load(open(state))
args = sys.argv[1:]
def opt(name):
return args[args.index(name) + 1] if name in args else None
def log(line):
with open(state + ".log", "a") as f:
f.write(line + "\n")
if args[:2] == ["api", "user"]:
print("me")
elif args[:2] == ["issue", "list"]:
print(json.dumps([dict(i, author={"login": i["author"]}, labels=[{"name": l} for l in i["labels"]]) for i in issues]))
elif args[:2] == ["label", "create"]:
log("label create " + args[2])
elif args[:2] == ["issue", "create"]:
n = max([i["number"] for i in issues] + [0]) + 1
issues.append({"number": n, "title": opt("--title"), "state": "OPEN", "author": "me", "labels": [opt("--label")]})
log("issue create %d %s | %s" % (n, opt("--title"), opt("--body")))
print("https://github.com/me/alpha/issues/%d" % n)
elif args[:2] == ["issue", "edit"]:
issue = next(i for i in issues if i["number"] == int(args[2]))
if opt("--title"):
issue["title"] = opt("--title")
if opt("--add-label"):
issue["labels"].append(opt("--add-label"))
if opt("--remove-label"):
issue["labels"].remove(opt("--remove-label"))
log("issue edit " + " ".join(args[2:]))
else:
sys.exit("fake gh: unexpected " + " ".join(args))
json.dump(issues, open(state, "w"))
"#;
#[test]
fn sync_plans_then_applies_and_settles() {
use std::os::unix::fs::PermissionsExt;
let s = Scratch::new("stage4");
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
fs::write(bin.join("gh"), FAKE_GH).unwrap();
fs::set_permissions(bin.join("gh"), fs::Permissions::from_mode(0o755)).unwrap();
let state = s.0.join("issues.json");
fs::write(
&state,
r#"[{"number": 1, "title": "fixed elsewhere", "state": "CLOSED", "author": "me", "labels": []},
{"number": 2, "title": "old crash title", "state": "OPEN", "author": "me", "labels": ["pma:critical"]},
{"number": 3, "title": "please add X", "state": "OPEN", "author": "alice", "labels": []}]"#,
)
.unwrap();
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = s.project(
"root/alpha",
"# TODO\n\n## Critical\n\n- [ ] segfault on empty input\n when stdin is empty\n- [ ] renamed crash gh:2\n\n## High\n\n- [ ] fixed elsewhere gh:1\n",
);
git(&alpha, &["init", "-q"], None);
git(
&alpha,
&["remote", "add", "origin", "https://github.com/me/alpha.git"],
None,
);
let beta = s.project("root/beta", "## High\n");
git(&beta, &["init", "-q"], None);
let sync = |args: &[&str]| {
let out = Command::new(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.env(
"PATH",
format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
)
.env("GH_STATE", &state)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
};
sync(&["root", "add", root.to_str().unwrap()]);
sync(&["scan", "--offline"]);
let original = fs::read_to_string(alpha.join("TODO.md")).unwrap();
let (out, err) = sync(&["sync"]);
assert_eq!(
out,
"alpha:5: open an issue for `segfault on empty input`\n\
alpha: retitle #2 to `renamed crash`\n\
alpha:11: mark done, #1 is closed\n\
alpha: untriaged #3 by alice: please add X\n\
beta: skipped: TODO.md has lint errors; see `pma lint`\n\
3 changes; run `pma sync --apply` to make them\n",
"{err}"
);
assert_eq!(fs::read_to_string(alpha.join("TODO.md")).unwrap(), original);
assert!(
!s.0.join("issues.json.log").exists(),
"a dry run changes nothing"
);
let (out, err) = sync(&["sync", "--apply", "alpha"]);
assert!(
out.ends_with(
"3 changes made\nTODO.md changed, uncommitted, in: alpha; commit, then `pma scan`\n"
),
"{out}{err}"
);
assert_eq!(
fs::read_to_string(s.0.join("issues.json.log")).unwrap(),
"label create pma:critical\n\
issue create 4 segfault on empty input | when stdin is empty\n\n\
Opened by pma from a `## Critical` item in TODO.md.\n\
issue edit 2 -R me/alpha --title renamed crash\n"
);
assert_eq!(
fs::read_to_string(alpha.join("TODO.md")).unwrap(),
"# TODO\n\n## Critical\n\n- [ ] segfault on empty input gh:4\n when stdin is empty\n- [ ] renamed crash gh:2\n\n## High\n\n- [x] fixed elsewhere gh:1\n"
);
let (out, _) = sync(&["sync", "alpha"]);
assert_eq!(out, "alpha: untriaged #3 by alice: please add X\nin sync\n");
}
#[test]
fn notes_and_measured_dependencies() {
use std::os::unix::fs::PermissionsExt;
let s = Scratch::new("stage5");
let home = s.0.join("home");
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
fs::write(
bin.join("uv"),
"#!/bin/sh\nprintf 'alpha v1\\n|-- ruff v0.14.10 (group: dev) (latest: v0.16.7)\\n`-- ty v0.0.8 (latest: v0.0.81)\\n'\n",
)
.unwrap();
fs::set_permissions(bin.join("uv"), fs::Permissions::from_mode(0o755)).unwrap();
let run = |args: &[&str]| {
let out = Command::new(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.env(
"PATH",
format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
};
let ok = |args: &[&str]| {
let (out, err, success) = run(args);
assert!(success, "pma {args:?}: {err}");
out
};
assert_eq!(
ok(&["note"]),
"no notes; add one with `pma note add <text>`\n"
);
assert_eq!(
ok(&["note", "add", "move", "CI", "to", "one", "workflow"]),
"#1\n"
);
assert_eq!(ok(&["note", "add", "second"]), "#2\n");
ok(&["note", "edit", "1", "move CI to reusable workflows"]);
ok(&["note", "rm", "2"]);
let (_, err, success) = run(&["note", "rm", "2"]);
assert!(!success && err.contains("no note #2"), "{err}");
let list = ok(&["note"]);
assert!(list.starts_with("#1 20"), "{list}");
assert!(
list.ends_with(" move CI to reusable workflows\n"),
"{list}"
);
let root = s.0.join("root");
let alpha = s.project("root/alpha", "# TODO\n\n## High\n");
git(&alpha, &["init", "-q"], None);
fs::write(alpha.join("uv.lock"), "").unwrap();
ok(&["root", "add", root.to_str().unwrap()]);
ok(&["tier", "alpha", "1"]);
ok(&["scan", "--offline"]);
assert!(ok(&["status", "--explain"]).contains("not measured; `pma scan --deps`"));
let (_, err, success) = run(&["dispatch", "alpha:deps"]);
assert!(
!success && err.contains("no outdated dependencies"),
"{err}"
);
let scanned = ok(&["scan", "--deps"]);
assert!(
scanned.ends_with(", outdated dependencies in 1\n"),
"{scanned}"
);
ok(&["scan", "--offline"]);
let status = ok(&["status", "--explain"]);
assert!(
status.contains("2 outdated, measured today"),
"kept: {status}"
);
assert!(
ok(&["matrix", "--all"])
.contains("alpha T1 medium open 0d update dependencies: 2 outdated"),
"the deps signal task"
);
}
const MODEL_WORKER: &str = r#"#!/bin/sh
cd "$3" || exit 1
echo hi > hello.txt
echo "model=$4"
# Whatever a preset added arrives after the model, so the test can see it.
shift 4 2>/dev/null || shift $#
while [ $# -gt 0 ]; do
case "$1" in --thinking) echo "thinking=$2"; shift ;; esac
shift
done
"#;
#[test]
fn a_target_can_name_a_heading_or_a_quadrant() {
let s = Scratch::new("select");
let (env, _, _) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let (_, err, success) = env.run(&["dispatch", "-a", "pi", "alpha:5"]);
assert!(
!success && err.contains("unknown agent `pi`; `pma agent` lists claude"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:soon"]);
assert!(
!success && err.contains("expected a TODO.md line, ci, deps, critical"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:critical"]);
assert!(
!success && err.contains("alpha: no open critical items at the last scan"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha"]);
assert!(
!success && err.contains("opens a list, which needs a terminal"),
"{err}"
);
let out = env.ok(&["dispatch", "alpha:q2"]);
assert!(
out.starts_with("#1 alpha: add greeting\n#2 alpha: second task\n#3 alpha: third task\n"),
"{out}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:high"]);
assert!(
!success && err.ends_with("pma: nothing to dispatch: every task named was passed over\n"),
"{err}"
);
assert_eq!(err.matches("already has a run").count(), 3, "{err}");
assert!(err.contains("warning: alpha:5: already has a run"), "{err}");
let (_, err, success) = env.run(&["dispatch", "alpha:low"]);
assert!(
!success && err.contains("`guarded task` is class D and is not dispatched"),
"{err}"
);
}
#[test]
fn a_worker_no_longer_names_a_model() {
let s = Scratch::new("flags");
let (env, _, _) = dispatch_env(&s, &[("mw", MODEL_WORKER)]);
env.ok(&["agent", "set", "mw", "command", "mw"]);
env.ok(&[
"agent",
"set",
"mw",
"args",
r#"["--task","{prompt}","{dir}","{model}","{extra}"]"#,
]);
let (_, err, ok) = env.run(&["agent", "set", "mw", "model", "haiku"]);
assert!(!ok, "the field is gone");
assert!(
err.contains("pma preset set"),
"it says where it went: {err}"
);
let (_, err, ok) = env.run(&["config", "model", "haiku"]);
assert!(!ok && err.contains("pma agent set"), "{err}");
env.ok(&["config", "agent", "mw"]);
env.ok(&["dispatch", "alpha:5"]);
assert!(
env.ok(&["review", "1"]).contains("model=\n"),
"no model named"
);
env.ok(&["dispatch", "-m", "sonnet", "alpha:7"]);
assert!(
env.ok(&["review", "2"]).contains("model=sonnet"),
"the flag"
);
let listed = env.ok(&["agent"]);
assert!(listed.contains("* mw"), "{listed}");
assert!(!listed.contains("sonnet"), "no model column: {listed}");
assert!(
env.ok(&["agent", "show", "mw"]).contains("{extra}"),
"args shown"
);
}
#[test]
fn a_preset_names_a_worker_a_model_and_its_configuration() {
let s = Scratch::new("presets");
let (env, _, _) = dispatch_env(&s, &[("mw", MODEL_WORKER)]);
env.ok(&["agent", "set", "mw", "command", "mw"]);
env.ok(&[
"agent",
"set",
"mw",
"args",
r#"["--task","{prompt}","{dir}","{model}","{extra}"]"#,
]);
assert!(env.ok(&["preset"]).contains("no presets"), "empty");
let (_, err, ok) = env.run(&["preset", "set", "bad", "nosuch", "haiku"]);
assert!(!ok && err.contains("no worker `nosuch`"), "{err}");
env.ok(&["preset", "set", "plain", "mw"]);
env.ok(&["preset", "set", "cheap", "mw", "haiku"]);
env.ok(&[
"preset",
"set",
"cheap-high",
"mw",
"haiku",
"--thinking",
"high",
]);
let listed = env.ok(&["preset"]);
assert!(
listed.contains("its own default"),
"a preset may name no model"
);
assert!(listed.contains("--thinking high"), "{listed}");
env.ok(&["dispatch", "-p", "cheap-high", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("model=haiku"), "{detail}");
assert!(
detail.contains("thinking=high"),
"the effort reached it: {detail}"
);
env.ok(&["dispatch", "-p", "cheap-high", "-m", "sonnet", "alpha:7"]);
let detail = env.ok(&["review", "2"]);
assert!(detail.contains("model=sonnet"), "{detail}");
assert!(
detail.contains("thinking=high"),
"the rest of the preset stands"
);
env.ok(&["preset", "use", "cheap"]);
assert!(env.ok(&["preset"]).contains("* cheap"), "marked default");
env.ok(&["dispatch", "alpha:8"]);
let detail = env.ok(&["review", "3"]);
assert!(detail.contains("model=haiku"), "{detail}");
assert!(
!detail.contains("thinking=high"),
"a different preset: {detail}"
);
env.ok(&["preset", "rm", "cheap"]);
let listed = env.ok(&["preset"]);
assert!(!listed.contains("* cheap"), "the default is gone: {listed}");
let settings = env.ok(&["config"]);
let preset_line = settings
.lines()
.find(|l| l.starts_with("preset ="))
.unwrap_or_default();
assert_eq!(
preset_line.trim_end_matches(" (set)").trim(),
"preset =",
"the setting is cleared with it"
);
}
#[test]
fn a_workflow_document_is_a_draft_until_its_cost_is_accepted() {
let s = Scratch::new("workflow");
let home = s.0.join("home");
fs::create_dir_all(&home).unwrap();
let run = |args: &[&str]| {
let out = Command::new(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
};
let script = s.0.join("lib.rhai");
fs::write(
&script,
r#"
fn reviewer(node, what) {
|g| g.expand(node, "finding", "{$breadth}",
"Review `{name}` for " + what + ". Write findings to {out}.")
}
let graph = source("project")
.fan([reviewer("bugs", "correctness"), reviewer("tests", "coverage")])
.join("merge", ["title"])
.filter("confirm", ["reason"], "Confirm each unit in {in}.")
.output();
document(#{ finding: #{ fields: #{
title: req(unique(line(200))),
reason: line(200),
}}}, [
workflow("look", graph, #{
params: #{ breadth: bounded("int", 4, 6) },
caps: #{ max_units: 40, max_edits: 0 },
}),
])
"#,
)
.unwrap();
assert!(run(&["workflow"]).0.contains("no workflow revisions"));
let (out, err, ok) = run(&["workflow", "check", script.to_str().unwrap()]);
assert!(ok, "{err}");
assert!(
out.contains("look(in: [project], breadth = 4) -> [finding] pure"),
"{out}"
);
assert!(
out.contains("worst case: 14 agent runs, 0 edits, $14.00"),
"{out}"
);
let (json, err, ok) = run(&["workflow", "check", script.to_str().unwrap(), "--emit-json"]);
assert!(ok, "{err}");
let from_json = s.0.join("lib.json");
fs::write(&from_json, &json).unwrap();
let (a, _, _) = run(&["workflow", "check", from_json.to_str().unwrap()]);
assert_eq!(a, out, "the generated document estimates the same");
let (out, err, ok) = run(&[
"workflow",
"propose",
script.to_str().unwrap(),
"--by",
"me",
]);
assert!(ok, "{err}");
assert!(
out.contains("revision 1 stored as a draft: at most $14.00"),
"{out}"
);
let listed = run(&["workflow"]).0;
assert!(
listed.contains("draft") && listed.contains("look"),
"{listed}"
);
run(&["config", "workflow_budget", "10"]);
let (_, err, ok) = run(&["workflow", "activate", "1", "--by", "me"]);
assert!(!ok, "a revision over budget must not activate");
assert!(err.contains("$14.00") && err.contains("$10.00"), "{err}");
assert!(run(&["workflow"]).0.contains("draft"), "still a draft");
run(&["config", "workflow_budget", "20"]);
let (out, err, ok) = run(&["workflow", "activate", "1", "--by", "me"]);
assert!(ok, "{err}");
assert!(out.contains("in effect"), "{out}");
assert!(run(&["workflow"]).0.contains("active, by me"));
let shown = run(&["workflow", "show"]).0;
assert!(shown.contains("\"name\": \"look\""), "{shown}");
assert!(shown.contains("\"max_units\": \"{$breadth}\""), "{shown}");
assert!(!shown.contains("fan("), "the script is not the document");
}
#[test]
fn a_pass_runs_the_free_nodes_and_never_spends_without_approval() {
let s = Scratch::new("pass");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## Critical\n\n## High\n\n- [ ] first thing\n- [x] done thing\n\n## Medium\n\n## Low\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let free = s.0.join("free.rhai");
fs::write(
&free,
r#"
let graph = source("project")
.check("lint", "lint-todo")
.rule_expand("items", "item", "todo-items", 100)
.rule_filter("open", "where:done=false")
.emit_note("record", #{ text: "open: {text}" })
.output();
document(#{}, [
workflow("stocktake", graph, #{ caps: #{ max_units: 200, max_edits: 0 }}),
])
"#,
)
.unwrap();
let out = ok(&home, &["workflow", "propose", free.to_str().unwrap()]);
assert!(
out.contains("at most $0.00"),
"a graph of rules is free: {out}"
);
ok(&home, &["workflow", "activate", "1"]);
let out = ok(
&home,
&["workflow", "run", "stocktake", "alpha", "--dry-run"],
);
assert!(out.contains("instance 1"), "{out}");
assert!(
out.contains("would run: lint") && out.contains("a rule, free"),
"{out}"
);
assert!(
ok(&home, &["note"]).contains("no notes"),
"a dry run writes nothing"
);
let out = ok(&home, &["workflow", "run", "stocktake", "--instance", "1"]);
assert!(out.contains("nothing left to run"), "{out}");
let notes = ok(&home, &["note"]);
assert!(notes.contains("open: first thing"), "{notes}");
assert!(
!notes.contains("done thing"),
"the filter dropped it: {notes}"
);
let out = ok(&home, &["workflow", "run", "stocktake", "--instance", "1"]);
assert!(out.contains("nothing left to run"), "{out}");
assert_eq!(
ok(&home, &["note"]).lines().count(),
notes.lines().count(),
"a second pass writes no second note"
);
}
#[test]
fn an_agent_node_is_priced_and_left_for_approval() {
let s = Scratch::new("gate");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] a thing\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
let log = s.0.join("spend.log");
fs::write(
bin.join("claude"),
format!(
"#!/bin/sh\necho invoked >> {}\nprintf '{{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"total_cost_usd\":0.1}}\\n'\n",
log.display()
),
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(bin.join("claude"), fs::Permissions::from_mode(0o755)).unwrap();
}
let run = |args: &[&str]| {
let path = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.env("PATH", path)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
};
run(&["root", "add", root.to_str().unwrap()]);
run(&["scan", "--offline"]);
let wf = s.0.join("review.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.expand("review", "finding", 3, "Review `{name}`. Write findings to {out}.")
.output();
document(#{ finding: #{ fields: #{ title: req(line(200)) }}}, [
workflow("review", graph, #{ caps: #{ max_units: 10, max_edits: 0 }}),
])
"#,
)
.unwrap();
let (out, err, success) = run(&["workflow", "propose", wf.to_str().unwrap()]);
assert!(success, "{err}");
assert!(out.contains("at most $1.00"), "{out}");
run(&["workflow", "activate", "1"]);
let (out, err, success) = run(&["workflow", "run", "review", "alpha", "-m", "haiku"]);
assert!(success, "{err}");
assert!(out.contains("next: review"), "{out}");
assert!(
out.contains("claude/haiku"),
"the cheap model it would use: {out}"
);
assert!(out.contains("Nothing was spent"), "{out}");
assert!(out.contains("--yes"), "it says how to approve: {out}");
assert!(!log.exists(), "the worker must not have run");
let (_, err, success) = run(&["workflow", "run", "review", "--instance", "1", "--yes"]);
assert!(!success && err.contains("not executable yet"), "{err}");
assert!(!log.exists(), "still nothing spent");
}