tests/review3_render.rs 8.4 KiB 264 lines Raw
1 //! Review #3 UI/build regressions (f0rge #83–#96): indexes, footer,2 //! 404, CRLF gutter, encoded paths, redacted CI logs, absolute social3 //! and feed URLs, dir headings.4 5 use std::io::{BufRead, BufReader, Read, Write};6 use std::net::TcpStream;7 use std::path::{Path, PathBuf};8 use std::process::{Child, Command, Stdio};9 use std::sync::atomic::{AtomicU64, Ordering};10 use std::time::Duration;11 12 static SEQ: AtomicU64 = AtomicU64::new(0);13 14 fn fresh(prefix: &str) -> PathBuf {15     let dir = std::env::temp_dir().join(format!(16         "hu13-review3-{prefix}-{}-{}",17         std::process::id(),18         std::time::SystemTime::now()19             .duration_since(std::time::UNIX_EPOCH)20             .map(|d| d.as_nanos())21             .unwrap_or(0)22             .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))23     ));24     std::fs::create_dir_all(&dir).unwrap();25     dir26 }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 write(repo: &Path, rel: &str, body: &str) {39     let dest = repo.join(rel);40     std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();41     std::fs::write(dest, body).unwrap();42 }43 44 const ISSUE: &str = "11111111-1111-4111-8111-111111111111";45 const PR: &str = "44444444-4444-4444-8444-444444444444";46 47 fn fixture() -> PathBuf {48     let repo = fresh("repo");49     git(&repo, &["init", "-b", "main"]);50     git(&repo, &["config", "user.name", "hu13-test"]);51     git(&repo, &["config", "user.email", "[email protected]"]);52     git(&repo, &["config", "commit.gpgsign", "false"]);53     write(&repo, "README.md", "# review fixture\n");54     write(&repo, "a#b.txt", "hash name\n");55     write(&repo, "q?x.txt", "query name\n");56     write(&repo, "src/hi.txt", "subdir\n");57     write(&repo, "crlf.txt", "a\r\nb\r\n");58     write(59         &repo,60         &format!(".forge/issues/{ISSUE}/issue.md"),61         &format!(62             "---\nid: {ISSUE}\ntitle: Closed issue\nstate: closed\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"63         ),64     );65     write(66         &repo,67         &format!(".forge/prs/{PR}/pr.md"),68         &format!(69             "---\nid: {PR}\ntitle: Closed pull\nstate: closed\nbase: main\nhead: feature/topic\ncreated: 2026-09-10T03:30:00Z\ndraft: false\nissues: []\nci: pass\n---\nbody\n"70         ),71     );72     write(73         &repo,74         ".forge/ci/runs/2026-09-10T00-00-00Z/summary.md",75         "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: 00_test.sh\n    exit: 0\n    duration_ms: 3\n---\nsummary body\n",76     );77     write(78         &repo,79         ".forge/ci/runs/2026-09-10T00-00-00Z/steps/00_test.sh.log",80         &format!("Checking hu13 at {}\nfinished\n", repo.display()),81     );82     git(&repo, &["add", "-A"]);83     git(&repo, &["commit", "-m", "one"]);84     repo85 }86 87 fn build(repo: &Path, out: &Path) -> std::process::Output {88     Command::new(env!("CARGO_BIN_EXE_hu13"))89         .arg("build")90         .arg(repo)91         .arg("-o")92         .arg(out)93         .arg("--public-origin")94         .arg("https://hu13.example")95         .output()96         .expect("spawn hu13 build")97 }98 99 fn read(path: &Path) -> String {100     std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))101 }102 103 #[test]104 fn review3_build_regressions() {105     let repo = fixture();106     let out = fresh("out");107     let res = build(&repo, &out);108     assert!(109         res.status.success(),110         "build: {}",111         String::from_utf8_lossy(&res.stderr)112     );113 114     let index = read(&out.join("index.html"));115     assert!(116         index.contains("href=\"/files/a%23b.txt.html\""),117         "hash filename is percent-encoded in links"118     );119     assert!(120         index.contains("href=\"/files/q%3Fx.txt.html\""),121         "query filename is percent-encoded in links"122     );123     assert!(!index.contains("href=\"/files/a#b.txt.html\""));124     assert!(125         index.contains("content=\"https://hu13.example/og.png\""),126         "og:image is absolute with --public-origin"127     );128     assert!(129         index.contains("href=\"/new-issue.html\">submit</a>"),130         "footer submit points at the form, not POST-only /inbox/issue"131     );132 133     let hash_page = read(&out.join("files/a#b.txt.html"));134     assert!(hash_page.contains("href=\"/raw/a%23b.txt\""));135     let crlf = read(&out.join("files/crlf.txt.html"));136     assert!(crlf.contains("id=\"L2\""), "two lines survive");137     assert!(138         !crlf.contains("id=\"L3\""),139         "no phantom L404 after trailing CRLF"140     );141     assert!(142         !crlf.contains("&#13;") && !crlf.contains('\r'),143         "CR stripped"144     );145 146     let dir_page = read(&out.join("files/src.html"));147     assert!(dir_page.contains("<h1>src/</h1>"));148     assert!(!dir_page.contains("dir: src"), "debug heading removed");149 150     let issues = read(&out.join("issues/index.html"));151     let pulls = read(&out.join("pulls/index.html"));152     for page in [&issues, &pulls] {153         assert!(!page.contains("title=\"<"), "no HTML in title attributes");154         assert!(155             !page.contains("</span>\">"),156             "no leaked span attribute text"157         );158         assert!(159             !page.contains("</time>\">"),160             "no leaked time attribute text"161         );162     }163     assert!(issues.contains("title=\"closed\""), "plain state title");164     assert!(pulls.contains("class=\"lane\""), "branch lanes are tokens");165 166     let ci = read(&out.join("ci/index.html"));167     assert!(168         !ci.contains(&repo.display().to_string()),169         "builder path redacted from CI logs"170     );171     assert!(172         ci.contains("&lt;repo&gt;") || ci.contains("<repo>"),173         "redaction marker"174     );175 176     let feed = read(&out.join("feed.atom"));177     assert!(178         feed.contains("<link rel=\"self\" href=\"https://hu13.example/feed.atom\"/>"),179         "feed self link absolute"180     );181     assert!(182         feed.contains("href=\"https://hu13.example/commit/"),183         "entry links absolute"184     );185 186     let not_found = read(&out.join("404.html"));187     assert!(not_found.contains("<h1>404</h1>"));188     assert!(not_found.contains("site-footer"), "branded 404 has footer");189     assert!(out.join("_routes.json").is_file(), "Pages routes emitted");190     let routes = read(&out.join("_routes.json"));191     assert!(routes.contains("\"/inbox/*\""));192 }193 194 struct Server {195     child: Child,196     addr: String,197 }198 199 impl Drop for Server {200     fn drop(&mut self) {201         self.child.kill().ok();202         self.child.wait().ok();203     }204 }205 206 fn start(www: &Path, inbox: &Path) -> Server {207     let mut child = Command::new(env!("CARGO_BIN_EXE_hu13"))208         .arg("serve")209         .arg("--www")210         .arg(www)211         .arg("--inbox-dir")212         .arg(inbox)213         .arg("--listen")214         .arg("127.0.0.1:0")215         .stdout(Stdio::piped())216         .stderr(Stdio::null())217         .spawn()218         .expect("spawn serve");219     let stdout = child.stdout.take().expect("stdout");220     let mut addr = String::new();221     for line in BufReader::new(stdout).lines().take(50).flatten() {222         if let Some(rest) = line.split("listening on ").nth(1) {223             addr = rest.trim().to_string();224             break;225         }226     }227     assert!(!addr.is_empty(), "serve printed no address");228     Server { child, addr }229 }230 231 fn get(addr: &str, path: &str) -> String {232     let mut s = TcpStream::connect(addr).expect("connect");233     s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();234     write!(235         s,236         "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"237     )238     .unwrap();239     let mut out = Vec::new();240     s.read_to_end(&mut out).unwrap();241     String::from_utf8_lossy(&out).into_owned()242 }243 244 #[test]245 fn serve_uses_branded_404_and_streams_encoded_paths() {246     let repo = fixture();247     let out = fresh("serve-out");248     let res = build(&repo, &out);249     assert!(res.status.success());250     let inbox = fresh("inbox");251     let srv = start(&out, &inbox);252     std::thread::sleep(Duration::from_millis(100));253 254     let missing = get(&srv.addr, "/definitely-not-here");255     assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");256     assert!(missing.contains("<h1>404</h1>"));257     assert!(missing.contains("class=\"site-footer\""), "same chrome");258 259     let encoded = get(&srv.addr, "/files/a%23b.txt.html");260     assert!(encoded.starts_with("HTTP/1.1 200"), "{encoded}");261     let raw = get(&srv.addr, "/raw/a%23b.txt");262     assert!(raw.starts_with("HTTP/1.1 200"), "{raw}");263     assert!(raw.contains("Content-Disposition: attachment"));264 }