use std::path::Path;
use std::process::Command as StdCommand;
use assert_cmd::prelude::*;
use serde_json::Value;
use tempfile::TempDir;
struct Project {
dir: TempDir,
}
impl Project {
fn new() -> Self {
Project {
dir: tempfile::tempdir().unwrap(),
}
}
fn path(&self) -> &Path {
self.dir.path()
}
fn cmd(&self, args: &[&str]) -> StdCommand {
let mut c = StdCommand::cargo_bin("wipe").unwrap();
c.current_dir(self.dir.path());
c.env("WIPE_AUTHOR", "Tester <t@example.com>");
c.env("WIPE_CONFIG_DIR", self.dir.path());
c.args(args);
c
}
fn run(&self, args: &[&str]) -> String {
let out = self.cmd(args).output().unwrap();
assert!(
out.status.success(),
"command {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap()
}
fn json(&self, args: &[&str]) -> Value {
let mut v = args.to_vec();
v.push("--json");
let stdout = self.run(&v);
serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("bad json from {args:?}: {e}\n{stdout}"))
}
fn json_as(&self, author: &str, args: &[&str]) -> Value {
let mut v = args.to_vec();
v.push("--json");
let mut c = self.cmd(&v);
c.env("WIPE_AUTHOR", author);
let out = c.output().unwrap();
assert!(
out.status.success(),
"command {args:?} as {author} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).unwrap();
serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("bad json from {args:?}: {e}\n{stdout}"))
}
}
#[test]
fn init_creates_board_and_status_shows_lists() {
let p = Project::new();
p.run(&["init", ".", "--name", "Demo"]);
assert!(p.path().join(".wipe/board.json").is_file());
let status = p.json(&["status"]);
assert_eq!(status["board"], "Demo");
let lists = status["lists"].as_array().unwrap();
assert_eq!(lists.len(), 4);
assert_eq!(lists[0]["list"], "backlog");
}
#[test]
fn full_agent_flow() {
let p = Project::new();
p.run(&["init", ".", "--name", "Flow"]);
let t1 = p.json(&[
"ticket",
"create",
"--title",
"Add login",
"--priority",
"high",
]);
assert_eq!(t1["id"], "T-1");
let t2 = p.json(&["ticket", "create", "--title", "Fix navbar"]);
assert_eq!(t2["id"], "T-2");
p.json(&["ticket", "move", "T-1", "--to", "in-progress"]);
let c = p.json(&["comment", "add", "T-1", "--body", "Use OAuth"]);
assert_eq!(c["comment"], "c-1");
assert_eq!(c["author"], "Tester <t@example.com>");
p.json(&["label", "assign", "T-1", "needs-review"]);
let show = p.json(&["ticket", "show", "T-1"]);
assert_eq!(show["list"], "in-progress");
assert_eq!(show["labels"][0], "needs-review");
assert_eq!(show["comments"][0]["body"], "Use OAuth");
let closed = p.json(&["ticket", "close", "T-1"]);
assert_eq!(closed["list"], "done");
let backlog = p.json(&["ticket", "list", "--list", "backlog"]);
let arr = backlog.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], "T-2");
}
#[test]
fn json_error_object_and_nonzero_exit_on_missing_ticket() {
let p = Project::new();
p.run(&["init", ".", "--name", "Err"]);
let out = p
.cmd(&["ticket", "show", "T-99", "--json"])
.output()
.unwrap();
assert!(!out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["ok"], false);
assert!(v["error"].as_str().unwrap().contains("T-99"));
}
#[test]
fn delete_requires_confirmation() {
let p = Project::new();
p.run(&["init", ".", "--name", "Del"]);
p.json(&["ticket", "create", "--title", "Temp"]);
let refused = p.cmd(&["ticket", "delete", "T-1"]).output().unwrap();
assert!(!refused.status.success());
p.json(&["ticket", "delete", "T-1", "--yes"]);
let missing = p
.cmd(&["ticket", "show", "T-1", "--json"])
.output()
.unwrap();
assert!(!missing.status.success());
}
#[test]
fn discovers_board_from_subdirectory() {
let p = Project::new();
p.run(&["init", ".", "--name", "Nested"]);
let sub = p.path().join("src").join("deep");
std::fs::create_dir_all(&sub).unwrap();
let mut c = StdCommand::cargo_bin("wipe").unwrap();
c.current_dir(&sub)
.env("WIPE_AUTHOR", "T <t@e.com>")
.args(["status", "--json"]);
let out = c.output().unwrap();
assert!(out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["board"], "Nested");
}
#[test]
fn config_roundtrip() {
let p = Project::new();
p.run(&["init", ".", "--name", "Cfg"]);
p.json(&["config", "set", "daemon.port", "9999"]);
let got = p.json(&["config", "get", "daemon.port"]);
assert_eq!(got["value"], 9999);
p.json(&["config", "set", "daemon.expose", "tailscale"]);
let expose = p.json(&["config", "get", "daemon.expose"]);
assert_eq!(expose["value"], "tailscale");
}
#[test]
fn skill_is_embedded() {
let p = Project::new();
let out = p.run(&["skill"]);
assert!(out.contains("wipe - agent operating guide"));
assert!(p.run(&["skill", "show"]).contains("agent operating guide"));
}
#[test]
fn init_empty_starter_has_no_lists() {
let p = Project::new();
let v = p.json(&[
"init",
".",
"--name",
"Blank",
"--yes",
"--starter",
"empty",
]);
assert_eq!(v["starter"], "empty");
let status = p.json(&["status"]);
assert!(status["lists"].as_array().unwrap().is_empty());
}
#[test]
fn skill_install_writes_file_and_respects_force() {
let p = Project::new();
let base = p.path().join("dest");
let base_s = base.to_str().unwrap();
let v = p.json(&["skill", "install", "--dir", base_s, "--target", "claude"]);
assert_eq!(v["target"], "claude");
assert!(base.join("skills/wipe/SKILL.md").is_file());
let again = p
.cmd(&["skill", "install", "--dir", base_s])
.output()
.unwrap();
assert!(!again.status.success());
p.json(&["skill", "install", "--dir", base_s, "--force"]);
let path = p.json(&["skill", "path", "--dir", base_s, "--target", "agents"]);
assert!(path["path"].as_str().unwrap().contains("SKILL.md"));
}
#[test]
fn skill_install_auto_detects_agents_dir() {
let p = Project::new();
std::fs::create_dir_all(p.path().join(".agents")).unwrap();
let v = p.json(&["skill", "install"]);
assert_eq!(v["target"], "agents");
assert!(p.path().join(".agents/skills/wipe/SKILL.md").is_file());
}
#[test]
fn global_config_roundtrip() {
let p = Project::new();
p.json(&["config", "--global", "set", "autoserve", "true"]);
p.json(&["config", "--global", "set", "starter", "empty"]);
assert_eq!(
p.json(&["config", "--global", "get", "autoserve"])["value"],
true
);
assert_eq!(
p.json(&["config", "--global", "get", "starter"])["value"],
"empty"
);
let v = p.json(&["init", ".", "--name", "G", "--yes"]);
assert_eq!(v["starter"], "empty");
assert_eq!(v["autoserve"], true);
}
#[test]
fn forum_threads_replies_and_search() {
let p = Project::new();
p.run(&["init", ".", "--name", "F"]);
let t = p.json(&[
"forum",
"post",
"-t",
"Auth",
"-b",
"use OAuth 2.1",
"--label",
"decision",
]);
assert_eq!(t["id"], "F-1");
let r = p.json(&[
"forum",
"reply",
"F-1",
"-b",
"watch token races",
"--label",
"gotcha",
]);
assert_eq!(r["id"], "F-1.1");
let nested = p.json(&["forum", "reply", "F-1.1", "-b", "use a mutex"]);
assert_eq!(nested["id"], "F-1.1.1");
let hits = p.json(&["forum", "search", "token|mutex"]);
assert_eq!(hits.as_array().unwrap().len(), 2);
let g = p.json(&["forum", "search", "--label", "gotcha"]);
assert_eq!(g.as_array().unwrap().len(), 1);
assert_eq!(g[0]["id"], "F-1.1");
let titles = p.json(&["forum", "search", "auth", "--titles"]);
assert_eq!(titles.as_array().unwrap().len(), 1);
assert_eq!(titles[0]["id"], "F-1");
let scoped = p.json(&["forum", "search", "--scope", "F-1"]);
assert_eq!(scoped.as_array().unwrap().len(), 3);
}
#[test]
fn forum_delete_removes_subtree_and_requires_yes() {
let p = Project::new();
p.run(&["init", ".", "--name", "F"]);
p.json(&["forum", "post", "-t", "T", "-b", "root"]);
p.json(&["forum", "reply", "F-1", "-b", "a"]); p.json(&["forum", "reply", "F-1.1", "-b", "aa"]); p.json(&["forum", "reply", "F-1", "-b", "b"]);
assert!(!p
.cmd(&["forum", "delete", "F-1.1"])
.output()
.unwrap()
.status
.success());
p.json(&["forum", "delete", "F-1.1", "--yes"]);
let ids: Vec<String> = p
.json(&["forum", "search"])
.as_array()
.unwrap()
.iter()
.map(|v| v["id"].as_str().unwrap().to_string())
.collect();
assert!(ids.contains(&"F-1".to_string()));
assert!(ids.contains(&"F-1.2".to_string()));
assert!(!ids.iter().any(|i| i.starts_with("F-1.1")));
}
#[test]
fn multi_agent_forum_collaboration() {
let p = Project::new();
p.run(&["init", ".", "--name", "Calc Service"]);
let dec = p.json_as(
"architect",
&[
"forum",
"post",
"-t",
"Money handling",
"-b",
"All money is integer cents; never floats.",
"--label",
"decision",
],
);
assert_eq!(dec["id"], "F-1");
p.json_as(
"architect",
&[
"forum",
"reply",
"F-1",
"-b",
"Gotcha: JSON serializes big ints as strings; parse carefully.",
"--label",
"gotcha",
],
);
p.json(&[
"ticket",
"create",
"--title",
"Implement add()",
"--list",
"todo",
"--body",
"Follow the money rules in the forum.",
]);
let rules = p.json_as("coder", &["forum", "search", "cents|float|money"]);
let rules = rules.as_array().unwrap();
assert!(
!rules.is_empty(),
"coder must discover the architect's decision via search"
);
assert!(rules.iter().any(|r| r["author"] == "architect"));
let thread = rules[0]["thread_id"].as_str().unwrap().to_string();
assert_eq!(thread, "F-1");
p.json_as(
"coder",
&[
"forum",
"reply",
"F-1",
"-b",
"Confirmed. Using i64 cents in add().",
],
);
p.json_as(
"coder",
&[
"forum",
"post",
"-t",
"Rounding",
"-b",
"Round half-up at display only.",
"--label",
"rule",
],
);
let all_rules = p.json_as("reviewer", &["forum", "search", "--label", "decision"]);
assert!(all_rules
.as_array()
.unwrap()
.iter()
.any(|r| r["author"] == "architect"));
let authors: std::collections::HashSet<String> = p
.json_as("reviewer", &["forum", "search", "."])
.as_array()
.unwrap()
.iter()
.map(|r| r["author"].as_str().unwrap().to_string())
.collect();
assert!(
authors.contains("architect") && authors.contains("coder"),
"the reviewer should see contributions from every agent: {authors:?}"
);
let in_thread = p.json_as("reviewer", &["forum", "search", "--scope", "F-1"]);
assert_eq!(in_thread.as_array().unwrap().len(), 3); }
#[test]
fn supervision_protocol_offline() {
let p = Project::new();
p.run(&["init", ".", "--name", "Calc Service"]);
let filed = p.json(&[
"ticket",
"create",
"--title",
"Implement add",
"--list",
"todo",
"--body",
"Create calc.py defining add(a, b).",
]);
assert_eq!(filed["id"], "T-1");
let todo = p.json(&["ticket", "list", "--list", "todo"]);
let assigned = todo.as_array().unwrap();
assert_eq!(assigned.len(), 1);
let id = assigned[0]["id"].as_str().unwrap().to_string();
let spec = p.json(&["ticket", "show", &id]);
assert!(spec["body"].as_str().unwrap().contains("calc.py"));
p.json(&[
"comment",
"add",
&id,
"--body",
"Implemented add(a,b) in calc.py",
]);
p.json(&["ticket", "move", &id, "--to", "done"]);
let done = p.json(&["ticket", "show", &id]);
assert_eq!(done["list"], "done");
assert!(!done["comments"].as_array().unwrap().is_empty());
}