tests/releases_api.rs 8.8 KiB 250 lines Raw
1 //! m3 releases + machine surface (f0rge issue #8):2 //! 1 release with `out/` artifacts (byte-identical) + 1 notes-only,3 //! api counts == html counts, agents.md curl examples hittable,4 //! feed includes the release.5 6 use std::sync::atomic::{AtomicU64, Ordering};7 8 static SEQ: AtomicU64 = AtomicU64::new(0);9 10 use std::path::{Path, PathBuf};11 use std::process::Command;12 13 fn git(repo: &Path, args: &[&str]) {14     let st = Command::new("git")15         .arg("-C")16         .arg(repo)17         .args(args)18         .status()19         .expect("spawn git");20     assert!(st.success(), "git {args:?} failed");21 }22 23 fn fresh_dir(prefix: &str) -> PathBuf {24     let dir = std::env::temp_dir().join(format!(25         "hu13-relapi-{prefix}-{}-{}",26         std::process::id(),27         std::time::SystemTime::now()28             .duration_since(std::time::UNIX_EPOCH)29             .map(|d| d.as_nanos())30             .unwrap_or(0)31             .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))32     ));33     std::fs::create_dir_all(&dir).unwrap();34     dir35 }36 37 fn forge_write(repo: &Path, rel: &str, content: &str) {38     let dest = repo.join(".forge").join(rel);39     std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();40     std::fs::write(&dest, content).unwrap();41 }42 43 fn json_len(text: &str) -> usize {44     let v: serde_json::Value = serde_json::from_str(text).expect("valid json");45     v.as_array().expect("top-level array").len()46 }47 48 #[test]49 fn releases_artifacts_api_agents_feed() {50     let repo = fresh_dir("repo");51     git(&repo, &["init", "-b", "main"]);52     git(&repo, &["config", "user.name", "hu13-test"]);53     git(&repo, &["config", "user.email", "[email protected]"]);54     git(&repo, &["config", "commit.gpgsign", "false"]);55     std::fs::write(repo.join("README.md"), "# relapi fixture\n").unwrap();56 57     // One issue + one PR (for api/html count equality).58     forge_write(59         &repo,60         "issues/11111111-1111-4111-8111-111111111111/issue.md",61         "---\nid: 11111111-1111-4111-8111-111111111111\ntitle: API issue\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nIssue body.\n",62     );63     forge_write(64         &repo,65         "prs/44444444-4444-4444-8444-444444444444/pr.md",66         "---\nid: 44444444-4444-4444-8444-444444444444\ntitle: API pr\nstate: open\nbase: main\nhead: feat\ncreated: 2026-09-10T03:30:00Z\n---\nPR body.\n",67     );68     // One wiki page.69     forge_write(&repo, "wiki/notes.md", "# Notes\n\nwiki body\n");70 71     // Release WITH artifacts: notes carry a sha256 line.72     let digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";73     forge_write(74         &repo,75         "releases/v1.0.md",76         &format!(77             "---\nversion: v1.0\ntitle: First release\ncreated: 2026-09-10T02:00:00Z\nsha: abc1234\n---\n# v1.0 notes\n\nShips `app.bin`.\n\n{digest}  app.bin\n"78         ),79     );80     let artifact_bytes: Vec<u8> = (0u8..=255).collect();81     let out_file = repo.join(".forge").join("releases/out/v1.0/app.bin");82     std::fs::create_dir_all(out_file.parent().unwrap()).unwrap();83     std::fs::write(&out_file, &artifact_bytes).unwrap();84     std::fs::write(85         repo.join(".forge").join("releases/out/v1.0/nested.txt"),86         "nested artifact\n",87     )88     .unwrap();89     // Release WITHOUT artifacts: notes-only.90     forge_write(91         &repo,92         "releases/v2.0.md",93         "---\nversion: v2.0\ncreated: 2026-09-10T04:00:00Z\n---\n# v2.0 notes\n\nNo artifacts yet.\n",94     );95 96     git(&repo, &["add", "-A"]);97     git(&repo, &["commit", "-m", "relapi fixture"]);98 99     let out = fresh_dir("out");100     let bin = env!("CARGO_BIN_EXE_hu13");101     let produced = Command::new(bin)102         .arg("build")103         .arg(&repo)104         .arg("-o")105         .arg(&out)106         .output()107         .expect("spawn hu13 build");108     assert!(109         produced.status.success(),110         "build exits 0; stderr: {}",111         String::from_utf8_lossy(&produced.stderr)112     );113 114     let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);115 116     // --- releases: index + both detail pages ---117     let index = read("releases/index.html");118     assert_eq!(index.matches("<tr><td").count(), 2, "two release rows");119     assert!(index.contains("v1.0"), "v1.0 listed");120     assert!(index.contains("v2.0"), "v2.0 listed");121 122     let with_art = read("releases/v1.0.html");123     assert!(with_art.contains("First release"), "title shown");124     assert!(with_art.contains("app.bin"), "artifact linked");125     assert!(with_art.contains("nested.txt"), "nested artifact linked");126     assert!(with_art.contains("bytes"), "sizes shown");127     assert!(with_art.contains(digest), "sha256 line from notes shown");128     assert!(129         !with_art.contains("No artifacts attached."),130         "artifacts present"131     );132     // Escaping: no raw passthrough of the version block.133     assert!(!with_art.contains("<script>"));134 135     let notes_only = read("releases/v2.0.html");136     assert!(notes_only.contains("v2.0 notes"), "notes rendered");137     assert!(138         notes_only.contains("No artifacts attached."),139         "notes-only state"140     );141 142     // Artifacts byte-identical, whole dir copied.143     for rel in ["releases/v1.0/app.bin", "releases/v1.0/nested.txt"] {144         let got = std::fs::read(out.join(rel)).expect(rel);145         let want = std::fs::read(146             repo.join(".forge").join(147                 rel.strip_prefix("releases/")148                     .map(|r| format!("releases/out/v1.0/{}", r.rsplit('/').next().unwrap()))149                     .unwrap(),150             ),151         )152         .expect("fixture artifact");153         assert_eq!(got, want, "byte-identical: {rel}");154     }155     assert_eq!(156         std::fs::read(out.join("releases/v1.0/app.bin")).unwrap(),157         artifact_bytes158     );159 160     // --- api counts == html counts (same struct pass) ---161     let issues_html = read("issues/index.html")162         .matches("<tr><td class=\"title")163         .count();164     let pulls_html = read("pulls/index.html")165         .matches("<tr><td class=\"title")166         .count();167     let commits_html = read("commits.html")168         .matches("<tr><td class=\"title")169         .count();170     let releases_html = read("releases/index.html").matches("<tr><td").count();171     let wiki_html = read("wiki/index.html")172         .matches("<li><a href=\"/wiki/")173         .count();174     assert_eq!(175         json_len(&read("api/issues.json")),176         issues_html,177         "issues count"178     );179     assert_eq!(json_len(&read("api/pulls.json")), pulls_html, "pulls count");180     assert_eq!(181         json_len(&read("api/commits.json")),182         commits_html,183         "commits count"184     );185     assert_eq!(186         json_len(&read("api/releases.json")),187         releases_html,188         "releases count"189     );190     assert_eq!(json_len(&read("api/wiki.json")), wiki_html, "wiki count");191     // Compact fixed-order JSON: single line, expected first keys.192     for (rel, key) in [193         ("api/issues.json", "\"number\""),194         ("api/pulls.json", "\"number\""),195         ("api/commits.json", "\"sha\""),196         ("api/releases.json", "\"version\""),197         ("api/wiki.json", "\"path\""),198     ] {199         let text = read(rel);200         assert!(!text.contains('\n'), "{rel} is compact");201         assert!(text.contains(key), "{rel} fixed order starts with {key}");202     }203 204     // --- agents.html + agents.md ---205     let agents_html = read("agents.html");206     let agents_md = read("agents.md");207     for needle in [208         "/raw/",209         "/api/issues.json",210         "/repo.tar.gz",211         "/repo.bundle",212         "application/x-www-form-urlencoded",213         "honeypot",214         "<=300",215         "<=20000",216         "<=32KB",217         "By submitting this text",218         "detailed actionable text",219         "ONE prompt not code",220     ] {221         assert!(agents_md.contains(needle), "agents.md has {needle}");222         assert!(223             agents_html.contains(224                 &needle225                     .replace('<', "&lt;")226                     .replace('>', "&gt;")227                     .replace('"', "&quot;")228             ) || agents_html.contains(needle),229             "agents.html has {needle}"230         );231     }232     // Curl examples hittable: exact m4 route paths in both files.233     for path in ["/inbox/issue", "/inbox/pr-prompt"] {234         assert!(agents_md.contains(path), "agents.md curl hits {path}");235         assert!(agents_html.contains(path), "agents.html curl hits {path}");236     }237     assert!(agents_md.contains("curl -X POST"), "curl example present");238 239     // --- feed includes the release ---240     let feed = read("feed.atom");241     assert!(feed.contains("v1.0"), "feed has release entry");242     assert!(feed.contains("v2.0"), "feed has second release");243     assert!(feed.contains("urn:release:"), "release urn ids");244     assert!(!feed.contains("TODO(m3)"), "no m3 marker left");245 246     // --- nav links releases + agents ---247     let home = read("index.html");248     assert!(home.contains("/releases/index.html"), "nav links releases");249     assert!(home.contains("/agents.html"), "nav links agents");250 }