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 }