tests/favicon.rs 10.6 KiB 306 lines Raw
1 //! Favicon + head standards (m6, f0rge issue #16):2 //! `static/favicon.svg` (SVG-first, no binary blobs) + head tags on every3 //! built page (icon links, `apple-touch-icon`, light+dark `theme-color`,4 //! og tags, manifest link) + `manifest.webmanifest` valid JSON in the5 //! output + correct serve mime types over HTTP.6 7 use std::sync::atomic::{AtomicU64, Ordering};8 9 static SEQ: AtomicU64 = AtomicU64::new(0);10 11 use std::io::{BufRead, BufReader, Read, Write};12 use std::net::TcpStream;13 use std::path::{Path, PathBuf};14 use std::process::{Child, Command, Stdio};15 use std::time::Duration;16 17 fn bin() -> &'static str {18     env!("CARGO_BIN_EXE_hu13")19 }20 21 fn manifest_dir() -> PathBuf {22     PathBuf::from(env!("CARGO_MANIFEST_DIR"))23 }24 25 fn fresh(prefix: &str) -> PathBuf {26     let dir = std::env::temp_dir().join(format!(27         "hu13-favicon-{prefix}-{}-{}",28         std::process::id(),29         std::time::SystemTime::now()30             .duration_since(std::time::UNIX_EPOCH)31             .map(|d| d.as_nanos())32             .unwrap_or(0)33             .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))34     ));35     std::fs::create_dir_all(&dir).unwrap();36     dir37 }38 39 fn git(repo: &Path, args: &[&str]) {40     let st = Command::new("git")41         .arg("-C")42         .arg(repo)43         .args(args)44         .status()45         .expect("spawn git");46     assert!(st.success(), "git {args:?} failed");47 }48 49 /// Minimal repo: README + one file + one subdir file, one commit.50 fn fixture_repo() -> PathBuf {51     let dir = fresh("repo");52     git(&dir, &["init", "-b", "main"]);53     git(&dir, &["config", "user.name", "hu13-test"]);54     git(&dir, &["config", "user.email", "[email protected]"]);55     git(&dir, &["config", "commit.gpgsign", "false"]);56     std::fs::write(dir.join("README.md"), "# favicon fixture\n").unwrap();57     std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();58     std::fs::create_dir_all(dir.join("sub")).unwrap();59     std::fs::write(dir.join("sub").join("nested.txt"), "nested\n").unwrap();60     git(&dir, &["add", "-A"]);61     git(&dir, &["commit", "-m", "init"]);62     dir63 }64 65 fn build_fixture() -> (PathBuf, PathBuf) {66     let repo = fixture_repo();67     let out = fresh("out");68     let st = Command::new(bin())69         .arg("build")70         .arg(&repo)71         .arg("-o")72         .arg(&out)73         .status()74         .expect("spawn hu13 build");75     assert!(st.success(), "fixture build exits 0");76     (repo, out)77 }78 79 /// Every head tag the rubric requires on every page.80 const HEAD_TAGS: &[&str] = &[81     "<link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\">",82     "<link rel=\"apple-touch-icon\" href=\"/favicon.svg\">",83     "<meta name=\"theme-color\" media=\"(prefers-color-scheme: light)\" content=\"#f6f7f9\">",84     "<meta name=\"theme-color\" media=\"(prefers-color-scheme: dark)\" content=\"#1a1b26\">",85     "<meta property=\"og:title\"",86     "<meta property=\"og:type\" content=\"website\">",87     "<link rel=\"manifest\" href=\"/manifest.webmanifest\">",88 ];89 90 fn collect_html(dir: &Path) -> Vec<PathBuf> {91     let mut out = Vec::new();92     let mut stack = vec![dir.to_path_buf()];93     while let Some(d) = stack.pop() {94         for ent in std::fs::read_dir(&d).expect("read_dir") {95             let p = ent.expect("dir entry").path();96             if p.is_dir() {97                 stack.push(p);98             } else if p.extension().is_some_and(|e| e == "html") {99                 out.push(p.strip_prefix(dir).expect("prefix").to_path_buf());100             }101         }102     }103     out.sort();104     out105 }106 107 #[test]108 fn every_built_page_carries_head_standards() {109     let (_repo, out) = build_fixture();110     let pages = collect_html(&out);111     assert!(112         pages.len() >= 5,113         "fixture emits several pages, got {pages:?}"114     );115     // Spot-check the headline pages explicitly, then sweep all of them.116     for spot in ["index.html", "commits.html", "issues/index.html"] {117         assert!(118             out.join(spot).exists(),119             "spot page {spot} exists, pages: {pages:?}"120         );121     }122     for rel in &pages {123         let html = std::fs::read_to_string(out.join(rel)).expect("read page");124         for tag in HEAD_TAGS {125             assert!(html.contains(tag), "{} carries {tag}", rel.display());126         }127     }128 }129 130 #[test]131 fn manifest_emitted_and_valid_json() {132     let (_repo, out) = build_fixture();133     let raw = std::fs::read_to_string(out.join("manifest.webmanifest"))134         .expect("build emits manifest.webmanifest");135     let v: serde_json::Value = serde_json::from_str(&raw).expect("manifest parses as JSON");136     assert_eq!(v["short_name"], "hu13", "short_name hu13");137     assert_eq!(v["name"], "hu13", "name hu13");138     assert!(139         v["display"].as_str().is_some_and(|d| d.contains("minimal")),140         "display minimal, got {}",141         v["display"]142     );143     assert_eq!(v["theme_color"], "#1a1b26", "dark theme color");144     let icons = v["icons"].as_array().expect("icons array");145     assert!(!icons.is_empty(), "at least one icon");146     assert!(147         icons148             .iter()149             .any(|i| i["type"] == "image/svg+xml" && i["src"] == "/favicon.svg"),150         "svg icon entry, got {icons:?}"151     );152     // The emitted manifest matches the static source byte-for-byte.153     let src = std::fs::read_to_string(manifest_dir().join("static").join("manifest.webmanifest"))154         .expect("static/manifest.webmanifest exists");155     assert_eq!(raw, src, "emitted manifest mirrors static source");156 }157 158 #[test]159 fn favicon_emitted_svg_only_no_binary_blobs() {160     let (_repo, out) = build_fixture();161     let raw = std::fs::read(out.join("favicon.svg")).expect("build emits favicon.svg");162     assert!(!raw.contains(&0), "favicon has no NUL bytes (text SVG)");163     let text = String::from_utf8(raw).expect("favicon is valid UTF-8 text");164     assert!(text.contains("<svg"), "favicon is an SVG document");165     assert!(text.contains("viewBox"), "scales via viewBox");166     // Simplified mark-only 16px variant (#27): same diamond-hub palette167     // as the lockup, but no letter <path>s and no <text>/fonts.168     let fav_src = std::fs::read_to_string(manifest_dir().join("static").join("favicon.svg"))169         .expect("favicon.svg");170     assert_eq!(text, fav_src, "emitted favicon mirrors static source");171     assert!(!text.contains("<text"), "favicon has no <text>");172     assert!(!text.contains("<path"), "favicon is mark-only");173     assert!(!text.to_lowercase().contains("font"), "no font refs");174     // Header lockup (#27 + #28) is emitted too and carries the letters.175     let logo_raw = std::fs::read(out.join("logo.svg")).expect("build emits logo.svg");176     let logo = String::from_utf8(logo_raw).expect("logo is valid UTF-8 text");177     assert!(logo.contains("<svg"), "logo is an SVG document");178     assert!(logo.contains("<path"), "logo letters are <path>");179     assert!(!logo.contains("<text"), "logo has no <text>");180     assert_ne!(text, logo, "favicon is the simplified variant, not a copy");181     // No binary icon formats anywhere in static/ or the build output.182     for dir in [manifest_dir().join("static"), out.clone()] {183         let mut stack = vec![dir.clone()];184         while let Some(d) = stack.pop() {185             for ent in std::fs::read_dir(&d).expect("read_dir") {186                 let p = ent.expect("dir entry").path();187                 if p.is_dir() {188                     stack.push(p);189                     continue;190                 }191                 let ext = p192                     .extension()193                     .map(|e| e.to_string_lossy().to_ascii_lowercase())194                     .unwrap_or_default();195                 let is_social_card = p.file_name().is_some_and(|n| n == "og.png");196                 assert!(197                     !["ico", "png", "icns"].contains(&ext.as_str()) || is_social_card,198                     "no binary icon blobs (social card excepted): {}",199                     p.display()200                 );201             }202         }203     }204 }205 206 // ---- serve mime over HTTP ----------------------------------------------207 208 struct Server {209     child: Child,210     addr: String,211 }212 213 impl Drop for Server {214     fn drop(&mut self) {215         self.child.kill().ok();216         self.child.wait().ok();217     }218 }219 220 fn start_server(www: &Path, inbox: &Path) -> Server {221     let mut child = Command::new(bin())222         .arg("serve")223         .arg("--www")224         .arg(www)225         .arg("--inbox-dir")226         .arg(inbox)227         .arg("--listen")228         .arg("127.0.0.1:0")229         .stdout(Stdio::piped())230         .stderr(Stdio::piped())231         .spawn()232         .expect("spawn hu13 serve");233     let stdout = child.stdout.take().expect("piped stdout");234     let mut lines = BufReader::new(stdout).lines();235     let mut addr = String::new();236     for _ in 0..50 {237         match lines.next() {238             Some(Ok(line)) if line.contains("listening on ") => {239                 addr = line240                     .rsplit("listening on ")241                     .next()242                     .unwrap_or("")243                     .trim()244                     .to_string();245                 break;246             }247             Some(Ok(_)) => continue,248             _ => break,249         }250     }251     assert!(!addr.is_empty(), "serve printed no `listening on` line");252     std::thread::sleep(Duration::from_millis(100));253     Server { child, addr }254 }255 256 fn get(addr: &str, path: &str) -> String {257     let mut s = TcpStream::connect(addr).expect("connect");258     s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();259     s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();260     let req = format!("GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");261     s.write_all(req.as_bytes()).expect("write");262     let mut out = Vec::new();263     s.read_to_end(&mut out).expect("read");264     String::from_utf8_lossy(&out).into_owned()265 }266 267 fn header<'a>(resp: &'a str, name: &str) -> Option<&'a str> {268     let want = name.to_ascii_lowercase();269     resp.split("\r\n").find_map(|line| {270         let (k, v) = line.split_once(':')?;271         if k.trim().to_ascii_lowercase() == want {272             Some(v.trim())273         } else {274             None275         }276     })277 }278 279 #[test]280 fn serve_maps_webmanifest_and_svg_mime() {281     let (_repo, out) = build_fixture();282     let inbox = fresh("inbox");283     let srv = start_server(&out, &inbox);284 285     let m = get(&srv.addr, "/manifest.webmanifest");286     assert!(287         m.split("\r\n").next().unwrap_or("").contains("200"),288         "manifest serves 200, got: {m}"289     );290     assert_eq!(291         header(&m, "Content-Type"),292         Some("application/manifest+json"),293         "webmanifest mime, got: {m}"294     );295 296     let f = get(&srv.addr, "/favicon.svg");297     assert!(298         f.split("\r\n").next().unwrap_or("").contains("200"),299         "favicon serves 200, got: {f}"300     );301     assert_eq!(302         header(&f, "Content-Type"),303         Some("image/svg+xml"),304         "svg mime, got: {f}"305     );306 }