tests/truncate.rs 9.1 KiB 273 lines Raw
1 //! Truncate all table fields (m6/truncate, f0rge #42):2 //! every table cell carries `.trunc` + `title=`, desktop fits 1100px.3 4 use std::sync::atomic::{AtomicU64, Ordering};5 6 static SEQ: AtomicU64 = AtomicU64::new(0);7 8 use std::path::{Path, PathBuf};9 use std::process::Command;10 11 fn bin() -> &'static str {12     env!("CARGO_BIN_EXE_hu13")13 }14 15 fn manifest() -> PathBuf {16     PathBuf::from(env!("CARGO_MANIFEST_DIR"))17 }18 19 fn fresh(prefix: &str) -> PathBuf {20     let dir = std::env::temp_dir().join(format!(21         "hu13-truncate-{prefix}-{}-{}",22         std::process::id(),23         std::time::SystemTime::now()24             .duration_since(std::time::UNIX_EPOCH)25             .map(|d| d.as_nanos())26             .unwrap_or(0)27             .wrapping_add(u128::from(SEQ.fetch_add(1, Ordering::Relaxed)))28     ));29     std::fs::create_dir_all(&dir).unwrap();30     dir31 }32 33 fn git(repo: &Path, args: &[&str]) {34     let st = Command::new("git")35         .arg("-C")36         .arg(repo)37         .args(args)38         .status()39         .expect("spawn git");40     assert!(st.success(), "git {args:?} failed");41 }42 43 fn forge_write(repo: &Path, rel: &str, content: &str) {44     let dest = repo.join(".forge").join(rel);45     std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();46     std::fs::write(&dest, content).unwrap();47 }48 49 fn css() -> String {50     std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")51 }52 53 /// Evil long fixture: unbroken 300-char strings + HTML metachars in every54 /// table (commits subject/author, issues/pulls/tasks/releases titles,55 /// file-table message, CI step name). Returns (dist, evil_title, evil_subject).56 fn build_evil_fixture() -> (PathBuf, String, String) {57     let repo = fresh("repo");58     git(&repo, &["init", "-b", "main"]);59     git(&repo, &["config", "user.name", "hu13-test"]);60     git(&repo, &["config", "user.email", "[email protected]"]);61     git(&repo, &["config", "commit.gpgsign", "false"]);62     // Evil long subject: unbroken + <script> + quotes (tests esc in cell + title=).63     let evil_subject = format!(64         "S{} <script>alert(1)</script> \"quoted\" & more",65         "x".repeat(260)66     );67     // Long evil author via --author on one commit.68     let evil_author = format!("{} <[email protected]>", "A".repeat(120));69     std::fs::write(repo.join("README.md"), "# evil fixture\n").unwrap();70     std::fs::write(repo.join("notes.txt"), "hello\n").unwrap();71     git(&repo, &["add", "-A"]);72     git(73         &repo,74         &[75             "commit",76             "-m",77             &evil_subject,78             &format!("--author={evil_author}"),79         ],80     );81     std::fs::write(repo.join("notes.txt"), "hello again\nsecond\n").unwrap();82     git(&repo, &["add", "-A"]);83     git(&repo, &["commit", "-m", "second commit"]);84 85     let evil_title = format!("T{} <b>bold</b> \"q\" & tail", "y".repeat(260));86     forge_write(87         &repo,88         "issues/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa/issue.md",89         &format!(90             "---\nid: aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa\ntitle: {evil_title}\nstate: open\nlabels: []\nassignees: []\ncreated: 2026-09-10T03:00:00Z\n---\nBody.\n"91         ),92     );93     forge_write(94         &repo,95         "prs/bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb/pr.md",96         &format!(97             "---\nid: bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb\ntitle: {evil_title}\nstate: open\nbase: main\nhead: evil-branch-name-that-is-way-too-long-for-a-table-cell-to-fit\ncreated: 2026-09-10T03:30:00Z\n---\nPR body.\n"98         ),99     );100     forge_write(101         &repo,102         "tasks/cccccccc-cccc-4ccc-cccc-cccccccccccc.md",103         &format!(104             "---\nid: cccccccc-cccc-4ccc-cccc-cccccccccccc\ntitle: {evil_title}\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n"105         ),106     );107     forge_write(&repo, "ci/ci.env", "runner: local\n");108     let long_step = format!("{}_step.sh", "s".repeat(100));109     forge_write(110         &repo,111         &format!("ci/steps/{long_step}"),112         "#!/bin/sh\nexit 0\n",113     );114     forge_write(115         &repo,116         "ci/runs/2026-09-10T00-00-00Z/summary.md",117         &format!(118             "---\nrun: 2026-09-10T00-00-00Z\nverdict: pass\ncreated: 2026-09-10T00:00:00Z\nsteps:\n  - name: {long_step}\n    exit: 0\n    duration_ms: 3\n---\nlog\n"119         ),120     );121     forge_write(122         &repo,123         "releases/v9.9.md",124         &format!(125             "---\nversion: v9.9\ntitle: {evil_title}\ncreated: 2026-09-10T02:00:00Z\n---\nNotes.\n"126         ),127     );128     git(&repo, &["add", "-A"]);129     git(&repo, &["commit", "-m", "forge evil fixture"]);130     let out = fresh("out");131     let st = Command::new(bin())132         .arg("build")133         .arg(&repo)134         .arg("-o")135         .arg(&out)136         .status()137         .expect("spawn hu13 build");138     assert!(st.success(), "evil fixture build exits 0");139     (out, evil_title, evil_subject)140 }141 142 fn assert_every_td_truncated(html: &str, page: &str) {143     // m6/cards2 (f0rge #46): the file-table page (index.html) stays a real144     // table — trunc + title= kept, no data-label card treatment.145     assert_every_td_truncated_opts(html, page, page != "index.html");146 }147 148 fn assert_every_td_truncated_opts(html: &str, page: &str, expect_labels: bool) {149     let parts: Vec<&str> = html.split("<td").skip(1).collect();150     assert!(!parts.is_empty(), "{page} has cells");151     for (i, p) in parts.iter().enumerate() {152         let head = p.split('>').next().unwrap_or("");153         assert!(154             head.contains("trunc"),155             "{page} td #{i} carries .trunc (got {head:?})"156         );157         assert!(158             head.contains("title="),159             "{page} td #{i} carries title= (got {head:?})"160         );161         if expect_labels {162             assert!(163                 head.contains("data-label="),164                 "{page} td #{i} keeps data-label (got {head:?})"165             );166         }167     }168     if !expect_labels {169         assert!(170             !html.contains("data-label="),171             "{page} has no card data-labels"172         );173     }174 }175 176 #[test]177 fn trunc_css_utilities_and_widths() {178     let css = css();179     for needle in [180         ".trunc",181         "text-overflow:ellipsis",182         "40ch",183         "16ch",184         "10ch",185         "72ch",186         "60ch",187     ] {188         assert!(css.contains(needle), "CSS mentions {needle}");189     }190     // Desktop tables capped at the container (fit 1100px, no page h-scroll).191     assert!(css.contains("max-width:100%"), "tables capped at container");192     assert!(193         css.contains("overflow-x:clip") || css.contains("overflow-x:auto"),194         "no page h-scroll"195     );196     // Narrow keeps card reflow (trunc disabled, scroll off).197     assert!(198         css.contains("@media (max-width:767px)"),199         "narrow query kept"200     );201 }202 203 #[test]204 fn every_table_cell_truncates_with_title() {205     let (out, evil_title, evil_subject) = build_evil_fixture();206     let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);207     // Full text is never lost: escaped long strings stay in the DOM and in title=.208     for page in [209         "commits.html",210         "issues/index.html",211         "pulls/index.html",212         "tasks/index.html",213         "releases/index.html",214         "ci/index.html",215         "index.html",216     ] {217         let html = read(page);218         assert!(219             !html.contains("<script>alert(1)</script>"),220             "{page} escapes evil HTML"221         );222         assert!(!html.contains("<b>bold</b>"), "{page} escapes evil tags");223     }224     // Evil subject + title survive escaped (cell text + title attr).225     let commits = read("commits.html");226     assert!(commits.contains("&lt;script&gt;"), "commits escape kept");227     assert!(commits.contains("trunc-sha"), "commits sha width");228     assert!(commits.contains("trunc-subject"), "commits subject width");229     assert!(commits.contains("trunc-author"), "commits author width");230     // Per-table trunc asserts + title attrs.231     for page in [232         "commits.html",233         "issues/index.html",234         "pulls/index.html",235         "releases/index.html",236         "ci/index.html",237         "index.html",238     ] {239         assert_every_td_truncated(&read(page), page);240     }241     // Kind-specific widths per table.242     assert!(243         read("issues/index.html").contains("trunc-title"),244         "issues title 60ch"245     );246     assert!(247         read("pulls/index.html").contains("trunc-title"),248         "pulls title 60ch"249     );250     assert!(251         read("releases/index.html").contains("trunc-title"),252         "releases title 60ch"253     );254     assert!(255         read("index.html").contains("trunc-message"),256         "file-table message 72ch"257     );258     // Tasks list uses trunc + title on titles (no table by design).259     let tasks = read("tasks/index.html");260     assert!(!tasks.contains("<table"), "tasks stays list-only");261     assert!(tasks.contains("trunc-title"), "tasks titles truncate");262     assert!(tasks.contains("title="), "tasks titles keep full text");263     // Long content kept (CSS truncates visually, nothing cut in HTML).264     assert!(265         read("issues/index.html").contains(&"y".repeat(100)),266         "long issue title kept"267     );268     assert!(269         commits.contains(&"x".repeat(100)),270         "long commit subject kept"271     );272     let _ = (evil_title, evil_subject);273 }