crates/cli/tests/cli.rs 9.4 KiB 236 lines Raw
1 //! M2 acceptance: budgets enforced on real binary output + init skeleton.2 //! Budgets: root <=12 lines <=300B; sub -h <=8 lines <=250B;3 //! --skill <=30 lines <=1000B; errors 1 line + code.4 5 use std::process::Command;6 7 fn bin() -> Command {8     Command::new(env!("CARGO_BIN_EXE_f0rge"))9 }10 11 fn run(args: &[&str]) -> (String, String, i32) {12     let out = bin().args(args).output().expect("run f0rge");13     (14         String::from_utf8_lossy(&out.stdout).into_owned(),15         String::from_utf8_lossy(&out.stderr).into_owned(),16         out.status.code().unwrap_or(-1),17     )18 }19 20 fn lines_and_bytes(s: &str) -> (usize, usize) {21     (s.lines().count(), s.len())22 }23 24 const SUBS: &[&str] =25     &["issue", "pr", "task", "label", "milestone", "ci", "wiki", "release", "merge", "init", "skill", "spec"];26 27 #[test]28 fn version_is_clean() {29     let (out, err, code) = run(&["--version"]);30     assert_eq!(code, 0);31     assert_eq!(out.trim(), concat!("f0rge ", env!("CARGO_PKG_VERSION")));32     assert!(err.is_empty());33 }34 35 #[test]36 fn root_help_within_budget() {37     let (out, _, code) = run(&[]);38     assert_eq!(code, 0);39     let (lines, bytes) = lines_and_bytes(&out);40     assert!(lines <= 12, "root {lines} lines:\n{out}");41     assert!(bytes <= 300, "root {bytes} bytes:\n{out}");42 }43 44 #[test]45 fn every_sub_help_within_budget() {46     for sub in SUBS {47         let (out, _, code) = run(&[sub, "-h"]);48         assert_eq!(code, 0, "{sub} -h exit {code}");49         let (lines, bytes) = lines_and_bytes(&out);50         assert!(lines <= 8, "{sub} {lines} lines:\n{out}");51         assert!(bytes <= 250, "{sub} {bytes} bytes:\n{out}");52     }53 }54 55 #[test]56 fn skill_within_budget() {57     let (out, _, code) = run(&["--skill"]);58     assert_eq!(code, 0);59     let (lines, bytes) = lines_and_bytes(&out);60     assert!(lines <= 30, "skill {lines} lines:\n{out}");61     assert!(bytes <= 1000, "skill {bytes} bytes:\n{out}");62     // content gates: decisions from spec/schema.md must appear.63     for needle in ["uuid4", "(created,uuid)", "one file per entity", "unknown field warning",64         "missing required error", "exit code", "close/archive only", "mirrors folder tree"]65     {66         assert!(out.contains(needle), "skill missing `{needle}`:\n{out}");67     }68 }69 70 #[test]71 fn skill_single_source_body_equals_skill_text() {72     let (out, _, code) = run(&["--skill"]);73     assert_eq!(code, 0);74     let md = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../skill/skill.md"))75         .expect("read skill/skill.md");76     let mut parts = md.splitn(3, "---\n");77     let _ = parts.next().expect("leading fence");78     let fm = parts.next().expect("frontmatter");79     let body = parts.next().expect("body");80     assert!(fm.contains("name:"), "frontmatter name missing");81     assert!(fm.contains("description:"), "frontmatter description missing");82     assert_eq!(body, out, "skill.md body != --skill output");83     let (root, _, _) = run(&[]);84     let root_first = root.lines().next().unwrap_or("");85     let skill_first = out.lines().next().unwrap_or("");86     assert!(skill_first.contains("CONTRACT"), "contract must self-identify: {skill_first:?}");87     assert_ne!(root_first, skill_first, "openings confusable");88     assert!(root.contains("contract: f0rge --skill"), "root must point at contract");89 }90 91 #[test]92 fn init_creates_skeleton_idempotent() {93     let dir = std::env::temp_dir().join(format!("f0rge-cli-init-{}-{}", std::process::id(), line!()));94     let _ = std::fs::remove_dir_all(&dir);95     std::fs::create_dir_all(&dir).unwrap();96     let s = dir.to_string_lossy().into_owned();97     let (_, _, code) = run(&["init", &s]);98     assert_eq!(code, 0);99     let forge = dir.join(".forge");100     for d in ["issues", "prs", "tasks", "wiki/docs", "wiki/research", "wiki/experiments",101         "ci/steps", "releases/build", "merges"]102     {103         assert!(forge.join(d).is_dir(), "missing dir {d}");104     }105     for f in ["labels.md", "milestones.md", "ci/ci.env"] {106         assert!(forge.join(f).is_file(), "missing file {f}");107     }108     let gi = std::fs::read_to_string(dir.join(".gitignore")).unwrap();109     for w in [".forge/ci/runs/", ".forge/releases/out/", "*.tmp"] {110         assert!(gi.lines().any(|l| l.trim() == w), ".gitignore missing {w}:\n{gi}");111     }112     // idempotent: second run keeps content, no dup gitignore lines.113     std::fs::write(forge.join("labels.md"), "- name: x\n").unwrap();114     let (_, _, code) = run(&["init", &s]);115     assert_eq!(code, 0);116     assert_eq!(std::fs::read_to_string(forge.join("labels.md")).unwrap(), "- name: x\n");117     let gi2 = std::fs::read_to_string(dir.join(".gitignore")).unwrap();118     for w in [".forge/ci/runs/", ".forge/releases/out/", "*.tmp"] {119         assert_eq!(gi2.lines().filter(|l| l.trim() == w).count(), 1, "dup {w}:\n{gi2}");120     }121     let _ = std::fs::remove_dir_all(&dir);122 }123 124 #[test]125 fn errors_are_one_line_with_codes() {126     let (_, err, code) = run(&["--bogus"]);127     assert_eq!(code, 2);128     assert_eq!(err.lines().count(), 1, "stderr:\n{err}");129     let (_, err, code) = run(&["issue"]);130     assert_eq!(code, 2);131     assert_eq!(err.lines().count(), 1, "stderr:\n{err}");132     let (_, err, code) = run(&["nope"]);133     assert_eq!(code, 2);134     assert_eq!(err.lines().count(), 1, "stderr:\n{err}");135 }136 137 #[test]138 fn jq_missing_is_one_line_note() {139     // jq exists on most dev boxes; either filtered output or the note.140     let (out, err, code) = run(&["--jq", ".", "--skill"]);141     assert_eq!(code, 0);142     if err.is_empty() {143         assert!(!out.is_empty());144     } else {145         assert_eq!(err.lines().count(), 1, "stderr:\n{err}");146     }147 }148 149 #[test]150 fn epipe_closed_stdout_is_clean_exit_zero() {151     // #18: closed stdout pipe = clean end, exit 0, no panic, no stderr.152     use std::process::Stdio;153     let dir = std::env::temp_dir()154         .join(format!("f0rge-cli-epipe-{}-{}", std::process::id(), line!()));155     let _ = std::fs::remove_dir_all(&dir);156     std::fs::create_dir_all(&dir).unwrap();157     let out = bin().current_dir(&dir).arg("init").output().expect("init");158     assert!(out.status.success());159     // Wide listing so output exceeds the pipe buffer: reader close => EPIPE mid-write.160     for i in 0..300 {161         let out = bin()162             .current_dir(&dir)163             .args(["issue", "create", "--title", &format!("bulk-{i}")])164             .output()165             .expect("create");166         assert!(out.status.success(), "seed {i} failed");167     }168     for args in [vec!["issue", "list", "--json"], vec!["issue", "list"]] {169         let mut child = bin()170             .current_dir(&dir)171             .args(&args)172             .stdout(Stdio::piped())173             .stderr(Stdio::piped())174             .spawn()175             .expect("spawn");176         // Close the reader at once: `| true` with the read end already gone.177         drop(child.stdout.take());178         let res = child.wait_with_output().expect("wait");179         let code = res.status.code().unwrap_or(-1);180         let err = String::from_utf8_lossy(&res.stderr);181         assert_eq!(code, 0, "{args:?} exit {code}, stderr: {err}");182         assert!(!err.contains("panicked"), "{args:?} panicked: {err}");183         assert!(err.is_empty(), "{args:?} stderr not empty: {err}");184     }185     let _ = std::fs::remove_dir_all(&dir);186 }187 #[test]188 fn sub_help_single_style_with_contract_pointer() {189     // #19: every `f0rge <cmd> -h` = title + usage + truthful detail + contract pointer.190     for sub in SUBS {191         let (out, _, code) = run(&[sub, "-h"]);192         assert_eq!(code, 0, "{sub} -h exit {code}");193         let first = out.lines().next().unwrap_or("");194         assert!(first.starts_with(&format!("f0rge {sub}")), "{sub} style: {first:?}");195         assert!(out.contains("usage:"), "{sub} missing usage:\n{out}");196         assert!(197             out.trim_end().ends_with("contract: f0rge --skill"),198             "{sub} missing contract pointer:\n{out}"199         );200     }201 }202 203 204 #[test]205 fn spec_prints_embedded_schema() {206     let (out, _, code) = run(&["spec"]);207     assert_eq!(code, 0);208     let canon = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/schema.md"))209         .expect("read spec/schema.md");210     assert_eq!(out, canon, "f0rge spec != spec/schema.md");211 }212 213 #[test]214 fn spec_write_overwrites_and_init_never_clobbers() {215     use std::process::Command;216     let dir = std::env::temp_dir().join(format!("f0rge-cli-spec-{}-{}", std::process::id(), line!()));217     let _ = std::fs::remove_dir_all(&dir);218     std::fs::create_dir_all(&dir).unwrap();219     let s = dir.to_string_lossy().into_owned();220     let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).args(["init", &s]).output().unwrap();221     assert!(st.status.success(), "init: {st:?}");222     let schema = dir.join("spec/schema.md");223     assert!(schema.is_file(), "init must write spec/schema.md");224     let canon = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/schema.md")).unwrap();225     assert_eq!(std::fs::read_to_string(&schema).unwrap(), canon);226     // init never clobbers: hand-edit then re-init keeps edit.227     std::fs::write(&schema, "custom\n").unwrap();228     let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).args(["init", &s]).output().unwrap();229     assert!(st.status.success());230     assert_eq!(std::fs::read_to_string(&schema).unwrap(), "custom\n");231     // spec --write re-clobbers back to canonical.232     let st = Command::new(env!("CARGO_BIN_EXE_f0rge")).arg("spec").arg("--write").current_dir(&dir).output().unwrap();233     assert!(st.status.success(), "spec --write: {st:?}");234     assert_eq!(std::fs::read_to_string(&schema).unwrap(), canon);235     let _ = std::fs::remove_dir_all(&dir);236 }