Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
193 changes: 193 additions & 0 deletions test-issue-1122-details-row-clamp-e2e.js
Original file line number Diff line number Diff line change
@@ -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); });
Loading