From 98e0c2eac9e15a4ba6f868c875c7159d35bc6dd1 Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Mon, 28 Sep 2026 18:10:26 +0200 Subject: [PATCH 1/5] fix(packets): pack short columns, one expand arrow, Full Names toggle The packets table stored every column width as a percentage of the table, measured once on load. Percentages scale with the screen, so on a 2000px display "17s ago" sat in a 180px column. The measurement also read the virtual-scroll spacer row (one colspan cell as wide as the table) as column 0, which is why the expand column came out 160-950px wide. Grouped rows drew two arrows: a CSS ::before glyph next to the SVG caret, which itself pointed up when collapsed. Columns: fitColumnsToContent (app.js) sizes Time, Hash, Size, HB, Type, Scope, Observer and Rpt to their content in px; Path and Details split the rest. Every visible column gets an explicit width and the table their sum, because a colspan spacer keeps a slot for each hidden column that would otherwise take a share of the slack as a blank strip. When space is short the widest fixed columns shrink first, then the flex columns down to 60px, then the wrapper scrolls. The layout re-runs on container resize (detail panel), on TableResponsive hiding or revealing columns (new table-columns-changed event), after sorting and column toggles; after each render, grow() widens columns for the rows just inserted and never narrows them, so nothing jitters on scroll. Drag handles store px; double-click resets. makeColumnsResizable (nodes, observers, analytics) now skips colspan rows when measuring. Full Names: a toolbar toggle (localStorage, and fullNames=1 in the URL) shows observer and hop names untruncated. Paths stay on one line; hops past the edge go behind the "+N" pill. The pill was appended after the overflowing chips and so never showed; it is now sticky at the right edge, counts only hop chips, and hovering or focusing it shows the full path one hop per line (click, Enter or Space pins it). Rows drawn before /api/observers resolves show raw pubkeys, so loadObservers now re-renders and re-measures instead of leaving Observer at id width. Child rows indent only the Time cell: indenting every cell misaligned them with the headers and widened every column on expand. Perf: grow() is ~0.9ms per full render at 63 rows; a scroll step measures only its inserted rows (~73 rect reads, was 5,472). Constraint: virtual scroll positions rows at one fixed height, so Full Names cannot wrap paths Rejected: Wrap long paths onto extra lines under Full Names | breaks VSCROLL_ROW_HEIGHT row positioning Rejected: Keep percentage widths, fix only the spacer measurement | still inflates short columns on wide screens Rejected: Leave Path and Details width:auto | the phantom spacer slots take a share and render a blank strip Directive: fitColumnsToContent sets table.style.width; a table using it must not rely on width:100% Directive: rows must keep a constant height; anything that wraps in #pktTable breaks virtual scroll offsets Confidence: high Scope-risk: moderate Not-tested: very large expanded groups (hundreds of child rows on screen at once) --- .github/workflows/deploy.yml | 2 + public/app.js | 291 +++++++++++- public/packets.js | 290 +++++++++--- public/style.css | 43 +- scripts/non-unit-tests.json | 1 + tests/e2e/test-packets-compact-columns-e2e.js | 417 ++++++++++++++++++ tests/unit/test-issue-1189-composed-cell.js | 1 + ...test-issue-2012-clear-filters-selection.js | 4 +- tests/unit/test-packets.js | 137 +++++- tests/unit/test-xss-escape-sinks.js | 85 ++-- 10 files changed, 1169 insertions(+), 102 deletions(-) create mode 100644 tests/e2e/test-packets-compact-columns-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f859fb25..8012b4774 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -705,6 +705,8 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-marker-outline-weight.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-node-reach-e2e.js ===" CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-node-reach-e2e.js 2>&1 | tee -a e2e-output.txt + echo "=== E2E SUITE: test-packets-compact-columns-e2e.js ===" + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-packets-compact-columns-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-packets-scope-column.js ===" CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-packets-scope-column.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-path-inspector-e2e.js ===" diff --git a/public/app.js b/public/app.js index 28b97b399..6cc80e0e3 100644 --- a/public/app.js +++ b/public/app.js @@ -1987,8 +1987,7 @@ function makeColumnsResizable(tableSelector, storageKey) { if (!widths) { // Measure actual max content width per column by scanning visible rows - const tbody = table.querySelector('tbody'); - const rows = tbody ? Array.from(tbody.querySelectorAll('tr')).slice(0, 30) : []; + const rows = measurableRows(table); // Temporarily set auto layout to measure table.style.tableLayout = 'auto'; @@ -2105,3 +2104,291 @@ function makeColumnsResizable(tableSelector, storageKey) { }); } // end addResizeHandles } + +/** + * Body rows that can be measured column by column. Virtual-scroll spacers + * and "loading" rows are a single colspan cell; indexing their children by + * column puts the spacer's full table width into column 0. + */ +function measurableRows(table, limit) { + const tbody = table.querySelector('tbody'); + if (!tbody) return []; + return Array.from(tbody.children) + .filter(r => !Array.prototype.some.call(r.children, c => c.colSpan > 1)) + .slice(0, limit || 30); +} + +let _cellMeasureRange = null; + +/** + * Width a cell needs to show its content on one line, padding included. + * Measured from the laid-out content, so it works under table-layout: fixed + * without switching the table to auto. Resize handles and the "+N hidden" + * pill are absolutely positioned or transient and are left out. + */ +function cellContentWidth(cell) { + const range = _cellMeasureRange || (_cellMeasureRange = document.createRange()); + // Span from the leftmost to the rightmost node, so margins between them + // (the IATA badge's margin-left) count too. + let left = Infinity; + let right = -Infinity; + for (const n of cell.childNodes) { + if (n.nodeType === 1 && n.matches('.col-resize-handle, .col-hidden-pill')) continue; + range.selectNode(n); + const r = range.getBoundingClientRect(); + if (!r.width) continue; + left = Math.min(left, r.left); + right = Math.max(right, r.right); + } + const w = right > left ? right - left : 0; + const cs = getComputedStyle(cell); + return Math.ceil(w + parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight)); +} + +/** + * Split `avail` px between content-sized columns and flex columns. + * + * Each fixed column keeps its content width `w` when there is room. When + * there is not, the widest ones are capped at a common level (water-filling) + * so the flex columns still get `flexMin` each, but no column goes below its + * `floor`. Flex columns share what is left, the rounding remainder going to + * the last one. When even the floors leave less than `flexMin`, the flex + * columns shrink further, down to `flexHardMin`; below that `total` exceeds + * `avail` and the caller's wrapper scrolls. + * + * @param {{w: number, floor: number}[]} fixed + * @param {number} flexCount + * @param {number} avail + * @param {number} flexMin + * @param {number} [flexHardMin=flexMin] + * @returns {{fixed: number[], flex: number[], total: number}} + */ +function distributeColumnWidths(fixed, flexCount, avail, flexMin, flexHardMin) { + const hardMin = flexHardMin == null ? flexMin : Math.min(flexHardMin, flexMin); + const budget = avail - flexCount * flexMin; + const sumAt = cap => fixed.reduce((s, c) => s + Math.max(c.floor, Math.min(c.w, cap)), 0); + let cap = Infinity; + if (sumAt(Infinity) > budget) { + // Largest integer cap whose total fits; sumAt is monotonic in cap. + let lo = 0; + let hi = Math.max(0, ...fixed.map(c => c.w)); + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (sumAt(mid) <= budget) lo = mid; else hi = mid - 1; + } + cap = lo; + } + const widths = fixed.map(c => Math.max(c.floor, Math.min(c.w, cap))); + const fixedSum = widths.reduce((s, w) => s + w, 0); + const spare = Math.max(0, avail - fixedSum); + const share = flexCount ? Math.max(hardMin, Math.floor(spare / flexCount)) : 0; + const flexW = []; + for (let i = 0; i < flexCount; i++) flexW.push(share); + if (flexCount) flexW[flexCount - 1] += Math.max(0, spare - share * flexCount); + return { fixed: widths, flex: flexW, total: fixedSum + flexW.reduce((s, w) => s + w, 0) }; +} + +/** + * Pixel column sizing for dense tables: every column not listed in + * `opts.flex` is sized to its content, and the flex columns split whatever + * width the container has left. makeColumnsResizable stores percentages, + * which inflate a "17s ago" column to 180px on a wide screen; this keeps + * short columns short at every width. + * + * Every visible column gets an explicit px width and the table gets their + * sum as its width. Leaving the flex columns auto would hand a share of the + * slack to phantom columns too: a colspan spacer row keeps a slot for each + * hidden column, and that slot renders as a blank strip at the right edge. + * When the container is too narrow, the widest content-sized columns give + * way first (down to `opts.shrinkFloor`, their text ellipsised) so the flex + * columns keep `opts.flexMin`; then the flex columns shrink to + * `opts.flexHardMin`; past that the wrapper scrolls. A header's CSS + * max-width (Scope's 110px) caps its measured width. + * + * `opts.min` sets floors by column class, for columns whose widest content + * may not be on screen at measure time (the expand caret only appears on + * grouped rows). + * + * Content-sized columns have drag handles; dragged widths are saved to + * localStorage in px, keyed by column class, and give way under pressure + * like measured ones. Double-clicking a handle forgets its width. Flex + * columns have no handle: they always share the remaining width. + * + * Re-measures on its own when the container resizes (the detail panel) and + * when the table fires `table-columns-changed` (TableResponsive hiding or + * revealing columns). Call refit() after anything else that changes cell + * content width: column toggles, name display modes, sorting (the sort + * arrow moves to another header). Call grow() after rows render, with the + * inserted rows when only some are new. + * + * @param {string} tableSelector + * @param {string} storageKey + * @param {{flex: string[], flexMin?: number, flexHardMin?: number, shrinkFloor?: number, min?: Object}} opts + * @returns {{refit: function(): void, grow: function(Element[]=): void, destroy: function(): void}|null} + */ +function fitColumnsToContent(tableSelector, storageKey, opts) { + const table = document.querySelector(tableSelector); + if (!table) return null; + const ths = Array.from(table.querySelectorAll('thead tr:first-child th')); + if (ths.length < 2) return null; + const wrap = table.parentElement; + const flex = new Set(opts.flex); + const flexMin = opts.flexMin || 120; + const flexHardMin = opts.flexHardMin || 60; + const mins = opts.min || {}; + const shrinkFloor = opts.shrinkFloor || 64; + const MIN_DRAG = 28; + const colKey = th => Array.from(th.classList).find(c => c.startsWith('col-') && c !== 'col-hidden'); + + let dragged = {}; + try { dragged = JSON.parse(localStorage.getItem(storageKey)) || {}; } catch { dragged = {}; } + if (typeof dragged !== 'object' || Array.isArray(dragged)) dragged = {}; + for (const k of Object.keys(dragged)) { + if (flex.has(k) || !Number.isFinite(dragged[k]) || dragged[k] < MIN_DRAG) delete dragged[k]; + } + function saveDragged() { + try { localStorage.setItem(storageKey, JSON.stringify(dragged)); } catch { /* quota / private mode */ } + } + + table.style.tableLayout = 'fixed'; + + // Content widths of the visible fixed columns, from the last measure(). + let natural = new Map(); + let borderPx = 0; + + // Content widths per visible fixed column over `rows` (default: every + // rendered row; virtual scroll already bounds how many exist). + function measure(rows) { + rows = (rows || measurableRows(table, Infinity)) + .filter(r => !Array.prototype.some.call(r.children, c => c.colSpan > 1)); + const next = new Map(); + ths.forEach((th, i) => { + const key = colKey(th); + if (th.offsetParent === null || dragged[key] || flex.has(key)) return; + let w = Math.max(mins[key] || 0, cellContentWidth(th)); + rows.forEach(r => { if (r.children[i]) w = Math.max(w, cellContentWidth(r.children[i])); }); + const maxW = parseFloat(getComputedStyle(th).maxWidth); + if (Number.isFinite(maxW)) w = Math.min(w, maxW); + next.set(th, w); + }); + return next; + } + + function layout() { + const fixedThs = []; + const fixedCols = []; + const flexThs = []; + ths.forEach(th => { + if (th.offsetParent === null) return; + const key = colKey(th); + const w = dragged[key] || natural.get(th); + if (flex.has(key)) flexThs.push(th); + else if (w) { + fixedThs.push(th); + fixedCols.push({ w, floor: Math.min(w, Math.max(mins[key] || 0, shrinkFloor)) }); + } else th.style.width = ''; + }); + const apply = () => { + const d = distributeColumnWidths(fixedCols, flexThs.length, wrap.clientWidth - borderPx, flexMin, flexHardMin); + fixedThs.forEach((th, i) => { th.style.width = d.fixed[i] + 'px'; }); + flexThs.forEach((th, i) => { th.style.width = d.flex[i] + 'px'; }); + table.style.width = d.total + 'px'; + return d.total; + }; + // Collapsed borders (the row's coloured left edge) render a few px + // beyond the column sum. Learn how many on the first pass and lay out + // again without them, so a table that should fit grows no scrollbar. + // Bounded: anything bigger is not a border and must not eat the width. + const total = apply(); + const extra = Math.min(8, Math.max(0, table.offsetWidth - total)); + if (extra !== borderPx) { borderPx = extra; apply(); } + } + + function refit() { + if (!table.isConnected) return; + natural = measure(); + layout(); + } + + // After new rows render: widen any column whose content outgrew it (rows + // age from "9m ago" to "10m ago", scrolling brings longer names), but never + // narrow one, so columns do not jitter as rows scroll past. Pass just the + // rows that were inserted when the others are unchanged (virtual-scroll + // steps): measuring is ~10 rects per cell. + function grow(rows) { + if (!table.isConnected) return; + if (rows && !rows.length) return; + let changed = false; + measure(rows).forEach((w, th) => { + if (w > (natural.get(th) || 0)) { natural.set(th, w); changed = true; } + }); + if (changed) layout(); + } + + if (!table.dataset.resizable) { + table.dataset.resizable = '1'; + ths.forEach(th => { + const key = colKey(th); + if (flex.has(key)) return; + const handle = document.createElement('div'); + handle.className = 'col-resize-handle'; + handle.title = 'Drag to resize, double-click to fit the content'; + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const startW = th.offsetWidth; + handle.classList.add('active'); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + function onMove(e2) { + // Re-lay out on every move so the flex columns give the width + // back: setting only this lets fixed layout spread the + // difference over every column, and the edge lags the pointer. + dragged[key] = Math.max(MIN_DRAG, startW + e2.clientX - startX); + layout(); + } + function onUp() { + handle.classList.remove('active'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + if (dragged[key]) saveDragged(); + refit(); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + handle.addEventListener('dblclick', (e) => { + e.preventDefault(); + e.stopPropagation(); + delete dragged[key]; + saveDragged(); + refit(); + }); + // A handle click must not reach the header's sort handler. + handle.addEventListener('click', (e) => e.stopPropagation()); + th.appendChild(handle); + }); + } + + // The container changes width when the detail panel opens or is dragged: + // redistribute only. Responsive hiding changes which columns exist. + let lastWrapW = wrap.clientWidth; + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => { + if (!table.isConnected || wrap.clientWidth === lastWrapW) return; + lastWrapW = wrap.clientWidth; + layout(); + }) : null; + if (ro) ro.observe(wrap); + table.addEventListener('table-columns-changed', refit); + + function destroy() { + if (ro) ro.disconnect(); + table.removeEventListener('table-columns-changed', refit); + } + + refit(); + return { refit, grow, destroy }; +} diff --git a/public/packets.js b/public/packets.js index 5abee67ac..35b511d2c 100644 --- a/public/packets.js +++ b/public/packets.js @@ -26,6 +26,17 @@ function thsOf(table) { return Array.from(table.querySelectorAll('thead > tr > th')); } + // Fire `table-columns-changed` on the table when the set of hidden columns + // differs from the last call, so column sizing (fitColumnsToContent) can + // re-measure. apply() runs on every tbody mutation; most leave the set + // alone and dispatch nothing. + function notifyIfChanged(table) { + const sig = thsOf(table).map(th => th.classList.contains(HIDDEN_CLASS) ? '1' : '0').join(''); + if (table.__trHiddenSig === sig) return; + table.__trHiddenSig = sig; + table.dispatchEvent(new CustomEvent('table-columns-changed')); + } + function clearHidden(table) { table.querySelectorAll('.' + HIDDEN_CLASS).forEach(el => el.classList.remove(HIDDEN_CLASS)); const pill = table.querySelector('.' + PILL_CLASS); @@ -55,6 +66,7 @@ if (table[REVEAL_FLAG]) { // user explicitly requested reveal — clear hidden state and skip clearHidden(table); + notifyIfChanged(table); return; } clearHidden(table); @@ -101,6 +113,7 @@ ev.preventDefault(); table[REVEAL_FLAG] = true; clearHidden(table); + notifyIfChanged(table); // Add a small "hide again" affordance after reveal so the user isn't stuck. const rehide = document.createElement('button'); rehide.type = 'button'; @@ -127,6 +140,7 @@ }); host.appendChild(pill); } + notifyIfChanged(table); } // Track tables we've wired up so resize triggers re-apply. @@ -662,6 +676,12 @@ if (!o) return id; return o.name; } + // Observer name as the table cell shows it: cut to `maxLen` characters + // unless the Full Names toggle is on. Escaped, ready for innerHTML. + function obsCellName(id, maxLen) { + const name = obsNameOnly(id); + return escapeHtml(showFullNames ? name : truncate(name, maxLen)); + } // #1189 R1 mesh-operator feedback: in a grouped row the old cell showed ONE // observer's IATA + `+N` — operators couldn't tell whether the N additional // observers were SAME-region (redundant copies) or CROSS-region (interesting @@ -743,6 +763,10 @@ let _packetSortColumn = null; let _packetSortDirection = 'desc'; let showHexHashes = localStorage.getItem('meshcore-hex-hashes') === 'true'; + // "Full Names": show observer and path hop names untruncated (path chips + // lose their 120px cap; hops past the column edge go behind the +N pill). + let showFullNames = localStorage.getItem('meshcore-full-names') === 'true'; + let _colFit = null; // fitColumnsToContent controller for #pktTable var _pendingUrlRegion = null; var DEFAULT_TIME_WINDOW = 15; @@ -756,6 +780,7 @@ if (filters.observer) parts.push('observer=' + encodeURIComponent(filters.observer)); if (filters.channel) parts.push('channel=' + encodeURIComponent(filters.channel)); if (filters._filterExpr) parts.push('filter=' + encodeURIComponent(filters._filterExpr)); + if (showFullNames) parts.push('fullNames=1'); // Sort state (#749) — encode as 'col[:asc]'; default 'time:desc' is omitted. if (_packetSortColumn) { var sortDefault = _packetSortColumn === 'time' && _packetSortDirection === 'desc'; @@ -1181,6 +1206,12 @@ // Read URL params (router strips query from routeParam; read from location.hash) var _initUrlParams = getHashParams(); directObsId = _initUrlParams.get('obs'); + // Full Names is a view mode: a shared link carries it (fullNames=1/0). + var _urlFullNames = _initUrlParams.get('fullNames'); + if (_urlFullNames === '1' || _urlFullNames === '0') { + showFullNames = _urlFullNames === '1'; + localStorage.setItem('meshcore-full-names', showFullNames); + } var _urlTimeWindow = Number(_initUrlParams.get('timeWindow')); if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) { savedTimeWindowMin = _urlTimeWindow; @@ -1435,6 +1466,8 @@ if (wsHandler) offWS(wsHandler); wsHandler = null; if (_tableSortInstance) { _tableSortInstance.destroy(); _tableSortInstance = null; } + if (_colFit) { _colFit.destroy(); _colFit = null; } + if (_pathPopoverClose) _pathPopoverClose(false); detachVScrollListener(); clearTimeout(_wsRenderTimer); if (_wsRafId) { cancelAnimationFrame(_wsRafId); _wsRafId = null; } @@ -1478,6 +1511,10 @@ if (typeof _rebuildObserverMenu === 'function') { try { _rebuildObserverMenu(); } catch {} } + // Rows drawn before observers arrived show raw observer ids (a 64-char + // pubkey under Full Names). Redraw them with names and re-measure: + // grow() alone would leave the Observer column at the id's width. + if (_colFit) renderTableRows().then(() => { if (_colFit) _colFit.refit(); }); } catch {} } @@ -1720,10 +1757,11 @@
+
${(window.HopDisplay && HopDisplay.renderPathSymbolsLegend) ? HopDisplay.renderPathSymbolsLegend() : ''}
-
+
@@ -2190,6 +2228,7 @@ }); localStorage.setItem('packets-visible-cols', JSON.stringify(visibleCols)); localStorage.setItem('packets-known-cols', JSON.stringify(COL_DEFS.map(c => c.key))); + if (_colFit) _colFit.refit(); } colMenu.innerHTML = COL_DEFS.map(c => `` @@ -2216,6 +2255,17 @@ renderTableRows(); }); + document.getElementById('fullNamesToggle').addEventListener('click', function () { + showFullNames = !showFullNames; + localStorage.setItem('meshcore-full-names', showFullNames); + this.classList.toggle('active', showFullNames); + pktTable.classList.toggle('pkt-full-names', showFullNames); + updatePacketsUrl(); + // Observer widths follow the names: re-measure once rows are rebuilt + // (grow() alone would never shrink the column back). + renderTableRows().then(() => { if (_colFit) _colFit.refit(); }); + }); + // Node name filter with autocomplete const fNode = document.getElementById('fNode'); const fNodeDrop = document.getElementById('fNodeDropdown'); @@ -2300,7 +2350,7 @@ if (pktBody) { const handler = (e) => { // Let hop links navigate naturally without selecting the row - if (e.target.closest('[data-hop-link]')) return; + if (e.target.closest('[data-hop-link], .path-overflow-pill')) return; const row = e.target.closest('tr[data-action]'); if (!row) return; if (e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; @@ -2345,7 +2395,18 @@ }); renderTableRows(); - makeColumnsResizable('#pktTable', 'meshcore-pkt-col-widths'); + // Short columns (time, hash, size, ...) take the pixels their content + // needs; the rest of the width goes to Path and Details. The old + // percentage layout scaled a "17s ago" column to 180px on wide screens. + try { localStorage.removeItem('meshcore-pkt-col-widths'); } catch (_) {} + if (_colFit) _colFit.destroy(); + _colFit = fitColumnsToContent('#pktTable', 'meshcore-pkt-col-px', { + flex: ['col-path', 'col-details'], + // Floors for content that only grouped rows carry, which may not be + // on screen when the table is measured: the caret and a 2-digit + // "seen N times" badge. + min: { 'col-expand': 32, 'col-rpt': 58 }, + }); // #1056: register fluid-column responsive behavior (drops priority>1 cols // when narrow, shows "+N hidden" pill, reveals on click). Idempotent. if (window.TableResponsive) { @@ -2369,6 +2430,7 @@ sortPacketsArray(); renderTableRows(); updatePacketsUrl(); + if (_colFit) _colFit.refit(); } }); // Apply initial sort state from TableSort @@ -2380,6 +2442,9 @@ } } } + // Re-measure now that the sort arrow is in its header and responsive + // hiding has run. + if (_colFit) _colFit.refit(); } // Build HTML for a single grouped packet row @@ -2415,7 +2480,7 @@ const _grpHashStripe = _hashStripeStyle(p.hash); const _grpStyle = _grpHashStripe + _grpChanStyle; let html = ` - + @@ -2423,7 +2488,7 @@ - + @@ -2458,7 +2523,7 @@ - + @@ -2495,7 +2560,7 @@ - + @@ -2634,6 +2699,8 @@ if (h > 0) { VSCROLL_ROW_HEIGHT = h; _vscrollRowHeightMeasured = true; } } } + if (_colFit) _colFit.grow(); + _closeStalePathPopover(); if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: full rebuild %d entries, %.2fms', endIdx - startIdx, performance.now() - _rvr_t0); _finalizePathOverflow(tbody); // #1128 (Bug 1): hop-resolver mutates chip text from hex prefix to a @@ -2658,13 +2725,18 @@ const row = bottomSpacer.previousElementSibling; if (row && row !== topSpacer) row.remove(); } + // Rows inserted below, collected so column fitting measures only them. + const inserted = []; + const collect = (from, to) => { for (let r = from; r && r !== to; r = r.nextElementSibling) inserted.push(r); }; // Prepend rows that scrolled into view at the top if (startIdx < prevStart) { let html = ''; for (let i = startIdx; i < Math.min(prevStart, endIdx); i++) { html += builder(_displayPackets[i], i); } + const oldFirst = topSpacer.nextElementSibling; topSpacer.insertAdjacentHTML('afterend', html); + collect(topSpacer.nextElementSibling, oldFirst); } // Append rows that scrolled into view at the bottom if (endIdx > prevEnd) { @@ -2672,8 +2744,12 @@ for (let i = Math.max(prevEnd, startIdx); i < endIdx; i++) { html += builder(_displayPackets[i], i); } + const oldLast = bottomSpacer.previousElementSibling; bottomSpacer.insertAdjacentHTML('beforebegin', html); + collect(oldLast === topSpacer || !oldLast ? topSpacer.nextElementSibling : oldLast.nextElementSibling, bottomSpacer); } + if (_colFit) _colFit.grow(inserted); + _closeStalePathPopover(); if (window.__PERF_LOG_RENDER) console.log('[perf] renderVisibleRows: incremental head=%d tail=%d, %.2fms', headRowCount, tailRowCount, performance.now() - _rvr_t0); _finalizePathOverflow(tbody); _scheduleReFinalizePathOverflow(tbody); @@ -2696,11 +2772,11 @@ var hostRight = host.getBoundingClientRect().right; if (!hostRight) continue; var hidden = 0; - // Walk pairs of chip + arrow; count chips (not arrows) whose right edge - // is past the host's right edge. + // Count hop chips whose right edge is past the host's right edge. + // Arrows and a hop's warning buttons are not hops of their own. for (var j = 0; j < children.length; j++) { var ch = children[j]; - if (ch.classList.contains('arrow')) continue; + if (!ch.classList.contains('hop')) continue; var r = ch.getBoundingClientRect(); if (r.left >= hostRight || r.right > hostRight + 0.5) hidden++; } @@ -2708,10 +2784,10 @@ var pill = document.createElement('span'); pill.className = 'path-overflow-pill'; pill.textContent = '+' + hidden; - pill.title = hidden + ' more hop' + (hidden === 1 ? '' : 's') + ' — click to view'; + // No native title: hovering shows the full-path popover instead. pill.setAttribute('role', 'button'); pill.setAttribute('tabindex', '0'); - pill.setAttribute('aria-label', hidden + ' more hops'); + pill.setAttribute('aria-label', hidden + ' more hop' + (hidden === 1 ? '' : 's') + ', show the full path'); host.appendChild(pill); } host.dataset.overflowChecked = '1'; @@ -2760,65 +2836,164 @@ } } - // Delegated click for path overflow pills — show popover of full path. + // Split a rendered .path-hops host into hops: each hop is its chip plus + // any warning buttons that follow it, up to the next arrow. The overflow + // pill itself is left out. + function _pathHopSegments(host) { + var segments = []; + var current = ''; + var kids = Array.prototype.slice.call(host.children); + for (var i = 0; i < kids.length; i++) { + var k = kids[i]; + if (k.classList.contains('path-overflow-pill')) continue; + if (k.classList.contains('arrow')) { + if (current) segments.push(current); + current = ''; + continue; + } + current += k.outerHTML; + } + if (current) segments.push(current); + return segments; + } + + // The full path as a vertical list, one hop per line, in path order. Each + // chip keeps its .path-hops styling; the 120px cap is lifted in CSS. + function _buildPathPopover(host) { + var segments = _pathHopSegments(host); + var pop = document.createElement('div'); + pop.className = 'path-popover'; + pop.id = 'pathPopover'; + pop.setAttribute('role', 'tooltip'); + var html = '
Full path · ' + segments.length + ' hop' + (segments.length === 1 ? '' : 's') + '
    '; + for (var i = 0; i < segments.length; i++) { + html += '
  1. ' + (i + 1) + '' + segments[i] + '
  2. '; + } + pop.innerHTML = html + '
'; + return pop; + } + + // Below the pill by default; above it when there is no room below. Kept + // inside the viewport horizontally. + function _positionPathPopover(pop, pill) { + var r = pill.getBoundingClientRect(); + var popH = pop.getBoundingClientRect().height; + var roomBelow = window.innerHeight - r.bottom; + var top = (roomBelow < popH + 12 && r.top > popH + 12) + ? window.scrollY + r.top - popH - 4 + : window.scrollY + r.bottom + 4; + pop.style.top = top + 'px'; + pop.style.left = (window.scrollX + r.left) + 'px'; + var pr = pop.getBoundingClientRect(); + if (pr.right > window.innerWidth - 8) { + pop.style.left = Math.max(8, window.scrollX + window.innerWidth - pr.width - 8) + 'px'; + } + } + + // Set by _wirePathOverflowPopover: closes the open path popover (or only + // one whose pill has left the DOM). The popover lives on , outside + // the table, so neither row re-renders nor page changes remove it. + let _pathPopoverClose = null; + function _closeStalePathPopover() { if (_pathPopoverClose) _pathPopoverClose(true); } + + // Delegated handlers for path overflow pills. Hovering or focusing a pill + // shows the full path; moving into the popover keeps it open so its hop + // links can be clicked. Clicking the pill (or Enter/Space) pins it until + // an outside click or Escape; clicking a pinned pill closes it. function _wirePathOverflowPopover() { if (window.__pathOverflowWired) return; window.__pathOverflowWired = true; + var HOVER_DELAY_MS = 120; + var HIDE_GRACE_MS = 150; var existing = null; + var anchor = null; + var pinned = false; + var showTimer = null; + var hideTimer = null; + _pathPopoverClose = function (onlyIfStale) { + if (!existing) return; + if (onlyIfStale && anchor && anchor.isConnected) return; + dismiss(); + }; + function pillOf(el) { return el && el.closest ? el.closest('.path-overflow-pill') : null; } + function inPopover(el) { return !!(existing && el && existing.contains(el)); } function dismiss() { + clearTimeout(showTimer); + clearTimeout(hideTimer); if (existing) { existing.remove(); existing = null; } + if (anchor) { anchor.removeAttribute('aria-describedby'); anchor = null; } + pinned = false; document.removeEventListener('mousedown', onDoc, true); - document.removeEventListener('keydown', onKey, true); } function onDoc(ev) { - if (existing && !existing.contains(ev.target) && !ev.target.classList.contains('path-overflow-pill')) dismiss(); + if (!inPopover(ev.target) && !pillOf(ev.target)) dismiss(); } - function onKey(ev) { if (ev.key === 'Escape') dismiss(); } - document.addEventListener('click', function(ev) { - var pill = ev.target.closest && ev.target.closest('.path-overflow-pill'); - if (!pill) return; - ev.stopPropagation(); - var host = pill.closest('.path-hops'); - if (!host) return; + function open(pill, pin) { + clearTimeout(showTimer); + clearTimeout(hideTimer); + if (existing && anchor === pill) { if (pin) pinned = true; return; } dismiss(); - var pop = document.createElement('div'); - pop.className = 'path-popover'; - // Clone all children except the pill, preserving rendered chips/arrows. - var inner = '
Full path (' + (host.children.length) + ' items)
'; - var kids = Array.prototype.slice.call(host.children); - for (var i = 0; i < kids.length; i++) { - if (kids[i].classList.contains('path-overflow-pill')) continue; - inner += kids[i].outerHTML; - } - inner += '
'; - pop.innerHTML = inner; + var host = pill.closest('.path-hops'); + if (!host || !pill.isConnected) return; + var pop = _buildPathPopover(host); document.body.appendChild(pop); - var r = pill.getBoundingClientRect(); - // #1128 (Bug 2): position below by default, but flip ABOVE when there - // isn't enough room — keeps the popover anchored to the pill instead of - // hanging arbitrarily over adjacent rows / off-screen. - var pr0 = pop.getBoundingClientRect(); - var popH = pr0.height; - var roomBelow = window.innerHeight - r.bottom; - var top; - if (roomBelow < popH + 12 && r.top > popH + 12) { - top = window.scrollY + r.top - popH - 4; - } else { - top = window.scrollY + r.bottom + 4; - } - var left = window.scrollX + r.left; - pop.style.top = top + 'px'; - pop.style.left = left + 'px'; - var pr = pop.getBoundingClientRect(); - if (pr.right > window.innerWidth - 8) { - pop.style.left = Math.max(8, window.scrollX + window.innerWidth - pr.width - 8) + 'px'; - } + _positionPathPopover(pop, pill); existing = pop; - setTimeout(function() { - document.addEventListener('mousedown', onDoc, true); - document.addEventListener('keydown', onKey, true); - }, 0); + anchor = pill; + pinned = !!pin; + pill.setAttribute('aria-describedby', pop.id); + setTimeout(function () { document.addEventListener('mousedown', onDoc, true); }, 0); + } + function scheduleHide() { + clearTimeout(showTimer); + if (pinned || !existing) return; + clearTimeout(hideTimer); + hideTimer = setTimeout(dismiss, HIDE_GRACE_MS); + } + function toggle(pill) { + if (existing && anchor === pill && pinned) dismiss(); + else open(pill, true); + } + document.addEventListener('mouseover', function (ev) { + var pill = pillOf(ev.target); + if (pill) { + clearTimeout(hideTimer); + if (existing && anchor === pill) return; + clearTimeout(showTimer); + showTimer = setTimeout(function () { open(pill, false); }, HOVER_DELAY_MS); + } else if (inPopover(ev.target)) { + clearTimeout(hideTimer); + } + }); + document.addEventListener('mouseout', function (ev) { + if (!pillOf(ev.target) && !inPopover(ev.target)) return; + var to = ev.relatedTarget; + if (inPopover(to) || (anchor && pillOf(to) === anchor)) return; + scheduleHide(); + }); + document.addEventListener('focusin', function (ev) { + var pill = pillOf(ev.target); + if (pill) open(pill, false); }); + document.addEventListener('focusout', function (ev) { + if (pillOf(ev.target) && !inPopover(ev.relatedTarget)) scheduleHide(); + }); + // Capture phase: the table row's own click/keyboard handlers on #pktBody + // would otherwise select the row first. + document.addEventListener('click', function (ev) { + var pill = pillOf(ev.target); + if (!pill) return; + ev.stopPropagation(); + toggle(pill); + }, true); + document.addEventListener('keydown', function (ev) { + if (ev.key === 'Escape' && existing) { dismiss(); return; } + var pill = pillOf(ev.target); + if (!pill || (ev.key !== 'Enter' && ev.key !== ' ')) return; + ev.preventDefault(); + ev.stopPropagation(); + toggle(pill); + }, true); } // Attach/detach scroll listener for virtual scrolling @@ -4220,6 +4395,7 @@ renderTableRows, _setPackets: function(p) { packets = p; }, _setFilter: function(k, v) { filters[k] = v; }, + _setFullNames: function(v) { showFullNames = !!v; }, }; } diff --git a/public/style.css b/public/style.css index 7b95eda7b..7622ece89 100644 --- a/public/style.css +++ b/public/style.css @@ -1590,6 +1590,19 @@ body.scroll-locked { overflow: hidden; } * abbreviated 7-char chips + arrow. .path-hops already uses 11px font, so * no chip-level font change is needed here. */ .data-table td.col-path, .data-table th.col-path { min-width: 140px; } +/* Packets columns are sized to their widest cell (fitColumnsToContent in + * app.js). Proportional digits make "20m ago" wider than "13m ago", so a + * column measured on one row clipped the next; fixed-width digits keep + * same-length values the same width. */ +#pktTable td.col-time, #pktTable td.col-size { font-variant-numeric: tabular-nums; } +/* "Full Names" toggle (packets.js): hop chips keep their whole name. The + * path stays on one line; hops past the edge go behind the "+N" overflow + * pill (_finalizePathOverflow). Wrapping would make rows taller than the + * fixed VSCROLL_ROW_HEIGHT the virtual scroller positions rows with. No cap + * at all, not even 100%: a single name longer than the column must overflow + * so the pill (and its full-path popover) appears, rather than ellipsise + * inside its chip with no way to read it. */ +#pktTable.pkt-full-names .path-hops .hop-named { max-width: none; } .data-table td.col-path { height: 28px; line-height: 22px; overflow: hidden; } /* === Modal === */ @@ -2567,11 +2580,15 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } /* === Grouped Packet Rows === */ .group-header { cursor: pointer; font-weight: 600; } -.group-header td:first-child::before { content: '▶ '; font-size: 10px; color: var(--link-color); transition: transform 0.15s; display: inline-block; } -.group-header.expanded td:first-child::before { content: '▼ '; } +/* The expand chevron is the SVG caret packets.js renders into td.col-expand; + * a text ::before glyph here drew a second arrow next to it. */ +.group-header td.col-expand { color: var(--link-color); } .group-header:hover { background: var(--row-hover); } .group-child { font-size: 12px; } -.group-child td { padding-left: 20px; } +/* Indent only the first data cell: indenting every cell pushed child rows + * out of line with the headers, and made each content-sized column measure + * 14px wider once a group was expanded. */ +.group-child td.col-time { padding-left: 20px; } .group-child.hidden { display: none; } /* === Reduced Motion === */ @@ -5010,18 +5027,32 @@ body[data-cb-preset="achromat"] { border-radius: 8px; cursor: pointer; user-select: none; -} -.path-overflow-pill:hover { color: var(--text); background: var(--bg-hover, var(--surface)); } + /* The pill is the last flex item after the chips that overflow, so on its + * own it sits past the clipped edge and never shows. Stick it to the + * right edge of .path-hops (overflow: hidden), over the last chip. */ + position: sticky; right: 0; z-index: 1; +} +/* Opaque on hover: the pill sits over the last chip, and --bg-hover is a + * translucent tint that let the chip show through. */ +.path-overflow-pill:hover { color: var(--text); background: var(--row-hover); border-color: var(--accent); } .path-popover { position: absolute; z-index: var(--z-popover); background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); - padding: 8px 10px; max-width: min(360px, calc(100vw - 32px)); max-height: 240px; overflow: auto; + padding: 8px 10px; max-width: min(480px, calc(100vw - 32px)); max-height: min(420px, 60vh); overflow: auto; font-family: var(--mono); font-size: 12px; } .path-popover .path-popover-title { font-family: var(--font); font-size: 11px; color: var(--text-muted); margin-bottom: 4px; } .path-popover .hop, .path-popover .hop-named { white-space: nowrap; } +/* Full path, one hop per line (packets.js _buildPathPopover). Each hop sits + * in its own .path-hops so chips keep their table styling, minus the 120px + * cap: the popover exists to show the names the table had to cut. */ +.path-popover-list { list-style: none; margin: 0; padding: 0; } +.path-popover-list li { display: flex; align-items: center; gap: 8px; padding: 2px 0; } +.path-popover-idx { min-width: 2ch; text-align: right; color: var(--text-muted); font-size: 11px; font-variant-numeric: tabular-nums; } +.path-popover .path-hops { max-width: none; } +.path-popover .path-hops .hop-named { max-width: none; } .fux-popover-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--surface); } .fux-popover-close { background: transparent; border: none; color: var(--text-muted); font-size: 16px; cursor: pointer; padding: 0 4px; } .fux-popover-close:hover { color: var(--text); } diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index b72ee94fe..37c048e84 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -103,6 +103,7 @@ "tests/e2e/test-node-reach-e2e.js", "tests/e2e/test-nodes-export-e2e.js", "tests/e2e/test-observer-iata-1188-e2e.js", + "tests/e2e/test-packets-compact-columns-e2e.js", "tests/e2e/test-packets-scope-column.js", "tests/e2e/test-path-inspector-coverage-e2e.js", "tests/e2e/test-rx-coverage-mobile-nav-e2e.js", diff --git a/tests/e2e/test-packets-compact-columns-e2e.js b/tests/e2e/test-packets-compact-columns-e2e.js new file mode 100644 index 000000000..f6b0d35aa --- /dev/null +++ b/tests/e2e/test-packets-compact-columns-e2e.js @@ -0,0 +1,417 @@ +/** + * Playwright E2E — packets table column packing and the Full Names toggle. + * + * The packets table used to store every column width as a percentage of the + * table, measured once on load. Percentages scale with the screen, so on a + * wide monitor "17s ago" sat in a 180px column; and the measurement read the + * virtual-scroll spacer row (one colspan cell as wide as the table) as + * column 0, inflating the expand column too. fitColumnsToContent (app.js) + * now sizes short columns to their content in px and gives the rest to Path + * and Details. + * + * Usage: BASE_URL=http://localhost:13581 node tests/e2e/test-packets-compact-columns-e2e.js + */ +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; +const results = []; + +async function test(name, fn) { + try { + await fn(); + results.push({ name, pass: true }); + console.log(` ✅ ${name}`); + } catch (err) { + results.push({ name, pass: false, error: err.message }); + console.log(` ❌ ${name}: ${err.message}`); + } +} + +function assert(condition, msg) { + if (!condition) throw new Error(msg || 'Assertion failed'); +} + +// Upper bounds for the short columns at any desktop width. Content needs +// ~30-70px; the old percentage layout gave them 100-180px at 1920px. +const MAX_WIDTH = { + 'col-expand': 40, + 'col-time': 100, + 'col-size': 70, + 'col-hashsize': 50, + 'col-scope': 80, + 'col-rpt': 70, +}; + +async function gotoPackets(page, prefs) { + // The window goes in the URL: mobile clamps a stored window above 180min + // back to 15min, and the fixture is freshened at the start of the CI job. + await page.goto(BASE + '/#/packets?timeWindow=10080', { waitUntil: 'domcontentloaded' }); + await page.evaluate((extra) => { + localStorage.removeItem('packets-visible-cols'); + localStorage.removeItem('packets-known-cols'); + localStorage.removeItem('meshcore-pkt-col-px'); + localStorage.removeItem('meshcore-full-names'); + for (const [k, v] of Object.entries(extra || {})) localStorage.setItem(k, v); + }, prefs); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', + { state: 'attached', timeout: 30000 }); + // Let the post-render refit settle. + await page.waitForTimeout(400); +} + +function layout(page) { + return page.evaluate(() => { + const t = document.getElementById('pktTable'); + const wrap = t.parentElement; + const widths = {}; + t.querySelectorAll('thead th').forEach(th => { + if (th.offsetParent === null) return; + const key = Array.from(th.classList).find(c => c.startsWith('col-')); + widths[key] = th.offsetWidth; + }); + const row = Array.from(t.querySelectorAll('tbody tr')) + .find(r => r.children.length === t.querySelectorAll('thead th').length); + const cells = row ? Array.from(row.children).filter(td => td.offsetParent !== null) : []; + const lastCell = cells[cells.length - 1]; + return { + widths, + tableRight: t.getBoundingClientRect().right, + lastCellRight: lastCell ? lastCell.getBoundingClientRect().right : null, + hScroll: wrap.scrollWidth - wrap.clientWidth, + }; + }); +} + +(async () => { + console.log(`\nPackets compact columns — ${BASE}\n`); + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } }); + + await gotoPackets(page); + + await test('short columns stay short on a wide screen', async () => { + const { widths } = await layout(page); + for (const [col, max] of Object.entries(MAX_WIDTH)) { + if (widths[col] == null) continue; // hidden by a preference + assert(widths[col] <= max, `${col} is ${widths[col]}px, expected <= ${max}px (all: ${JSON.stringify(widths)})`); + } + }); + + await test('Path and Details take the freed width', async () => { + const { widths } = await layout(page); + assert(widths['col-path'] >= 400, `col-path only ${widths['col-path']}px`); + assert(widths['col-details'] >= 400, `col-details only ${widths['col-details']}px`); + }); + + await test('no blank strip after the last column, no horizontal scroll', async () => { + const l = await layout(page); + assert(l.lastCellRight != null, 'no data row to measure'); + assert(Math.abs(l.tableRight - l.lastCellRight) <= 2, + `last cell ends at ${l.lastCellRight}, table at ${l.tableRight}: a hidden column's slot is taking width`); + assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px`); + }); + + await test('opening the detail panel re-fits the table without scrolling', async () => { + await page.click('#pktTable tbody tr:not([id^=vscroll]) td.col-time'); + await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 }); + await page.waitForTimeout(400); + const l = await layout(page); + assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px with the panel open`); + await page.keyboard.press('Escape'); + await page.waitForTimeout(400); + const after = await layout(page); + assert(after.widths['col-details'] >= 400, `Details did not grow back after closing the panel: ${after.widths['col-details']}px`); + }); + + await test('stale percentage widths from the old layout are dropped', async () => { + const stale = JSON.stringify([12.6, 0.4, 8.7, 9.1, 5, 3.4, 12.6, 6.2, 12.6, 12.6, 4, 12.8]); + await gotoPackets(page, { 'meshcore-pkt-col-widths': stale }); + const gone = await page.evaluate(() => localStorage.getItem('meshcore-pkt-col-widths') === null); + assert(gone, 'meshcore-pkt-col-widths still present'); + const { widths } = await layout(page); + assert(widths['col-time'] <= MAX_WIDTH['col-time'], `col-time ${widths['col-time']}px after a stale save`); + }); + + await test('Full Names shows observer names untruncated and lifts the chip cap', async () => { + await gotoPackets(page); + const before = await page.evaluate(() => ({ + cls: document.getElementById('pktTable').classList.contains('pkt-full-names'), + chipMax: (() => { const c = document.querySelector('#pktTable .path-hops .hop-named'); return c ? getComputedStyle(c).maxWidth : null; })(), + })); + assert(!before.cls, 'pkt-full-names set before toggling'); + if (before.chipMax) assert(before.chipMax === '120px', `default chip cap is ${before.chipMax}`); + + await page.click('#fullNamesToggle'); + await page.waitForTimeout(600); + const after = await page.evaluate(() => { + const t = document.getElementById('pktTable'); + const obs = Array.from(t.querySelectorAll('td.col-observer')).map(td => td.textContent); + const chip = t.querySelector('.path-hops .hop-named'); + return { + cls: t.classList.contains('pkt-full-names'), + active: document.getElementById('fullNamesToggle').classList.contains('active'), + ellipsised: obs.filter(s => s.includes('…')).length, + chipMax: chip ? getComputedStyle(chip).maxWidth : null, + hScroll: t.parentElement.scrollWidth - t.parentElement.clientWidth, + // The virtual scroller positions rows at a fixed height: a path that + // wrapped onto a second line would break it. + tallPaths: Array.from(t.querySelectorAll('.path-hops')).filter(h => h.offsetHeight > 24).length, + // Hops past the edge are reachable through the +N pill, which must + // sit inside the clipped path rather than past its edge. + offscreenPills: Array.from(t.querySelectorAll('.path-overflow-pill')).filter(p => { + const host = p.closest('.path-hops').getBoundingClientRect(); + const r = p.getBoundingClientRect(); + return r.right > host.right + 1 || r.left < host.left; + }).length, + }; + }); + assert(after.cls && after.active, 'toggle did not switch the table to full names'); + assert(after.ellipsised === 0, `${after.ellipsised} observer cells still end in "…"`); + if (after.chipMax) assert(after.chipMax !== '120px', 'path chips still capped at 120px'); + assert(after.hScroll <= 0, `table overflows by ${after.hScroll}px in full-name mode`); + assert(after.tallPaths === 0, `${after.tallPaths} paths wrapped onto a second line`); + assert(after.offscreenPills === 0, `${after.offscreenPills} "+N" overflow pills are outside the visible path`); + }); + + await test('Full Names persists across reloads', async () => { + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', + { state: 'attached', timeout: 30000 }); + const on = await page.evaluate(() => + document.getElementById('pktTable').classList.contains('pkt-full-names') && + document.getElementById('fullNamesToggle').classList.contains('active')); + assert(on, 'full-name mode lost on reload'); + await page.click('#fullNamesToggle'); + }); + + await test('hovering the +N pill lists the full path vertically; click pins it', async () => { + await gotoPackets(page, { 'meshcore-full-names': 'true' }); + // The pill is (re)computed up to 1s after rendering. + await page.waitForTimeout(1500); + const pillSel = await page.evaluate(() => { + const pills = Array.from(document.querySelectorAll('#pktTable .path-overflow-pill')); + if (!pills.length) return null; + pills[0].closest('tr').setAttribute('data-e2e-pill-row', '1'); + return '#pktTable tr[data-e2e-pill-row] .path-overflow-pill'; + }); + // At 1920px with full names the fixture's multi-hop paths always overflow; + // no pill means the pill logic broke, not that there is nothing to test. + assert(pillSel, 'no "+N" overflow pill rendered under Full Names'); + const hopsInRow = await page.$eval(pillSel, p => + p.closest('.path-hops').querySelectorAll('.arrow').length + 1); + + await page.hover(pillSel); + await page.waitForSelector('#pathPopover', { timeout: 3000 }); + // The pill sits over the last chip: a translucent hover background let + // the chip show through it. + const hoverBg = await page.$eval(pillSel, p => getComputedStyle(p).backgroundColor); + const alpha = /rgba\([^)]*,\s*([\d.]+)\)/.exec(hoverBg); + assert(hoverBg !== 'transparent' && (!alpha || parseFloat(alpha[1]) === 1), + `hovered pill background is not opaque: ${hoverBg}`); + const pop = await page.evaluate(() => { + const el = document.getElementById('pathPopover'); + const lis = Array.from(el.querySelectorAll('li')); + return { + items: lis.length, + vertical: lis.every((li, i) => i === 0 || li.getBoundingClientRect().top > lis[i - 1].getBoundingClientRect().top), + truncated: Array.from(el.querySelectorAll('.hop-named')).filter(c => c.scrollWidth > c.clientWidth + 1).length, + title: el.querySelector('.path-popover-title').textContent, + }; + }); + assert(pop.items === hopsInRow, `popover lists ${pop.items} hops, row has ${hopsInRow}`); + assert(pop.vertical, 'hops are not stacked one per line'); + assert(pop.truncated === 0, `${pop.truncated} names truncated in the popover`); + assert(pop.title.includes(hopsInRow + ' hop'), `title "${pop.title}" should count hops, not arrows`); + + // Moving away closes an unpinned popover. + await page.mouse.move(5, 5); + await page.waitForTimeout(400); + assert(!(await page.$('#pathPopover')), 'popover still open after the pointer left'); + + // Clicking pins it, and must not select the row underneath. + const panelEmptyBefore = await page.$eval('#pktRight', el => el.classList.contains('empty')); + await page.click(pillSel); + await page.waitForSelector('#pathPopover', { timeout: 3000 }); + await page.mouse.move(5, 5); + await page.waitForTimeout(400); + assert(await page.$('#pathPopover'), 'pinned popover closed when the pointer left'); + const panelEmptyAfter = await page.$eval('#pktRight', el => el.classList.contains('empty')); + assert(panelEmptyBefore === panelEmptyAfter, 'clicking the pill also selected the row'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + assert(!(await page.$('#pathPopover')), 'Escape did not close the pinned popover'); + + // Keyboard: focusing a pill shows the popover too. Escape above also + // closes the detail panel, which re-renders the rows: query afresh. + await page.waitForTimeout(1500); + await page.focus('#pktTable .path-overflow-pill'); + await page.waitForSelector('#pathPopover', { timeout: 3000 }); + await page.keyboard.press('Escape'); + + // The popover lives on : leaving the page must take it along. + await page.click('#pktTable .path-overflow-pill'); + await page.waitForSelector('#pathPopover', { timeout: 3000 }); + await page.evaluate(() => { location.hash = '#/home'; }); + await page.waitForTimeout(800); + assert(!(await page.$('#pathPopover')), 'pinned path popover survived navigating away from Packets'); + await page.evaluate(() => { localStorage.removeItem('meshcore-full-names'); }); + }); + + await test('Full Names is carried in the URL', async () => { + await gotoPackets(page); + await page.click('#fullNamesToggle'); + await page.waitForTimeout(300); + const hash = await page.evaluate(() => location.hash); + assert(/[?&]fullNames=1\b/.test(hash), `toggle did not put fullNames=1 in the URL: ${hash}`); + // A shared link switches the mode on for a visitor whose own pref is off. + await page.evaluate(() => { localStorage.setItem('meshcore-full-names', 'false'); }); + await page.goto(BASE + '/#/packets?timeWindow=10080&fullNames=1', { waitUntil: 'domcontentloaded' }); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', { state: 'attached', timeout: 30000 }); + const on = await page.$eval('#pktTable', t => t.classList.contains('pkt-full-names')); + assert(on, 'fullNames=1 in the URL did not turn Full Names on'); + await page.evaluate(() => { localStorage.removeItem('meshcore-full-names'); }); + }); + + await test('Observer column shrinks back once observer names arrive late', async () => { + // Rows can render before /api/observers resolves (#1692), showing raw + // 64-char pubkeys — ~600px under Full Names. grow() alone would keep the + // column that wide after the names replace them. + await page.route('**/api/observers*', async route => { + await new Promise(r => setTimeout(r, 3000)); + await route.continue(); + }); + try { + await gotoPackets(page, { 'meshcore-full-names': 'true' }); + await page.waitForFunction(() => + !Array.from(document.querySelectorAll('#pktTable td.col-observer')) + .some(td => /[0-9A-F]{40}/.test(td.textContent)), null, { timeout: 15000 }); + await page.waitForTimeout(600); + const { th, widest } = await page.evaluate(() => { + const cells = Array.from(document.querySelectorAll('#pktTable td.col-observer')) + .filter(td => td.offsetParent !== null); + return { + th: document.querySelector('#pktTable th.col-observer').offsetWidth, + widest: Math.max(...cells.map(td => cellContentWidth(td))), + }; + }); + assert(th <= widest + 8, `Observer column ${th}px for ${widest}px of content: stuck at the pubkey width`); + } finally { + await page.unroute('**/api/observers*'); + } + }); + + await test('dragging a handle resizes the column; double-click fits it back', async () => { + await gotoPackets(page); + const flexHandles = await page.$$eval('#pktTable th.col-path .col-resize-handle, #pktTable th.col-details .col-resize-handle', h => h.length); + assert(flexHandles === 0, 'Path/Details must not have drag handles: they share the remaining width'); + const before = await page.$eval('#pktTable th.col-hash', th => th.offsetWidth); + // Grab the handle 2px inside the header: its outer half overhangs the + // next header, which paints over it. + const box = await page.$eval('#pktTable th.col-hash', th => { + const r = th.getBoundingClientRect(); return { x: r.right - 2, y: r.top + r.height / 2 }; + }); + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + await page.mouse.move(box.x + 30, box.y, { steps: 3 }); + await page.mouse.move(box.x + 60, box.y, { steps: 3 }); + await page.mouse.up(); + await page.waitForTimeout(200); + const dragged = await page.evaluate(() => ({ + w: document.querySelector('#pktTable th.col-hash').offsetWidth, + saved: JSON.parse(localStorage.getItem('meshcore-pkt-col-px') || '{}')['col-hash'], + })); + // The edge follows the pointer: no lag from fixed layout spreading the delta. + assert(Math.abs(dragged.w - (before + 60)) <= 3, `dragged +60px from ${before}px, column is ${dragged.w}px`); + assert(Math.abs(dragged.saved - dragged.w) <= 1, `saved ${dragged.saved}px, column ${dragged.w}px`); + const edge = await page.$eval('#pktTable th.col-hash', th => { + const r = th.getBoundingClientRect(); return { x: r.right - 2, y: r.top + r.height / 2 }; + }); + await page.mouse.dblclick(edge.x, edge.y); + await page.waitForTimeout(200); + const reset = await page.evaluate(() => ({ + w: document.querySelector('#pktTable th.col-hash').offsetWidth, + saved: JSON.parse(localStorage.getItem('meshcore-pkt-col-px') || '{}')['col-hash'], + })); + assert(Math.abs(reset.w - before) <= 1, `double-click left the column at ${reset.w}px, expected ${before}px`); + assert(reset.saved === undefined, 'double-click did not forget the saved width'); + }); + + await test('expanding a group leaves the column widths alone', async () => { + // CI seeds one grouped transmission (fae0c9e6d357a814, 3 observations). + await page.goto(BASE + '/#/packets?hash=fae0c9e6d357a814&timeWindow=0', { waitUntil: 'networkidle' }); + await page.waitForTimeout(800); + if (!(await page.$('#pktTable tr.group-header'))) { + console.log(' (no grouped row in this fixture, skipped)'); + return; + } + const widths = () => page.$$eval('#pktTable thead th', ths => Object.fromEntries(ths + .filter(th => th.offsetParent !== null && !th.matches('.col-path, .col-details')) + .map(th => [th.className.split(' ')[0], th.offsetWidth]))); + const before = await widths(); + await page.click('#pktTable tr.group-header td.col-expand'); + await page.waitForSelector('#pktTable tr.group-child', { timeout: 10000 }); + await page.waitForTimeout(400); + const after = await widths(); + // Child rows name other observers and may legitimately need a few px + // more; the bug was every column jumping ~14px from the row indent. + const grew = Object.keys(before).filter(k => k !== 'col-time' && after[k] - before[k] > 8); + assert(grew.length === 0, `columns widened on expand: ${grew.map(k => `${k} ${before[k]}→${after[k]}`).join(', ')}`); + await page.keyboard.press('Escape'); + }); + + await test('1100px with the detail panel open: the table still fits', async () => { + // All columns are visible above 1024px, and the panel leaves ~650px. + await page.setViewportSize({ width: 1100, height: 900 }); + await gotoPackets(page); + await page.click('#pktTable tbody tr:not([id^=vscroll]) td.col-time'); + await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 }); + await page.waitForTimeout(400); + const { hScroll } = await layout(page); + assert(hScroll <= 0, `table overflows its wrapper by ${hScroll}px`); + await page.keyboard.press('Escape'); + }); + + await test('revealing and re-hiding responsive columns re-fits the table', async () => { + // At <=1024px TableResponsive hides Hash, Observer and Rpt behind "+N hidden". + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoPackets(page); + const pill = await page.$('#pktTable .col-hidden-pill:not(.col-rehide-pill)'); + assert(pill, 'no "+N hidden" pill at 1000px'); + const check = async (label) => { + const l = await layout(page); + assert(l.hScroll <= 0, `${label}: table overflows by ${l.hScroll}px`); + assert(Math.abs(l.tableRight - l.lastCellRight) <= 2, + `${label}: last cell ends at ${l.lastCellRight}, table at ${l.tableRight} (blank strip)`); + }; + await pill.click(); + await page.waitForTimeout(300); + assert(await page.$eval('#pktTable th.col-observer', th => th.offsetParent !== null), 'Observer not revealed'); + await check('after reveal'); + await page.click('#pktTable .col-rehide-pill'); + await page.waitForTimeout(300); + await check('after re-hide'); + }); + + await test('mobile (375px): table fits without horizontal scroll', async () => { + await page.setViewportSize({ width: 375, height: 800 }); + await gotoPackets(page); + const { hScroll } = await layout(page); + assert(hScroll <= 0, `table overflows by ${hScroll}px at 375px`); + }); + + await browser.close(); + + const passed = results.filter(r => r.pass).length; + const failed = results.length - passed; + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/tests/unit/test-issue-1189-composed-cell.js b/tests/unit/test-issue-1189-composed-cell.js index 9970bef36..193312096 100644 --- a/tests/unit/test-issue-1189-composed-cell.js +++ b/tests/unit/test-issue-1189-composed-cell.js @@ -61,6 +61,7 @@ if (m) { isSingle: false, truncate(s, n) { return String(s || '').slice(0, n); }, obsNameOnly(_id) { return 'NameA'; }, + obsCellName(_id, n) { return 'NameA'.slice(0, n); }, obsIataBadge(_p) { return ''; }, groupedObserverIataBadgesHtml(_p) { return 'SJCSFO +1'; diff --git a/tests/unit/test-issue-2012-clear-filters-selection.js b/tests/unit/test-issue-2012-clear-filters-selection.js index 8d12db8db..9184d126f 100644 --- a/tests/unit/test-issue-2012-clear-filters-selection.js +++ b/tests/unit/test-issue-2012-clear-filters-selection.js @@ -107,13 +107,13 @@ function setup(hash = '#/packets', initialFilters = {}) { 'filters', 'observers', 'observerMap', 'SHORT_BY_ID', 'escapeHtml', 'document', 'localStorage', 'RegionFilter', 'renderTableRows', 'loadPackets', '_rebuildObserverMenu', '_observerFilterSet', 'savedTimeWindowMin', 'DEFAULT_TIME_WINDOW', - 'location', 'history', 'window', '_packetSortColumn', '_packetSortDirection', + 'location', 'history', 'window', '_packetSortColumn', '_packetSortDirection', 'showFullNames', hashParamsSrc + '\n' + urlSrc + '\n' + multiSelectSrc + '\n' + clearSrc + '\n' + 'updatePacketsUrl(); return { updatePacketsUrl, buildPacketsQuery };' ); const actions = run(filters, observers, observerMap, SHORT_BY_ID, escapeHtml, document, localStorage, RegionFilter, noop, noop, null, null, 15, 15, - location, history, {}, null, null); + location, history, {}, null, null, false); // Observer rows live in #observerMenu today and in #observerList after // #1884; the change listener stays on #observerMenu in both layouts. diff --git a/tests/unit/test-packets.js b/tests/unit/test-packets.js index 39072f5cf..3e776d049 100644 --- a/tests/unit/test-packets.js +++ b/tests/unit/test-packets.js @@ -959,7 +959,26 @@ console.log('\n=== packets.js: buildGroupRowHtml ==='); }; const result = api.buildGroupRowHtml(p); assert(result.includes('group-header')); - assert(result.includes('href="/icons/phosphor-sprite.svg#ph-caret-up"')); // collapsed arrow + assert(result.includes('href="/icons/phosphor-sprite.svg#ph-caret-right"')); // collapsed arrow + }); + + test('buildGroupRowHtml draws exactly one chevron in the expand cell', () => { + const p = { + hash: 'one', count: 3, latest: '2024-01-01T00:00:00Z', + observer_id: null, raw_hex: 'aabbcc', payload_type: 0, + route_type: 0, decoded_json: '{}', path_json: '[]', + observation_count: 3, observer_count: 2 + }; + const result = api.buildGroupRowHtml(p); + const cell = result.match(/`'; const fn = new Function( - 'p','escapeHtml','truncate','obsNameOnly','obsIataBadge', + 'p','obsCellName','obsIataBadge', 'return ' + tpl + ';' ); - // Use short payloads — truncate(_, 16) would lop off the trailing single - // quote of a longer string. Both must survive in the first 16 chars. - const html = fn( - { observer_id: 'obs-1' }, escapeHtml, - (s, n) => String(s).slice(0, n), - () => "' "' Date: Tue, 29 Sep 2026 17:40:53 +0200 Subject: [PATCH 2/5] fix(lint): declare fitColumnsToContent as a global packets.js calls fitColumnsToContent, defined in app.js. Every such cross-file global is listed in .eslintrc.json; this one was missing, so eslint failed the Go Build & Test job with no-undef. Confidence: high Scope-risk: narrow --- .eslintrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.json b/.eslintrc.json index ab6e2ded7..c2ec902a7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -245,6 +245,7 @@ "isFavorite": "readonly", "isTransportRoute": "readonly", "makeColumnsResizable": "readonly", + "fitColumnsToContent": "readonly", "makeRoleMarkerSVG": "readonly", "miniMarkdown": "readonly", "module": "readonly", From de2fd58e811acc72bbacc97b506547016d406a63 Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Tue, 29 Sep 2026 18:38:37 +0200 Subject: [PATCH 3/5] test(e2e): click packet rows on the Time cell; Full Names URL check both ways #1281 clicked each packet row at its centre. With the short columns packed, an ADVERT row's centre is a hop link in the Path column, which opens the node page instead of selecting the row, so no ADVERT detail with a Location row was ever found. It now clicks the Time cell, which holds nothing interactive, and re-queries the row before each click: selecting a row re-renders the table, so the handles it collected up front were detached after the first click and it only ever tried row 0. The compact-columns URL check assumed Full Names started off. The previous case leaves it on in the page's own state, and returning to Packets can write fullNames=1 back into the hash before the reload, so the click turned it off. It now asserts that the URL matches the state after each of two clicks. Rejected: Also switch test-e2e-playwright.js's row loops to the helper | they then check real rows and hit a hex-strip/hop-count mismatch that exists on master, outside this change Confidence: high Scope-risk: narrow --- tests/e2e/test-issue-1281-location-row-e2e.js | 20 ++++++++++++++++--- tests/e2e/test-packets-compact-columns-e2e.js | 18 +++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/tests/e2e/test-issue-1281-location-row-e2e.js b/tests/e2e/test-issue-1281-location-row-e2e.js index ffde5cc6f..420648aa4 100644 --- a/tests/e2e/test-issue-1281-location-row-e2e.js +++ b/tests/e2e/test-issue-1281-location-row-e2e.js @@ -47,13 +47,27 @@ async function gotoPackets(page) { await page.waitForSelector('table tbody tr[data-hash]', { timeout: 15000 }); } +// Click the i-th packet row on its Time cell. A row's centre can land on a +// hop link (which opens the node page) or on the "+N" path pill (which opens +// the path popover), and neither selects the row; the Time cell is never +// interactive. Rows are re-queried on every call because selecting one +// re-renders the table and detaches handles taken before the click. +async function clickPacketRow(page, rowSelector, i) { + const rows = await page.$$(rowSelector); + if (!rows[i]) return false; + const cell = await rows[i].$('td.col-time'); + await (cell || rows[i]).click({ timeout: 3000 }).catch(() => null); + return true; +} + // Click rows until detail pane's Payload Type matches `wantType` (e.g. "Advert" // or any non-"Advert"). Returns true on hit, false if exhausted. async function findPacketDetailByType(page, predicate, maxRows = 40) { await page.waitForTimeout(400); - const rows = await page.$$('table tbody tr[data-hash][data-action]'); - for (let i = 0; i < Math.min(rows.length, maxRows); i++) { - await rows[i].click({ timeout: 3000 }).catch(() => null); + const ROWS = 'table tbody tr[data-hash][data-action]'; + const count = (await page.$$(ROWS)).length; + for (let i = 0; i < Math.min(count, maxRows); i++) { + if (!(await clickPacketRow(page, ROWS, i))) break; await page.waitForTimeout(350); const meta = await page.evaluate(() => { const dts = document.querySelectorAll('dl.detail-meta dt'); diff --git a/tests/e2e/test-packets-compact-columns-e2e.js b/tests/e2e/test-packets-compact-columns-e2e.js index f6b0d35aa..f9e9a88ca 100644 --- a/tests/e2e/test-packets-compact-columns-e2e.js +++ b/tests/e2e/test-packets-compact-columns-e2e.js @@ -264,10 +264,20 @@ function layout(page) { await test('Full Names is carried in the URL', async () => { await gotoPackets(page); - await page.click('#fullNamesToggle'); - await page.waitForTimeout(300); - const hash = await page.evaluate(() => location.hash); - assert(/[?&]fullNames=1\b/.test(hash), `toggle did not put fullNames=1 in the URL: ${hash}`); + // Don't assume the starting state: the previous test turned Full Names on, + // and the in-page setting (not just localStorage) can reach this page's + // URL before gotoPackets reloads it. Check both directions instead. + const state = () => page.evaluate(() => ({ + on: document.getElementById('pktTable').classList.contains('pkt-full-names'), + inUrl: /[?&]fullNames=1\b/.test(location.hash), + hash: location.hash, + })); + for (let n = 0; n < 2; n++) { + await page.click('#fullNamesToggle'); + await page.waitForTimeout(300); + const s = await state(); + assert(s.on === s.inUrl, `Full Names is ${s.on ? 'on' : 'off'} but the URL says otherwise: ${s.hash}`); + } // A shared link switches the mode on for a visitor whose own pref is off. await page.evaluate(() => { localStorage.setItem('meshcore-full-names', 'false'); }); await page.goto(BASE + '/#/packets?timeWindow=10080&fullNames=1', { waitUntil: 'domcontentloaded' }); From 98c2f7ba5306e37b4d8c303dd11e562106872b26 Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Wed, 30 Sep 2026 17:19:34 +0200 Subject: [PATCH 4/5] fix(packets): address review on #2090 Hiding both Path and Details left no flex column, so the table shrank to the sum of the short columns (~400px in a 1900px wrapper). distributeColumnWidths now gives the spare width to the last fixed column. That column is marked fit-last and loses its drag handle: it has nothing to resize against, and the handle's 4px overhang (right: -4px) gave the wrapper a scrollbar. Review decisions: - measurableRows, shared with makeColumnsResizable, gets unit tests. Of 20 tables on the nodes, observers and analytics pages, 18 measure identically to master; the Scopes empty state (one colspan row) no longer sizes the Region column to 915px; Route Patterns varies between runs on master too. - #pktTable clears cell min-width: the shared .col-time/.col-path/ .col-observer/.col-scope minimums are for auto-layout tables, and only Chromium ignores them under table-layout: fixed. - fullNames in the URL applies to that page only and no longer overwrites the visitor's saved preference; without the parameter each visit reads the saved preference again. Minor: container resizes are coalesced to one layout per frame; the sizer uses its own data-fit-columns flag instead of makeColumnsResizable's data-resizable; .eslintrc.json globals back in order. E2E: new case hides Path and Details and asserts the table reaches the wrapper's right edge; the grouped-row case fails instead of skipping; fixed sleeps replaced by condition waits except one that must outlast the popover's 150ms close grace; drag tolerance 3px -> 5px. Rejected: table.style.width = '100%' when no flex column is visible | phantom colspan slots take the slack and reappear as a blank strip Rejected: clamp measure() by the cells' CSS min-width | brings Time back to 108px, the waste this PR removes Rejected: write fullNames=0 into every URL | clutters every link for a default-off mode Confidence: high Scope-risk: narrow Not-tested: non-Chromium engines (CI runs Chromium only) --- .eslintrc.json | 2 +- public/app.js | 33 ++++- public/packets.js | 5 +- public/style.css | 10 ++ tests/e2e/test-packets-compact-columns-e2e.js | 133 ++++++++++++++---- ...test-issue-2012-clear-filters-selection.js | 1 + tests/unit/test-packets.js | 56 +++++++- 7 files changed, 198 insertions(+), 42 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index c2ec902a7..2652f3ad6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,6 +22,7 @@ "EXTERNAL_URLS": "readonly", "FAV_KEY": "readonly", "FilterUX": "readonly", + "fitColumnsToContent": "readonly", "GestureHints": "readonly", "HEALTH_THRESHOLDS": "readonly", "HashColor": "readonly", @@ -245,7 +246,6 @@ "isFavorite": "readonly", "isTransportRoute": "readonly", "makeColumnsResizable": "readonly", - "fitColumnsToContent": "readonly", "makeRoleMarkerSVG": "readonly", "miniMarkdown": "readonly", "module": "readonly", diff --git a/public/app.js b/public/app.js index 6cc80e0e3..75a8dc3a4 100644 --- a/public/app.js +++ b/public/app.js @@ -2154,7 +2154,9 @@ function cellContentWidth(cell) { * `floor`. Flex columns share what is left, the rounding remainder going to * the last one. When even the floors leave less than `flexMin`, the flex * columns shrink further, down to `flexHardMin`; below that `total` exceeds - * `avail` and the caller's wrapper scrolls. + * `avail` and the caller's wrapper scrolls. With no flex column (both hidden + * by the user), the last fixed column takes the spare width, so the table + * still fills `avail`. * * @param {{w: number, floor: number}[]} fixed * @param {number} flexCount @@ -2185,7 +2187,9 @@ function distributeColumnWidths(fixed, flexCount, avail, flexMin, flexHardMin) { const flexW = []; for (let i = 0; i < flexCount; i++) flexW.push(share); if (flexCount) flexW[flexCount - 1] += Math.max(0, spare - share * flexCount); - return { fixed: widths, flex: flexW, total: fixedSum + flexW.reduce((s, w) => s + w, 0) }; + else if (widths.length) widths[widths.length - 1] += spare; + const total = widths.reduce((s, w) => s + w, 0) + flexW.reduce((s, w) => s + w, 0); + return { fixed: widths, flex: flexW, total }; } /** @@ -2278,6 +2282,11 @@ function fitColumnsToContent(tableSelector, storageKey, opts) { const fixedThs = []; const fixedCols = []; const flexThs = []; + // The last visible column absorbs the slack when no flex column is + // shown; `fit-last` hides its drag handle, which overhangs the table's + // right edge by a few px and would give the wrapper a scrollbar. + const visible = ths.filter(th => th.offsetParent !== null); + ths.forEach(th => th.classList.toggle('fit-last', th === visible[visible.length - 1])); ths.forEach(th => { if (th.offsetParent === null) return; const key = colKey(th); @@ -2325,8 +2334,9 @@ function fitColumnsToContent(tableSelector, storageKey, opts) { if (changed) layout(); } - if (!table.dataset.resizable) { - table.dataset.resizable = '1'; + // Its own flag: `resizable` is makeColumnsResizable's already-wired guard. + if (!table.dataset.fitColumns) { + table.dataset.fitColumns = '1'; ths.forEach(th => { const key = colKey(th); if (flex.has(key)) return; @@ -2375,17 +2385,26 @@ function fitColumnsToContent(tableSelector, storageKey, opts) { // The container changes width when the detail panel opens or is dragged: // redistribute only. Responsive hiding changes which columns exist. + // Coalesced to one layout per frame: dragging the detail-panel divider + // resizes the wrapper on every pointer move, and layout() forces a + // synchronous reflow. let lastWrapW = wrap.clientWidth; + let layoutRaf = 0; const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => { - if (!table.isConnected || wrap.clientWidth === lastWrapW) return; - lastWrapW = wrap.clientWidth; - layout(); + if (layoutRaf) return; + layoutRaf = requestAnimationFrame(() => { + layoutRaf = 0; + if (!table.isConnected || wrap.clientWidth === lastWrapW) return; + lastWrapW = wrap.clientWidth; + layout(); + }); }) : null; if (ro) ro.observe(wrap); table.addEventListener('table-columns-changed', refit); function destroy() { if (ro) ro.disconnect(); + if (layoutRaf) cancelAnimationFrame(layoutRaf); table.removeEventListener('table-columns-changed', refit); } diff --git a/public/packets.js b/public/packets.js index 35b511d2c..57ed04b91 100644 --- a/public/packets.js +++ b/public/packets.js @@ -1207,10 +1207,13 @@ var _initUrlParams = getHashParams(); directObsId = _initUrlParams.get('obs'); // Full Names is a view mode: a shared link carries it (fullNames=1/0). + // It applies to this page only; the visitor's saved preference is theirs, + // so opening somebody else's link does not overwrite it. var _urlFullNames = _initUrlParams.get('fullNames'); if (_urlFullNames === '1' || _urlFullNames === '0') { showFullNames = _urlFullNames === '1'; - localStorage.setItem('meshcore-full-names', showFullNames); + } else { + showFullNames = localStorage.getItem('meshcore-full-names') === 'true'; } var _urlTimeWindow = Number(_initUrlParams.get('timeWindow')); if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) { diff --git a/public/style.css b/public/style.css index 7622ece89..a0ad8aa3c 100644 --- a/public/style.css +++ b/public/style.css @@ -1595,6 +1595,12 @@ body.scroll-locked { overflow: hidden; } * column measured on one row clipped the next; fixed-width digits keep * same-length values the same width. */ #pktTable td.col-time, #pktTable td.col-size { font-variant-numeric: tabular-nums; } +/* fitColumnsToContent sets every #pktTable column width in px. The shared + * min-width rules on .col-time (clamp 72-108px), .col-path (140px), + * .col-observer (70px) and .col-scope (56px) are written for auto-layout + * tables; Chromium ignores cell min-width under table-layout: fixed, other + * engines need not. Clear them here so the JS widths are the only input. */ +#pktTable th, #pktTable td { min-width: 0; } /* "Full Names" toggle (packets.js): hop chips keep their whole name. The * path stays on one line; hops past the edge go behind the "+N" overflow * pill (_finalizePathOverflow). Wrapping would make rows taller than the @@ -2718,6 +2724,10 @@ button.ch-item:hover .ch-icon-btn { opacity: 1; } .col-resize-handle:hover::after, .col-resize-handle.active::after { background: var(--accent); opacity: 0.6; } +/* fitColumnsToContent: the last visible column takes whatever width is left, + * so it has nothing to resize against, and its handle (right: -4px) would + * overhang the table and scroll the wrapper sideways. */ +th.fit-last > .col-resize-handle { display: none; } /* Encrypted channels de-emphasized */ button.ch-item.ch-item-encrypted { opacity: 0.5; } diff --git a/tests/e2e/test-packets-compact-columns-e2e.js b/tests/e2e/test-packets-compact-columns-e2e.js index f9e9a88ca..965d7b7bb 100644 --- a/tests/e2e/test-packets-compact-columns-e2e.js +++ b/tests/e2e/test-packets-compact-columns-e2e.js @@ -56,8 +56,34 @@ async function gotoPackets(page, prefs) { await page.reload({ waitUntil: 'networkidle' }); await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', { state: 'attached', timeout: 30000 }); - // Let the post-render refit settle. - await page.waitForTimeout(400); + await settle(page); +} + +// Column layout runs synchronously after a render, and container resizes are +// coalesced to the next animation frame: two frames cover both. +function settle(page) { + return page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); +} + +// The "+N" pills are recomputed once hop names settle (up to ~1s after a +// render). Wait for that pass to finish rather than for a fixed delay. +function pillsReady(page) { + return page.waitForFunction(() => { + const tbody = document.getElementById('pktBody'); + return tbody && !tbody._rePathOverflowObserver && + document.querySelector('#pktTable .path-overflow-pill'); + }, null, { timeout: 10000 }); +} + +async function setColumnVisible(page, key, visible) { + // A checkbox click bubbles to the document handler that closes the menu, + // so each toggle needs its own open. + await page.click('#colToggleBtn'); + const box = await page.waitForSelector(`#colToggleMenu input[data-col="${key}"]`); + if ((await box.isChecked()) !== visible) await box.click(); + await page.waitForFunction(([k, v]) => + document.getElementById('pktTable').classList.contains('hide-col-' + k) === !v, [key, visible]); + await settle(page); } function layout(page) { @@ -74,8 +100,10 @@ function layout(page) { .find(r => r.children.length === t.querySelectorAll('thead th').length); const cells = row ? Array.from(row.children).filter(td => td.offsetParent !== null) : []; const lastCell = cells[cells.length - 1]; + const wr = wrap.getBoundingClientRect(); return { widths, + wrapRight: wr.left + wrap.clientLeft + wrap.clientWidth, tableRight: t.getBoundingClientRect().right, lastCellRight: lastCell ? lastCell.getBoundingClientRect().right : null, hScroll: wrap.scrollWidth - wrap.clientWidth, @@ -114,16 +142,44 @@ function layout(page) { assert(Math.abs(l.tableRight - l.lastCellRight) <= 2, `last cell ends at ${l.lastCellRight}, table at ${l.tableRight}: a hidden column's slot is taking width`); assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px`); + assert(Math.abs(l.wrapRight - l.tableRight) <= 2, + `table ends at ${l.tableRight}, its wrapper at ${l.wrapRight}`); + }); + + await test('hiding both Path and Details keeps the table at the wrapper width', async () => { + // Review on #2090: with no flex column the table shrank to the sum of + // the short columns (~400px in a 1900px wrapper). The gap was between + // the table and its wrapper, which the check above did not measure. + let l; + try { + await setColumnVisible(page, 'path', false); + await setColumnVisible(page, 'details', false); + l = await layout(page); + } finally { + // Restore before asserting, so a failure here does not leak two + // hidden columns into every later case. + await setColumnVisible(page, 'path', true); + await setColumnVisible(page, 'details', true); + } + assert(l.widths['col-path'] == null && l.widths['col-details'] == null, 'Path/Details still visible'); + assert(Math.abs(l.wrapRight - l.tableRight) <= 2, + `table ends at ${l.tableRight}, its wrapper at ${l.wrapRight}: ${Math.round(l.wrapRight - l.tableRight)}px empty`); + assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px`); + const back = await layout(page); + assert(back.widths['col-details'] >= 400, `Details did not take the width back: ${back.widths['col-details']}px`); }); await test('opening the detail panel re-fits the table without scrolling', async () => { await page.click('#pktTable tbody tr:not([id^=vscroll]) td.col-time'); - await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 }); - await page.waitForTimeout(400); + await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 }) + .catch(() => { throw new Error('clicking a row did not open the detail panel'); }); + await settle(page); const l = await layout(page); assert(l.hScroll <= 0, `table overflows its wrapper by ${l.hScroll}px with the panel open`); await page.keyboard.press('Escape'); - await page.waitForTimeout(400); + await page.waitForSelector('#pktRight.empty', { state: 'attached', timeout: 10000 }) + .catch(() => { throw new Error('Escape did not close the detail panel'); }); + await settle(page); const after = await layout(page); assert(after.widths['col-details'] >= 400, `Details did not grow back after closing the panel: ${after.widths['col-details']}px`); }); @@ -147,7 +203,9 @@ function layout(page) { if (before.chipMax) assert(before.chipMax === '120px', `default chip cap is ${before.chipMax}`); await page.click('#fullNamesToggle'); - await page.waitForTimeout(600); + await page.waitForFunction(() => document.getElementById('pktTable').classList.contains('pkt-full-names')); + await pillsReady(page); + await settle(page); const after = await page.evaluate(() => { const t = document.getElementById('pktTable'); const obs = Array.from(t.querySelectorAll('td.col-observer')).map(td => td.textContent); @@ -191,8 +249,8 @@ function layout(page) { await test('hovering the +N pill lists the full path vertically; click pins it', async () => { await gotoPackets(page, { 'meshcore-full-names': 'true' }); - // The pill is (re)computed up to 1s after rendering. - await page.waitForTimeout(1500); + // At 1920px with full names the fixture's multi-hop paths always overflow. + await pillsReady(page).catch(() => null); const pillSel = await page.evaluate(() => { const pills = Array.from(document.querySelectorAll('#pktTable .path-overflow-pill')); if (!pills.length) return null; @@ -228,27 +286,29 @@ function layout(page) { assert(pop.truncated === 0, `${pop.truncated} names truncated in the popover`); assert(pop.title.includes(hopsInRow + ' hop'), `title "${pop.title}" should count hops, not arrows`); - // Moving away closes an unpinned popover. + // Moving away closes an unpinned popover (after a 150ms grace period). await page.mouse.move(5, 5); - await page.waitForTimeout(400); - assert(!(await page.$('#pathPopover')), 'popover still open after the pointer left'); + await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 3000 }) + .catch(() => { throw new Error('popover still open after the pointer left'); }); // Clicking pins it, and must not select the row underneath. const panelEmptyBefore = await page.$eval('#pktRight', el => el.classList.contains('empty')); await page.click(pillSel); await page.waitForSelector('#pathPopover', { timeout: 3000 }); await page.mouse.move(5, 5); + // Deliberately fixed: proving it stays open means outlasting the 150ms + // close grace an unpinned popover would get. await page.waitForTimeout(400); assert(await page.$('#pathPopover'), 'pinned popover closed when the pointer left'); const panelEmptyAfter = await page.$eval('#pktRight', el => el.classList.contains('empty')); assert(panelEmptyBefore === panelEmptyAfter, 'clicking the pill also selected the row'); await page.keyboard.press('Escape'); - await page.waitForTimeout(200); - assert(!(await page.$('#pathPopover')), 'Escape did not close the pinned popover'); + await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 3000 }) + .catch(() => { throw new Error('Escape did not close the pinned popover'); }); // Keyboard: focusing a pill shows the popover too. Escape above also // closes the detail panel, which re-renders the rows: query afresh. - await page.waitForTimeout(1500); + await pillsReady(page); await page.focus('#pktTable .path-overflow-pill'); await page.waitForSelector('#pathPopover', { timeout: 3000 }); await page.keyboard.press('Escape'); @@ -257,8 +317,8 @@ function layout(page) { await page.click('#pktTable .path-overflow-pill'); await page.waitForSelector('#pathPopover', { timeout: 3000 }); await page.evaluate(() => { location.hash = '#/home'; }); - await page.waitForTimeout(800); - assert(!(await page.$('#pathPopover')), 'pinned path popover survived navigating away from Packets'); + await page.waitForSelector('#pathPopover', { state: 'detached', timeout: 5000 }) + .catch(() => { throw new Error('pinned path popover survived navigating away from Packets'); }); await page.evaluate(() => { localStorage.removeItem('meshcore-full-names'); }); }); @@ -273,8 +333,9 @@ function layout(page) { hash: location.hash, })); for (let n = 0; n < 2; n++) { + const was = (await state()).on; await page.click('#fullNamesToggle'); - await page.waitForTimeout(300); + await page.waitForFunction(w => document.getElementById('pktTable').classList.contains('pkt-full-names') !== w, was); const s = await state(); assert(s.on === s.inUrl, `Full Names is ${s.on ? 'on' : 'off'} but the URL says otherwise: ${s.hash}`); } @@ -285,6 +346,14 @@ function layout(page) { await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', { state: 'attached', timeout: 30000 }); const on = await page.$eval('#pktTable', t => t.classList.contains('pkt-full-names')); assert(on, 'fullNames=1 in the URL did not turn Full Names on'); + // ...for that page only: the visitor's own saved preference is untouched. + const saved = await page.evaluate(() => localStorage.getItem('meshcore-full-names')); + assert(saved === 'false', `opening a fullNames=1 link overwrote the saved preference with ${saved}`); + await page.goto(BASE + '/#/packets?timeWindow=10080', { waitUntil: 'domcontentloaded' }); + await page.reload({ waitUntil: 'networkidle' }); + await page.waitForSelector('#pktTable tbody tr:not([id^=vscroll]) td.col-type', { state: 'attached', timeout: 30000 }); + const offAgain = await page.$eval('#pktTable', t => !t.classList.contains('pkt-full-names')); + assert(offAgain, 'without the parameter the page did not return to the saved preference (off)'); await page.evaluate(() => { localStorage.removeItem('meshcore-full-names'); }); }); @@ -301,7 +370,7 @@ function layout(page) { await page.waitForFunction(() => !Array.from(document.querySelectorAll('#pktTable td.col-observer')) .some(td => /[0-9A-F]{40}/.test(td.textContent)), null, { timeout: 15000 }); - await page.waitForTimeout(600); + await settle(page); const { th, widest } = await page.evaluate(() => { const cells = Array.from(document.querySelectorAll('#pktTable td.col-observer')) .filter(td => td.offsetParent !== null); @@ -331,19 +400,19 @@ function layout(page) { await page.mouse.move(box.x + 30, box.y, { steps: 3 }); await page.mouse.move(box.x + 60, box.y, { steps: 3 }); await page.mouse.up(); - await page.waitForTimeout(200); + await settle(page); const dragged = await page.evaluate(() => ({ w: document.querySelector('#pktTable th.col-hash').offsetWidth, saved: JSON.parse(localStorage.getItem('meshcore-pkt-col-px') || '{}')['col-hash'], })); // The edge follows the pointer: no lag from fixed layout spreading the delta. - assert(Math.abs(dragged.w - (before + 60)) <= 3, `dragged +60px from ${before}px, column is ${dragged.w}px`); + assert(Math.abs(dragged.w - (before + 60)) <= 5, `dragged +60px from ${before}px, column is ${dragged.w}px`); assert(Math.abs(dragged.saved - dragged.w) <= 1, `saved ${dragged.saved}px, column ${dragged.w}px`); const edge = await page.$eval('#pktTable th.col-hash', th => { const r = th.getBoundingClientRect(); return { x: r.right - 2, y: r.top + r.height / 2 }; }); await page.mouse.dblclick(edge.x, edge.y); - await page.waitForTimeout(200); + await settle(page); const reset = await page.evaluate(() => ({ w: document.querySelector('#pktTable th.col-hash').offsetWidth, saved: JSON.parse(localStorage.getItem('meshcore-pkt-col-px') || '{}')['col-hash'], @@ -355,18 +424,18 @@ function layout(page) { await test('expanding a group leaves the column widths alone', async () => { // CI seeds one grouped transmission (fae0c9e6d357a814, 3 observations). await page.goto(BASE + '/#/packets?hash=fae0c9e6d357a814&timeWindow=0', { waitUntil: 'networkidle' }); - await page.waitForTimeout(800); - if (!(await page.$('#pktTable tr.group-header'))) { - console.log(' (no grouped row in this fixture, skipped)'); - return; - } + // Fail, don't skip: without the seeded row this case would silently stop + // asserting (review on #2090). + await page.waitForSelector('#pktTable tr.group-header', { timeout: 10000 }) + .catch(() => { throw new Error('seeded grouped row fae0c9e6d357a814 not found: check the CI fixture seeding step'); }); + await settle(page); const widths = () => page.$$eval('#pktTable thead th', ths => Object.fromEntries(ths .filter(th => th.offsetParent !== null && !th.matches('.col-path, .col-details')) .map(th => [th.className.split(' ')[0], th.offsetWidth]))); const before = await widths(); await page.click('#pktTable tr.group-header td.col-expand'); await page.waitForSelector('#pktTable tr.group-child', { timeout: 10000 }); - await page.waitForTimeout(400); + await settle(page); const after = await widths(); // Child rows name other observers and may legitimately need a few px // more; the bug was every column jumping ~14px from the row indent. @@ -381,7 +450,7 @@ function layout(page) { await gotoPackets(page); await page.click('#pktTable tbody tr:not([id^=vscroll]) td.col-time'); await page.waitForSelector('#pktRight:not(.empty)', { timeout: 10000 }); - await page.waitForTimeout(400); + await settle(page); const { hScroll } = await layout(page); assert(hScroll <= 0, `table overflows its wrapper by ${hScroll}px`); await page.keyboard.press('Escape'); @@ -400,11 +469,13 @@ function layout(page) { `${label}: last cell ends at ${l.lastCellRight}, table at ${l.tableRight} (blank strip)`); }; await pill.click(); - await page.waitForTimeout(300); - assert(await page.$eval('#pktTable th.col-observer', th => th.offsetParent !== null), 'Observer not revealed'); + await page.waitForFunction(() => document.querySelector('#pktTable th.col-observer').offsetParent !== null, null, { timeout: 5000 }) + .catch(() => { throw new Error('Observer not revealed'); }); + await settle(page); await check('after reveal'); await page.click('#pktTable .col-rehide-pill'); - await page.waitForTimeout(300); + await page.waitForFunction(() => document.querySelector('#pktTable th.col-observer').offsetParent === null, null, { timeout: 5000 }); + await settle(page); await check('after re-hide'); }); diff --git a/tests/unit/test-issue-2012-clear-filters-selection.js b/tests/unit/test-issue-2012-clear-filters-selection.js index 9184d126f..f4b21e6b2 100644 --- a/tests/unit/test-issue-2012-clear-filters-selection.js +++ b/tests/unit/test-issue-2012-clear-filters-selection.js @@ -274,6 +274,7 @@ test('#2091: init restores obs from the hash after router strips the query', () const readInitialObservation = new Function('location', 'routeParam', 'let directObsId = "stale", directPacketId = null, directPacketHash = null; ' + 'let savedTimeWindowMin = 15, _pendingUrlRegion = null; const filters = {}, window = {}; ' + + 'let showFullNames = false; const localStorage = { getItem: () => null, setItem() {} }; ' + hashParamsSrc + '\n' + initParamsSrc + '\nreturn directObsId;'); for (const route of ['aabbccddeeff0011', 'id/42']) { assert.strictEqual(readInitialObservation({ hash: '#/packets/' + route + '?obs=123' }, route), '123'); diff --git a/tests/unit/test-packets.js b/tests/unit/test-packets.js index 3e776d049..356d7ba51 100644 --- a/tests/unit/test-packets.js +++ b/tests/unit/test-packets.js @@ -1319,10 +1319,62 @@ console.log('\n=== app.js: distributeColumnWidths (packets column packing) ===') assert.strictEqual(d.total, 720); }); - test('no flex columns: fixed widths only', () => { + test('no flex columns (Path and Details hidden): the last column takes the slack', () => { + // A 109px table in a 2000px wrapper was the review finding on #2090. const d = distribute([col(65), col(44)], 0, 2000, 120); assert.deepStrictEqual(Array.from(d.flex), []); - assert.strictEqual(d.total, 109); + assert.deepStrictEqual(Array.from(d.fixed), [65, 1935]); + assert.strictEqual(d.total, 2000); + }); + + test('no flex columns and no room: nothing is added', () => { + const d = distribute([col(300, 300), col(300, 300)], 0, 500, 120); + assert.deepStrictEqual(Array.from(d.fixed), [300, 300]); + assert.strictEqual(d.total, 600); + }); +} + +console.log('\n=== app.js: measurableRows (shared with makeColumnsResizable) ==='); +{ + const ctx = loadPacketsSandbox(); + const cell = (colSpan) => ({ colSpan: colSpan || 1 }); + const row = (...cells) => ({ children: cells, id: '' }); + const tableWith = rows => ({ querySelector: sel => (sel === 'tbody' ? { children: rows } : null) }); + + test('skips virtual-scroll spacer and loading rows (one colspan cell)', () => { + const spacer = row(cell(12)); + const data = row(cell(), cell(), cell()); + const rows = ctx.measurableRows(tableWith([spacer, data, spacer])); + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0], data); + }); + + test('keeps rows whose cells are all single-column, whatever their count', () => { + // Other tables (nodes, observers, analytics) can have rows shorter than + // the header; those were measured before and still are. + const short = row(cell(), cell()); + const full = row(cell(), cell(), cell()); + assert.strictEqual(ctx.measurableRows(tableWith([short, full])).length, 2); + }); + + test('drops any row that contains a spanning cell, not only full-width ones', () => { + const partial = row(cell(), cell(2)); + assert.strictEqual(ctx.measurableRows(tableWith([partial])).length, 0); + }); + + test('limits to 30 rows by default, as makeColumnsResizable always sampled', () => { + const many = Array.from({ length: 50 }, () => row(cell(), cell())); + assert.strictEqual(ctx.measurableRows(tableWith(many)).length, 30); + assert.strictEqual(ctx.measurableRows(tableWith(many), Infinity).length, 50); + }); + + test('no tbody: no rows', () => { + assert.strictEqual(ctx.measurableRows({ querySelector: () => null }).length, 0); + }); + + test('style.css clears cell min-width on #pktTable (JS widths are the only input)', () => { + const css = fs.readFileSync('public/style.css', 'utf8'); + assert(/#pktTable th, #pktTable td \{ min-width: 0; \}/.test(css)); }); } From 541bde601d8acdc41021fc89efe9a20d5550b204 Mon Sep 17 00:00:00 2001 From: Sylvain Rabot Date: Wed, 30 Sep 2026 17:51:51 +0200 Subject: [PATCH 5/5] test(e2e): count analytics chart columns only once the grid CSS applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI run 36735899064 failed "viewport 1440 / wrapper 1300px → side-by-side (≥2 cols): expected ≥2 columns at wrapper 1300px; got 1". At 1300px the grid rule (repeat(auto-fit, minmax(min(100%, 380px), 1fr))) gives three columns; one column is what block-flow, unstyled cards give, so the cards were measured before style.css applied. The harness loaded with waitUntil 'domcontentloaded', which does not wait for a stylesheet on a page without scripts. It now waits for 'load', then for .analytics-charts to compute to display:grid, and fails with a message naming style.css if it never does, so a real CSS regression still fails. Not reproduced locally: 10/10 passes before the change, including with 20x CPU throttling; only this run failed on it among recent failed deploy runs. Confidence: medium Scope-risk: narrow Not-tested: the CI-runner timing that produced the failure --- tests/e2e/test-analytics-fluid-charts.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/e2e/test-analytics-fluid-charts.js b/tests/e2e/test-analytics-fluid-charts.js index ba56f2524..658037ce1 100644 --- a/tests/e2e/test-analytics-fluid-charts.js +++ b/tests/e2e/test-analytics-fluid-charts.js @@ -80,7 +80,15 @@ function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } const tmp = path.join(os.tmpdir(), `1058-harness-${wrapperWidth}-${viewportWidth}.html`); fs.writeFileSync(tmp, harnessHTML(wrapperWidth)); - await page.goto('file://' + tmp, { waitUntil: 'domcontentloaded' }); + // Count only once style.css applies. With a and + // no scripts, DOMContentLoaded does not wait for the stylesheet; CI once + // counted 1 column at 1300px, which only unstyled (block-flow) cards + // give. Wait for 'load', then for the grid rule itself: a real CSS + // regression still fails, with a message that says so. + await page.goto('file://' + tmp, { waitUntil: 'load' }); + await page.waitForFunction(() => + getComputedStyle(document.getElementById('grid')).display === 'grid', null, { timeout: 5000 }) + .catch(() => { throw new Error('.analytics-charts never became display:grid: style.css not applied'); }); } // Helper: count distinct column-x-positions of chart cards.
RegionTimeHashSize HB
${isSingle ? '' : (isExpanded ? '' : '')}${isSingle ? '' : (isExpanded ? '' : '')} ${groupRegion ? `${groupRegion}` : '—'} ${renderTimestampCell(p.latest)} ${truncate(p.hash || '—', 8)}${groupHashBytes} ${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} ${scopeCellHtml(p.scope_name)}${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)}${isSingle ? obsCellName(headerObserverId, 16) + obsIataBadge(p) : obsCellName(headerObserverId, 10) + groupedObserverIataBadgesHtml(p)} ${groupPathStr} ${p.observation_count > 1 ? ' ' + p.observation_count + '' : (isSingle ? '' : p.count)} ${getDetailPreview(getParsedDecoded(p))}${childHashBytes} ${typeName}${transportBadge(c.route_type)} ${scopeCellHtml(c.scope_name)}${escapeHtml(truncate(obsNameOnly(c.observer_id), 16))}${obsIataBadge(c)}${obsCellName(c.observer_id, 16)}${obsIataBadge(c)} ${childPathStr} ${getDetailPreview(getParsedDecoded(c))}${hashBytes} ${typeName}${transportBadge(p.route_type)} ${scopeCellHtml(p.scope_name)}${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)}${obsCellName(p.observer_id, 16)}${obsIataBadge(p)} ${pathStr} ${detail}]*>([\s\S]*?)<\/td>/)[1]; + assert.strictEqual((cell.match(/ph-caret-/g) || []).length, 1); + assert(!/[▶▼▲▸]/.test(cell), 'no text glyph alongside the SVG caret'); + }); + + test('style.css adds no ::before glyph to group-header rows', () => { + const css = fs.readFileSync('public/style.css', 'utf8'); + assert(!/\.group-header[^{]*::before/.test(css), + 'a ::before on .group-header draws a second arrow next to the SVG caret'); }); test('buildGroupRowHtml shows observation count badge', () => { @@ -1242,6 +1261,122 @@ console.log('\n=== packets.js: scroll position preserved across renderTableRows }); } +console.log('\n=== app.js: distributeColumnWidths (packets column packing) ==='); +{ + const ctx = loadPacketsSandbox(); + const distribute = ctx.distributeColumnWidths; + const col = (w, floor) => ({ w, floor: floor == null ? Math.min(w, 64) : floor }); + const sum = a => a.reduce((s, w) => s + w, 0); + + test('roomy container: fixed columns keep their content width, flex columns take the rest', () => { + // time 65, size 44, hb 29 on a 2000px container: nothing inflates. + const d = distribute([col(65), col(44), col(29)], 2, 2000, 120); + assert.deepStrictEqual(Array.from(d.fixed), [65, 44, 29]); + assert.strictEqual(sum(d.flex), 2000 - 138); + assert.strictEqual(d.total, 2000); + }); + + test('rounding remainder goes to the last flex column', () => { + const d = distribute([col(10, 10)], 2, 111, 20); + assert.deepStrictEqual(Array.from(d.flex), [50, 51]); + assert.strictEqual(d.total, 111); + }); + + test('tight container: widest fixed columns give way first so flex keeps its minimum', () => { + // observer 178 and type 115 absorb the squeeze; the short columns do not move. + const d = distribute([col(65), col(44), col(115), col(178)], 2, 600, 120); + assert.deepStrictEqual(Array.from(d.flex), [120, 120]); + assert.strictEqual(d.fixed[0], 65); + assert.strictEqual(d.fixed[1], 44); + assert(d.fixed[3] <= 178 && d.fixed[3] >= 64, 'observer capped, not below its floor'); + assert(d.fixed[2] <= 115, 'type capped at the same level'); + assert(d.total <= 600, 'fits: total=' + d.total); + }); + + test('floors are never crossed: overflow instead (caller scrolls)', () => { + const d = distribute([col(200, 150), col(200, 150)], 1, 300, 120); + assert.deepStrictEqual(Array.from(d.fixed), [150, 150]); + assert.deepStrictEqual(Array.from(d.flex), [120]); + assert.strictEqual(d.total, 420); + }); + + test('a dragged column (floor = width) keeps its width under pressure', () => { + const d = distribute([col(300, 300), col(200)], 1, 500, 120); + assert.strictEqual(d.fixed[0], 300); + assert(d.fixed[1] < 200); + }); + + test('floors leave less than flexMin: flex columns shrink to the hard minimum before overflowing', () => { + // ~1100px viewport with the detail panel open: floors 467px, 650px available. + const d = distribute([col(467, 467)], 2, 650, 120, 60); + assert.deepStrictEqual(Array.from(d.flex), [91, 92]); + assert.strictEqual(d.total, 650); + }); + + test('hard minimum still overflows when even it does not fit', () => { + const d = distribute([col(600, 600)], 2, 650, 120, 60); + assert.deepStrictEqual(Array.from(d.flex), [60, 60]); + assert.strictEqual(d.total, 720); + }); + + test('no flex columns: fixed widths only', () => { + const d = distribute([col(65), col(44)], 0, 2000, 120); + assert.deepStrictEqual(Array.from(d.flex), []); + assert.strictEqual(d.total, 109); + }); +} + +console.log('\n=== packets.js: Full Names toggle (observer column) ==='); +{ + const ctx = loadPacketsSandbox(); + const api = ctx._packetsTestAPI; + // Not in observerMap, so obsNameOnly falls back to the id itself. + const longName = 'DntnMarina Rptr mrymesh.net'; + const p = { + id: 9, hash: 'fn', timestamp: '', observer_id: longName, + raw_hex: 'aabb', payload_type: 0, route_type: 0, + decoded_json: '{}', path_json: '[]' + }; + const observerCell = html => html.match(/]*>([\s\S]*?)<\/td>/)[1]; + + test('observer name is shortened by default', () => { + api._setFullNames(false); + const cell = observerCell(api.buildFlatRowHtml(p)); + assert(!cell.includes(longName), 'expected a truncated name, got ' + cell); + }); + + test('Full Names shows the observer name untruncated', () => { + api._setFullNames(true); + const cell = observerCell(api.buildFlatRowHtml(p)); + assert(cell.includes(longName), 'expected the full name, got ' + cell); + api._setFullNames(false); + }); + + test('Full Names also untruncates grouped rows (10-char cut)', () => { + const g = { + hash: 'fng', count: 3, latest: '2024-01-01T00:00:00Z', + observer_id: longName, raw_hex: 'aabb', payload_type: 0, + route_type: 0, decoded_json: '{}', path_json: '[]', + observation_count: 3, observer_count: 2 + }; + api._setFullNames(false); + assert(!observerCell(api.buildGroupRowHtml(g)).includes(longName)); + api._setFullNames(true); + assert(observerCell(api.buildGroupRowHtml(g)).includes(longName)); + api._setFullNames(false); + }); + + test('style.css lifts the path chip cap only under .pkt-full-names', () => { + const css = fs.readFileSync('public/style.css', 'utf8'); + assert(/\.path-hops \.hop-named \{[^}]*max-width: 120px/.test(css), 'default 120px cap kept'); + assert(/#pktTable\.pkt-full-names \.path-hops \.hop-named \{[^}]*max-width: none/.test(css), + "no cap under full names: one long name must overflow so the +N pill appears"); + // Wrapping would make rows taller than the virtual scroller's fixed row + // height; overflowing hops go behind the +N pill instead. + assert(!/pkt-full-names[^{]*\{[^}]*flex-wrap: wrap/.test(css), 'full names must not wrap the path'); + }); +} + // ===== SUMMARY ===== console.log(`\n${'='.repeat(40)}`); console.log(`packets.js tests: ${passed} passed, ${failed} failed`); diff --git a/tests/unit/test-xss-escape-sinks.js b/tests/unit/test-xss-escape-sinks.js index 984edbcf3..350927cc8 100644 --- a/tests/unit/test-xss-escape-sinks.js +++ b/tests/unit/test-xss-escape-sinks.js @@ -193,11 +193,23 @@ test('observers.js renderRow: observer name cell escapes o.name', () => { assertNoXss(html, 'observers.js obs name cell'); }); +// The observer name reaches the packets table through obsCellName(), which +// shortens it (unless the Full Names toggle is on) and escapes it. Build the +// REAL helper from packets.js source so a regression in either the template +// or the helper turns these tests red. +function loadObsCellName(nameFn, fullNames) { + const src = fs.readFileSync('public/packets.js', 'utf8'); + const m = src.match(/function obsCellName\(id, maxLen\) \{[\s\S]*?\n \}/); + assert.ok(m, 'packets.js obsCellName helper not found'); + return new Function('escapeHtml', 'truncate', 'obsNameOnly', 'showFullNames', + m[0] + '; return obsCellName;')( + escapeHtml, (s, n) => String(s).slice(0, n), nameFn, fullNames); +} + // --- 5. public/packets.js observer cell (grouped header — isSingle path) -- test('packets.js: grouped observer cell escapes observer name', () => { - // Capture the isSingle ternary on the grouped header row. - // Master form: `${isSingle ? truncate(obsNameOnly(headerObserverId), 16) + obsIataBadge(p) : ...}` - // Fixed form: `${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : ...}` + // Capture the isSingle ternary on the grouped header row: + // `${isSingle ? obsCellName(headerObserverId, 16) + obsIataBadge(p) : ...}` const src = fs.readFileSync('public/packets.js', 'utf8'); const m = src.match( /()/ @@ -205,51 +217,56 @@ test('packets.js: grouped observer cell escapes observer name', () => { assert.ok(m, 'packets.js grouped observer cell template not found'); const tpl = '`' + m[1] + '`'; const fn = new Function( - 'isSingle','headerObserverId','escapeHtml','truncate','obsNameOnly', + 'isSingle','headerObserverId','escapeHtml','obsCellName','obsNameOnly', 'obsIataBadge','groupedObserverIataBadgesHtml','p', 'return ' + tpl + ';' ); - const html = fn( - true, 'obs-1', escapeHtml, - (s, n) => String(s).slice(0, n), - () => TAG_PAYLOAD + ATTR_PAYLOAD, - () => '', () => '', {} - ); - assertNoXss(html, 'packets.js grouped observer cell'); + const name = () => TAG_PAYLOAD + ATTR_PAYLOAD; + // Full Names on: the whole payload reaches the cell. + for (const isSingle of [true, false]) { + const html = fn(isSingle, 'obs-1', escapeHtml, loadObsCellName(name, true), name, + () => '', () => '', {}); + assertNoXss(html, 'packets.js grouped observer cell (full names, isSingle=' + isSingle + ')'); + } + // Shortened: a 10-char payload survives the 10/16-char cut intact. + const short = () => "' '', () => '', {}); + assert.ok(!/ { - // Capture the flat observer cell — the OUTER form, with or without - // escapeHtml: `${truncate(obsNameOnly(p.observer_id), 16)}${obsIataBadge(p)}` - // or `${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)}`. + // Capture the flat observer cell: `${obsCellName(p.observer_id, 16)}${obsIataBadge(p)}`. const src = fs.readFileSync('public/packets.js', 'utf8'); - // Find the FLAT row (the third occurrence with p.observer_id, not c.observer_id). - const re = /\$\{(?:escapeHtml\()?truncate\(obsNameOnly\(p\.observer_id\),\s*16\)\)?\}\$\{obsIataBadge\(p\)\}/; + const re = /\$\{obsCellName\(p\.observer_id,\s*16\)\}\$\{obsIataBadge\(p\)\}/; const m = src.match(re); assert.ok(m, 'packets.js flat observer cell template not found'); const tpl = '`' + m[0] + '