#38 m6/truncate
Patch
src/build.rs+18 −7--- a/src/build.rs+++ b/src/build.rs@@ -584,8 +584,9 @@ pub fn file_table_html(584584 );585585 if let Some((href, label)) = parent {586586 out.push_str(&format!(587- "<tr class=\"parent\"><td data-label=\"Name\"><a href=\"{}\">{}</a></td>\588- <td data-label=\"Last commit\"></td><td data-label=\"Age\"></td></tr>\n",587+ "<tr class=\"parent\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"{}\">{}</a></td>\588+ <td class=\"trunc trunc-message\" data-label=\"Last commit\" title=\"\"></td><td class=\"trunc\" data-label=\"Age\" title=\"\"></td></tr>\n",589+ esc(label),589590 esc(href),590591 esc(label)591592 ));@@ -600,20 +601,26 @@ pub fn file_table_html(600601 let msg_short = truncate_msg(&msg);601602 if e.is_dir {602603 out.push_str(&format!(603- "<tr class=\"dir\"><td data-label=\"Name\"><a href=\"/files/{}.html\">{}/</a></td>\604- <td class=\"title\" data-label=\"Last commit\">{}</td><td data-label=\"Age\">{}</td></tr>\n",604+ "<tr class=\"dir\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}/</a></td>\605+ <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",606+ esc(&e.name),605607 href_esc(&full),606608 esc(&e.name),609+ esc(&msg),607610 esc(&msg_short),611+ esc(&age),608612 esc(&age)609613 ));610614 } else {611615 out.push_str(&format!(612- "<tr class=\"file\"><td data-label=\"Name\"><a href=\"/files/{}.html\">{}</a></td>\613- <td class=\"title\" data-label=\"Last commit\">{}</td><td data-label=\"Age\">{}</td></tr>\n",616+ "<tr class=\"file\"><td class=\"trunc\" data-label=\"Name\" title=\"{}\"><a href=\"/files/{}.html\">{}</a></td>\617+ <td class=\"title trunc trunc-message\" data-label=\"Last commit\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"Age\" title=\"{}\">{}</td></tr>\n",618+ esc(&e.name),614619 href_esc(&full),615620 esc(&e.name),621+ esc(&msg),616622 esc(&msg_short),623+ esc(&age),617624 esc(&age)618625 ));619626 }@@ -1717,7 +1724,11 @@ mod tests {17171724 "breadcrumb kept on subdir"17181725 );17191726 // Message + age columns exist even when history is empty-ish.1720- assert!(sub.contains("<td class=\"title\""), "message cell");1727+ assert!(1728+ sub.contains("<td class=\"title trunc trunc-message\""),1729+ "message cell trunc"1730+ );1731+ assert!(sub.contains("title=\""), "title attrs kept");17211732 }17221733 17231734 /// m6/no-scroll (f0rge #39): file-table rows carry data-labels for cards.
src/commits.rs+14 −3--- a/src/commits.rs+++ b/src/commits.rs@@ -43,11 +43,15 @@ pub fn render_loaded(4343 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>sha</th><th>subject</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");4444 for c in log {4545 body.push_str(&format!(46- "<tr><td class=\"sha\" data-label=\"sha\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"title\" data-label=\"subject\">{}</td><td data-label=\"author\">{}</td><td data-label=\"date\">{}</td></tr>\n",46+ "<tr><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",47+ esc(&c.sha),4748 esc(&c.sha),4849 esc(short_sha(&c.sha)),4950 esc(&c.subject),51+ esc(&c.subject),5052 esc(&c.author),53+ esc(&c.author),54+ esc(&c.date),5155 esc(&c.date),5256 ));5357 }@@ -359,8 +363,15 @@ mod tests {359363 list.contains("<div class=\"table-scroll\"><table>"),360364 "wrapper"361365 );362- assert!(list.contains("<td class=\"title\""), "title cell");363- assert!(list.contains("<td class=\"sha\""), "sha cell");366+ assert!(367+ list.contains("<td class=\"title trunc trunc-subject\""),368+ "title cell trunc"369+ );370+ assert!(371+ list.contains("<td class=\"sha trunc trunc-sha\""),372+ "sha cell trunc"373+ );374+ assert!(list.contains("title=\""), "title attrs kept");364375 assert!(list.contains(&long_subject), "long subject kept");365376 let sha = shas(&dir)[0].clone();366377 assert!(
src/forge.rs+16 −8--- a/src/forge.rs+++ b/src/forge.rs@@ -1001,7 +1001,7 @@ fn render_issues_index(10011001 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>comments</th><th>created</th></tr></thead>\n<tbody>\n");10021002 for issue in &data.issues {10031003 body.push_str(&format!(1004- "<tr><td class=\"num\" data-label=\"#\">{n}</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{id}.html\">{title}</a></td><td data-label=\"state\">{state}</td><td data-label=\"comments\">{comments}</td><td data-label=\"created\">{created}</td></tr>\n",1004+ "<tr><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{title}\"><a href=\"/issues/{id}.html\">{title}</a></td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"comments\" title=\"{comments}\">{comments}</td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",10051005 n = issue.number,10061006 id = esc(&issue.id),10071007 title = esc(&issue.title),@@ -1062,13 +1062,17 @@ fn render_pulls_index(10621062 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>#</th><th>title</th><th>state</th><th>head→base</th><th>created</th></tr></thead>\n<tbody>\n");10631063 for pr in prs {10641064 let lanes = format!("{}→{}", esc(&pr.head), esc(&pr.base));1065+ let lanes_plain = format!("{}→{}", pr.head, pr.base);10651066 let draft = if pr.draft { " draft" } else { "" };1067+ let full_title = format!("{}{}", pr.title, draft);10661068 body.push_str(&format!(1067- "<tr><td class=\"num\" data-label=\"#\">{n}</td><td class=\"title\" data-label=\"title\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td data-label=\"state\">{state}</td><td data-label=\"head→base\"><code>{lanes}</code></td><td data-label=\"created\">{created}</td></tr>\n",1069+ "<tr><td class=\"num trunc\" data-label=\"#\" title=\"{n}\">{n}</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{full}\"><a href=\"/pulls/{id}.html\">{title}</a>{draft}</td><td class=\"trunc\" data-label=\"state\" title=\"{state}\">{state}</td><td class=\"trunc\" data-label=\"head→base\" title=\"{lanes_title}\"><code>{lanes}</code></td><td class=\"trunc\" data-label=\"created\" title=\"{created}\">{created}</td></tr>\n",10681070 n = pr.number,10691071 id = esc(&pr.id),10701072 title = esc(&pr.title),1073+ full = esc(&full_title),10711074 state = esc(&pr.state),1075+ lanes_title = esc(&lanes_plain),10721076 created = esc(&pr.created),10731077 ));10741078 }@@ -1117,7 +1121,7 @@ fn render_tasks_index(11171121 meta.push_str(&format!(" · closed {}", esc(c)));11181122 }11191123 body.push_str(&format!(1120- "<li class=\"task\">\n<p><strong>{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",1124+ "<li class=\"task\">\n<p><strong class=\"trunc trunc-title\" title=\"{title}\">{title}</strong> <span class=\"muted\">{meta}</span></p>\n{assignee}{body_md}</li>\n",11211125 title = esc(&t.title),11221126 assignee = t.assignee.as_ref().map_or(String::new(), |a| format!(11231127 "<p class=\"muted\">assignee: <code>{}</code></p>\n",@@ -1175,10 +1179,10 @@ fn render_ci_index(11751179 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>step</th><th>exit</th><th>duration_ms</th></tr></thead>\n<tbody>\n");11761180 for s in &run.steps {11771181 body.push_str(&format!(1178- "<tr><td data-label=\"step\"><code>{}</code></td><td data-label=\"exit\">{}</td><td data-label=\"duration_ms\">{}</td></tr>\n",1179- esc(&s.name),1180- s.exit,1181- s.duration_ms,1182+ "<tr><td class=\"trunc\" data-label=\"step\" title=\"{name}\"><code>{name}</code></td><td class=\"trunc\" data-label=\"exit\" title=\"{exit}\">{exit}</td><td class=\"trunc\" data-label=\"duration_ms\" title=\"{dur}\">{dur}</td></tr>\n",1183+ name = esc(&s.name),1184+ exit = s.exit,1185+ dur = s.duration_ms,11821186 ));11831187 }11841188 body.push_str("</tbody>\n</table></div>\n");@@ -1742,7 +1746,11 @@ mod tests {17421746 html.contains("<div class=\"table-scroll\"><table>"),17431747 "{page}: wrapper"17441748 );1745- assert!(html.contains("<td class=\"title\""), "{page}: title cell");1749+ assert!(1750+ html.contains("<td class=\"title trunc trunc-title\""),1751+ "{page}: title cell trunc"1752+ );1753+ assert!(html.contains("title=\""), "{page}: title attrs");17461754 assert!(html.contains(&long_title), "{page}: long title kept");17471755 }17481756 let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");
src/releases.rs+6 −2--- a/src/releases.rs+++ b/src/releases.rs@@ -398,7 +398,7 @@ pub fn render_loaded(398398 body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>version</th><th>title</th><th>created</th><th>artifacts</th></tr></thead>\n<tbody>\n");399399 for rel in releases {400400 body.push_str(&format!(401- "<tr><td data-label=\"version\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"title\" data-label=\"title\">{t}</td><td data-label=\"created\">{c}</td><td data-label=\"artifacts\">{n}</td></tr>\n",401+ "<tr><td class=\"trunc\" data-label=\"version\" title=\"{v}\"><a href=\"/releases/{v}.html\">{ve}</a></td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"{t}\">{t}</td><td class=\"trunc\" data-label=\"created\" title=\"{c}\">{c}</td><td class=\"trunc\" data-label=\"artifacts\" title=\"{n}\">{n}</td></tr>\n",402402 v = esc(&rel.version),403403 ve = esc(&rel.version),404404 t = esc(rel.display_title()),@@ -512,7 +512,11 @@ mod tests {512512 index.contains("<div class=\"table-scroll\"><table>"),513513 "wrapper"514514 );515- assert!(index.contains("<td class=\"title\""), "title cell");515+ assert!(516+ index.contains("<td class=\"title trunc trunc-title\""),517+ "title cell trunc"518+ );519+ assert!(index.contains("title=\""), "title attrs kept");516520 assert!(index.contains(&long_title), "long title kept");517521 }518522
static/style.css+13 −1--- a/static/style.css+++ b/static/style.css@@ -169,10 +169,21 @@ a.ln:hover,a.ln:focus{text-decoration:underline}169169 /* List tables scroll, never squash (m6/table-scroll, f0rge #30): natural170170 width inside .table-scroll; cells nowrap except title cells which wrap171171 with a floor so rows scroll horizontally. Hover tint via var. */172-.table-scroll table{width:max-content;min-width:100%}172+.table-scroll table{width:max-content;min-width:100%;max-width:100%}173173 .table-scroll th,.table-scroll td{white-space:nowrap}174174 .table-scroll td.title{white-space:normal;overflow-wrap:anywhere;min-width:12rem}175175 .table-scroll tbody tr:hover td{background:var(--bg-th)}176+/* Truncate all table fields (m6/truncate, f0rge #42): ellipsis + nowrap177+ with per-kind max-widths so desktop tables fit the 1100px container178+ without page-level h-scroll. Full text stays in title= attrs.179+ Later + equal specificity beats td.title wrap above on desktop. */180+.trunc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:24ch;min-width:0}181+.table-scroll td.trunc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:24ch;min-width:0}182+.table-scroll td.trunc-subject,.trunc-subject{max-width:40ch}183+.table-scroll td.trunc-author,.trunc-author{max-width:16ch}184+.table-scroll td.trunc-sha,.trunc-sha{max-width:10ch}185+.table-scroll td.trunc-message,.trunc-message{max-width:72ch}186+.table-scroll td.trunc-title,.trunc-title{max-width:60ch}176187 /* Page titles with inline buttons (m6/table-scroll, f0rge #31): h1 +177188 button share one flex row; wrap allowed on 360px but stays adjacent. */178189 .page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}@@ -212,4 +223,5 @@ details.clonebox>summary:focus-visible{outline:2px solid var(--blue);outline-off212223 .table-scroll td.num,.table-scroll td.sha{width:auto}213224 .table-scroll td code,.table-scroll td a{overflow-wrap:anywhere;word-break:break-all;min-width:0}214225 .table-scroll tbody tr:hover td{background:none}226+ .table-scroll td.trunc,.trunc{white-space:normal;max-width:none;overflow:visible;text-overflow:clip}215227 }
tests/forge_readers.rs+5 −5--- a/tests/forge_readers.rs+++ b/tests/forge_readers.rs@@ -224,19 +224,19 @@ fn forge_fixture_renders_counts_numbers_and_ci() {224224 225225 // --- issues index: 2 rendered rows (broken one skipped) ---226226 let issues = read("issues/index.html");227- let rows = issues.matches("<tr><td class=\"num\"").count();227+ let rows = issues.matches("<tr><td class=\"num").count();228228 assert_eq!(rows, 2, "rendered issues match file-count minus broken");229229 assert!(!issues.contains("No title here"), "broken issue skipped");230230 // 5-number spot check (1): #1 links the older uuid, #2 the newer uuid.231231 assert!(232232 issues.contains(&format!(233- "<td class=\"num\" data-label=\"#\">1</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{OLDER_UUID}.html\">"233+ "<td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Older issue\"><a href=\"/issues/{OLDER_UUID}.html\">"234234 )),235235 "issue #1 -> older uuid"236236 );237237 assert!(238238 issues.contains(&format!(239- "<td class=\"num\" data-label=\"#\">2</td><td class=\"title\" data-label=\"title\"><a href=\"/issues/{NEWER_UUID}.html\">"239+ "<td class=\"num trunc\" data-label=\"#\" title=\"2\">2</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Newer issue\"><a href=\"/issues/{NEWER_UUID}.html\">"240240 )),241241 "issue #2 -> newer uuid"242242 );@@ -263,13 +263,13 @@ fn forge_fixture_renders_counts_numbers_and_ci() {263263 // (4): pulls — same shape + head/base/draft/ci + patch <pre>.264264 let pulls = read("pulls/index.html");265265 assert_eq!(266- pulls.matches("<tr><td class=\"num\"").count(),266+ pulls.matches("<tr><td class=\"num").count(),267267 1,268268 "prs match file-count"269269 );270270 assert!(271271 pulls.contains(&format!(272- "<td class=\"num\" data-label=\"#\">1</td><td class=\"title\" data-label=\"title\"><a href=\"/pulls/{PR_UUID}.html\">"272+ "<td class=\"num trunc\" data-label=\"#\" title=\"1\">1</td><td class=\"title trunc trunc-title\" data-label=\"title\" title=\"Fix the thing\"><a href=\"/pulls/{PR_UUID}.html\">"273273 )),274274 "pr #1 -> uuid"275275 );
tests/releases_api.rs+3 −5--- a/tests/releases_api.rs+++ b/tests/releases_api.rs@@ -154,14 +154,12 @@ fn releases_artifacts_api_agents_feed() {154154 155155 // --- api counts == html counts (same struct pass) ---156156 let issues_html = read("issues/index.html")157- .matches("<tr><td class=\"num\"")157+ .matches("<tr><td class=\"num")158158 .count();159159 let pulls_html = read("pulls/index.html")160- .matches("<tr><td class=\"num\"")161- .count();162- let commits_html = read("commits.html")163- .matches("<tr><td class=\"sha\"")160+ .matches("<tr><td class=\"num")164161 .count();162+ let commits_html = read("commits.html").matches("<tr><td class=\"sha").count();165163 let releases_html = read("releases/index.html").matches("<tr><td").count();166164 let wiki_html = read("wiki/index.html")167165 .matches("<li><a href=\"/wiki/")
tests/responsive.rs+11 −7--- a/tests/responsive.rs+++ b/tests/responsive.rs@@ -377,24 +377,28 @@ fn forge_list_tables_scroll_and_wrap_long_titles() {377377 for page in ["issues/index.html", "pulls/index.html"] {378378 let html = read(page);379379 assert!(380- html.contains("<td class=\"title\""),381- "{page} marks title cells"380+ html.contains("<td class=\"title trunc trunc-title\""),381+ "{page} marks title cells trunc"382382 );383383 assert!(384384 html.contains(&long_title),385385 "{page} keeps the full long title"386386 );387- assert!(html.contains("<td class=\"num\""), "{page} keeps # narrow");387+ assert!(388+ html.contains("<td class=\"num trunc\""),389+ "{page} keeps # narrow trunc"390+ );391+ assert!(html.contains("title=\""), "{page} keeps title attrs");388392 }389393 let commits = read("commits.html");390394 assert!(391- commits.contains("<td class=\"sha\""),392- "commits marks sha cells"395+ commits.contains("<td class=\"sha trunc trunc-sha\""),396+ "commits marks sha cells trunc"393397 );394398 let releases = read("releases/index.html");395399 assert!(396- releases.contains("<td class=\"title\""),397- "releases marks title cells"400+ releases.contains("<td class=\"title trunc trunc-title\""),401+ "releases marks title cells trunc"398402 );399403 // Labels from the fixture still list on the issues index.400404 assert!(
tests/truncate.rs+254 −1--- /dev/null+++ b/tests/truncate.rs@@ -0,0 +1,254 @@1+//! Truncate all table fields (m6/truncate, f0rge #42):2+//! every table cell carries `.trunc` + `title=`, desktop fits 1100px.3+4+use std::path::{Path, PathBuf};5+use std::process::Command;6+7+fn bin() -> &'static str {8+ env!("CARGO_BIN_EXE_hu13")9+}10+11+fn manifest() -> PathBuf {12+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))13+}14+15+fn fresh(prefix: &str) -> PathBuf {16+ let dir = std::env::temp_dir().join(format!(17+ "hu13-truncate-{prefix}-{}-{}",18+ std::process::id(),19+ std::time::SystemTime::now()20+ .duration_since(std::time::UNIX_EPOCH)21+ .map(|d| d.as_nanos())22+ .unwrap_or(0)23+ ));24+ std::fs::create_dir_all(&dir).unwrap();25+ dir26+}27+28+fn git(repo: &Path, args: &[&str]) {29+ let st = Command::new("git")30+ .arg("-C")31+ .arg(repo)32+ .args(args)33+ .status()34+ .expect("spawn git");35+ assert!(st.success(), "git {args:?} failed");36+}37+38+fn forge_write(repo: &Path, rel: &str, content: &str) {39+ let dest = repo.join(".forge").join(rel);40+ std::fs::create_dir_all(dest.parent().expect("parent")).unwrap();41+ std::fs::write(&dest, content).unwrap();42+}43+44+fn css() -> String {45+ std::fs::read_to_string(manifest().join("static").join("style.css")).expect("read style.css")46+}47+48+/// Evil long fixture: unbroken 300-char strings + HTML metachars in every49+/// table (commits subject/author, issues/pulls/tasks/releases titles,50+/// file-table message, CI step name). Returns (dist, evil_title, evil_subject).51+fn build_evil_fixture() -> (PathBuf, String, String) {52+ let repo = fresh("repo");53+ git(&repo, &["init", "-b", "main"]);54+ git(&repo, &["config", "user.name", "hu13-test"]);55+ git(&repo, &["config", "user.email", "[email protected]"]);56+ git(&repo, &["config", "commit.gpgsign", "false"]);57+ // Evil long subject: unbroken + <script> + quotes (tests esc in cell + title=).58+ let evil_subject = format!(59+ "S{} <script>alert(1)</script> \"quoted\" & more",60+ "x".repeat(260)61+ );62+ // Long evil author via --author on one commit.63+ let evil_author = format!("{} <[email protected]>", "A".repeat(120));64+ std::fs::write(repo.join("README.md"), "# evil fixture\n").unwrap();65+ std::fs::write(repo.join("notes.txt"), "hello\n").unwrap();66+ git(&repo, &["add", "-A"]);67+ git(68+ &repo,69+ &[70+ "commit",71+ "-m",72+ &evil_subject,73+ &format!("--author={evil_author}"),74+ ],75+ );76+ std::fs::write(repo.join("notes.txt"), "hello again\nsecond\n").unwrap();77+ git(&repo, &["add", "-A"]);78+ git(&repo, &["commit", "-m", "second commit"]);79+80+ let evil_title = format!("T{} <b>bold</b> \"q\" & tail", "y".repeat(260));81+ forge_write(82+ &repo,83+ "issues/aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa/issue.md",84+ &format!(85+ "---\nid: aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa\ntitle: {evil_title}\nstate: open\nlabels: []\nassignees: []\ncreated: 2026-09-10T03:00:00Z\n---\nBody.\n"86+ ),87+ );88+ forge_write(89+ &repo,90+ "prs/bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb/pr.md",91+ &format!(92+ "---\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"93+ ),94+ );95+ forge_write(96+ &repo,97+ "tasks/cccccccc-cccc-4ccc-cccc-cccccccccccc.md",98+ &format!(99+ "---\nid: cccccccc-cccc-4ccc-cccc-cccccccccccc\ntitle: {evil_title}\nstatus: todo\ncreated: 2026-09-10T03:00:00Z\n---\nTask body.\n"100+ ),101+ );102+ forge_write(&repo, "ci/ci.env", "runner: local\n");103+ let long_step = format!("{}_step.sh", "s".repeat(100));104+ forge_write(105+ &repo,106+ &format!("ci/steps/{long_step}"),107+ "#!/bin/sh\nexit 0\n",108+ );109+ forge_write(110+ &repo,111+ "ci/runs/2026-09-10T00-00-00Z/summary.md",112+ &format!(113+ "---\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"114+ ),115+ );116+ forge_write(117+ &repo,118+ "releases/v9.9.md",119+ &format!(120+ "---\nversion: v9.9\ntitle: {evil_title}\ncreated: 2026-09-10T02:00:00Z\n---\nNotes.\n"121+ ),122+ );123+ git(&repo, &["add", "-A"]);124+ git(&repo, &["commit", "-m", "forge evil fixture"]);125+ let out = fresh("out");126+ let st = Command::new(bin())127+ .arg("build")128+ .arg(&repo)129+ .arg("-o")130+ .arg(&out)131+ .status()132+ .expect("spawn hu13 build");133+ assert!(st.success(), "evil fixture build exits 0");134+ (out, evil_title, evil_subject)135+}136+137+fn assert_every_td_truncated(html: &str, page: &str) {138+ let parts: Vec<&str> = html.split("<td").skip(1).collect();139+ assert!(!parts.is_empty(), "{page} has cells");140+ for (i, p) in parts.iter().enumerate() {141+ let head = p.split('>').next().unwrap_or("");142+ assert!(143+ head.contains("trunc"),144+ "{page} td #{i} carries .trunc (got {head:?})"145+ );146+ assert!(147+ head.contains("title="),148+ "{page} td #{i} carries title= (got {head:?})"149+ );150+ assert!(151+ head.contains("data-label="),152+ "{page} td #{i} keeps data-label (got {head:?})"153+ );154+ }155+}156+157+#[test]158+fn trunc_css_utilities_and_widths() {159+ let css = css();160+ for needle in [161+ ".trunc",162+ "text-overflow:ellipsis",163+ "40ch",164+ "16ch",165+ "10ch",166+ "72ch",167+ "60ch",168+ ] {169+ assert!(css.contains(needle), "CSS mentions {needle}");170+ }171+ // Desktop tables capped at the container (fit 1100px, no page h-scroll).172+ assert!(css.contains("max-width:100%"), "tables capped at container");173+ assert!(174+ css.contains("overflow-x:clip") || css.contains("overflow-x:auto"),175+ "no page h-scroll"176+ );177+ // Narrow keeps card reflow (trunc disabled, scroll off).178+ assert!(179+ css.contains("@media (max-width:767px)"),180+ "narrow query kept"181+ );182+}183+184+#[test]185+fn every_table_cell_truncates_with_title() {186+ let (out, evil_title, evil_subject) = build_evil_fixture();187+ let read = |rel: &str| std::fs::read_to_string(out.join(rel)).expect(rel);188+ // Full text is never lost: escaped long strings stay in the DOM and in title=.189+ for page in [190+ "commits.html",191+ "issues/index.html",192+ "pulls/index.html",193+ "tasks/index.html",194+ "releases/index.html",195+ "ci/index.html",196+ "index.html",197+ ] {198+ let html = read(page);199+ assert!(200+ !html.contains("<script>alert(1)</script>"),201+ "{page} escapes evil HTML"202+ );203+ assert!(!html.contains("<b>bold</b>"), "{page} escapes evil tags");204+ }205+ // Evil subject + title survive escaped (cell text + title attr).206+ let commits = read("commits.html");207+ assert!(commits.contains("<script>"), "commits escape kept");208+ assert!(commits.contains("trunc-sha"), "commits sha width");209+ assert!(commits.contains("trunc-subject"), "commits subject width");210+ assert!(commits.contains("trunc-author"), "commits author width");211+ // Per-table trunc asserts + title attrs.212+ for page in [213+ "commits.html",214+ "issues/index.html",215+ "pulls/index.html",216+ "releases/index.html",217+ "ci/index.html",218+ "index.html",219+ ] {220+ assert_every_td_truncated(&read(page), page);221+ }222+ // Kind-specific widths per table.223+ assert!(224+ read("issues/index.html").contains("trunc-title"),225+ "issues title 60ch"226+ );227+ assert!(228+ read("pulls/index.html").contains("trunc-title"),229+ "pulls title 60ch"230+ );231+ assert!(232+ read("releases/index.html").contains("trunc-title"),233+ "releases title 60ch"234+ );235+ assert!(236+ read("index.html").contains("trunc-message"),237+ "file-table message 72ch"238+ );239+ // Tasks list uses trunc + title on titles (no table by design).240+ let tasks = read("tasks/index.html");241+ assert!(!tasks.contains("<table"), "tasks stays list-only");242+ assert!(tasks.contains("trunc-title"), "tasks titles truncate");243+ assert!(tasks.contains("title="), "tasks titles keep full text");244+ // Long content kept (CSS truncates visually, nothing cut in HTML).245+ assert!(246+ read("issues/index.html").contains(&"y".repeat(100)),247+ "long issue title kept"248+ );249+ assert!(250+ commits.contains(&"x".repeat(100)),251+ "long commit subject kept"252+ );253+ let _ = (evil_title, evil_subject);254+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.