tests/responsive.rs 17.5 KiB 513 lines Raw
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 }