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('<', "<")226 .replace('>', ">")227 .replace('"', """)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 }