From 8990b6dedf9ed11f04681ce62659d30da00a68e1 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 4 Oct 2026 15:09:54 +0200 Subject: [PATCH 1/2] test(analytics): pin the Hash Stats adopters sort per column, direction and deep link (#226) Red on master: the header map misses Role, so each header sorts by the cell to its left (Role does nothing, Last Seen compares "x ago" text); the sort is ascending only, has no indicator, is lost at the next filter click and is not in the URL. - test-hash-stats-sort-226.js: each column's own cells in order, both directions; Last Seen by timestamp with a missing one last; aria-sort and arrow; header and filter clicks through the card's real handler; mbsort=/mbdir= written next to mbf=. - test-analytics-subtab-deeplinks-205.js: mbsort=/mbdir= deep links on the whole page, hostile values give the server order and a canonical URL, URL only, dropped on a tab switch. - test-issue-226-hash-stats-sort-e2e.js (added to the Playwright step): every header and both directions on the fixture, filter click, reload, deep link, hostile value, tab switch. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy.yml | 1 + test-all.sh | 1 + test-analytics-subtab-deeplinks-205.js | 84 ++++++++ test-hash-stats-sort-226.js | 287 +++++++++++++++++++++++++ test-issue-226-hash-stats-sort-e2e.js | 241 +++++++++++++++++++++ 5 files changed, 614 insertions(+) create mode 100644 test-hash-stats-sort-226.js create mode 100644 test-issue-226-hash-stats-sort-e2e.js 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/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 = /([^<]*)<\/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'); +})(); From 523b3fbd186114afd4390aa94488e9268e0922d5 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 4 Oct 2026 15:09:54 +0200 Subject: [PATCH 2/2] fix(analytics): sort the Hash Stats adopters table by the clicked column, with direction (#226) The header click sorted the rendered rows by a column map that missed Role, so every header read the cell to its left, and Last Seen compared the "5m ago" text. It sorted ascending only, showed nothing, and the next filter click re-rendered the table in the server's order. The sort is now state of the card ({ col, dir }) and is applied to the row data before the table is rendered, by the same buildTableContent the filter click uses, so a filter click keeps it. Each column sorts by its own value: Node and Role as lower-case text, Status by weight, Hash Size and Adverts as numbers, Last Seen by timestamp (a missing one last in both directions); ties keep the server's order. Clicking the sorted header flips the direction, another header starts ascending; the sorted header gets aria-sort, sort-active and the arrow the channel table uses. The sort is deep-linked as mbsort= and mbdir= next to mbf=, URL only, through the #205/#208 view-param helpers: values are only compared with the fixed column keys, an unknown one is the default (the server's order), and a direction without a column is dropped. Both keys are dropped on a tab switch. Co-Authored-By: Claude Opus 5.5 --- public/analytics.js | 117 +++++++++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 34 deletions(-) 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);
NodeRoleStatusHash SizeAdvertsLast Seen