diff --git a/.eslintrc.json b/.eslintrc.json index ab6e2ded..2652f3ad 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", diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9f859fb2..8012b477 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 28b97b39..75a8dc3a 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,310 @@ 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. 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 + * @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); + 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 }; +} + +/** + * 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 = []; + // 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); + 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(); + } + + // 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; + 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. + // 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 (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); + } + + refit(); + return { refit, grow, destroy }; +} diff --git a/public/packets.js b/public/packets.js index 5abee67a..57ed04b9 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,15 @@ // 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). + // 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'; + } else { + showFullNames = localStorage.getItem('meshcore-full-names') === 'true'; + } var _urlTimeWindow = Number(_initUrlParams.get('timeWindow')); if (Number.isFinite(_urlTimeWindow) && _urlTimeWindow > 0) { savedTimeWindowMin = _urlTimeWindow; @@ -1435,6 +1469,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 +1514,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 +1760,11 @@
+
${(window.HopDisplay && HopDisplay.renderPathSymbolsLegend) ? HopDisplay.renderPathSymbolsLegend() : ''}
-
+
@@ -2190,6 +2231,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 +2258,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 +2353,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 +2398,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 +2433,7 @@ sortPacketsArray(); renderTableRows(); updatePacketsUrl(); + if (_colFit) _colFit.refit(); } }); // Apply initial sort state from TableSort @@ -2380,6 +2445,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 +2483,7 @@ const _grpHashStripe = _hashStripeStyle(p.hash); const _grpStyle = _grpHashStripe + _grpChanStyle; let html = ` - + @@ -2423,7 +2491,7 @@ - + @@ -2458,7 +2526,7 @@ - + @@ -2495,7 +2563,7 @@ - + @@ -2634,6 +2702,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 +2728,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 +2747,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 +2775,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 +2787,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 +2839,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'); + function open(pill, pin) { + clearTimeout(showTimer); + clearTimeout(hideTimer); + if (existing && anchor === pill) { if (pin) pinned = true; return; } + dismiss(); + var host = pill.closest('.path-hops'); + if (!host || !pill.isConnected) return; + var pop = _buildPathPopover(host); + document.body.appendChild(pop); + _positionPathPopover(pop, pill); + existing = pop; + 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(); - var host = pill.closest('.path-hops'); - if (!host) 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; - 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'; - } - existing = pop; - setTimeout(function() { - document.addEventListener('mousedown', onDoc, true); - document.addEventListener('keydown', onKey, true); - }, 0); - }); + 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 +4398,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 7b95eda7..a0ad8aa3 100644 --- a/public/style.css +++ b/public/style.css @@ -1590,6 +1590,25 @@ 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; } +/* 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 + * 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 +2586,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 === */ @@ -2701,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; } @@ -5010,18 +5037,32 @@ body[data-cb-preset="achromat"] { border-radius: 8px; cursor: pointer; user-select: none; + /* 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; } -.path-overflow-pill:hover { color: var(--text); background: var(--bg-hover, var(--surface)); } +/* 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 b72ee94f..37c048e8 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-analytics-fluid-charts.js b/tests/e2e/test-analytics-fluid-charts.js index ba56f252..658037ce 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. diff --git a/tests/e2e/test-issue-1281-location-row-e2e.js b/tests/e2e/test-issue-1281-location-row-e2e.js index ffde5cc6..420648aa 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 new file mode 100644 index 00000000..965d7b7b --- /dev/null +++ b/tests/e2e/test-packets-compact-columns-e2e.js @@ -0,0 +1,498 @@ +/** + * 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 }); + 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) { + 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]; + 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, + }; + }); +} + +(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`); + 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 }) + .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.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`); + }); + + 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.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); + 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' }); + // 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; + 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 (after a 150ms grace period). + await page.mouse.move(5, 5); + 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.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 pillsReady(page); + 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.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'); }); + }); + + await test('Full Names is carried in the URL', async () => { + await gotoPackets(page); + // 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++) { + const was = (await state()).on; + await page.click('#fullNamesToggle'); + 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}`); + } + // 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'); + // ...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'); }); + }); + + 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 settle(page); + 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 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)) <= 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 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'], + })); + 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' }); + // 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 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. + 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 settle(page); + 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.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.waitForFunction(() => document.querySelector('#pktTable th.col-observer').offsetParent === null, null, { timeout: 5000 }); + await settle(page); + 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 9970bef3..19331209 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 8d12db8d..f4b21e6b 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. @@ -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 39072f5c..356d7ba5 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), - () => "' "'
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,174 @@ 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 (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.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)); + }); +} + +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 984edbcf..350927cc 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] + '