crates/cli/tests/bench.rs 6.9 KiB 186 lines Raw
1 //! M8 hardening: perf gates (release-only), atomicity, schema N-1, hashes.2 //! No new deps; temp dirs under $TMPDIR. Perf tests skip in debug.3 4 #![forbid(unsafe_code)]5 6 use std::path::{Path, PathBuf};7 use std::process::Command;8 use std::sync::atomic::{AtomicU64, Ordering};9 10 static CTR: AtomicU64 = AtomicU64::new(0);11 12 fn bin() -> &'static str {13     env!("CARGO_BIN_EXE_f0rge")14 }15 16 fn tmp_repo(tag: &str) -> PathBuf {17     let d = std::env::temp_dir().join(format!(18         "f0rge-m8-{tag}-{}-{}",19         std::process::id(),20         CTR.fetch_add(1, Ordering::Relaxed)21     ));22     let _ = std::fs::remove_dir_all(&d);23     std::fs::create_dir_all(&d).unwrap();24     let st = Command::new(bin()).arg("init").current_dir(&d).output().unwrap();25     assert!(st.status.success(), "init failed: {st:?}");26     d27 }28 29 fn uuid(i: u32) -> String {30     format!("{:08x}-0000-4000-8000-{:012x}", i, i)31 }32 33 fn run(dir: &Path, args: &[&str]) -> (std::process::Output, std::time::Duration) {34     let t = std::time::Instant::now();35     let o = Command::new(bin()).args(args).current_dir(dir).output().unwrap();36     (o, t.elapsed())37 }38 39 fn lines(s: &[u8]) -> Vec<String> {40     String::from_utf8(s.to_vec())41         .unwrap()42         .lines()43         .filter(|l| !l.is_empty())44         .map(|l| l.to_string())45         .collect()46 }47 48 /// Release-only gate: debug runs skip (fast), release runs enforce.49 fn release_only(test: &str) -> bool {50     if cfg!(debug_assertions) {51         eprintln!("skip {test}: release-only bench");52         return false;53     }54     true55 }56 57 #[test]58 fn issue_list_10k_json_under_300ms() {59     if !release_only("issue_list_10k") {60         return;61     }62     let dir = tmp_repo("list10k");63     let base = dir.join(".forge/issues");64     for i in 0..10_000u32 {65         let id = uuid(i);66         let d = base.join(&id);67         std::fs::create_dir_all(&d).unwrap();68         std::fs::write(69             d.join("issue.md"),70             format!(71                 "---\nid: {id}\ntitle: t{i}\nstate: open\ncreated: 2025-09-{:02}T00:00:00Z\n---\nb{i}\n",72                 1 + (i % 28)73             ),74         )75         .unwrap();76     }77     let (o, dt) = run(&dir, &["issue", "list", "--json"]);78     assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));79     assert!(o.stderr.is_empty(), "leak: {}", String::from_utf8_lossy(&o.stderr));80     let ls = lines(&o.stdout);81     assert_eq!(ls.len(), 10_000);82     assert!(ls[0].starts_with("{\"number\":1,"));83     eprintln!("10k issue list --json: {dt:?}");84     assert!(dt.as_millis() < 300, "too slow: {dt:?}");85     let _ = std::fs::remove_dir_all(&dir);86 }87 88 #[test]89 fn wiki_list_500_json_under_100ms() {90     if !release_only("wiki_list_500") {91         return;92     }93     let dir = tmp_repo("wiki500");94     let base = dir.join(".forge/wiki/docs");95     std::fs::create_dir_all(&base).unwrap();96     for i in 0..500u32 {97         std::fs::write(base.join(format!("p{i:03}.md")), format!("# Title {i}\nbody {i}\n")).unwrap();98     }99     let (o, dt) = run(&dir, &["wiki", "list", "--json"]);100     assert!(o.status.success(), "stderr: {}", String::from_utf8_lossy(&o.stderr));101     let body = String::from_utf8(o.stdout).unwrap();102     let rows: Vec<serde_json::Value> = serde_json::from_str(body.trim()).unwrap();103     assert_eq!(rows.len(), 500);104     assert!(body.contains("Title 7"));105     eprintln!("500-file wiki list --json: {dt:?}");106     assert!(dt.as_millis() < 100, "too slow: {dt:?}");107     let _ = std::fs::remove_dir_all(&dir);108 }109 110 #[test]111 fn stale_tmp_ignored_by_cli() {112     let dir = tmp_repo("staletmp");113     let (o, _) = run(&dir, &["issue", "create", "--title", "t", "--json"]);114     assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));115     let mut ids = Vec::new();116     for e in std::fs::read_dir(dir.join(".forge/issues")).unwrap().flatten() {117         ids.push(e.file_name().to_string_lossy().into_owned());118     }119     assert_eq!(ids.len(), 1);120     // crash debris beside the entity: strict-tmp name + bare .tmp121     let target = dir.join(".forge/issues").join(&ids[0]).join("issue.md");122     std::fs::write(target.with_extension("tmp.999-0"), b"---\nid: garbage\n---\npartial").unwrap();123     std::fs::write(target.with_extension("tmp"), b"partial").unwrap();124     let (o, _) = run(&dir, &["issue", "list", "--json"]);125     assert!(o.status.success());126     assert_eq!(lines(&o.stdout).len(), 1);127     let (o, _) = run(&dir, &["issue", "view", &ids[0], "--json"]);128     assert!(o.status.success());129     assert!(String::from_utf8_lossy(&o.stdout).contains("\"title\":\"t\""));130     let _ = std::fs::remove_dir_all(&dir);131 }132 133 #[test]134 fn old_schema_warns_names_file_and_keeps_going() {135     let dir = tmp_repo("schema");136     let id = uuid(7);137     let d = dir.join(".forge/issues").join(&id);138     std::fs::create_dir_all(&d).unwrap();139     std::fs::write(140         d.join("issue.md"),141         format!(142             "---\nid: {id}\ntitle: t\nstate: open\ncreated: 2025-09-08T02:00:00Z\nschema: v0\n---\nbody\n"143         ),144     )145     .unwrap();146     let (o, _) = run(&dir, &["issue", "view", &id, "--json"]);147     assert!(o.status.success());148     let err = String::from_utf8_lossy(&o.stderr).into_owned();149     assert!(err.contains("issue.md") && err.contains("v0"), "{err:?}");150     assert!(!err.trim_end().contains('\n'), "one line: {err:?}");151     assert!(String::from_utf8_lossy(&o.stdout).contains("\"title\":\"t\""));152     let _ = std::fs::remove_dir_all(&dir);153 }154 155 #[test]156 fn release_create_appends_sha256_and_empty_safe() {157     let dir = tmp_repo("relhash");158     // artifacts pre-exist in out/ (as a build would leave them)159     let out = dir.join(".forge/releases/out/v9");160     std::fs::create_dir_all(&out).unwrap();161     std::fs::write(out.join("a.bin"), b"aaa").unwrap();162     std::fs::write(out.join("b.bin"), b"bbb").unwrap();163     let notes = dir.join("n.md");164     std::fs::write(&notes, "notes\n").unwrap();165     let ns = notes.to_string_lossy().into_owned();166     let (o, _) = run(&dir, &["release", "create", "v9", "--notes", ns.as_str()]);167     assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));168     let md = std::fs::read_to_string(dir.join(".forge/releases/v9.md")).unwrap();169     assert!(md.starts_with("---\n"), "{md:?}");170     assert!(md.contains("## sha256"), "{md:?}");171     for name in ["a.bin", "b.bin"] {172         let sum = Command::new("sha256sum").arg(out.join(name)).output().unwrap();173         let hash = String::from_utf8(sum.stdout).unwrap();174         let hash = hash.split_whitespace().next().unwrap().to_string();175         assert_eq!(hash.len(), 64);176         assert!(md.contains(&format!("{hash}  {name}")), "{md:?}");177     }178     // empty-safe: no artifacts -> no block, notes intact179     std::fs::write(&notes, "empty notes\n").unwrap();180     let (o, _) = run(&dir, &["release", "create", "v10", "--notes", ns.as_str()]);181     assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));182     let md10 = std::fs::read_to_string(dir.join(".forge/releases/v10.md")).unwrap();183     assert!(!md10.contains("## sha256"), "{md10:?}");184     assert!(md10.contains("empty notes"));185     let _ = std::fs::remove_dir_all(&dir);186 }