crates/cli/tests/issue_e2e.rs 18.5 KiB 470 lines Raw
1 //! M3a acceptance: full issue lifecycle on a temp repo via the binary.2 //! create/list(filters)/view/comment/close/reopen/assign/label/edit,3 //! golden JSON field order, error codes, budgets, derived-number stability.4 5 use std::path::{Path, PathBuf};6 use std::process::Command;7 8 fn bin() -> Command {9     Command::new(env!("CARGO_BIN_EXE_f0rge"))10 }11 12 fn run_in(dir: &Path, args: &[&str]) -> (String, String, i32) {13     let out = bin().current_dir(dir).args(args).output().expect("run f0rge");14     (15         String::from_utf8_lossy(&out.stdout).into_owned(),16         String::from_utf8_lossy(&out.stderr).into_owned(),17         out.status.code().unwrap_or(-1),18     )19 }20 21 fn tmp(tag: &str) -> PathBuf {22     let d = std::env::temp_dir()23         .join(format!("f0rge-m3a-{tag}-{}-{}", std::process::id(), line!()));24     let _ = std::fs::remove_dir_all(&d);25     std::fs::create_dir_all(&d).unwrap();26     d27 }28 29 fn jv(s: &str) -> serde_json::Value {30     serde_json::from_str(s.trim()).expect("json line")31 }32 33 fn run_in_stdin(dir: &Path, args: &[&str], input: &str) -> (String, String, i32) {34     use std::io::Write;35     let mut kid = bin()36         .current_dir(dir)37         .args(args)38         .stdin(std::process::Stdio::piped())39         .stdout(std::process::Stdio::piped())40         .stderr(std::process::Stdio::piped())41         .spawn()42         .expect("spawn f0rge");43     kid.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();44     let out = kid.wait_with_output().expect("wait f0rge");45     (46         String::from_utf8_lossy(&out.stdout).into_owned(),47         String::from_utf8_lossy(&out.stderr).into_owned(),48         out.status.code().unwrap_or(-1),49     )50 }51 52 fn field_order(s: &str, keys: &[&str]) {53     let mut last = 0;54     for k in keys {55         let i = s.find(&format!("\"{k}\":")).unwrap_or_else(|| panic!("missing {k} in {s}"));56         assert!(i >= last, "order broken at {k} in {s}");57         last = i;58     }59 }60 61 const ISSUE_KEYS: &[&str] =62     &["number", "title", "state", "labels", "assignees", "milestone", "body", "createdAt", "comments"];63 64 #[test]65 fn full_lifecycle() {66     let dir = tmp("life");67     let (out, err, code) = run_in(&dir, &["init"]);68     assert_eq!((code, out.as_str(), err.as_str()), (0, "", ""));69 70     // create A (full) + B (minimal)71     let (a_out, _, code) = run_in(72         &dir,73         &["issue", "create", "--title", "Alpha", "--body", "first body", "--label", "bug",74             "--assignee", "ann", "--milestone", "v1", "--json"],75     );76     assert_eq!(code, 0);77     assert_eq!(a_out.lines().count(), 1);78     assert!(!a_out.contains("\", \"") && !a_out.contains("\": "));79     field_order(&a_out, ISSUE_KEYS);80     let a = jv(&a_out);81     assert_eq!(a["title"], "Alpha");82     assert_eq!(a["state"], "open");83     assert_eq!(a["labels"], serde_json::json!(["bug"]));84     assert_eq!(a["assignees"], serde_json::json!(["ann"]));85     assert_eq!(a["milestone"], "v1");86     assert_eq!(a["body"], "first body\n");87     assert_eq!(a["comments"], 0);88     assert!(a["createdAt"].as_str().is_some());89     let aid = uuid::Uuid::parse_str(a["number"].as_u64().unwrap().to_string().as_str()).ok();90     assert!(aid.is_none()); // number is numeric, not uuid91     let anum = a["number"].as_u64().unwrap();92     let _ = anum; // snapshot only; re-resolved from list below93 94     let (b_out, _, code) = run_in(&dir, &["issue", "create", "--title", "Beta", "--json"]);95     assert_eq!(code, 0);96     let b = jv(&b_out);97     assert_eq!(b["labels"], serde_json::json!([]));98     assert_eq!(b["milestone"], serde_json::Value::Null);99 100     // numbers never stored in files101     let issues_dir = dir.join(".forge/issues");102     for e in std::fs::read_dir(&issues_dir).unwrap().flatten() {103         let md = issues_dir.join(e.file_name()).join("issue.md");104         let t = std::fs::read_to_string(&md).unwrap();105         assert!(!t.contains("number"), "number stored in {}", md.display());106     }107 108     // list stable across runs109     let (l1, _, code) = run_in(&dir, &["issue", "list", "--json"]);110     assert_eq!(code, 0);111     let (l2, _, _) = run_in(&dir, &["issue", "list", "--json"]);112     assert_eq!(l1, l2);113     assert_eq!(l1.lines().count(), 2);114     for line in l1.lines() {115         field_order(line, ISSUE_KEYS);116     }117     // numbers are derived snapshots: re-resolve current numbers by title118     let mut by_title = std::collections::BTreeMap::new();119     for line in l1.lines() {120         let v = jv(line);121         by_title.insert(v["title"].as_str().unwrap().to_string(), v["number"].as_u64().unwrap());122     }123     assert_eq!(by_title.len(), 2);124     let mut nums: Vec<u64> = by_title.values().copied().collect();125     nums.sort();126     assert_eq!(nums, vec![1, 2]);127     let anum = by_title["Alpha"];128     let bnum = by_title["Beta"];129     assert_ne!(anum, bnum);130     let _ = bnum;131 132     // filters133     let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--label", "bug"]);134     assert_eq!(o.lines().count(), 1);135     assert_eq!(jv(&o)["title"], "Alpha");136     let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--assignee", "ann"]);137     assert_eq!(jv(&o)["title"], "Alpha");138     let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--state", "open"]);139     assert_eq!(o.lines().count(), 2);140     let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--search", "BETA"]);141     assert_eq!(o.lines().count(), 1);142     assert_eq!(jv(&o)["title"], "Beta");143     let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--milestone", "v1"]);144     assert_eq!(jv(&o)["title"], "Alpha");145     let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--label", "nope"]);146     assert_eq!(o, "[]\n");147 148     // view by number + uuid, json has comments array149     let (v, _, code) = run_in(&dir, &["issue", "view", &anum.to_string(), "--json"]);150     assert_eq!(code, 0);151     field_order(&v, &["number", "title", "state", "labels", "assignees", "milestone", "body", "createdAt", "comments"]);152     let vv = jv(&v);153     assert_eq!(vv["comments"], serde_json::json!([]));154     assert_eq!(vv["body"], "first body\n");155 156     // comment append-only: two comments = two files, first untouched157     let (c1, _, code) =158         run_in(&dir, &["issue", "comment", &anum.to_string(), "--body", "hello", "--author", "bob"]);159     assert_eq!(code, 0);160     let c1v = jv(&c1);161     assert_eq!(c1v["author"], "bob");162     assert_eq!(c1v["body"], "hello\n");163     let cdir = issues_dir164         .read_dir()165         .unwrap()166         .flatten()167         .map(|e| issues_dir.join(e.file_name()))168         .find(|p| {169             std::fs::read_to_string(p.join("issue.md")).unwrap().contains("Alpha")170                 || std::fs::read_to_string(p.join("issue.md")).unwrap().contains("Alpha2")171         });172     // locate A's dir via list number is awkward; find comments dir with 1 file173     let mut found: Vec<PathBuf> = Vec::new();174     for e in std::fs::read_dir(&issues_dir).unwrap().flatten() {175         let cd = issues_dir.join(e.file_name()).join("comments");176         if cd.is_dir() {177             found.push(cd);178         }179     }180     assert_eq!(found.len(), 1);181     let first_file = std::fs::read_dir(&found[0]).unwrap().flatten().next().unwrap().path();182     let first_bytes = std::fs::read(&first_file).unwrap();183     let _ = cdir;184     let (_, _, code) =185         run_in(&dir, &["issue", "comment", &anum.to_string(), "--body", "second", "--json"]);186     assert_eq!(code, 0);187     assert_eq!(std::fs::read(&first_file).unwrap(), first_bytes); // never edited188     assert_eq!(std::fs::read_dir(&found[0]).unwrap().count(), 2);189     let (v, _, _) = run_in(&dir, &["issue", "view", &anum.to_string(), "--json"]);190     let vv = jv(&v);191     assert_eq!(vv["comments"].as_array().unwrap().len(), 2);192     let by_body = |b: &str| {193         vv["comments"].as_array().unwrap().iter().find(|c| c["body"] == b).cloned().expect("comment body")194     };195     assert_eq!(by_body("hello\n")["author"], "bob");196     assert_eq!(by_body("second\n")["author"], serde_json::Value::Null);197     assert_eq!(vv["number"], anum);198 199     // close / reopen round-trip via number200     let (o, _, code) = run_in(&dir, &["issue", "close", &anum.to_string()]);201     assert_eq!(code, 0);202     assert_eq!(jv(&o)["state"], "closed");203     let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--state", "closed"]);204     assert_eq!(o.lines().count(), 1);205     let (o, _, _) = run_in(&dir, &["issue", "list", "--json", "--state", "open"]);206     assert_eq!(o.lines().count(), 1); // only Beta207     let (o, _, code) = run_in(&dir, &["issue", "reopen", &anum.to_string()]);208     assert_eq!(code, 0);209     assert_eq!(jv(&o)["state"], "open");210 211     // assign append + label append, then edit212     let (o, _, code) = run_in(&dir, &["issue", "assign", &anum.to_string(), "--assignee", "zed"]);213     assert_eq!(code, 0);214     let av = jv(&o);215     assert!(av["assignees"].as_array().unwrap().contains(&serde_json::json!("ann")));216     assert!(av["assignees"].as_array().unwrap().contains(&serde_json::json!("zed")));217     let (o, _, code) = run_in(&dir, &["issue", "label", &anum.to_string(), "--label", "urgent"]);218     assert_eq!(code, 0);219     let lv = jv(&o);220     assert!(lv["labels"].as_array().unwrap().contains(&serde_json::json!("bug")));221     assert!(lv["labels"].as_array().unwrap().contains(&serde_json::json!("urgent")));222     let (o, _, code) =223         run_in(&dir, &["issue", "edit", &anum.to_string(), "--title", "Alpha2", "--body", "new"]);224     assert_eq!(code, 0);225     let ev = jv(&o);226     assert_eq!(ev["title"], "Alpha2");227     assert_eq!(ev["body"], "new\n");228     assert_eq!(ev["number"], anum); // number stable through mutations229 230     // human view shows body + comments231     let (h, _, code) = run_in(&dir, &["issue", "view", &anum.to_string()]);232     assert_eq!(code, 0);233     assert!(h.contains("Alpha2") && h.contains("new") && h.contains("hello"));234 235     let _ = std::fs::remove_dir_all(&dir);236 }237 238 #[test]239 fn errors_and_budgets() {240     let dir = tmp("err");241     run_in(&dir, &["init"]);242 243     // missing title -> usage exit 2, one line244     let (_, err, code) = run_in(&dir, &["issue", "create", "--body", "x"]);245     assert_eq!(code, 2);246     assert_eq!(err.lines().count(), 1);247     // no verb -> exit 2248     let (_, err, code) = run_in(&dir, &["issue"]);249     assert_eq!(code, 2);250     assert_eq!(err.lines().count(), 1);251     // unknown flag -> exit 2252     let (_, err, code) = run_in(&dir, &["issue", "list", "--bogus", "1"]);253     assert_eq!(code, 2);254     assert_eq!(err.lines().count(), 1);255     // bad state -> exit 2256     let (_, err, code) = run_in(&dir, &["issue", "list", "--state", "bogus"]);257     assert_eq!(code, 2);258     assert_eq!(err.lines().count(), 1);259     // unknown id -> runtime exit 1, one line260     let (_, err, code) = run_in(&dir, &["issue", "view", "9999"]);261     assert_eq!(code, 1);262     assert_eq!(err.lines().count(), 1);263     // comment without body -> exit 2264     let (o, _, _) = run_in(&dir, &["issue", "create", "--title", "T", "--json"]);265     let n = jv(&o)["number"].to_string();266     let (_, err, code) = run_in(&dir, &["issue", "comment", &n]);267     assert_eq!(code, 2);268     assert_eq!(err.lines().count(), 1);269     // edit with neither field -> exit 2270     let (_, err, code) = run_in(&dir, &["issue", "edit", &n]);271     assert_eq!(code, 2);272     assert_eq!(err.lines().count(), 1);273 274     // help budget275     let (h, _, code) = run_in(&dir, &["issue", "-h"]);276     assert_eq!(code, 0);277     assert!(h.lines().count() <= 8, "help lines:\n{h}");278     assert!(h.len() <= 250, "help bytes {}:\n{h}", h.len());279     for w in ["create", "list", "view", "comment", "close", "reopen", "assign", "label", "edit"] {280         assert!(h.contains(w), "help missing {w}:\n{h}");281     }282 283     let _ = std::fs::remove_dir_all(&dir);284 }285 286 #[test]287 fn create_rejects_state_and_silent_flags() {288     // #31: gh-literal, no silent accepts on create.289     let dir = tmp("reject");290     run_in(&dir, &["init"]);291     for flag in ["--state", "--search", "--author"] {292         let (_, err, code) = run_in(&dir, &["issue", "create", "--title", "T", flag, "x"]);293         assert_eq!(code, 2, "flag {flag}");294         assert_eq!(err.lines().count(), 1, "flag {flag}: {err:?}");295         assert!(err.contains(flag), "flag {flag} unnamed: {err:?}");296     }297     let (_, err, code) = run_in(&dir, &["issue", "create", "--title", "T", "--remove"]);298     assert_eq!(code, 2);299     assert_eq!(err.lines().count(), 1);300     assert!(err.contains("--remove"), "{err:?}");301     // default state only via create; list --state still filters.302     let (o, _, code) = run_in(&dir, &["issue", "create", "--title", "T", "--json"]);303     assert_eq!(code, 0);304     assert_eq!(jv(&o)["state"], "open");305     let (o, _, code) = run_in(&dir, &["issue", "list", "--json", "--state", "open"]);306     assert_eq!(code, 0);307     assert_eq!(o.lines().count(), 1);308     let _ = std::fs::remove_dir_all(&dir);309 }310 311 #[test]312 fn edit_milestone_label_assignee_and_state_rejection() {313     // #43: edit gains --milestone/--label/--assignee (append, like the314     // label/assign verbs); --state stays rejected with the try-path hint.315     let dir = tmp("editflags");316     run_in(&dir, &["init"]);317     let (o, _, code) = run_in(318         &dir,319         &["issue", "create", "--title", "E", "--body", "b", "--label", "bug",320             "--assignee", "ann", "--milestone", "v1", "--json"],321     );322     assert_eq!(code, 0);323     let n = jv(&o)["number"].to_string();324 325     // --milestone regroups, everything else intact326     let (o, _, code) = run_in(&dir, &["issue", "edit", &n, "--milestone", "v2"]);327     assert_eq!(code, 0);328     let v = jv(&o);329     assert_eq!(v["milestone"], "v2");330     assert_eq!(v["labels"], serde_json::json!(["bug"]));331     assert_eq!(v["assignees"], serde_json::json!(["ann"]));332     assert_eq!(v["title"], "E");333     assert_eq!(v["body"], "b\n");334 335     // --label/--assignee append with dedup (verb semantics, no replace)336     let (o, _, code) = run_in(337         &dir,338         &["issue", "edit", &n, "--label", "urgent", "--label", "bug", "--assignee", "zed"],339     );340     assert_eq!(code, 0);341     let v = jv(&o);342     assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));343     assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));344     assert_eq!(v["milestone"], "v2");345 346     // --state rejected: exit 2, one line, try-path hint; nothing changed347     let (_, err, code) = run_in(&dir, &["issue", "edit", &n, "--state", "closed"]);348     assert_eq!(code, 2);349     assert_eq!(err.lines().count(), 1);350     assert!(err.contains("--state") && err.contains("(try: f0rge issue edit --help)"), "{err:?}");351     let (o, _, _) = run_in(&dir, &["issue", "view", &n, "--json"]);352     assert_eq!(jv(&o)["state"], "open");353 354     let _ = std::fs::remove_dir_all(&dir);355 }356 357 #[test]358 fn body_file_create_and_comment() {359     // #44: --body-file <path|-> on create + comment; stdin via `-`.360     let dir = tmp("bodyfile");361     run_in(&dir, &["init"]);362     let hostile = "shell-hostile: `back` $var \"dq\" 'sq'\nline2 `$(x)`\n";363     std::fs::write(dir.join("notes.md"), hostile).unwrap();364 365     let (o, _, code) =366         run_in(&dir, &["issue", "create", "--title", "F", "--body-file", "notes.md", "--json"]);367     assert_eq!(code, 0);368     let v = jv(&o);369     assert_eq!(v["body"], hostile);370     let n = v["number"].to_string();371 372     // stdin via `-`373     let (o, _, code) = run_in_stdin(374         &dir,375         &["issue", "create", "--title", "S", "--body-file", "-", "--json"],376         "stdin `body` $x\n",377     );378     assert_eq!(code, 0);379     assert_eq!(jv(&o)["body"], "stdin `body` $x\n");380 381     // comment from file + stdin382     let (o, _, code) =383         run_in(&dir, &["issue", "comment", &n, "--body-file", "notes.md", "--json"]);384     assert_eq!(code, 0);385     assert_eq!(jv(&o)["body"], hostile);386     let (o, _, code) = run_in_stdin(387         &dir,388         &["issue", "comment", &n, "--body-file", "-", "--json"],389         "c2\n",390     );391     assert_eq!(code, 0);392     assert_eq!(jv(&o)["body"], "c2\n");393 394     // --body with --body-file rejected (exit 2, one line)395     let (_, err, code) = run_in(396         &dir,397         &["issue", "create", "--title", "X", "--body", "b", "--body-file", "notes.md"],398     );399     assert_eq!(code, 2);400     assert_eq!(err.lines().count(), 1);401 402     // missing file -> runtime exit 1, one line403     let (_, err, code) =404         run_in(&dir, &["issue", "create", "--title", "X", "--body-file", "nope.md"]);405     assert_eq!(code, 1);406     assert_eq!(err.lines().count(), 1);407 408     let _ = std::fs::remove_dir_all(&dir);409 }410 411 #[test]412 fn field_preservation_regression() {413     // #45: an issue carrying milestone+labels+assignee survives label/assign414     // edits with ALL fields intact (the silent-wipe class stays dead).415     let dir = tmp("preserve");416     run_in(&dir, &["init"]);417     let (o, _, code) = run_in(418         &dir,419         &["issue", "create", "--title", "P", "--body", "keep", "--label", "bug",420             "--assignee", "ann", "--milestone", "v9", "--json"],421     );422     assert_eq!(code, 0);423     let n = jv(&o)["number"].to_string();424 425     // label verb (append) must not wipe milestone/assignees/body426     let (o, _, code) = run_in(&dir, &["issue", "label", &n, "--label", "urgent"]);427     assert_eq!(code, 0);428     let v = jv(&o);429     assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));430     assert_eq!(v["milestone"], "v9");431     assert_eq!(v["assignees"], serde_json::json!(["ann"]));432     assert_eq!(v["body"], "keep\n");433 434     // assign verb (append) must not wipe milestone/labels/body435     let (o, _, code) = run_in(&dir, &["issue", "assign", &n, "--assignee", "zed"]);436     assert_eq!(code, 0);437     let v = jv(&o);438     assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));439     assert_eq!(v["milestone"], "v9");440     assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));441 442     // edit --title keeps the rest (partial-record-write class stays dead)443     let (o, _, code) = run_in(&dir, &["issue", "edit", &n, "--title", "P2"]);444     assert_eq!(code, 0);445     let v = jv(&o);446     assert_eq!(v["title"], "P2");447     assert_eq!(v["state"], "open");448     assert_eq!(v["milestone"], "v9");449     assert_eq!(v["labels"], serde_json::json!(["bug", "urgent"]));450     assert_eq!(v["assignees"], serde_json::json!(["ann", "zed"]));451     assert_eq!(v["body"], "keep\n");452 453     // raw record still carries every field454     let issues_dir = dir.join(".forge/issues");455     let mut files = Vec::new();456     for e in std::fs::read_dir(&issues_dir).unwrap().flatten() {457         files.push(std::fs::read_to_string(issues_dir.join(e.file_name()).join("issue.md")).unwrap());458     }459     assert_eq!(files.len(), 1);460     for needle in ["P2", "bug", "urgent", "ann", "zed", "v9", "keep"] {461         assert!(files[0].contains(needle), "record lost `{needle}`:\n{}", files[0]);462     }463 464     // human list shows labels (+ milestone when set)465     let (h, _, code) = run_in(&dir, &["issue", "list"]);466     assert_eq!(code, 0);467     assert!(h.contains("bug") && h.contains("urgent") && h.contains("v9"), "list:\n{h}");468 469     let _ = std::fs::remove_dir_all(&dir);470 }