tests/forge_readers.rs 13.1 KiB 351 lines Raw
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("&lt;script&gt;"), "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("&lt;tag&gt;"),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 }