From 954e3b6e0d141266b2941a63a6a07a80ab96eff7 Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 09:35:20 +0100 Subject: [PATCH] m6/truncate: ellipsis + title attrs on all tables --- src/build.rs | 25 ++-- src/commits.rs | 17 ++- src/forge.rs | 24 ++-- src/releases.rs | 8 +- static/style.css | 14 ++- tests/forge_readers.rs | 10 +- tests/releases_api.rs | 8 +- tests/responsive.rs | 18 +-- tests/truncate.rs | 254 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 340 insertions(+), 38 deletions(-) create mode 100644 tests/truncate.rs diff --git a/src/build.rs b/src/build.rs index 5d176ba..bc0a10b 100644 --- a/src/build.rs +++ b/src/build.rs @@ -584,8 +584,9 @@ pub fn file_table_html( ); if let Some((href, label)) = parent { out.push_str(&format!( - "{}\ - \n", + "{}\ + \n", + esc(label), esc(href), esc(label) )); @@ -600,20 +601,26 @@ pub fn file_table_html( let msg_short = truncate_msg(&msg); if e.is_dir { out.push_str(&format!( - "{}/\ - {}{}\n", + "{}/\ + {}{}\n", + esc(&e.name), href_esc(&full), esc(&e.name), + esc(&msg), esc(&msg_short), + esc(&age), esc(&age) )); } else { out.push_str(&format!( - "{}\ - {}{}\n", + "{}\ + {}{}\n", + esc(&e.name), href_esc(&full), esc(&e.name), + esc(&msg), esc(&msg_short), + esc(&age), esc(&age) )); } @@ -1717,7 +1724,11 @@ mod tests { "breadcrumb kept on subdir" ); // Message + age columns exist even when history is empty-ish. - assert!(sub.contains("\n\n\n"); for c in log { body.push_str(&format!( - "\n", + "\n", + esc(&c.sha), esc(&c.sha), esc(short_sha(&c.sha)), esc(&c.subject), + esc(&c.subject), esc(&c.author), + esc(&c.author), + esc(&c.date), esc(&c.date), )); } @@ -359,8 +363,15 @@ mod tests { list.contains("
shasubjectauthordate
{}{}{}{}
{}{}{}{}
"), "wrapper" ); - assert!(list.contains("
\n\n\n"); for issue in &data.issues { body.push_str(&format!( - "\n", + "\n", n = issue.number, id = esc(&issue.id), title = esc(&issue.title), @@ -1062,13 +1062,17 @@ fn render_pulls_index( body.push_str("
#titlestatecommentscreated
{n}{title}{state}{comments}{created}
{n}{title}{state}{comments}{created}
\n\n\n"); for pr in prs { let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base)); + let lanes_plain = format!("{}→{}", pr.head, pr.base); let draft = if pr.draft { " draft" } else { "" }; + let full_title = format!("{}{}", pr.title, draft); body.push_str(&format!( - "\n", + "\n", n = pr.number, id = esc(&pr.id), title = esc(&pr.title), + full = esc(&full_title), state = esc(&pr.state), + lanes_title = esc(&lanes_plain), created = esc(&pr.created), )); } @@ -1117,7 +1121,7 @@ fn render_tasks_index( meta.push_str(&format!(" · closed {}", esc(c))); } body.push_str(&format!( - "
  • \n

    {title} {meta}

    \n{assignee}{body_md}
  • \n", + "
  • \n

    {title} {meta}

    \n{assignee}{body_md}
  • \n", title = esc(&t.title), assignee = t.assignee.as_ref().map_or(String::new(), |a| format!( "

    assignee: {}

    \n", @@ -1175,10 +1179,10 @@ fn render_ci_index( body.push_str("
    #titlestatehead→basecreated
    {n}{title}{draft}{state}{lanes}{created}
    {n}{title}{draft}{state}{lanes}{created}
    \n\n\n"); for s in &run.steps { body.push_str(&format!( - "\n", - esc(&s.name), - s.exit, - s.duration_ms, + "\n", + name = esc(&s.name), + exit = s.exit, + dur = s.duration_ms, )); } body.push_str("\n
    stepexitduration_ms
    {}{}{}
    {name}{exit}{dur}
    \n"); @@ -1742,7 +1746,11 @@ mod tests { html.contains("
    "), "{page}: wrapper" ); - assert!(html.contains("
    \n\n\n"); for rel in releases { body.push_str(&format!( - "\n", + "\n", v = esc(&rel.version), ve = esc(&rel.version), t = esc(rel.display_title()), @@ -512,7 +512,11 @@ mod tests { index.contains("
    versiontitlecreatedartifacts
    {ve}{t}{c}{n}
    {ve}{t}{c}{n}
    "), "wrapper" ); - assert!(index.contains("
    summary:focus-visible{outline:2px solid var(--blue);outline-off .table-scroll td.num,.table-scroll td.sha{width:auto} .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0} .table-scroll tbody tr:hover td{background:none} + .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip} } diff --git a/tests/forge_readers.rs b/tests/forge_readers.rs index b3aa723..c03348b 100644 --- a/tests/forge_readers.rs +++ b/tests/forge_readers.rs @@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() { // --- issues index: 2 rendered rows (broken one skipped) --- let issues = read("issues/index.html"); - let rows = issues.matches("
    1" + "1" )), "issue #1 -> older uuid" ); assert!( issues.contains(&format!( - "2" + "2" )), "issue #2 -> newer uuid" ); @@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() { // (4): pulls — same shape + head/base/draft/ci + patch
    .
         let pulls = read("pulls/index.html");
         assert_eq!(
    -        pulls.matches("
    1" + "1" )), "pr #1 -> uuid" ); diff --git a/tests/releases_api.rs b/tests/releases_api.rs index ea32b2c..0c7e472 100644 --- a/tests/releases_api.rs +++ b/tests/releases_api.rs @@ -154,14 +154,12 @@ fn releases_artifacts_api_agents_feed() { // --- api counts == html counts (same struct pass) --- let issues_html = read("issues/index.html") - .matches("
    &'static str { + env!("CARGO_BIN_EXE_hu13") +} + +fn manifest() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn fresh(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-truncate-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); +} + +fn forge_write(repo: &Path, rel: &str, content: &str) { + let dest = repo.join(".forge").join(rel); + std::fs::create_dir_all(dest.parent().expect("parent")).unwrap(); + std::fs::write(&dest, content).unwrap(); +} + +fn css() -> String { + std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css") +} + +/// Evil long fixture: unbroken 300-char strings + HTML metachars in every +/// table (commits subject/author, issues/pulls/tasks/releases titles, +/// file-table message, CI step name). Returns (dist, evil_title, evil_subject). +fn build_evil_fixture() -> (PathBuf, String, String) { + let repo = fresh("repo"); + git(&repo, &["init", "-b", "main"]); + git(&repo, &["config", "user.name", "hu13-test"]); + git(&repo, &["config", "user.email", "hu13@test.local"]); + git(&repo, &["config", "commit.gpgsign", "false"]); + // Evil long subject: unbroken + \"quoted\" & more", + "x".repeat(260) + ); + // Long evil author via --author on one commit. + let evil_author = format!("{} ", "A".repeat(120)); + std::fs::write(repo.join("README.md"), "# evil fixture\n").unwrap(); + std::fs::write(repo.join("notes.txt"), "hello\n").unwrap(); + git(&repo, &["add", "-A"]); + git( + &repo, + &[ + "commit", + "-m", + &evil_subject, + &format!("--author={evil_author}"), + ], + ); + std::fs::write(repo.join("notes.txt"), "hello again\nsecond\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "second commit"]); + + let evil_title = format!("T{} bold \"q\" & tail", "y".repeat(260)); + forge_write( + &repo, + "issues/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa/issue.md", + &format!( + "---\nid: aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa\ntitle: {evil_title}\nstate: open\nlabels: []\nassignees: []\ncreated: 2026-09-10T03:00:00Z\n---\nBody.\n" + ), + ); + forge_write( + &repo, + "prs/bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb/pr.md", + &format!( + "---\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" + ), + ); + forge_write( + &repo, + "tasks/cccccccc-cccc-4ccc-cccc-cccccccccccc.md", + &format!( + "---\nid: cccccccc-cccc-4ccc-cccc-cccccccccccc\ntitle: {evil_title}\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n" + ), + ); + forge_write(&repo, "ci/ci.env", "runner: local\n"); + let long_step = format!("{}_step.sh", "s".repeat(100)); + forge_write( + &repo, + &format!("ci/steps/{long_step}"), + "#!/bin/sh\nexit 0\n", + ); + forge_write( + &repo, + "ci/runs/2026-09-10T00-00-00Z/summary.md", + &format!( + "---\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" + ), + ); + forge_write( + &repo, + "releases/v9.9.md", + &format!( + "---\nversion: v9.9\ntitle: {evil_title}\ncreated: 2026-09-10T02:00:00Z\n---\nNotes.\n" + ), + ); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "forge evil fixture"]); + let out = fresh("out"); + let st = Command::new(bin()) + .arg("build") + .arg(&repo) + .arg("-o") + .arg(&out) + .status() + .expect("spawn hu13 build"); + assert!(st.success(), "evil fixture build exits 0"); + (out, evil_title, evil_subject) +} + +fn assert_every_td_truncated(html: &str, page: &str) { + let parts: Vec<&str> = html.split("').next().unwrap_or(""); + assert!( + head.contains("trunc"), + "{page} td #{i} carries .trunc (got {head:?})" + ); + assert!( + head.contains("title="), + "{page} td #{i} carries title= (got {head:?})" + ); + assert!( + head.contains("data-label="), + "{page} td #{i} keeps data-label (got {head:?})" + ); + } +} + +#[test] +fn trunc_css_utilities_and_widths() { + let css = css(); + for needle in [ + ".trunc", + "text-overflow:ellipsis", + "40ch", + "16ch", + "10ch", + "72ch", + "60ch", + ] { + assert!(css.contains(needle), "CSS mentions {needle}"); + } + // Desktop tables capped at the container (fit 1100px, no page h-scroll). + assert!(css.contains("max-width:100%"), "tables capped at container"); + assert!( + css.contains("overflow-x:clip") || css.contains("overflow-x:auto"), + "no page h-scroll" + ); + // Narrow keeps card reflow (trunc disabled, scroll off). + assert!( + css.contains("@media (max-width:767px)"), + "narrow query kept" + ); +} + +#[test] +fn every_table_cell_truncates_with_title() { + let (out, evil_title, evil_subject) = build_evil_fixture(); + let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel); + // Full text is never lost: escaped long strings stay in the DOM and in title=. + for page in [ + "commits.html", + "issues/index.html", + "pulls/index.html", + "tasks/index.html", + "releases/index.html", + "ci/index.html", + "index.html", + ] { + let html = read(page); + assert!( + !html.contains(""), + "{page} escapes evil HTML" + ); + assert!(!html.contains("bold"), "{page} escapes evil tags"); + } + // Evil subject + title survive escaped (cell text + title attr). + let commits = read("commits.html"); + assert!(commits.contains("<script>"), "commits escape kept"); + assert!(commits.contains("trunc-sha"), "commits sha width"); + assert!(commits.contains("trunc-subject"), "commits subject width"); + assert!(commits.contains("trunc-author"), "commits author width"); + // Per-table trunc asserts + title attrs. + for page in [ + "commits.html", + "issues/index.html", + "pulls/index.html", + "releases/index.html", + "ci/index.html", + "index.html", + ] { + assert_every_td_truncated(&read(page), page); + } + // Kind-specific widths per table. + assert!( + read("issues/index.html").contains("trunc-title"), + "issues title 60ch" + ); + assert!( + read("pulls/index.html").contains("trunc-title"), + "pulls title 60ch" + ); + assert!( + read("releases/index.html").contains("trunc-title"), + "releases title 60ch" + ); + assert!( + read("index.html").contains("trunc-message"), + "file-table message 72ch" + ); + // Tasks list uses trunc + title on titles (no table by design). + let tasks = read("tasks/index.html"); + assert!(!tasks.contains("