@@ -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 ? '
' +
- '' +
- '| Node | ' +
- 'Role | ' +
- 'Status | ' +
- 'Hash Size | ' +
- 'Adverts | ' +
- 'Last Seen | ' +
- '
' +
+ 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 = /([^<]*)<\/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');
+})();
|