diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8fbdff0bc..f8e046c7b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -442,6 +442,7 @@ jobs: BASE_URL=http://localhost:13581 node test-slideover-1168-munger-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-logo-pulse-1173-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1122-details-row-clamp-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/style.css b/public/style.css index 6642eb74d..ac1a9aadf 100644 --- a/public/style.css +++ b/public/style.css @@ -1095,6 +1095,31 @@ img.brand-logo { max-width: 0; /* forces td to respect table width instead of expanding to content */ } .data-table td.col-details { white-space: normal; word-break: break-word; max-width: 480px; } +/* #1122/#1124: the packets "Details" cell is a one-line summary (the full + * message is in the detail pane / SlideOver). Letting it wrap made rows taller + * whenever table-layout:auto narrowed the column (e.g. 1030-1280px desktop + * widths), breaking the bounded row height and the virtual scroller's + * constant-row-height assumption (#1770). Clamp the INNER .col-details-clip to + * one line; the td keeps white-space:normal so the column's intrinsic widths + * (and thus the table layout) are unchanged -- see the #1805 note in the + * mobile block, whose inline-block/nowrap clip still overrides this. */ +.data-table td.col-details > .col-details-clip { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + line-clamp: 1; + overflow: hidden; +} +/* The one-line clip box is only a line box tall, so anything drawn outside + * it is clipped (padding would expose the top of the hidden 2nd line). + * Draw link focus rings inside the link box instead of around it... */ +.data-table td.col-details > .col-details-clip a:focus-visible { outline-offset: -2px; } +/* ...and give the line box room for .chan-tag pills (17px, baseline-aligned) + * where that is height-neutral: above 768px the Path chips already make rows + * ~30px tall. (<=640px keeps the #1805 inline-block clip below.) */ +@media (min-width: 769px) { + .data-table td.col-details > .col-details-clip { line-height: 18px; } +} /* Direct Neighbors' Configured Scope cell (observer-detail.js) can hold a * long comma-joined scope list (e.g. a #eu/#dk/#dk-sjl/... catch-all * observer) -- the generic max-width:0 ellipsis rule above truncated it @@ -2472,7 +2497,8 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } /* #1770: packets-view virtual-scroller assumes constant row height (VSCROLL_ROW_HEIGHT). The base rule at ~L1097 sets `.data-table td.col-details { white-space: normal; word-break: break-word }` - which lets the "Details" cell wrap on narrow viewports, producing + which let the "Details" cell wrap on narrow viewports (since #1122 the + inner clip is also line-clamped at wider widths, see ~L1106), producing variable row heights → visible jitter when scrolling past ~900px on iOS. Quick-fix (S path): clamp the cell to a single line on mobile. The proper L-path fix (per-row measurement) tracks separately. diff --git a/test-issue-1122-details-row-clamp-e2e.js b/test-issue-1122-details-row-clamp-e2e.js new file mode 100644 index 000000000..1802353bf --- /dev/null +++ b/test-issue-1122-details-row-clamp-e2e.js @@ -0,0 +1,193 @@ +/** + * E2E (#1122/#1124 follow-up): the packets "Details" cell must not make rows + * taller by wrapping when table-layout:auto narrows its column. + * + * Background: test-issue-1122-packets-filter-ux-e2e.js asserts every path cell + * (= its row) stays < 60px at 1400px. On Linux CI that started failing once + * the fixture was ~11.5 min old: the auto table layout narrowed Details from + * ~184px to ~135px and long channel messages wrapped to 2-3 lines (69/84px). + * The same wrapping happens deterministically at ordinary desktop/tablet + * widths (e.g. 1030-1280px, 900px), so this test controls the column width + * via the viewport instead of waiting for fixture age. + * + * Contract asserted at each viewport: + * A. Rows whose Details text is longer than the column (proven present, so + * the test cannot pass vacuously) render the Details summary on ONE line, + * and every packet row stays < 60px. + * B. The full message stays reachable: selecting a long channel-message row + * shows the complete decoded text in a visible detail surface (#pktRight + * on desktop, SlideOver <=1023px, bottom sheet on small mobile). + * C. Links inside Details (advert node links) stay visible and hit-testable. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-1122-details-row-clamp-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const SHOT_DIR = 'e2e-screenshots'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const VIEWPORTS = [ + { w: 1200, h: 900, name: 'desktop-1200' }, // Details column ~176px: wraps without the clamp + { w: 900, h: 1024, name: 'tablet-900' }, // above the 640px mobile clamp, below SlideOver BP + { w: 375, h: 812, name: 'mobile-375' }, // repo mobile convention (#1668 M6) +]; + +async function gotoPackets(page) { + await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#packetFilterInput', { state: 'attached', timeout: 8000 }); + await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 }); + // All time, so the fixture's long channel messages are rendered (same as #1122). + await page.evaluate(() => { + const sel = document.getElementById('fTimeWindow'); + if (sel) { sel.value = '0'; sel.dispatchEvent(new Event('change', { bubbles: true })); } + }); + await page.waitForFunction( + () => document.querySelectorAll('#pktBody tr[data-hash] td.col-details .col-details-clip').length > 20, + { timeout: 8000 }); + await page.evaluate(() => document.fonts.ready); +} + +// Measures every rendered packet row. `overflowing` = rows whose Details text, +// laid out on a single line in the clip's own font, is wider than the cell's +// content box -- i.e. rows that WOULD wrap without a one-line clamp. +function measureRows() { + const ctx = document.createElement('canvas').getContext('2d'); + const rows = [...document.querySelectorAll('#pktBody tr[data-hash]')] + .filter(r => r.getBoundingClientRect().height > 0); + return rows.map(r => { + const td = r.querySelector('td.col-details'); + const clip = td && td.querySelector('.col-details-clip'); + if (!clip) return null; + const cs = getComputedStyle(clip); + ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + const tdcs = getComputedStyle(td); + const contentW = td.clientWidth - parseFloat(tdcs.paddingLeft) - parseFloat(tdcs.paddingRight); + const textW = ctx.measureText(clip.textContent.replace(/\s+/g, ' ').trim()).width; + const lineH = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2; + // Visible height of the summary: the clip's content box (bounding box minus + // its vertical padding). A wrapped summary makes this a multiple of the + // line height; a one-line clamp keeps it at ~1 line. (getClientRects() is + // not used: a block-level clip always reports a single rect.) + const clipBox = clip.getBoundingClientRect(); + const clipH = clipBox.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); + return { + hash: r.getAttribute('data-hash'), + rowH: r.getBoundingClientRect().height, + clipH, + lineH, textW: Math.round(textW), contentW: Math.round(contentW), + overflowing: textW > contentW + 1, + text: clip.textContent.trim().slice(0, 60), + }; + }).filter(Boolean); +} + +(async () => { + if (!fs.existsSync(SHOT_DIR)) fs.mkdirSync(SHOT_DIR, { recursive: true }); + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + + console.log(`\n=== #1122 Details row clamp E2E against ${BASE} ===`); + + for (const vp of VIEWPORTS) { + const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + let rows = []; + await step(`[${vp.name}] navigate to /packets`, async () => { + await gotoPackets(page); + rows = await page.evaluate(measureRows); + assert(rows.length > 0, 'no packet rows rendered'); + }); + + await step(`[${vp.name}] long Details text is present (test is not vacuous)`, async () => { + const over = rows.filter(r => r.overflowing); + assert(over.length > 0, + 'no row has Details text wider than its column -- cannot exercise the clamp. Sample: ' + + JSON.stringify(rows.slice(0, 3))); + }); + + await step(`[${vp.name}] Details summaries stay on one line`, async () => { + // Every row, not just the overflowing ones; the overflowing ones are the + // rows that would wrap without the clamp (proven present above). + const bad = rows.filter(r => r.clipH > r.lineH * 1.5); + assert(bad.length === 0, + `${bad.length} Details summaries wrap (${bad.filter(r => r.overflowing).length} overflowing): ` + JSON.stringify(bad.slice(0, 4))); + }); + + await step(`[${vp.name}] every packet row stays < 60px`, async () => { + const tall = rows.filter(r => r.rowH >= 60); + assert(tall.length === 0, + `${tall.length} rows >= 60px: ` + JSON.stringify(tall.slice(0, 4).map(r => ({ h: r.rowH, text: r.text })))); + }); + + await step(`[${vp.name}] advert links in Details stay visible and clickable`, async () => { + const res = await page.evaluate(() => { + const links = [...document.querySelectorAll('#pktBody td.col-details .col-details-clip a.hop-link')]; + for (const a of links) { + const r = a.getBoundingClientRect(); + if (r.width < 2 || r.height < 2 || r.top < 0 || r.bottom > innerHeight) continue; + const td = a.closest('td').getBoundingClientRect(); + const x = Math.min(r.left + 4, td.right - 2), y = r.top + r.height / 2; + const hit = document.elementFromPoint(x, y); + return { found: true, hitIsLink: !!(hit && (hit === a || a.contains(hit))), text: a.textContent }; + } + return { found: false, count: links.length }; + }); + assert(res.found, 'no visible advert link in Details to check (' + JSON.stringify(res) + ')'); + assert(res.hitIsLink, 'advert link in Details is not hit-testable: ' + JSON.stringify(res)); + }); + + await step(`[${vp.name}] full message is shown when a long row is selected`, async () => { + // Pick an overflowing channel-message row and fetch its full decoded text. + const target = await page.evaluate(async (hashes) => { + for (const h of hashes) { + const res = await fetch('/api/packets/' + h); + if (!res.ok) continue; + const data = await res.json(); + const pkt = data && data.packet; + let d = pkt && pkt.decoded_json; + if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { d = null; } } + if (d && d.type === 'CHAN' && typeof d.text === 'string' && d.text.length > 20) return { hash: h, text: d.text }; + } + return null; + }, rows.filter(r => r.overflowing).map(r => r.hash)); + assert(target, 'no overflowing channel-message row with decoded text found'); + const row = page.locator(`#pktBody tr[data-hash="${target.hash}"]`).first(); + await row.scrollIntoViewIfNeeded(); + // Click a plain data cell (not the expand column, not a link). + await row.locator('td.col-time').click(); + // The full text must be VISIBLE in a detail surface (never the table): + // desktop split pane, SlideOver (<=1023px) or the small-mobile bottom + // sheet (which shows it in its header summary, #1471). innerText only + // includes rendered text, so display:none copies do not count. + await page.waitForFunction((t) => { + const norm = (s) => s.replace(/\s+/g, ' '); + return ['#pktRight', '.slide-over-panel', '#mobileDetailSheet.open'] + .map(sel => document.querySelector(sel)) + .some(el => el && el.getBoundingClientRect().width > 0 && norm(el.innerText).includes(norm(t))); + }, target.text, { timeout: 8000 }); + await page.screenshot({ path: path.join(SHOT_DIR, `issue-1122-details-clamp-${vp.name}.png`), fullPage: false }); + }); + + await ctx.close(); + } + + await browser.close(); + console.log(`\n=== Results: passed ${passed} failed ${failed} ===`); + process.exit(failed > 0 ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); });