diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 18df06c03..5813b4993 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -505,6 +505,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1567-corner-clears-drag-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1306-collisions-terminology-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-205-analytics-subtab-deeplinks-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-226-hash-stats-sort-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1374-route-map-a11y-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-list-render-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-channels-selection-flow-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/analytics.js b/public/analytics.js index 405e9c26e..4e1a20a3e 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -351,6 +351,10 @@ // #208 — Hash Stats' multi-byte adopters filter. URL only (no storageKey): // it had no stored state before, so a plain visit still opens on All. var HASHSTATS_MB_FILTER = { param: 'mbf', allowed: ['all', 'confirmed', 'suspected', 'unknown'], dflt: 'all' }; + // #226 — the adopters table's sort column and direction, URL only like mbf=. + // 'none' keeps the server's order. + var HASHSTATS_MB_SORT = { param: 'mbsort', allowed: ['none', 'name', 'role', 'status', 'hashSize', 'packets', 'lastSeen'], dflt: 'none' }; + var HASHSTATS_MB_DIR = { param: 'mbdir', allowed: ['asc', 'desc'], dflt: 'asc' }; // The hash keys each tab owns; _updateAnalyticsUrl drops them when // another tab is selected. Hash Issues' bytes= is deliberately not listed: @@ -360,7 +364,7 @@ var TAB_URL_PARAMS = { 'rf-health': ['range', 'observer', 'from', 'to'], collisions: ['section'], - hashsizes: [HASHSTATS_MB_FILTER.param], + hashsizes: [HASHSTATS_MB_FILTER.param, HASHSTATS_MB_SORT.param, HASHSTATS_MB_DIR.param], scopes: [SCOPES_SUBTAB.param, SCOPES_WINDOW.param], wardriving: [WARDRIVING_WINDOW.param], }; @@ -1540,7 +1544,10 @@ // ===================== HASH SIZES (original) ===================== function renderHashSizes(el, data) { - const mbFilter = restoreViewParams([HASHSTATS_MB_FILTER])[0]; // ?mbf= (#208) + // ?mbf= (#208), ?mbsort= and ?mbdir= (#226) + const [mbFilter, mbSortCol, mbSortDir] = restoreViewParams([HASHSTATS_MB_FILTER, HASHSTATS_MB_SORT, HASHSTATS_MB_DIR]); + const mbSort = { col: mbSortCol, dir: mbSortCol === HASHSTATS_MB_SORT.dflt ? HASHSTATS_MB_DIR.dflt : mbSortDir }; + if (mbSort.dir !== mbSortDir) setViewParam(HASHSTATS_MB_DIR, mbSort.dir); // no direction without a column const d = data.distribution; const total = data.total; const pct = (n) => total ? (n / total * 100).toFixed(1) : '0'; @@ -1590,7 +1597,7 @@ - ${renderMultiByteAdopters(data.multiByteNodes, data.multiByteCapability || [], mbFilter)} + ${renderMultiByteAdopters(data.multiByteNodes, data.multiByteCapability || [], mbFilter, mbSort)}
@@ -1614,9 +1621,53 @@ `; } + // #226 — the value an adopter row sorts by in a column: a lower-case string + // for Node and Role, a number otherwise. Last Seen is the timestamp (NaN when + // missing or unparseable), not the "5m ago" text the cell shows. + var MB_STATUS_WEIGHT = { confirmed: 0, suspected: 1, unknown: 2 }; + function mbAdopterSortValue(r, col) { + switch (col) { + case 'name': return String(r.name || '').toLowerCase(); + case 'role': return String(r.role || 'unknown').toLowerCase(); + case 'status': return Object.prototype.hasOwnProperty.call(MB_STATUS_WEIGHT, r.status) ? MB_STATUS_WEIGHT[r.status] : MB_STATUS_WEIGHT.unknown; + case 'hashSize': return Number(r.hashSize); + case 'packets': return Number(r.packets); + case 'lastSeen': return r.lastSeen ? Date.parse(r.lastSeen) : NaN; + } + return 0; + } + + // The rows in sort order; without a known column, as given. A missing value + // (NaN) is last in both directions, and ties keep the given order. + function sortMbAdopterRows(rows, sort) { + var col = sort && sort.col; + if (col === HASHSTATS_MB_SORT.dflt || HASHSTATS_MB_SORT.allowed.indexOf(col) < 0) return rows; + var sign = sort.dir === 'desc' ? -1 : 1; + return rows.map(function (r, i) { return { r: r, i: i, v: mbAdopterSortValue(r, col) }; }) + .sort(function (a, b) { + var an = typeof a.v === 'number' && isNaN(a.v), bn = typeof b.v === 'number' && isNaN(b.v); + if (an || bn) return an === bn ? a.i - b.i : (an ? 1 : -1); + if (a.v < b.v) return -sign; + if (a.v > b.v) return sign; + return a.i - b.i; + }) + .map(function (x) { return x.r; }); + } + + // Clicking the sorted column flips its direction; another column starts + // ascending. + function nextMbSort(sort, col) { + if (sort && sort.col === col) return { col: col, dir: sort.dir === 'asc' ? 'desc' : 'asc' }; + return { col: col, dir: 'asc' }; + } + // filter: the initially selected filter (All when missing or unknown). - function renderMultiByteAdopters(nodes, caps, filter) { + // sort: the initial { col, dir } (the server's order when missing or unknown). + function renderMultiByteAdopters(nodes, caps, filter, sort) { var initialFilter = HASHSTATS_MB_FILTER.allowed.indexOf(filter) >= 0 ? filter : HASHSTATS_MB_FILTER.dflt; + var initialSort = sort && HASHSTATS_MB_SORT.allowed.indexOf(sort.col) >= 0 + ? { col: sort.col, dir: sort.dir === 'desc' ? 'desc' : 'asc' } + : { col: HASHSTATS_MB_SORT.dflt, dir: HASHSTATS_MB_DIR.dflt }; var mbBtnClass = function (f) { return f === initialFilter ? 'tab-btn active' : 'tab-btn'; }; // Merge capability status into adopter nodes var capByPubkey = {}; @@ -1640,17 +1691,24 @@ var counts = { confirmed: 0, suspected: 0, unknown: 0 }; rows.forEach(function(r) { counts[r.status] = (counts[r.status] || 0) + 1; }); - function buildTableContent(rows, filter) { - var filtered = filter === 'all' ? rows : rows.filter(function(r) { return r.status === filter; }); + var sortCols = [ + { key: 'name', label: 'Node' }, { key: 'role', label: 'Role' }, { key: 'status', label: 'Status' }, + { key: 'hashSize', label: 'Hash Size' }, { key: 'packets', label: 'Adverts' }, { key: 'lastSeen', label: 'Last Seen' }, + ]; + // The keys are the fixed ones above, never a value from the URL. + function theadHtml(sort) { + return '' + sortCols.map(function (c) { + var active = c.key === sort.col; + return '' + + c.label + channelSortArrow(c.key, sort.col, sort.dir) + ''; + }).join('') + ''; + } + + function buildTableContent(rows, filter, sort) { + var filtered = sortMbAdopterRows(filter === 'all' ? rows : rows.filter(function(r) { return r.status === filter; }), sort); return (filtered.length ? '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + + theadHtml(sort) + '' + filtered.map(function(r) { var roleColor = (window.ROLE_COLORS || {})[r.role] || '#6b7280'; @@ -1688,7 +1746,7 @@ '' + '' + '' + - '
' + buildTableContent(rows, initialFilter) + '
' + + '
' + buildTableContent(rows, initialFilter, initialSort) + '
' + ''; // Use setTimeout for event delegation on the stable section container @@ -1696,6 +1754,7 @@ var section = document.getElementById('mbAdoptersSection'); if (!section) return; var currentFilter = initialFilter; + var currentSort = initialSort; section.addEventListener('click', function handler(e) { var btn = e.target.closest('[data-mb-filter]'); @@ -1706,31 +1765,21 @@ buttons.forEach(function(b) { b.classList.toggle('active', b.dataset.mbFilter === currentFilter); }); // Replace only the table content, not the whole section var wrap = section.querySelector('#mbAdoptersTableWrap'); - if (wrap) wrap.innerHTML = buildTableContent(rows, currentFilter); + if (wrap) wrap.innerHTML = buildTableContent(rows, currentFilter, currentSort); setViewParam(HASHSTATS_MB_FILTER, currentFilter); return; } + // #226: a header click re-renders the table (the same path as a + // filter click) in the new order, so the sort survives the next + // filter click and the header shows it. var th = e.target.closest('[data-sort]'); if (th) { - var tbody = section.querySelector('tbody'); - if (!tbody) return; - var sortRows = Array.from(tbody.querySelectorAll('tr')); var col = th.dataset.sort; - var colIdx = { name: 0, status: 1, hashSize: 2, packets: 3, lastSeen: 4 }; - var statusWeight = { 'confirmed': 0, 'suspected': 1, 'unknown': 2 }; - sortRows.sort(function(a, b) { - var va = a.children[colIdx[col]] ? a.children[colIdx[col]].textContent.trim() : ''; - var vb = b.children[colIdx[col]] ? b.children[colIdx[col]].textContent.trim() : ''; - if (col === 'status') { - va = statusWeight[va.toLowerCase().split(' ').pop()] !== undefined ? statusWeight[va.toLowerCase().split(' ').pop()] : 2; - vb = statusWeight[vb.toLowerCase().split(' ').pop()] !== undefined ? statusWeight[vb.toLowerCase().split(' ').pop()] : 2; - } - if (col === 'hashSize' || col === 'packets') { va = parseInt(va) || 0; vb = parseInt(vb) || 0; } - if (va < vb) return -1; - if (va > vb) return 1; - return 0; - }); - sortRows.forEach(function(r) { tbody.appendChild(r); }); + if (col === HASHSTATS_MB_SORT.dflt || HASHSTATS_MB_SORT.allowed.indexOf(col) < 0) return; + currentSort = nextMbSort(currentSort, col); + var sortWrap = section.querySelector('#mbAdoptersTableWrap'); + if (sortWrap) sortWrap.innerHTML = buildTableContent(rows, currentFilter, currentSort); + _writeViewParams([HASHSTATS_MB_SORT, HASHSTATS_MB_DIR], [currentSort.col, currentSort.dir]); } }); }, 100); diff --git a/test-all.sh b/test-all.sh index 4ea0fa42d..7e7bffec8 100755 --- a/test-all.sh +++ b/test-all.sh @@ -147,6 +147,7 @@ run test-confidence-indicator.js run test-1659-analytics-warmup.js run test-analytics-tab-state-and-query.js run test-analytics-subtab-deeplinks-205.js +run test-hash-stats-sort-226.js run test-channels-merge-1498-unit.js run test-issue-1518-home-url.js run test-live-region-filter.js diff --git a/test-analytics-subtab-deeplinks-205.js b/test-analytics-subtab-deeplinks-205.js index 7b29eb67e..0727735a7 100644 --- a/test-analytics-subtab-deeplinks-205.js +++ b/test-analytics-subtab-deeplinks-205.js @@ -731,6 +731,90 @@ function pageEnv(opts) { assert.deepStrictEqual(active(env.renderMultiByteAdopters(nodes, caps)), ['all']); }); + // #226: the adopters table's sort is deep-linked as mbsort= (column) and + // mbdir= (asc|desc), URL only like mbf=. Without mbsort= the table keeps the + // server's order; an unknown value is the default, never markup. + console.log('\n=== #226: Hash Stats multi-byte adopters sort (mbsort=, mbdir=) ==='); + + // The adopter names in the order the table shows them. + const adopterOrder = (env) => { + const html = env.content(); + const start = html.indexOf('id="mbAdoptersTable"'); + const out = []; + const re = /
NodeRoleStatusHash SizeAdvertsLast Seen
([^<]*)<\/strong><\/td>/g; + re.lastIndex = start < 0 ? html.length : start; + let m; + while ((m = re.exec(html)) && m.index < html.indexOf('
', start)) out.push(m[1]); + return out; + }; + const byPackets = REAL.hashData.multiByteNodes.slice().sort((a, b) => a.packets - b.packets).map((n) => n.name); + const byLastSeen = REAL.hashData.multiByteNodes.slice().sort((a, b) => Date.parse(a.lastSeen) - Date.parse(b.lastSeen)).map((n) => n.name); + const serverOrder = REAL.hashData.multiByteNodes.map((n) => n.name); + + await test('precondition: the fixture adopters differ in Adverts and Last Seen, and the server order is not ascending by Adverts', async () => { + assert.ok(serverOrder.length >= 2, 'fewer than two adopters'); + assert.notDeepStrictEqual(byPackets, serverOrder, 'server order already ascending by Adverts'); + }); + + for (const [query, want] of [ + ['mbsort=packets', byPackets], + ['mbsort=packets&mbdir=desc', byPackets.slice().reverse()], + ['mbsort=lastSeen', byLastSeen], + ['mbsort=lastSeen&mbdir=desc', byLastSeen.slice().reverse()], + ]) { + await test('#/analytics?tab=hashsizes&' + query + ' sorts the table, URL unchanged', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&' + query); + assert.strictEqual(env.initError(), null, 'init() threw'); + assert.deepStrictEqual(adopterOrder(env), want); + assert.strictEqual(env.hash(), '#/analytics?tab=hashsizes&' + query, 'URL not canonical'); + }); + } + + await test('mbsort= and mbf= together: filtered and sorted, both kept', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbf=confirmed&mbsort=packets&mbdir=desc'); + assert.deepStrictEqual(env.activeMbFilters(), ['confirmed']); + assert.strictEqual(env.hash(), '#/analytics?tab=hashsizes&mbf=confirmed&mbsort=packets&mbdir=desc'); + }); + + for (const value of ['', 'Packets', 'x"]', 'x"],[data-sort="name', '__proto__', 'constructor', 'none', '']) { + await test('?mbsort=' + JSON.stringify(value) + ' keeps the server order, canonical URL, no exception', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbsort=' + encodeURIComponent(value) + '&mbdir=desc'); + assert.strictEqual(env.initError(), null, 'init() threw'); + assert.deepStrictEqual(adopterOrder(env), serverOrder); + assert.strictEqual(env.hash(), '#/analytics?tab=hashsizes', 'URL not canonical'); + assert.ok(env.content().indexOf('onerror') < 0, 'URL value reached the markup'); + }); + } + + for (const value of ['', 'DESC', 'down', '__proto__', '']) { + await test('?mbdir=' + JSON.stringify(value) + ' is ascending, dropped from the URL', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbsort=packets&mbdir=' + encodeURIComponent(value)); + assert.deepStrictEqual(adopterOrder(env), byPackets); + assert.strictEqual(env.hash(), '#/analytics?tab=hashsizes&mbsort=packets'); + assert.ok(env.content().indexOf('onerror') < 0, 'URL value reached the markup'); + }); + } + + await test('mbsort=/mbdir= are URL only: nothing stored, a later plain visit keeps the server order', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbsort=packets&mbdir=desc'); + assert.deepStrictEqual(Object.keys(env.session), [], 'sessionStorage written: ' + JSON.stringify(env.session)); + env.destroy(); + await env.mount('#/analytics?tab=hashsizes'); + assert.deepStrictEqual(adopterOrder(env), serverOrder); + }); + + await test('switching from Hash Stats to another tab drops mbsort= and mbdir=', async () => { + const env = pageEnv(); + await env.mount('#/analytics?tab=hashsizes&mbf=confirmed&mbsort=packets&mbdir=desc&window=24h'); + await env.clickTab('topology'); + assert.strictEqual(env.hash(), '#/analytics?tab=topology&window=24h'); + }); + console.log('\n' + passed + ' passed, ' + failed + ' failed'); if (failed > 0) process.exit(1); })(); diff --git a/test-hash-stats-sort-226.js b/test-hash-stats-sort-226.js new file mode 100644 index 000000000..628d0f178 --- /dev/null +++ b/test-hash-stats-sort-226.js @@ -0,0 +1,287 @@ +/* test-hash-stats-sort-226.js + * + * #226: the Hash Stats multi-byte adopters table sorted by the wrong column + * (its column map missed Role, so each header read the cell to its left), + * compared Last Seen as "5m ago" text, could only sort ascending, had no + * state or indicator, and lost the order at the next filter click. + * + * Contract pinned here, on the REAL analytics.js in a vm: + * - each header sorts by its own column, in both directions, and the + * column's own cells come out in that order (ties keep the server order); + * - Last Seen follows the timestamps; a row without one is last either way; + * - clicking the sorted header flips the direction, another header starts + * ascending; aria-sort and the arrow mark the sorted column only; + * - a filter click keeps the sort; the filter itself is unchanged; + * - the sort is written to the URL as mbsort=/mbdir= (URL only), defaults + * left out; an unknown value is the default and never reaches markup. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e)); } +} + +const NOW = Date.now(); +const iso = (msAgo) => new Date(NOW - msAgo).toISOString(); +const MIN = 60 * 1000, HOUR = 60 * MIN, DAY = 24 * HOUR; + +// Every column orders these differently from the input order and from each +// other. Last Seen's text order ("10m ago" < "2h ago" < "3d ago" < "45s ago") +// is not its time order. +const NODES = [ + { name: 'delta', pubkey: 'aa', role: 'repeater', hashSize: 2, packets: 7, lastSeen: iso(2 * HOUR) }, + { name: 'Alpha', pubkey: 'bb', role: 'companion', hashSize: 3, packets: 30, lastSeen: iso(10 * MIN) }, + { name: 'charlie', pubkey: 'cc', role: 'room', hashSize: 2, packets: 4, lastSeen: iso(3 * DAY) }, + { name: 'bravo', pubkey: 'dd', role: 'sensor', hashSize: 3, packets: 100, lastSeen: '' }, + { name: 'echo', pubkey: 'ee', role: '', hashSize: 2, packets: 9, lastSeen: iso(45 * 1000) }, +]; +const CAPS = [ + { pubkey: 'aa', status: 'confirmed' }, + { pubkey: 'cc', status: 'suspected' }, + { pubkey: 'dd', status: 'confirmed' }, + { pubkey: 'ee', status: 'suspected' }, + // bb has no capability record: unknown +]; +const TS = Object.fromEntries(NODES.map((n) => [n.pubkey, n.lastSeen ? Date.parse(n.lastSeen) : null])); + +// Expected row order (by pubkey) per column and direction. +const EXPECTED = { + name: { asc: ['bb', 'dd', 'cc', 'aa', 'ee'], desc: ['ee', 'aa', 'cc', 'dd', 'bb'] }, + role: { asc: ['bb', 'aa', 'cc', 'dd', 'ee'], desc: ['ee', 'dd', 'cc', 'aa', 'bb'] }, + status: { asc: ['aa', 'dd', 'cc', 'ee', 'bb'], desc: ['bb', 'cc', 'ee', 'aa', 'dd'] }, + hashSize: { asc: ['aa', 'cc', 'ee', 'bb', 'dd'], desc: ['bb', 'dd', 'aa', 'cc', 'ee'] }, + packets: { asc: ['cc', 'aa', 'ee', 'bb', 'dd'], desc: ['dd', 'bb', 'ee', 'aa', 'cc'] }, + lastSeen: { asc: ['cc', 'aa', 'bb', 'ee', 'dd'], desc: ['ee', 'bb', 'aa', 'cc', 'dd'] }, +}; +const INPUT_ORDER = NODES.map((n) => n.pubkey); + +function env() { + const timers = []; + let handler = null; + const wrap = { innerHTML: '' }; + const buttons = ['all', 'confirmed', 'suspected', 'unknown'].map((f) => { + const cls = new Set(); + return { dataset: { mbFilter: f }, classList: { toggle: (c, on) => (on ? cls.add(c) : cls.delete(c)), contains: (c) => cls.has(c) } }; + }); + const section = { + addEventListener: (type, fn) => { if (type === 'click') handler = fn; }, + querySelector: (sel) => (sel === '#mbAdoptersTableWrap' ? wrap : null), + querySelectorAll: (sel) => (sel === '[data-mb-filter]' ? buttons : []), + }; + const ctx = { + window: { addEventListener() {}, removeEventListener() {}, dispatchEvent() {} }, + document: { + readyState: 'complete', body: { appendChild() {} }, head: { appendChild() {} }, + createElement: () => ({ style: {}, setAttribute() {}, appendChild() {} }), + getElementById: (id) => (id === 'mbAdoptersSection' ? section : null), + addEventListener() {}, querySelectorAll: () => [], querySelector: () => null, + documentElement: { style: {}, getAttribute: () => null, setAttribute() {} }, + }, + console: { log() {}, warn() {}, error() {} }, Date, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, + setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout() {}, + setInterval: () => 0, clearInterval() {}, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + performance: { now: () => 0 }, Map, Set, Promise, URLSearchParams, Infinity, NaN, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + sessionStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + location: { hash: '#/analytics?tab=hashsizes' }, + history: { state: null, replaceState(st, _t, url) { ctx.history.state = st; ctx.location.hash = url; } }, + CustomEvent: class CustomEvent {}, getComputedStyle: () => ({ getPropertyValue: () => '' }), + registerPage() {}, fetch: async () => ({ ok: true, json: async () => ({}) }), + }; + vm.createContext(ctx); + const load = (f) => { vm.runInContext(fs.readFileSync(f, 'utf8'), ctx); for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; }; + load('public/roles.js'); + load('public/url-state.js'); + try { load('public/app.js'); } catch (e) { /* DOM-only tail */ } + ctx.registerPage = () => {}; + load('public/analytics.js'); + const render = ctx._analyticsRenderMultiByteAdopters; + assert.strictEqual(typeof render, 'function', '_analyticsRenderMultiByteAdopters not exposed'); + return { + ctx, wrap, buttons, + timeAgo: ctx.timeAgo, + // Renders the card and wires its click handler, as the page does. + mount(filter, sort) { + const html = render(NODES, CAPS, filter, sort); + const m = /
([\s\S]*)<\/div><\/div><\/div>$/.exec(html); + wrap.innerHTML = m ? m[1] : ''; + while (timers.length) timers.shift()(); + return html; + }, + clickHeader(col) { + assert.ok(handler, 'no click handler on #mbAdoptersSection'); + const th = { dataset: { sort: col } }; + handler({ target: { closest: (sel) => (sel === '[data-sort]' || sel === 'th[data-sort]' ? th : null) } }); + }, + clickFilter(f) { + assert.ok(handler, 'no click handler on #mbAdoptersSection'); + const btn = buttons.find((b) => b.dataset.mbFilter === f); + handler({ target: { closest: (sel) => (sel === '[data-mb-filter]' ? btn : null) } }); + }, + params: () => Object.fromEntries(new URLSearchParams(ctx.location.hash.split('?')[1] || '')), + }; +} + +const strip = (s) => s.replace(/<[^>]*>/g, '').replace(/&/g, '&').replace(/\s+/g, ' ').trim(); + +// The table as { headers: [{ col, attrs, text }], rows: [{ pubkey, cells: [text] }] }. +function parseTable(html) { + const headers = []; + const thRe = /]*)?>([\s\S]*?)<\/th>/g; + let m; + while ((m = thRe.exec(html))) { + const col = /data-sort="([^"]*)"/.exec(m[1] || ''); + headers.push({ col: col ? col[1] : null, attrs: m[1] || '', text: strip(m[2]) }); + } + const rows = []; + const trRe = /]*data-value="#\/nodes\/([^"]*)"[^>]*>([\s\S]*?)<\/tr>/g; + while ((m = trRe.exec(html))) { + const cells = []; + const tdRe = /]*>([\s\S]*?)<\/td>/g; + let c; + while ((c = tdRe.exec(m[2]))) cells.push(strip(c[1])); + rows.push({ pubkey: decodeURIComponent(m[1]), cells }); + } + return { headers, rows }; +} + +// The cells of one column, read through the column's own header. +function columnCells(t, col) { + const i = t.headers.findIndex((h) => h.col === col); + assert.ok(i >= 0, 'no header for ' + col); + return t.rows.map((r) => r.cells[i]); +} + +// What each column's cells show for a row, to compare the column's own cells. +function cellOf(e, pubkey, col) { + const n = NODES.find((x) => x.pubkey === pubkey); + const cap = CAPS.find((c) => c.pubkey === pubkey); + switch (col) { + case 'name': return n.name; + case 'role': return n.role || 'unknown'; + case 'status': return { confirmed: 'Confirmed', suspected: 'Suspected' }[cap && cap.status] || 'Unknown'; + case 'hashSize': return n.hashSize + '-byte'; + case 'packets': return String(n.packets); + case 'lastSeen': return n.lastSeen ? e.timeAgo(n.lastSeen) : '—'; + } + throw new Error(col); +} + +console.log('\n=== #226: each header sorts by its own column, both directions ==='); +for (const col of Object.keys(EXPECTED)) { + for (const dir of ['asc', 'desc']) { + test(col + ' ' + dir + ': the column\'s own cells come out in order', () => { + const e = env(); + const t = parseTable(e.mount('all', { col, dir })); + assert.deepStrictEqual(columnCells(t, col), EXPECTED[col][dir].map((pk) => cellOf(e, pk, col)), 'cells of ' + col); + assert.deepStrictEqual(t.rows.map((r) => r.pubkey), EXPECTED[col][dir], 'row order'); + }); + } +} + +test('Last Seen follows the timestamps, not the "x ago" text; no timestamp is last both ways', () => { + for (const dir of ['asc', 'desc']) { + const t = parseTable(env().mount('all', { col: 'lastSeen', dir })); + const ts = t.rows.map((r) => TS[r.pubkey]); + assert.strictEqual(ts[ts.length - 1], null, dir + ': the row without Last Seen is not last'); + const known = ts.slice(0, -1); + for (let i = 1; i < known.length; i++) { + assert.ok(dir === 'asc' ? known[i - 1] <= known[i] : known[i - 1] >= known[i], dir + ': ' + JSON.stringify(known)); + } + } +}); + +test('no sort, "none" and unknown columns keep the server order', () => { + for (const sort of [undefined, null, { col: 'none', dir: 'asc' }, { col: 'bogus', dir: 'desc' }, { col: '__proto__', dir: 'asc' }]) { + const t = parseTable(env().mount('all', sort)); + assert.deepStrictEqual(t.rows.map((r) => r.pubkey), INPUT_ORDER, JSON.stringify(sort)); + } +}); + +test('aria-sort and the arrow mark the sorted column only', () => { + const t = parseTable(env().mount('all', { col: 'packets', dir: 'desc' })); + const sorted = t.headers.filter((h) => /aria-sort="(ascending|descending)"/.test(h.attrs)); + assert.deepStrictEqual(sorted.map((h) => h.col), ['packets']); + assert.ok(/aria-sort="descending"/.test(sorted[0].attrs), sorted[0].attrs); + assert.ok(/sort-active/.test(sorted[0].attrs), 'no sort-active class'); + assert.ok(sorted[0].text.indexOf('↓') >= 0, 'no ↓ arrow: ' + sorted[0].text); + for (const h of t.headers.filter((x) => x.col !== 'packets')) { + assert.ok(!/aria-sort="(ascending|descending)"/.test(h.attrs) && !/sort-active/.test(h.attrs), h.col + ' marked'); + } +}); + +console.log('\n=== #226: clicks ==='); + +test('clicking a header sorts ascending, clicking it again descending', () => { + const e = env(); + e.mount('all'); + e.clickHeader('packets'); + assert.deepStrictEqual(parseTable(e.wrap.innerHTML).rows.map((r) => r.pubkey), EXPECTED.packets.asc, 'first click'); + e.clickHeader('packets'); + assert.deepStrictEqual(parseTable(e.wrap.innerHTML).rows.map((r) => r.pubkey), EXPECTED.packets.desc, 'second click'); + e.clickHeader('role'); + assert.deepStrictEqual(parseTable(e.wrap.innerHTML).rows.map((r) => r.pubkey), EXPECTED.role.asc, 'another column starts ascending'); +}); + +test('every header click sorts its own column (Role included)', () => { + for (const col of Object.keys(EXPECTED)) { + const e = env(); + e.mount('all'); + e.clickHeader(col); + const t = parseTable(e.wrap.innerHTML); + assert.deepStrictEqual(columnCells(t, col), EXPECTED[col].asc.map((pk) => cellOf(e, pk, col)), col); + } +}); + +test('a filter click keeps the sort and still filters', () => { + const e = env(); + e.mount('all'); + e.clickHeader('packets'); + e.clickHeader('packets'); + e.clickFilter('confirmed'); + const t = parseTable(e.wrap.innerHTML); + assert.deepStrictEqual(t.rows.map((r) => r.pubkey), ['dd', 'aa'], 'confirmed rows, packets descending'); + assert.ok(t.headers.some((h) => h.col === 'packets' && /aria-sort="descending"/.test(h.attrs)), 'indicator lost'); + assert.strictEqual(e.params().mbf, 'confirmed', 'mbf= not written'); + assert.ok(e.buttons.find((b) => b.dataset.mbFilter === 'confirmed').classList.contains('active'), 'filter button not active'); +}); + +test('a header click keeps the filter', () => { + const e = env(); + e.mount('suspected'); + e.clickHeader('packets'); + assert.deepStrictEqual(parseTable(e.wrap.innerHTML).rows.map((r) => r.pubkey), ['cc', 'ee']); +}); + +console.log('\n=== #226: URL ==='); + +test('clicks write mbsort=/mbdir= next to mbf=; the default direction is left out', () => { + const e = env(); + e.mount('all'); + e.clickHeader('lastSeen'); + assert.deepStrictEqual(e.params(), { tab: 'hashsizes', mbsort: 'lastSeen' }); + e.clickHeader('lastSeen'); + assert.deepStrictEqual(e.params(), { tab: 'hashsizes', mbsort: 'lastSeen', mbdir: 'desc' }); + e.clickFilter('suspected'); + assert.deepStrictEqual(e.params(), { tab: 'hashsizes', mbf: 'suspected', mbsort: 'lastSeen', mbdir: 'desc' }); +}); + +test('hostile sort values never reach the markup', () => { + const evil = 'x">'; + const html = env().mount('all', { col: evil, dir: evil }); + assert.ok(html.indexOf('onerror') < 0, 'value echoed into the markup'); +}); + +console.log('\n' + passed + ' passed, ' + failed + ' failed'); +if (failed) { + console.log('test-hash-stats-sort-226.js: FAIL'); + process.exit(1); +} +console.log('test-hash-stats-sort-226.js: PASS'); diff --git a/test-issue-226-hash-stats-sort-e2e.js b/test-issue-226-hash-stats-sort-e2e.js new file mode 100644 index 000000000..35955974d --- /dev/null +++ b/test-issue-226-hash-stats-sort-e2e.js @@ -0,0 +1,241 @@ +#!/usr/bin/env node +/** + * E2E (#226): the Hash Stats multi-byte adopters table sorts by the clicked + * column, in both directions, keeps the sort across a filter click and a + * reload, and deep-links it as ?mbsort= and ?mbdir=. + * + * Before #226 each header read the cell to its left (the column map missed + * Role), Last Seen compared "5m ago" text, the sort was ascending only, had + * no indicator and was lost at the next filter click. + * + * The checks are on the table's own cells: each column must be in order + * after its header is clicked. Last Seen is checked against the adopters' + * timestamps from /api/analytics/hash-sizes, not against the "x ago" text. + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-226-hash-stats-sort-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const COLS = ['name', 'role', 'status', 'hashSize', 'packets', 'lastSeen']; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +const hash = (page) => page.evaluate(() => location.hash); +// The hash keys as a sorted list, for checks where the key order follows the +// click order. +const hashKeys = async (page) => (await hash(page)).split('?')[1].split('&').sort().join('&'); + +async function coldLoad(page, path) { + await page.goto('about:blank'); + await page.goto(BASE + '/' + path, { waitUntil: 'load' }); + await settle(page); +} + +// The tab renders again on the first theme refresh; the card wires its click +// handler 100 ms after each render. +async function settle(page) { + await page.waitForFunction(() => window.__themeRefreshed, null, { timeout: 8000 }).catch(() => {}); + await page.waitForSelector('#mbAdoptersTable tbody tr', { timeout: 15000 }); + await page.waitForTimeout(300); +} + +// The table as the visitor sees it: per header its sort key, aria-sort and +// text; per row its pubkey and cell texts. +function readTable(page) { + return page.evaluate(() => { + const t = document.getElementById('mbAdoptersTable'); + if (!t) return null; + const headers = Array.from(t.querySelectorAll('thead th')).map((th) => ({ + col: th.dataset.sort, ariaSort: th.getAttribute('aria-sort'), text: th.textContent.trim(), + })); + const rows = Array.from(t.querySelectorAll('tbody tr')).map((tr) => ({ + pubkey: decodeURIComponent((tr.dataset.value || '').replace('#/nodes/', '')), + cells: Array.from(tr.children).map((td) => td.textContent.trim()), + })); + return { headers, rows }; + }); +} + +// The comparable value of a cell of a column; Last Seen comes from the API +// timestamp of the row's node. +function cellValue(col, row, idx, lastSeenByPk) { + const text = row.cells[idx]; + switch (col) { + case 'name': case 'role': return text.toLowerCase(); + case 'status': return { confirmed: 0, suspected: 1, unknown: 2 }[text.toLowerCase().split(' ').pop()]; + case 'hashSize': case 'packets': return parseInt(text, 10); + case 'lastSeen': { + const iso = lastSeenByPk[row.pubkey]; + return iso ? Date.parse(iso) : NaN; + } + } + throw new Error('column ' + col); +} + +function assertOrdered(table, col, dir, lastSeenByPk) { + const idx = table.headers.findIndex((h) => h.col === col); + assert(idx >= 0, 'no header for ' + col); + const vals = table.rows.map((r) => cellValue(col, r, idx, lastSeenByPk)); + const known = vals.filter((v) => !(typeof v === 'number' && isNaN(v))); + // Rows without a value come last. + assert(vals.slice(0, known.length).every((v) => !(typeof v === 'number' && isNaN(v))), col + ': a missing value is not last'); + for (let i = 1; i < known.length; i++) { + const ok = dir === 'asc' ? known[i - 1] <= known[i] : known[i - 1] >= known[i]; + assert(ok, col + ' ' + dir + ' out of order at row ' + i + ': ' + JSON.stringify(known.slice(Math.max(0, i - 2), i + 2))); + } + const marked = table.headers.filter((h) => h.ariaSort === 'ascending' || h.ariaSort === 'descending'); + assert(marked.length === 1 && marked[0].col === col, 'aria-sort on ' + JSON.stringify(marked.map((h) => h.col))); + assert(marked[0].ariaSort === (dir === 'asc' ? 'ascending' : 'descending'), 'aria-sort ' + marked[0].ariaSort); + assert(marked[0].text.indexOf(dir === 'asc' ? '↑' : '↓') >= 0, 'arrow missing: ' + marked[0].text); +} + +async function clickHeader(page, col) { + await page.click('#mbAdoptersTable thead th[data-sort="' + col + '"]'); +} + +(async () => { + const requireChromium = process.env.CHROMIUM_REQUIRE === '1'; + let browser; + try { + browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + } catch (err) { + if (requireChromium) { + console.error('test-issue-226-hash-stats-sort-e2e.js: FAIL — Chromium required but unavailable: ' + err.message); + process.exit(1); + } + console.log('test-issue-226-hash-stats-sort-e2e.js: SKIP (Chromium unavailable: ' + err.message.split('\n')[0] + ')'); + process.exit(0); + } + + const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } }); + await ctx.addInitScript(() => { + window.addEventListener('theme-refresh', () => { window.__themeRefreshed = true; }, { once: true }); + }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + const pageErrors = []; + page.on('pageerror', (e) => { pageErrors.push(e.message); console.error('[pageerror]', e.message); }); + + console.log('\n=== #226 Hash Stats adopters sort E2E against ' + BASE + ' ==='); + + const api = await (await page.request.get(BASE + '/api/analytics/hash-sizes')).json(); + const nodes = api.multiByteNodes || []; + const lastSeenByPk = Object.fromEntries(nodes.map((n) => [n.pubkey, n.lastSeen])); + const serverOrder = nodes.map((n) => n.pubkey); + + await step('precondition: the fixture has adopters whose Last Seen and names differ', async () => { + assert(nodes.length >= 3, nodes.length + ' adopters'); + assert(new Set(nodes.map((n) => n.lastSeen)).size >= 3, 'Last Seen values are all equal'); + assert(new Set(nodes.map((n) => n.name)).size >= 3, 'names are all equal'); + }); + + await step('a plain visit keeps the server order and marks no column', async () => { + await coldLoad(page, '#/analytics?tab=hashsizes'); + const t = await readTable(page); + assert(JSON.stringify(t.rows.map((r) => r.pubkey)) === JSON.stringify(serverOrder), 'not the server order'); + assert(t.headers.every((h) => !h.ariaSort), 'aria-sort set: ' + JSON.stringify(t.headers)); + assert(await hash(page) === '#/analytics?tab=hashsizes', 'hash ' + await hash(page)); + }); + + for (const col of COLS) { + await step('clicking ' + col + ' sorts its own cells ascending, again descending; the URL follows', async () => { + await coldLoad(page, '#/analytics?tab=hashsizes'); + await clickHeader(page, col); + assertOrdered(await readTable(page), col, 'asc', lastSeenByPk); + assert(await hash(page) === '#/analytics?tab=hashsizes&mbsort=' + col, 'asc hash ' + await hash(page)); + await clickHeader(page, col); + assertOrdered(await readTable(page), col, 'desc', lastSeenByPk); + assert(await hash(page) === '#/analytics?tab=hashsizes&mbsort=' + col + '&mbdir=desc', 'desc hash ' + await hash(page)); + }); + } + + await step('Last Seen is not sorted by its "x ago" text', async () => { + await coldLoad(page, '#/analytics?tab=hashsizes'); + await clickHeader(page, 'lastSeen'); + const t = await readTable(page); + const idx = t.headers.findIndex((h) => h.col === 'lastSeen'); + const texts = t.rows.map((r) => r.cells[idx]); + const ts = t.rows.map((r) => Date.parse(lastSeenByPk[r.pubkey])); + assert(ts.every((v, i) => i === 0 || ts[i - 1] <= v), 'timestamps not ascending'); + // The fixture spans more than one unit ("…m ago", "…h ago"), so text order would differ. + const textSorted = texts.slice().sort(); + assert(JSON.stringify(textSorted) !== JSON.stringify(texts) || new Set(texts).size < 2, 'text order equals time order; the check proves nothing on this fixture'); + }); + + await step('a filter click keeps the sort; All brings back every row in the same order', async () => { + await coldLoad(page, '#/analytics?tab=hashsizes'); + await clickHeader(page, 'lastSeen'); + await clickHeader(page, 'lastSeen'); + await page.click('#mbCapFilters [data-mb-filter="confirmed"]'); + const t = await readTable(page); + assert(t.rows.length > 0, 'no confirmed rows'); + assertOrdered(t, 'lastSeen', 'desc', lastSeenByPk); + assert(await hashKeys(page) === 'mbdir=desc&mbf=confirmed&mbsort=lastSeen&tab=hashsizes', 'hash ' + await hash(page)); + await page.click('#mbCapFilters [data-mb-filter="all"]'); + const all = await readTable(page); + assert(all.rows.length === nodes.length, all.rows.length + ' rows under All'); + assertOrdered(all, 'lastSeen', 'desc', lastSeenByPk); + assert(await hashKeys(page) === 'mbdir=desc&mbsort=lastSeen&tab=hashsizes', 'hash ' + await hash(page)); + }); + + await step('reload keeps the sort and the filter', async () => { + await page.click('#mbCapFilters [data-mb-filter="confirmed"]'); + await page.reload({ waitUntil: 'load' }); + await settle(page); + assertOrdered(await readTable(page), 'lastSeen', 'desc', lastSeenByPk); + const active = await page.evaluate(() => Array.from(document.querySelectorAll('#mbCapFilters [data-mb-filter].active')).map((b) => b.dataset.mbFilter)); + assert(JSON.stringify(active) === '["confirmed"]', 'active filter ' + JSON.stringify(active)); + }); + + await step('cold load of #/analytics?tab=hashsizes&mbsort=name&mbdir=desc opens sorted, URL unchanged', async () => { + await coldLoad(page, '#/analytics?tab=hashsizes&mbsort=name&mbdir=desc'); + assertOrdered(await readTable(page), 'name', 'desc', lastSeenByPk); + assert(await hash(page) === '#/analytics?tab=hashsizes&mbsort=name&mbdir=desc', 'hash ' + await hash(page)); + }); + + await step('a hostile ?mbsort= keeps the server order, canonical URL, no page error', async () => { + const before = pageErrors.length; + await coldLoad(page, '#/analytics?tab=hashsizes&mbsort=' + encodeURIComponent('x"],[data-sort="name') + '&mbdir=desc'); + const t = await readTable(page); + assert(JSON.stringify(t.rows.map((r) => r.pubkey)) === JSON.stringify(serverOrder), 'not the server order'); + assert(await hash(page) === '#/analytics?tab=hashsizes', 'hash not canonical: ' + await hash(page)); + assert(pageErrors.length === before, 'page errors: ' + pageErrors.slice(before).join(' | ')); + }); + + await step('switching to another tab drops mbsort= and mbdir=', async () => { + await coldLoad(page, '#/analytics?tab=hashsizes&mbsort=packets&mbdir=desc'); + await page.click('#analyticsTabs [data-tab="topology"]'); + await page.waitForFunction(() => location.hash === '#/analytics?tab=topology', null, { timeout: 5000 }) + .catch(async () => { throw new Error('after the tab switch: ' + await hash(page)); }); + }); + + if (process.env.SCREENSHOT) { + await coldLoad(page, '#/analytics?tab=hashsizes&mbsort=lastSeen&mbdir=desc'); + const card = await page.$('#mbAdoptersSection'); + if (card) await card.screenshot({ path: process.env.SCREENSHOT }); + } + + await step('no page errors', async () => { + assert(pageErrors.length === 0, pageErrors.join(' | ')); + }); + + await browser.close(); + + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + if (failed > 0) { + console.error('test-issue-226-hash-stats-sort-e2e.js: FAIL'); + process.exit(1); + } + console.log('test-issue-226-hash-stats-sort-e2e.js: PASS'); +})();