1 #![forbid(unsafe_code)]2 //! M3d+M4 e2e: tasks kanban + ci script runs against temp repos via binary.3 use std::path::{Path, PathBuf};4 use std::process::{Child, Command};5 use std::sync::atomic::{AtomicU64, Ordering};6 7 static CTR: AtomicU64 = AtomicU64::new(0);8 9 fn bin() -> String {10 env!("CARGO_BIN_EXE_f0rge").to_string()11 }12 13 fn tmp_repo(tag: &str) -> PathBuf {14 let n = CTR.fetch_add(1, Ordering::Relaxed);15 let d = std::env::temp_dir().join(format!("f0rge-m3d4-{}-{}-{n}", std::process::id(), tag));16 let _ = std::fs::remove_dir_all(&d);17 std::fs::create_dir_all(&d).unwrap();18 d19 }20 21 fn f0rge(repo: &Path, args: &[&str]) -> (i32, String, String) {22 let o = Command::new(bin()).args(args).current_dir(repo).output().unwrap();23 (24 o.status.code().unwrap_or(-1),25 String::from_utf8_lossy(&o.stdout).into_owned(),26 String::from_utf8_lossy(&o.stderr).into_owned(),27 )28 }29 30 fn spawn(repo: &Path, args: &[&str]) -> Child {31 Command::new(bin()).args(args).current_dir(repo).spawn().unwrap()32 }33 34 fn setup() -> PathBuf {35 let repo = tmp_repo("e2e");36 let (c, _, e) = f0rge(&repo, &["init"]);37 assert_eq!(c, 0, "init: {e}");38 repo39 }40 41 fn jv(s: &str) -> serde_json::Value {42 serde_json::from_str(s.trim()).expect("json line")43 }44 45 #[cfg(unix)]46 fn exec_file(p: &Path, body: &str) {47 use std::os::unix::fs::PermissionsExt;48 std::fs::write(p, body).unwrap();49 let mut perm = std::fs::metadata(p).unwrap().permissions();50 perm.set_mode(0o755);51 std::fs::set_permissions(p, perm).unwrap();52 }53 54 fn run_dirs(repo: &Path) -> Vec<String> {55 let d = repo.join(".forge/ci/runs");56 let mut out: Vec<String> = Vec::new();57 if let Ok(rd) = std::fs::read_dir(&d) {58 for e in rd.flatten() {59 let ft = match e.file_type() {60 Ok(t) => t,61 Err(_) => continue,62 };63 if !ft.is_dir() {64 continue;65 }66 let n = e.file_name().to_string_lossy().into_owned();67 if n.starts_with('.') {68 continue;69 }70 out.push(n);71 }72 }73 out.sort();74 out75 }76 77 #[test]78 fn tasks_columns_derive_no_stored_state() {79 let repo = setup();80 // create two cards, second linked to an issue81 let (c, o, _) = f0rge(&repo, &["task", "create", "--title", "First", "--json"]);82 assert_eq!(c, 0);83 assert_eq!(o.lines().count(), 1);84 assert!(!o.contains("\", \"") && !o.contains("\": "));85 let v = jv(&o);86 assert_eq!(v["title"], "First");87 assert_eq!(v["status"], "todo");88 let id1 = v["id"].as_str().unwrap().to_string();89 assert!(uuid::Uuid::parse_str(&id1).is_ok());90 91 let link = uuid::Uuid::new_v4().to_string();92 let (c, o, _) = f0rge(&repo, &["task", "create", "--title", "Second", "--issue", &link, "--json"]);93 assert_eq!(c, 0);94 let v = jv(&o);95 assert_eq!(v["issue"], link);96 let id2 = v["id"].as_str().unwrap().to_string();97 98 // card files: status in frontmatter, no board/column stored anywhere99 let t1 = std::fs::read_to_string(repo.join(format!(".forge/tasks/{id1}.md"))).unwrap();100 assert!(t1.contains("status: todo"));101 assert!(t1.contains("First"));102 let t2 = std::fs::read_to_string(repo.join(format!(".forge/tasks/{id2}.md"))).unwrap();103 assert!(t2.contains(&format!("issue: {link}")));104 let mut walk: Vec<PathBuf> = Vec::new();105 let mut stack = vec![repo.join(".forge")];106 while let Some(d) = stack.pop() {107 for e in std::fs::read_dir(&d).unwrap().flatten() {108 let p = e.path();109 if p.is_dir() {110 stack.push(p);111 } else {112 walk.push(p);113 }114 }115 }116 assert!(!walk.iter().any(|p| {117 p.file_name().map(|n| n.to_string_lossy().contains("board")).unwrap_or(false)118 }));119 for p in &walk {120 if p.extension().map(|x| x == "md").unwrap_or(false) {121 let t = std::fs::read_to_string(p).unwrap();122 assert!(!t.contains("column"), "stored column in {}", p.display());123 }124 }125 126 // list json: status,title,createdAt,issue per card127 let (c, o, _) = f0rge(&repo, &["task", "list", "--json"]);128 assert_eq!(c, 0);129 assert_eq!(o.lines().count(), 2);130 for line in o.lines() {131 let v = jv(line);132 for k in ["status", "title", "createdAt", "id"] {133 assert!(v.get(k).is_some(), "missing {k} in {line}");134 }135 }136 // plain grouped render shows derived columns137 let (c, o, _) = f0rge(&repo, &["task", "list"]);138 assert_eq!(c, 0);139 for col in ["todo:", "doing:", "blocked:", "done:"] {140 assert!(o.contains(col), "missing col {col}:\n{o}");141 }142 assert!(o.contains("First") && o.contains("Second"));143 144 // move: owning file status rewrite only145 let (c, o, _) = f0rge(&repo, &["task", "move", &id1, "doing"]);146 assert_eq!(c, 0);147 assert_eq!(jv(&o)["status"], "doing");148 let t1 = std::fs::read_to_string(repo.join(format!(".forge/tasks/{id1}.md"))).unwrap();149 assert!(t1.contains("status: doing"));150 assert!(t1.contains("First")); // title untouched151 let (_, o, _) = f0rge(&repo, &["task", "list"]);152 let doing = o.split("doing:").nth(1).unwrap().split("blocked:").next().unwrap();153 assert!(doing.contains(&id1), "id1 not under doing:\n{o}");154 155 // done sets closed timestamp156 let (c, o, _) = f0rge(&repo, &["task", "done", &id1]);157 assert_eq!(c, 0);158 assert_eq!(jv(&o)["status"], "done");159 let t1 = std::fs::read_to_string(repo.join(format!(".forge/tasks/{id1}.md"))).unwrap();160 assert!(t1.contains("status: done") && t1.contains("closed:"));161 162 // errors: bad col / garbage id -> 2; unknown uuid -> 1; all one line163 let (c, _, e) = f0rge(&repo, &["task", "move", &id1, "bogus"]);164 assert_eq!(c, 2);165 assert_eq!(e.lines().count(), 1);166 let (c, _, e) = f0rge(&repo, &["task", "move", "nope", "doing"]);167 assert_eq!(c, 2);168 assert_eq!(e.lines().count(), 1);169 let fake = uuid::Uuid::new_v4().to_string();170 let (c, _, e) = f0rge(&repo, &["task", "done", &fake]);171 assert_eq!(c, 1);172 assert_eq!(e.lines().count(), 1);173 let (c, _, e) = f0rge(&repo, &["task", "create", "--title", ""]);174 assert_eq!(c, 2);175 assert_eq!(e.lines().count(), 1);176 let (c, o, _) = f0rge(&repo, &["task", "list", "--json", "--bogus", "1"]);177 let _ = o;178 assert_eq!(c, 2);179 180 let _ = std::fs::remove_dir_all(&repo);181 }182 183 #[test]184 fn ci_fail_pass_pr_link_list_view_log() {185 let repo = setup();186 let steps = repo.join(".forge/ci/steps");187 exec_file(&steps.join("00_true.sh"), "#!/bin/sh\necho hi-true\nexit 0\n");188 exec_file(&steps.join("10_fail.sh"), "#!/bin/sh\necho hi-fail\nexit 1\n");189 190 // fail-fast: verdict fail, exit 1, record exists191 let (c, o, e) = f0rge(&repo, &["ci", "run"]);192 assert_eq!(c, 1, "out={o} err={e}");193 assert!(o.contains("fail"), "out={o}");194 let runs = run_dirs(&repo);195 assert_eq!(runs.len(), 1);196 let sum = std::fs::read_to_string(repo.join(format!(".forge/ci/runs/{}/summary.md", runs[0]))).unwrap();197 assert!(sum.contains("verdict: fail"));198 assert!(sum.contains("00_true.sh") && sum.contains("10_fail.sh"));199 assert!(sum.contains("duration_ms:"));200 let l0 = std::fs::read_to_string(repo.join(format!(".forge/ci/runs/{}/steps/00_true.sh.log", runs[0]))).unwrap();201 assert!(l0.contains("hi-true"));202 // 10_fail ran (fail-fast stops after it, nothing after anyway)203 assert!(repo.join(format!(".forge/ci/runs/{}/steps/10_fail.sh.log", runs[0])).is_file());204 205 // list / view / log206 let (c, o, _) = f0rge(&repo, &["ci", "list", "--json"]);207 assert_eq!(c, 0);208 assert_eq!(o.lines().count(), 1);209 assert_eq!(jv(&o)["verdict"], "fail");210 let (c, o, _) = f0rge(&repo, &["ci", "view", &runs[0]]);211 assert_eq!(c, 0);212 assert!(o.contains("verdict: fail"));213 let (c, o, _) = f0rge(&repo, &["ci", "log", &runs[0], "00_true.sh"]);214 assert_eq!(c, 0);215 assert!(o.contains("hi-true"));216 let (c, o, _) = f0rge(&repo, &["ci", "log", &runs[0]]);217 assert_eq!(c, 0);218 assert!(o.contains("hi-true") && o.contains("hi-fail"));219 220 // --continue-on-error runs all: add failing first + passing last221 exec_file(&steps.join("05_bad.sh"), "#!/bin/sh\necho bad\nexit 1\n");222 exec_file(&steps.join("20_last.sh"), "#!/bin/sh\necho last\nexit 0\n");223 let (c, o, _) = f0rge(&repo, &["ci", "run", "--continue-on-error"]);224 assert_eq!(c, 1, "out={o}");225 let runs = run_dirs(&repo);226 assert_eq!(runs.len(), 2);227 assert!(repo.join(format!(".forge/ci/runs/{}/steps/20_last.sh.log", runs[1])).is_file());228 229 // fix all -> pass230 for s in ["05_bad.sh", "10_fail.sh"] {231 let name = steps.join(s).file_name().unwrap().to_string_lossy().into_owned();232 let stem = name.trim_end_matches(".sh");233 exec_file(&steps.join(s), &format!("#!/bin/sh\necho {stem}-ok\nexit 0\n"));234 }235 let (c, o, _) = f0rge(&repo, &["ci", "run"]);236 assert_eq!(c, 0, "out={o}");237 assert!(o.contains("pass"));238 let runs = run_dirs(&repo);239 assert_eq!(runs.len(), 3);240 241 // pr link: verdict lands in pr.md ci field242 let pr = uuid::Uuid::new_v4().to_string();243 let pdir = repo.join(format!(".forge/prs/{pr}"));244 std::fs::create_dir_all(&pdir).unwrap();245 std::fs::write(246 pdir.join("pr.md"),247 format!("---\nid: {pr}\ntitle: T\nstate: open\nbase: main\nhead: feat\ncreated: 2025-09-08T04:00:00Z\n---\ndesc\n"),248 )249 .unwrap();250 let (c, _, e) = f0rge(&repo, &["ci", "run", &pr]);251 assert_eq!(c, 0, "err={e}");252 let pm = std::fs::read_to_string(pdir.join("pr.md")).unwrap();253 assert!(pm.contains("ci: pass"), "pr.md:\n{pm}");254 // break a step -> fail lands too255 exec_file(&steps.join("10_fail.sh"), "#!/bin/sh\nexit 1\n");256 let (c, _, _) = f0rge(&repo, &["ci", "run", &pr]);257 assert_eq!(c, 1);258 let pm = std::fs::read_to_string(pdir.join("pr.md")).unwrap();259 assert!(pm.contains("ci: fail"), "pr.md:\n{pm}");260 261 // rerun adds a run dir; cancel with nothing running -> 1 one line262 let n = run_dirs(&repo).len();263 let last = run_dirs(&repo).last().cloned().unwrap();264 let (c, _, _) = f0rge(&repo, &["ci", "rerun", &last]);265 assert_eq!(c, 1); // steps still failing266 assert_eq!(run_dirs(&repo).len(), n + 1);267 let (c, _, e) = f0rge(&repo, &["ci", "cancel"]);268 assert_eq!(c, 1);269 assert_eq!(e.lines().count(), 1);270 271 // usage errors one line exit 2272 for a in [vec!["ci", "run", "nope"], vec!["ci", "view"], vec!["ci", "log"], vec!["ci", "nope"]] {273 let (c, _, e) = f0rge(&repo, &a);274 assert_eq!(c, 2, "args={a:?}");275 assert_eq!(e.lines().count(), 1, "args={a:?} err={e}");276 }277 278 let _ = std::fs::remove_dir_all(&repo);279 }280 281 #[test]282 fn ci_cancel_running_run() {283 let repo = setup();284 exec_file(&repo.join(".forge/ci/steps/00_sleep.sh"), "#!/bin/sh\nsleep 8\nexit 0\n");285 let mut child = spawn(&repo, &["ci", "run"]);286 std::thread::sleep(std::time::Duration::from_secs(1));287 let (c, o, e) = f0rge(&repo, &["ci", "cancel"]);288 assert_eq!(c, 0, "err={e}");289 assert!(o.contains("cancelled"), "out={o}");290 // run record says cancelled291 let runs = run_dirs(&repo);292 assert_eq!(runs.len(), 1);293 let sum = std::fs::read_to_string(repo.join(format!(".forge/ci/runs/{}/summary.md", runs[0]))).unwrap();294 assert!(sum.contains("verdict: cancelled"), "summary:\n{sum}");295 // child reaped (killed by cancel)296 let _ = child.wait();297 let _ = std::fs::remove_dir_all(&repo);298 }299 300 #[test]301 fn ci_run_prints_verdict_line() {302 // #26: run end prints ONE "<ts> <verdict>" line to stdout.303 let repo = setup();304 let steps = repo.join(".forge/ci/steps");305 exec_file(&steps.join("00_ok.sh"), "#!/bin/sh\necho out\nexit 0\n");306 let (c, o, _) = f0rge(&repo, &["ci", "run"]);307 assert_eq!(c, 0, "out={o}");308 let line = o.lines().last().unwrap().trim().to_string();309 let parts: Vec<&str> = line.split_whitespace().collect();310 assert_eq!(parts.len(), 2, "line={line:?} out={o:?}");311 assert_eq!(parts[1], "pass");312 // fail still prints the line on stdout (stderr holds the error).313 exec_file(&steps.join("00_ok.sh"), "#!/bin/sh\nexit 1\n");314 let (c, o, e) = f0rge(&repo, &["ci", "run"]);315 assert_eq!(c, 1, "err={e}");316 let line = o.lines().last().unwrap().trim().to_string();317 assert!(line.ends_with(" fail"), "line={line:?}");318 assert_eq!(e.lines().count(), 1);319 let _ = std::fs::remove_dir_all(&repo);320 }