1 //! CLI-level test for the f0rge readers (m3, f0rge issue #7).2 //!3 //! Builds a fixture `.forge/` exercising every required shape, runs4 //! `hu13 build` on it, and asserts the rendered site:5 //! - 2 issues with out-of-order `created` dates -> numbers rank by6 //! `(created, id)` (5-number spot check across list + detail pages)7 //! - 1 broken issue (missing `title`) -> skipped, warned on stderr8 //! naming file + field, build still exits 09 //! - 1 PR with `patch.diff` + review (state + body)10 //! - tasks in all 4 states (todo|doing|blocked|done)11 //! - a failing CI run fixture -> `fail` badge + steps + log12 //! - rendered counts match a direct file-count of the fixture13 14 use std::sync::atomic::{AtomicU64, Ordering};15 16 static SEQ: AtomicU64 = AtomicU64::new(0);17 18 use std::path::{Path, PathBuf};19 use std::process::Command;20 21 const OLDER_UUID: &str = "11111111-1111-4111-8111-111111111111";22 const NEWER_UUID: &str = "22222222-2222-4222-8222-222222222222";23 const BROKEN_UUID: &str = "33333333-3333-4333-8333-333333333333";24 const PR_UUID: &str = "44444444-4444-4444-8444-444444444444";25 const REVIEW_UUID: &str = "55555555-5555-4555-8555-555555555555";26 const COMMENT_UUID: &str = "66666666-6666-4666-8666-666666666666";27 28 fn git(repo: &Path, args: &[&str]) {29 let st = Command::new("git")30 .arg("-C")31 .arg(repo)32 .args(args)33 .status()34 .expect("spawn git");35 assert!(st.success(), "git {args:?} failed");36 }37 38 fn fresh_dir(prefix: &str) -> PathBuf {39 let dir = std::env::temp_dir().join(format!(40 "hu13-forge-{prefix}-{}-{}",41 std::process::id(),42 std::time::SystemTime::now()43 .duration_since(std::time::UNIX_EPOCH)44 .map(|d| d.as_nanos())45 .unwrap_or(0)46 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))47 ));48 std::fs::create_dir_all(&dir).unwrap();49 dir50 }51 52 fn forge_write(repo: &Path, rel: &str, content: &str) {53 let dest = repo.join(".forge").join(rel);54 std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();55 std::fs::write(&dest, content).unwrap();56 }57 58 /// Fixture `.forge/` covering every shape in f0rge issue #7.59 fn fixture_forge(repo: &Path) {60 // Older issue (must rank #1 despite sorting after the newer uuid).61 forge_write(62 repo,63 &format!("issues/{OLDER_UUID}/issue.md"),64 "---\n\65 id: 11111111-1111-4111-8111-111111111111\n\66 title: Older issue\n\67 state: open\n\68 labels:\n\69 - kind:test\n\70 assignees: []\n\71 milestone: m1\n\72 created: 2026-09-10T03:00:00Z\n\73 updated: null\n\74 ---\n\75 Older body with <script>alert(1)</script> inside.\n",76 );77 forge_write(78 repo,79 &format!("issues/{OLDER_UUID}/comments/{COMMENT_UUID}.md"),80 "---\n\81 id: 66666666-6666-4666-8666-666666666666\n\82 author: z\n\83 created: 2026-09-10T03:30:00Z\n\84 ---\n\85 First comment.\n",86 );87 // Newer issue (must rank #2).88 forge_write(89 repo,90 &format!("issues/{NEWER_UUID}/issue.md"),91 "---\n\92 id: 22222222-2222-4222-8222-222222222222\n\93 title: Newer issue\n\94 state: closed\n\95 labels: []\n\96 assignees: []\n\97 milestone: null\n\98 created: 2026-09-10T04:00:00Z\n\99 ---\n\100 Newer body.\n",101 );102 // Broken issue: missing required `title` -> skipped + warned.103 forge_write(104 repo,105 &format!("issues/{BROKEN_UUID}/issue.md"),106 "---\n\107 id: 33333333-3333-4333-8333-333333333333\n\108 state: open\n\109 created: 2026-09-10T05:00:00Z\n\110 ---\n\111 No title here.\n",112 );113 // PR with patch + review.114 forge_write(115 repo,116 &format!("prs/{PR_UUID}/pr.md"),117 "---\n\118 id: 44444444-4444-4444-8444-444444444444\n\119 title: Fix the thing\n\120 state: open\n\121 base: main\n\122 head: fix-thing\n\123 created: 2026-09-10T03:30:00Z\n\124 draft: false\n\125 issues:\n\126 - 11111111-1111-4111-8111-111111111111\n\127 ci: fail\n\128 ---\n\129 PR description.\n",130 );131 forge_write(132 repo,133 &format!("prs/{PR_UUID}/patch.diff"),134 "diff --git a/x.txt b/x.txt\n+added <tag> line\n",135 );136 forge_write(137 repo,138 &format!("prs/{PR_UUID}/reviews/{REVIEW_UUID}.md"),139 "---\n\140 id: 55555555-5555-4555-8555-555555555555\n\141 author: reviewer\n\142 state: approved\n\143 created: 2026-09-10T05:00:00Z\n\144 ---\n\145 Looks good to me.\n",146 );147 // Tasks in all four states.148 for (n, status) in ["todo", "doing", "blocked", "done"].iter().enumerate() {149 let id = format!("7777777{n}-7777-4777-8777-77777777777{n}");150 forge_write(151 repo,152 &format!("tasks/{id}.md"),153 &format!(154 "---\nid: {id}\ntitle: Task {status}\nstatus: {status}\n\155 created: 2026-09-10T03:0{n}:00Z\n---\nTask {status} body.\n"156 ),157 );158 }159 // Labels + milestones (YAML lists, no fences).160 forge_write(repo, "labels.md", "- name: kind:test\n");161 forge_write(repo, "milestones.md", "- name: m1\n state: open\n");162 // CI: env + one step + one failing run.163 forge_write(repo, "ci/ci.env", "runner: local\n");164 forge_write(repo, "ci/steps/00_test.sh", "#!/bin/sh\nexit 1\n");165 forge_write(166 repo,167 "ci/runs/2026-09-10T00-00-00Z/summary.md",168 "---\n\169 run: 2026-09-10T00-00-00Z\n\170 verdict: fail\n\171 created: 2026-09-10T00:00:00Z\n\172 steps:\n\173 - name: 00_test.sh\n\174 exit: 1\n\175 duration_ms: 7\n\176 ---\n\177 boom: the test failed\n",178 );179 }180 181 fn direct_count(dir: &Path) -> usize {182 std::fs::read_dir(dir)183 .map(|rd| rd.filter_map(Result::ok).count())184 .unwrap_or(0)185 }186 187 #[test]188 fn forge_fixture_renders_counts_numbers_and_ci() {189 let repo = fresh_dir("repo");190 git(&repo, &["init", "-b", "main"]);191 git(&repo, &["config", "user.name", "hu13-test"]);192 git(&repo, &["config", "user.email", "[email protected]"]);193 git(&repo, &["config", "commit.gpgsign", "false"]);194 std::fs::write(repo.join("README.md"), "# forge fixture\n").unwrap();195 fixture_forge(&repo);196 git(&repo, &["add", "-A"]);197 git(&repo, &["commit", "-m", "forge fixture"]);198 199 // Direct file-counts of the fixture (ground truth for the asserts).200 assert_eq!(direct_count(&repo.join(".forge/issues")), 3);201 assert_eq!(direct_count(&repo.join(".forge/prs")), 1);202 assert_eq!(direct_count(&repo.join(".forge/tasks")), 4);203 204 let out = fresh_dir("out");205 let bin = env!("CARGO_BIN_EXE_hu13");206 let produced = Command::new(bin)207 .arg("build")208 .arg(&repo)209 .arg("-o")210 .arg(&out)211 .output()212 .expect("spawn hu13 build");213 let stderr = String::from_utf8_lossy(&produced.stderr).into_owned();214 assert!(215 produced.status.success(),216 "broken issue must not fail the build; stderr: {stderr}"217 );218 // Broken file warned, naming file (uuid) + field (title).219 assert!(220 stderr.contains(BROKEN_UUID),221 "stderr names the broken file; got: {stderr}"222 );223 assert!(224 stderr.contains("title"),225 "stderr names the missing field; got: {stderr}"226 );227 228 let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);229 230 // --- issues index: 2 rendered rows (broken one skipped) ---231 let issues = read("issues/index.html");232 let rows = issues.matches("<tr><td class=\"title").count();233 assert_eq!(rows, 2, "rendered issues match file-count minus broken");234 assert!(!issues.contains("No title here"), "broken issue skipped");235 // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.236 assert!(237 issues.contains(&format!(238 "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Older issue\"><a href=\"/issues/{OLDER_UUID}.html\">Older issue</a></td><td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"239 )),240 "issue #1 -> older uuid"241 );242 assert!(243 issues.contains(&format!(244 "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Newer issue\"><a href=\"/issues/{NEWER_UUID}.html\">Newer issue</a></td><td class=\"num trunc\" data-label=\"#\" title=\"2\">2</td>"245 )),246 "issue #2 -> newer uuid"247 );248 // (2): rank order — older sorts first despite uuid order.249 let older_pos = issues.find("Older issue").expect("older row");250 let newer_pos = issues.find("Newer issue").expect("newer row");251 assert!(older_pos < newer_pos, "ranked by (created, id)");252 assert!(issues.contains("kind:test"), "labels shown");253 assert!(issues.contains("m1"), "milestones shown");254 255 // (3): detail pages display their numbers.256 let older = read(&format!("issues/{OLDER_UUID}.html"));257 assert!(older.contains("<h1>#1 Older issue</h1>"), "detail shows #1");258 assert!(older.contains("Comments (1)"), "comment counted");259 assert!(older.contains("First comment."), "comment rendered");260 assert!(261 !older.contains("<script>alert(1)</script>"),262 "issue body escaped"263 );264 assert!(older.contains("<script>"), "escaped form present");265 let newer = read(&format!("issues/{NEWER_UUID}.html"));266 assert!(newer.contains("<h1>#2 Newer issue</h1>"), "detail shows #2");267 268 // (4): pulls — same shape + head/base/draft/ci + patch <pre>.269 let pulls = read("pulls/index.html");270 assert_eq!(271 pulls.matches("<tr><td class=\"title").count(),272 1,273 "prs match file-count"274 );275 assert!(276 pulls.contains(&format!(277 "<td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Fix the thing\"><a href=\"/pulls/{PR_UUID}.html\">Fix the thing</a></td><td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td>"278 )),279 "pr #1 -> uuid"280 );281 let pr = read(&format!("pulls/{PR_UUID}.html"));282 assert!(283 pr.contains("<h1>#1 Fix the thing</h1>"),284 "pr detail shows #1"285 );286 assert!(pr.contains("fix-thing"), "head shown");287 assert!(pr.contains("main"), "base shown");288 assert!(pr.contains("diff --git"), "patch.diff shown");289 assert!(290 pr.contains("<tag>"),291 "patch escaped in <pre>, not markdown"292 );293 assert!(pr.contains("approved"), "review state shown");294 assert!(pr.contains("Looks good to me."), "review body shown");295 296 // --- tasks grouped in four columns ---297 let tasks = read("tasks/index.html");298 for status in ["todo", "doing", "blocked", "done"] {299 assert!(300 tasks.contains(&format!("<h2>{status} (1)</h2>")),301 "group {status} with one task"302 );303 assert!(304 tasks.contains(&format!("Task {status}")),305 "{status} task shown"306 );307 }308 309 // --- ci: env runner, step names, latest fail badge + steps + log ---310 let ci = read("ci/index.html");311 assert!(ci.contains("local"), "env runner shown");312 assert!(ci.contains("00_test.sh"), "step script name shown");313 assert!(ci.contains("badge fail"), "fail badge on ci page");314 assert!(ci.contains(">fail<"), "verdict text on ci page");315 assert!(ci.contains("boom: the test failed"), "run log in <pre>");316 317 // --- index header: open counts + verdict, no TODO markers ---318 // m6/open-counts (#29 + #36): nav is open-only (Older open, Newer319 // closed -> issues (1)); code tab keeps branch/sha/CI, drops the320 // redundant counts line; closed totals live on index pages.321 let index = read("index.html");322 assert!(index.contains("issues (1)"), "nav open issue count");323 assert!(index.contains("pulls (1)"), "nav open pull count");324 assert!(325 !index.contains("repo-meta"),326 "no dupe repo-meta (f0rge #53)"327 );328 assert!(!index.contains("<p>CI:"), "no dupe CI line (f0rge #53)");329 let summ = index330 .find("<summary><span class=\"cb-seg\">Clone")331 .expect("summary");332 let send = index[summ..].find("</summary>").expect("summary end") + summ;333 let summary = &index[summ..send];334 assert!(summary.contains("CI"), "CI in summary");335 assert!(summary.contains("badge fail"), "fail badge in summary");336 let head_start = index.find("<div class=\"repo-head\">").expect("repo-head");337 let details_end = index[head_start..].find("</details>").expect("details end") + head_start;338 let head = &index[head_start..details_end];339 for needle in ["issues (", "pulls (", "tasks (", "releases ("] {340 assert!(!head.contains(needle), "code tab drops {needle}");341 }342 assert!(index.contains("badge fail"), "index verdict badge");343 assert!(!index.contains("TODO"), "TODO markers deleted");344 assert!(index.contains("/issues/index.html"), "nav links issues");345 assert!(index.contains("/pulls/index.html"), "nav links pulls");346 assert!(index.contains("/tasks/index.html"), "nav links tasks");347 assert!(index.contains("/ci/index.html"), "nav links ci");348 // Closed totals stay visible on their index pages.349 assert!(issues.contains("1 closed"), "issues closed total");350 assert!(pulls.contains("0 closed"), "pulls closed total");351 }