diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8e33da8a..e337fa74 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -356,6 +356,7 @@ jobs: BASE_URL=http://localhost:13581 node test-map-modal-fluid-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-map-nodes-pagination-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-observer-iata-1188-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1639-observers-sort-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-fluid-1055-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1102-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-priority-1311-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/observers.js b/public/observers.js index f950f5d4..25b4eaeb 100644 --- a/public/observers.js +++ b/public/observers.js @@ -116,6 +116,7 @@ window.ObserversSummary = (function () { let wsHandler = null; let refreshTimer = null; let regionChangeHandler = null; + let _obsSortCtl = null; // #1641 r1 finding #6: tracked TableSort controller for destroy-before-reinit function init(app) { app.innerHTML = ` @@ -181,6 +182,10 @@ window.ObserversSummary = (function () { refreshTimer = null; if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler); regionChangeHandler = null; + if (_obsSortCtl && typeof _obsSortCtl.destroy === 'function') { + try { _obsSortCtl.destroy(); } catch (_) { /* ignore */ } + } + _obsSortCtl = null; observers = []; obsSkewMap = {}; } @@ -289,36 +294,71 @@ window.ObserversSummary = (function () {
- - + + ${filtered.map(o => { const h = healthStatus(o.last_seen); const shape = h.cls === 'health-green' ? '●' : h.cls === 'health-yellow' ? '▲' : '✕'; + // TableSort reads NaN-sortable raw values from each cell's + // data-value attr (table-sort.js comparators.numeric/time sort + // NaN last). Empty string ⇒ NaN ⇒ missing-sorts-last. + const _lastSeenMs = o.last_seen ? new Date(o.last_seen).getTime() : ''; + const _lastPktMs = o.last_packet_at ? new Date(o.last_packet_at).getTime() : ''; + const _firstSeenMs = o.first_seen ? new Date(o.first_seen).getTime() : 0; + const _uptimeMs = _firstSeenMs ? (Date.now() - _firstSeenMs) : ''; + const _skewSec = (obsSkewMap[o.id] && obsSkewMap[o.id].samples) + ? Math.abs(Number(obsSkewMap[o.id].offsetSec) || 0) + : ''; + // #1641 r1 finding #1: Packet Health sortable rank must differ + // from Last Packet — use the health bucket, not the timestamp. + // Higher rank = healthier; matches the desc-by-default semantics. + const _healthRank = h.cls === 'health-green' ? 2 : (h.cls === 'health-yellow' ? 1 : 0); + const _packetCount = (o.packet_count != null) ? o.packet_count : ''; + const _packetsHour = (o.packetsLastHour != null) ? o.packetsLastHour : ''; return ` - - - - - - - - - + + + + + + + + - + `; }).join('')}
Observer status and statistics
StatusNameRegionLast StatusLast PacketPacket HealthTotal PacketsPackets/HourClock OffsetUptimeStatusNameRegionLast StatusLast PacketPacket HealthTotal PacketsPackets/HourClock OffsetUptime
${shape} ${h.label}${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}${o.can_relay === false ? ' listener' : (o.can_relay === true ? ' repeater' : '')}${o.iata ? `${o.iata}` : '—'}${timeAgo(o.last_seen)}${o.last_packet_at ? timeAgo(o.last_packet_at) : ''}${packetBadge(o)}${(o.packet_count || 0).toLocaleString()}${sparkBar(o.packetsLastHour || 0, maxPktsHr)}${(function() { + ${shape} ${h.label}${escapeHtml(o.name || o.id)}${window.ObserversNaiveChip.render(o)}${o.can_relay === false ? ' listener' : (o.can_relay === true ? ' repeater' : '')}${o.iata ? `${o.iata}` : '—'}${timeAgo(o.last_seen)}${o.last_packet_at ? timeAgo(o.last_packet_at) : ''}${packetBadge(o)}${(o.packet_count || 0).toLocaleString()}${sparkBar(o.packetsLastHour || 0, maxPktsHr)}${(function() { var sk = obsSkewMap[o.id]; if (!sk || sk.samples == null || sk.samples === 0) return ''; var sev = observerSkewSeverity(sk.offsetSec); return renderSkewBadge(sev, sk.offsetSec) + ' (' + sk.samples + ')'; })()}${uptimeStr(o.first_seen)}${uptimeStr(o.first_seen)}
`; makeColumnsResizable('#obsTable', 'meshcore-obs-col-widths'); + const obsTbl = document.getElementById('obsTable'); // #1056: fluid columns + +N hidden pill - if (window.TableResponsive) { - var _obsTbl = document.getElementById('obsTable'); - if (_obsTbl) window.TableResponsive.register(_obsTbl); + if (obsTbl && window.TableResponsive) { + window.TableResponsive.register(obsTbl); + } + // #1639 — wire TableSort AFTER tbody is in the DOM (avoid the #679 init + // race). Re-init on every render so the controller binds to the new + // header instance, and persist sort state under meshcore-observers-sort. + if (obsTbl && window.TableSort) { + // #1641 r1 finding #6: destroy the prior controller before re-init so + // we don't leak header click listeners across the 30s render cycle. + if (_obsSortCtl && typeof _obsSortCtl.destroy === 'function') { + try { _obsSortCtl.destroy(); } catch (_) { /* ignore */ } + } + _obsSortCtl = TableSort.init(obsTbl, { + defaultColumn: 'last_seen', + defaultDirection: 'desc', + storageKey: 'meshcore-observers-sort' + }); + } else if (obsTbl && !window.TableSort) { + // #1641 r1 finding #14: surface bundler/loading regressions instead of + // silently rendering an unsortable table. + console.warn('[observers] window.TableSort missing — table will not be sortable'); } } diff --git a/public/table-sort.js b/public/table-sort.js index aa4b956b..5a2aed26 100644 --- a/public/table-sort.js +++ b/public/table-sort.js @@ -114,7 +114,12 @@ window.TableSort = (function() { state.direction = state.direction === 'asc' ? 'desc' : 'asc'; } else { state.column = key; - state.direction = options.defaultDirection || 'asc'; + // #1639/#1641: switching to a new column picks a sensible default + // direction per type — text columns default to ascending (A→Z), + // numeric/date/dbm default to descending (largest/newest first). + // options.defaultDirection only seeds the INITIAL load. + var thType = th.getAttribute('data-type'); + state.direction = (!thType || thType === 'text') ? 'asc' : 'desc'; } doSort(); }; diff --git a/test-issue-1639-observers-sort-e2e.js b/test-issue-1639-observers-sort-e2e.js new file mode 100644 index 00000000..7e5c68f0 --- /dev/null +++ b/test-issue-1639-observers-sort-e2e.js @@ -0,0 +1,174 @@ +/** + * E2E test (#1639): observers table at #/observers must be sortable. + * + * Same fix-pattern as #679 (nodes table) — wire TableSort with + * numeric / time column hints. Clicking a column header MUST reorder + * tbody rows. + * + * Round-1 test additions (PR #1641): + * - last_packet_at column reorder (regression guard for the round-0 + * data-type=date → numeric fix; without this the one-line fix is + * unguarded). + * - strict monotonic non-increasing assertion (no more .some(changed), + * which silently passes even when order is mostly wrong). + * - toggle (second-click ascending) on packet_count. + * - second-column string sort (Name) to exercise localeCompare. + * - cells are addressed via data-testid (added in observers.js for + * testability) — finding #8 dropped the broader data-col-key on + * s, and data-testid is the less-invasive replacement. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-1639-observers-sort-e2e.js + */ +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +async function test(name, fn) { + try { + await fn(); + console.log(` \u2705 ${name}`); + } catch (err) { + console.log(` \u274c ${name}: ${err.message}`); + process.exit(1); + } +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'Assertion failed'); +} + +// finding #5: strict monotonic non-increasing across ALL rows +function assertNonIncreasing(arr, label) { + for (let i = 0; i < arr.length - 1; i++) { + // NaN slots are sorted last by the comparator — accept them at the tail. + const a = arr[i], b = arr[i + 1]; + if (!Number.isFinite(a)) continue; // NaN tail + if (!Number.isFinite(b)) continue; + if (!(a >= b)) { + throw new Error(`${label}: not non-increasing at idx ${i}: ${a} < ${b}; full=${JSON.stringify(arr)}`); + } + } +} + +function assertNonDecreasing(arr, label) { + for (let i = 0; i < arr.length - 1; i++) { + const a = arr[i], b = arr[i + 1]; + if (!Number.isFinite(a)) continue; + if (!Number.isFinite(b)) continue; + if (!(a <= b)) { + throw new Error(`${label}: not non-decreasing at idx ${i}: ${a} > ${b}; full=${JSON.stringify(arr)}`); + } + } +} + +async function gotoObservers(page) { + await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => { try { localStorage.removeItem('meshcore-observers-sort'); } catch (_) {} }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#obsTable', { timeout: 10000 }); + await page.waitForSelector('#obsTable tbody tr', { timeout: 10000 }); +} + +async function readNumeric(page, testid) { + return await page.$$eval(`#obsTable tbody tr td[data-testid="${testid}"]`, (tds) => tds.map((td) => { + const dv = td.getAttribute('data-value'); + const raw = dv != null && dv !== '' ? dv : td.textContent.replace(/[^0-9.-]/g, ''); + return Number(raw); + })); +} + +async function readStrings(page, testid) { + return await page.$$eval(`#obsTable tbody tr td[data-testid="${testid}"]`, (tds) => + tds.map((td) => td.getAttribute('data-value') || '')); +} + +async function run() { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + }); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + + console.log(`\nRunning #1639 observers-sort E2E tests against ${BASE}\n`); + + await test('observers thead has data-sort-key + data-type on numeric columns', async () => { + await gotoObservers(page); + const totalPktsTh = await page.$('#obsTable thead th[data-sort-key="packet_count"]'); + assert(totalPktsTh, 'Total Packets must carry data-sort-key="packet_count"'); + const type = await totalPktsTh.getAttribute('data-type'); + assert(type === 'numeric', + `Total Packets must have data-type="numeric", got "${type}"`); + }); + + await test('clicking Total Packets header reorders rows numerically (desc)', async () => { + await gotoObservers(page); + const before = await readNumeric(page, 'obs-cell-packet-count'); + assert(before.length >= 2, `need >=2 rows, got ${before.length}`); + + const th = await page.$('#obsTable thead th[data-sort-key="packet_count"]'); + assert(th, 'Total Packets not found'); + await th.click(); + await page.waitForTimeout(300); + + const after = await readNumeric(page, 'obs-cell-packet-count'); + assert(after.length === before.length, `row count changed`); + // finding #5: strict non-increasing across ALL rows, not just first/last + assertNonIncreasing(after, 'packet_count desc'); + }); + + // finding #13: toggle (second click on the same header → ascending) + await test('second click on Total Packets header toggles to ascending', async () => { + await gotoObservers(page); + const th = await page.$('#obsTable thead th[data-sort-key="packet_count"]'); + await th.click(); // desc + await page.waitForTimeout(200); + await th.click(); // asc + await page.waitForTimeout(300); + const after = await readNumeric(page, 'obs-cell-packet-count'); + assert(after.length >= 2, 'need >=2 rows'); + assertNonDecreasing(after, 'packet_count asc (after toggle)'); + }); + + // finding #4: regression guard for round-0 data-type=date → numeric fix + await test('clicking Last Packet header reorders rows by timestamp (desc)', async () => { + await gotoObservers(page); + const th = await page.$('#obsTable thead th[data-sort-key="last_packet_at"]'); + assert(th, 'Last Packet not found'); + const type = await th.getAttribute('data-type'); + assert(type === 'numeric', + `Last Packet must have data-type="numeric" (round-0 fix), got "${type}"`); + await th.click(); + await page.waitForTimeout(300); + const after = await readNumeric(page, 'obs-cell-last-packet'); + assert(after.length >= 2, `need >=2 rows`); + assertNonIncreasing(after, 'last_packet_at desc'); + }); + + // finding #13: second-column test — Name string sort via localeCompare + await test('clicking Name header sorts alphabetically (string localeCompare)', async () => { + await gotoObservers(page); + const th = await page.$('#obsTable thead th[data-sort-key="name"]'); + assert(th, 'Name not found'); + await th.click(); + await page.waitForTimeout(300); + const after = await readStrings(page, 'obs-cell-name'); + assert(after.length >= 2, `need >=2 rows`); + // Default direction for text columns is asc — sorted A→Z + for (let i = 0; i < after.length - 1; i++) { + if (after[i] === '' || after[i + 1] === '') continue; + assert(after[i].localeCompare(after[i + 1]) <= 0, + `Name asc broken at idx ${i}: ${JSON.stringify(after[i])} > ${JSON.stringify(after[i + 1])}`); + } + }); + + await browser.close(); + console.log('\n✅ all #1639 sort tests passed\n'); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/test-xss-escape-sinks.js b/test-xss-escape-sinks.js index c741adcb..984edbcf 100644 --- a/test-xss-escape-sinks.js +++ b/test-xss-escape-sinks.js @@ -179,9 +179,14 @@ test('observers.js renderRow: observer name cell escapes o.name', () => { // Capture just the ${ ... o.name || o.id ... }${chip}. const html = evalTemplate( 'public/observers.js', - // Allow optional trailing content (e.g. listener/repeater badge added by #1290) - // between the naive chip and the closing . - /(\$\{[^}]*o\.name[^}]*\}\$\{window\.ObserversNaiveChip\.render\(o\)\}[^`]*?<\/td>)/, + // Allow optional attributes before class="mono" (e.g. data-testid / + // data-value added by #1639 for sortable-column tests) AND optional trailing + // content (e.g. listener/repeater badge added by #1290) between the naive + // chip and the closing . Capturing any data-value="${...}" attr makes + // this assertion STRICTER, not weaker: the eval now also renders those attr + // interpolations, so dropping escapeHtml() on either the attr OR the cell + // text trips assertNoXss (raw ]*\bclass="mono"[^>]*>\$\{[^}]*o\.name[^}]*\}\$\{window\.ObserversNaiveChip\.render\(o\)\}[^`]*?<\/td>)/, { o: { name: TAG_PAYLOAD + ATTR_PAYLOAD, id: 'obs-1' }, window: { ObserversNaiveChip: { render: () => '' } } } );