crates/cli/tests/m3d_m4.rs 12.2 KiB 320 lines Raw
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 }