1 //! Responsive pass (m6, f0rge issue #14):2 //! viewport meta, breakpoints, scroll wrappers, CSS budget, zero JS.3 //!4 //! Builds a small fixture repo with the real binary and string-asserts on5 //! the emitted HTML + `static/style.css` (no rendering engine needed).6 7 use std::sync::atomic::{AtomicU64, Ordering};8 9 static SEQ: AtomicU64 = AtomicU64::new(0);10 11 use std::path::{Path, PathBuf};12 use std::process::Command;13 14 fn bin() -> &'static str {15 env!("CARGO_BIN_EXE_hu13")16 }17 18 fn manifest() -> PathBuf {19 PathBuf::from(env!("CARGO_MANIFEST_DIR"))20 }21 22 fn fresh(prefix: &str) -> PathBuf {23 let dir = std::env::temp_dir().join(format!(24 "hu13-responsive-{prefix}-{}-{}",25 std::process::id(),26 std::time::SystemTime::now()27 .duration_since(std::time::UNIX_EPOCH)28 .map(|d| d.as_nanos())29 .unwrap_or(0)30 .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))31 ));32 std::fs::create_dir_all(&dir).unwrap();33 dir34 }35 36 fn git(repo: &Path, args: &[&str]) {37 let st = Command::new("git")38 .arg("-C")39 .arg(repo)40 .args(args)41 .status()42 .expect("spawn git");43 assert!(st.success(), "git {args:?} failed");44 }45 46 /// Fixture: README + one long-line text file + 2 commits (list + detail).47 fn fixture_repo() -> PathBuf {48 let dir = fresh("repo");49 git(&dir, &["init", "-b", "main"]);50 git(&dir, &["config", "user.name", "hu13-test"]);51 git(&dir, &["config", "user.email", "[email protected]"]);52 git(&dir, &["config", "commit.gpgsign", "false"]);53 std::fs::write(dir.join("README.md"), "# responsive fixture\n\nhello\n").unwrap();54 // Long unbroken line: would force page scroll without a scroll container.55 std::fs::write(56 dir.join("notes.txt"),57 format!("{}\nsecond line\n", "x".repeat(500)),58 )59 .unwrap();60 git(&dir, &["add", "-A"]);61 git(&dir, &["commit", "-m", "first commit"]);62 std::fs::write(63 dir.join("notes.txt"),64 format!("{}\nsecond line\nthird line\n", "y".repeat(500)),65 )66 .unwrap();67 git(&dir, &["add", "-A"]);68 git(&dir, &["commit", "-m", "second commit"]);69 dir70 }71 72 fn build_fixture() -> PathBuf {73 let repo = fixture_repo();74 let out = fresh("out");75 let st = Command::new(bin())76 .arg("build")77 .arg(&repo)78 .arg("-o")79 .arg(&out)80 .status()81 .expect("spawn hu13 build");82 assert!(st.success(), "fixture build exits 0");83 out84 }85 86 fn css() -> String {87 std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")88 }89 90 #[test]91 fn viewport_meta_on_all_pages() {92 let out = build_fixture();93 let commit_dir = out.join("commit");94 let sha_page = std::fs::read_dir(&commit_dir)95 .expect("commit dir")96 .filter_map(|e| e.ok())97 .map(|e| e.path())98 .find(|p| p.extension().is_some_and(|x| x == "html"))99 .expect("one commit page");100 for page in [101 out.join("index.html"),102 out.join("files").join("notes.txt.html"),103 out.join("commits.html"),104 sha_page,105 ] {106 let html = std::fs::read_to_string(&page).expect("read page");107 assert!(108 html.contains(109 "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"110 ),111 "{} carries the viewport meta",112 page.display()113 );114 }115 }116 117 #[test]118 fn css_breakpoints_present() {119 let css = css();120 assert!(121 css.contains("@media (min-width:768px)"),122 "768px breakpoint present"123 );124 assert!(125 css.contains("@media (min-width:1100px)"),126 "1100px breakpoint present"127 );128 let queries = css.matches("@media").count();129 assert!(queries >= 2, "at least 2 media queries, got {queries}");130 }131 132 #[test]133 fn scroll_wrappers_on_file_and_commit_pages() {134 let out = build_fixture();135 // File page: long-line <pre> sits inside a scroll container.136 let file = std::fs::read_to_string(out.join("files").join("notes.txt.html")).expect("file");137 assert!(138 file.contains("pre-scroll"),139 "file page has pre scroll wrapper"140 );141 assert!(file.contains("<pre"), "file page keeps <pre>");142 // Commit list: table inside a horizontal-scroll container.143 let list = std::fs::read_to_string(out.join("commits.html")).expect("list");144 assert!(145 list.contains("table-scroll"),146 "commits.html wraps its table"147 );148 assert!(list.contains("<table"), "commits.html keeps <table>");149 // Commit detail: message + diff pres wrapped.150 let commit_dir = out.join("commit");151 let sha_page = std::fs::read_dir(&commit_dir)152 .expect("commit dir")153 .filter_map(|e| e.ok())154 .map(|e| e.path())155 .find(|p| p.extension().is_some_and(|x| x == "html"))156 .expect("one commit page");157 let detail = std::fs::read_to_string(&sha_page).expect("detail");158 assert!(159 detail.contains("pre-scroll"),160 "commit detail wraps its pres"161 );162 // CSS side: wrappers scroll, pre scrolls, table never forces the page.163 let css = css();164 for needle in ["table-scroll", "pre-scroll", "overflow-x:auto"] {165 assert!(css.contains(needle), "CSS mentions {needle}");166 }167 }168 169 #[test]170 fn css_budget_and_sharp_corners() {171 let path = manifest().join("static").join("style.css");172 let len = std::fs::metadata(&path).expect("style.css exists").len();173 assert!(174 len < 32768,175 "static/style.css is {len} bytes, budget is 32768 (see gl0w.md)"176 );177 let css = css();178 for line in css.lines() {179 let mut rest = line;180 while let Some(i) = rest.find("border-radius") {181 let after = &rest[i + "border-radius".len()..];182 let value: String = after183 .chars()184 .skip_while(|c| *c == ':' || c.is_whitespace())185 .take_while(|c| !matches!(*c, ';' | '}' | ' ' | '\t'))186 .collect();187 assert!(188 value == "0",189 "only allowed border-radius value is 0, got {value:?} in {line:?}"190 );191 rest = &rest[i + "border-radius".len()..];192 }193 }194 }195 196 #[test]197 fn no_fixed_pixel_widths_on_layout() {198 let css = css();199 for line in css.lines() {200 let bytes = line.as_bytes();201 let mut i = 0;202 while let Some(pos) = line[i..].find("width:") {203 let abs = i + pos;204 // Skip `min-width:` / `max-width:` (responsive + touch targets).205 let is_prefixed =206 abs > 0 && bytes[abs - 1] == b'-' && line[..abs].ends_with(['-', 'n', 'x']);207 let prefixed = line[..abs].ends_with("min-") || line[..abs].ends_with("max-");208 if !is_prefixed && !prefixed {209 let after = line[abs + "width:".len()..].trim_start();210 let num: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();211 let unit: String = after[num.len()..].chars().take(2).collect();212 assert!(213 !(!num.is_empty() && unit == "px"),214 "no fixed `width: <N>px` on layout containers, got: {line:?}"215 );216 }217 i = abs + "width:".len();218 }219 }220 // Fluid container: max-width + side padding, never a fixed width.221 assert!(css.contains(".wrap"), ".wrap container styled");222 assert!(css.contains("max-width"), ".wrap uses max-width");223 }224 225 #[test]226 fn touch_targets_and_stacking_header() {227 let css = css();228 assert!(229 css.contains("min-height:40px"),230 ">=40px touch targets for nav/form/buttons"231 );232 assert!(233 css.contains("flex-wrap"),234 "nav/header wrap on narrow screens"235 );236 for needle in [".repo-head", ".clone", ".cols"] {237 assert!(css.contains(needle), "CSS styles {needle}");238 }239 let out = build_fixture();240 let index = std::fs::read_to_string(out.join("index.html")).expect("index");241 for needle in ["repo-head", "cols", "clonebox"] {242 assert!(index.contains(needle), "index.html has {needle}");243 }244 assert!(245 !index.contains("repo-meta"),246 "no dupe repo-meta (f0rge #53)"247 );248 }249 250 #[test]251 fn zero_js_and_reader_friendly() {252 let out = build_fixture();253 let mut pages = vec![254 out.join("index.html"),255 out.join("commits.html"),256 out.join("files").join("notes.txt.html"),257 ];258 if let Ok(rd) = std::fs::read_dir(out.join("commit")) {259 for e in rd.filter_map(|e| e.ok()) {260 if e.path().extension().is_some_and(|x| x == "html") {261 pages.push(e.path());262 break;263 }264 }265 }266 for page in &pages {267 let html = std::fs::read_to_string(page).expect("read page");268 assert!(269 !html.contains("<script"),270 "{} has no <script>",271 page.display()272 );273 assert!(274 !html.contains("onclick="),275 "{} has no handlers",276 page.display()277 );278 }279 // No JS files emitted anywhere under dist/.280 let mut stack = vec![out.clone()];281 while let Some(d) = stack.pop() {282 for e in std::fs::read_dir(&d).expect("read_dir") {283 let e = e.expect("entry");284 let p = e.path();285 if p.is_dir() {286 stack.push(p);287 } else {288 assert!(289 p.extension().is_none_or(|x| x != "js"),290 "no .js emitted: {}",291 p.display()292 );293 }294 }295 }296 }297 298 fn forge_write(repo: &Path, rel: &str, content: &str) {299 let dest = repo.join(".forge").join(rel);300 std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();301 std::fs::write(&dest, content).unwrap();302 }303 304 /// Long-title forge fixture (m6, f0rge issue #17): one 200-char unbroken305 /// title on an issue (+ labels) and on a PR, plus one task, one CI run306 /// with steps, and one release — so every list page renders non-empty.307 /// Returns `(dist_dir, long_title)`.308 fn build_long_title_fixture() -> (PathBuf, String) {309 let repo = fixture_repo();310 let long_title = "L".repeat(200);311 forge_write(312 &repo,313 "issues/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa/issue.md",314 &format!(315 "---\nid: aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa\ntitle: {long_title}\nstate: open\nlabels:\n- kind:scroll-test\nassignees: []\ncreated: 2026-09-10T03:00:00Z\n---\nLong-title body.\n"316 ),317 );318 forge_write(319 &repo,320 "prs/bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb/pr.md",321 &format!(322 "---\nid: bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb\ntitle: {long_title}\nstate: open\nbase: main\nhead: long-title-branch\ncreated: 2026-09-10T03:30:00Z\n---\nLong-title PR body.\n"323 ),324 );325 forge_write(326 &repo,327 "tasks/cccccccc-cccc-4ccc-cccc-cccccccccccc.md",328 "---\nid: cccccccc-cccc-4ccc-cccc-cccccccccccc\ntitle: Scroll task\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n",329 );330 forge_write(&repo, "labels.md", "- name: kind:scroll-test\n");331 forge_write(&repo, "ci/ci.env", "runner: local\n");332 forge_write(&repo, "ci/steps/00_wrap.sh", "#!/bin/sh\nexit 0\n");333 forge_write(334 &repo,335 "ci/runs/2026-09-10T00-00-00Z/summary.md",336 "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n - name: 00_wrap.sh\n exit: 0\n duration_ms: 3\n---\nwrap log\n",337 );338 forge_write(339 &repo,340 "releases/v9.9.md",341 "---\nversion: v9.9\ntitle: Scroll release\ncreated: 2026-09-10T02:00:00Z\n---\nRelease notes.\n",342 );343 git(&repo, &["add", "-A"]);344 git(&repo, &["commit", "-m", "long-title forge fixture"]);345 let out = fresh("tables");346 let st = Command::new(bin())347 .arg("build")348 .arg(&repo)349 .arg("-o")350 .arg(&out)351 .status()352 .expect("spawn hu13 build");353 assert!(st.success(), "long-title fixture build exits 0");354 (out, long_title)355 }356 357 #[test]358 fn forge_list_tables_scroll_and_wrap_long_titles() {359 let (out, long_title) = build_long_title_fixture();360 let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);361 // Every table-bearing list page wraps its table in .table-scroll and362 // leaves no bare <table> outside the scroll container. The wrapper is363 // the 360px story: the table scrolls inside instead of forcing the page.364 for page in [365 "issues/index.html",366 "pulls/index.html",367 "commits.html",368 "releases/index.html",369 "ci/index.html",370 ] {371 let html = read(page);372 assert!(html.contains("table-scroll"), "{page} wraps its table");373 assert!(html.contains("<table"), "{page} keeps <table>");374 let stripped = html.replace("<div class=\"table-scroll\"><table>", "");375 assert!(376 !stripped.contains("<table"),377 "{page} has no bare <table> outside .table-scroll"378 );379 }380 // Tasks list renders grouped <ul>s, never a table.381 let tasks = read("tasks/index.html");382 assert!(!tasks.contains("<table"), "tasks page has no <table>");383 // 200-char titles render in full (wrapped, not cut) inside title cells:384 // the cell class is the no-page-widen hook (overflow-wrap:anywhere),385 // and the # column keeps its narrow marker class.386 for page in ["issues/index.html", "pulls/index.html"] {387 let html = read(page);388 assert!(389 html.contains("<td class=\"title trunc trunc-title\""),390 "{page} marks title cells trunc"391 );392 assert!(393 html.contains(&long_title),394 "{page} keeps the full long title"395 );396 assert!(397 html.contains("<td class=\"num trunc\""),398 "{page} keeps # narrow trunc"399 );400 assert!(html.contains("title=\""), "{page} keeps title attrs");401 }402 let commits = read("commits.html");403 assert!(404 commits.contains("<td class=\"sha trunc trunc-sha\""),405 "commits marks sha cells trunc"406 );407 let releases = read("releases/index.html");408 assert!(409 releases.contains("<td class=\"title trunc trunc-title\""),410 "releases marks title cells trunc"411 );412 // Labels from the fixture still list on the issues index.413 assert!(414 read("issues/index.html").contains("kind:scroll-test"),415 "labels listed"416 );417 // CSS side: title/sha cells wrap anywhere, # column stays narrow.418 let css = css();419 for needle in [420 "td.title",421 "td.sha",422 "td.num",423 "overflow-wrap:anywhere",424 "white-space:nowrap",425 ] {426 assert!(css.contains(needle), "CSS mentions {needle}");427 }428 }429 430 #[test]431 fn zero_bare_tables_in_sources() {432 // Audit EVERY template (m6, f0rge issue #17): no `<table>` may open433 // outside a `.table-scroll` container. Hand-written tables are434 // emitted from src/*.rs render fns, so grep the sources: each line435 // opening a table must carry the scroll wrapper. (Markdown tables are436 // emitted by pulldown-cmark at runtime into `.markdown`, which wraps437 // by word instead of scrolling.)438 let mut checked = 0;439 let mut stack = vec![manifest().join("src")];440 while let Some(d) = stack.pop() {441 for e in std::fs::read_dir(&d).expect("read src") {442 let e = e.expect("entry");443 let p = e.path();444 if p.is_dir() {445 stack.push(p);446 continue;447 }448 if p.extension().is_none_or(|x| x != "rs") {449 continue;450 }451 let text = std::fs::read_to_string(&p).expect("read source");452 for (i, line) in text.lines().enumerate() {453 if line.contains("<table") {454 checked += 1;455 assert!(456 line.contains("table-scroll"),457 "{}:{} opens a bare <table>",458 p.display(),459 i + 1460 );461 }462 }463 }464 }465 assert!(checked >= 5, "audit saw all list tables, got {checked}");466 }467 468 #[test]469 fn mobile_nav_wraps_and_file_table_keeps_name_and_age() {470 // f0rge #75: no destination may be clipped at 390px; the dense file471 // table drops "Last commit" instead of wrapping/clipping.472 let css = css();473 let at = css.find("@media (max-width:767px)").expect("narrow block");474 let narrow = &css[at..];475 assert!(476 narrow.contains(".nav-scroll-row{flex-wrap:wrap"),477 "nav wraps into multiple rows on narrow: {narrow}"478 );479 assert!(480 narrow.contains(".site-nav.nav-scroll{flex:1 1 100%;overflow-x:visible"),481 "nav gets its own row and is not clipped"482 );483 assert!(484 narrow.contains(".file-table th:nth-child(2),.file-table td:nth-child(2){display:none}"),485 "file table drops Last commit on mobile"486 );487 assert!(488 css.contains(".cb-seg{white-space:nowrap}"),489 "clone summary segments never wrap mid-token"490 );491 let out = build_fixture();492 let index = std::fs::read_to_string(out.join("index.html")).expect("index");493 assert_eq!(494 index.matches("class=\"cb-seg\"").count(),495 3,496 "clone summary has three no-wrap segments"497 );498 assert!(499 index.contains("class=\"cb-seg\">CI <span class=\"badge"),500 "CI badge stays inside its segment"501 );502 // All nav destinations are present in the DOM (wrapping is CSS-only).503 for tab in [504 "/",505 "/commits.html",506 "/issues/index.html",507 "/pulls/index.html",508 "/ci/index.html",509 "/agents.html",510 ] {511 assert!(index.contains(&format!("href=\"{tab}\"")), "nav has {tab}");512 }513 }