mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-01 20:28:24 +00:00
fix(analytics): integrate channels list with PSK decrypt UX + add link from Channels page (#1042)
## What Integrates the Analytics → Channels section with the PSK decrypt UX (PRs #1021–#1040). Replaces nonsense `chNNN` placeholders with useful display names and groups the table the same way the Channels sidebar does. ## Before - Encrypted channels showed raw `ch185`, `ch64`, `ch?` placeholders. - Locally-decrypted PSK channels (with stored keys + labels) were not surfaced — every encrypted row looked identical and useless. - Single flat list, sorted by last activity by default. ## After - **My Channels** 🔑 — any analytics row whose hash byte matches a stored PSK key (via `ChannelDecrypt.getStoredKeys()` + `computeChannelHash`). Display name uses the user's label if set, otherwise the key name. - **Network** 📻 — known cleartext channels (server-provided names) and rainbow-table-decoded encrypted channels. - **Encrypted** 🔒 — unknown encrypted, rendered as `🔒 Encrypted (0xNN)` instead of `chNNN`. - Within each group: messages descending (most active first). - New `📊 Channel Analytics →` link in the Channels page sidebar header → `#/analytics`. ## How - Pure `decorateAnalyticsChannels(channels, hashByteToKeyName, labels)` — testable in isolation, sets `displayName` + `group` per row. - `buildHashKeyMap()` — async helper that resolves stored PSK keys to their channel hash bytes via `computeChannelHash`. Used at render time; first paint uses an empty map (best-effort) and re-renders once keys resolve. Graceful fallback when `ChannelDecrypt` is missing or there are no stored keys. - `channelTbodyHtml` gains an `opts.grouped` flag — opt-in so the existing flat sort still works for any other caller. - The analytics API endpoint is **unchanged** — this is purely frontend rendering. ## Tests `test-analytics-channels-integration.js` — 19 assertions covering decoration, grouping, sort order, and the channels-page link. Added to `test-all.sh`. Red commit: `5081b12` (12 assertion failures + stub). Green commit: `6be16d9` (all 19 pass). --------- Co-authored-by: bot <bot@corescope.local> Co-authored-by: meshcore-bot <bot@meshcore.local>
This commit is contained in:
co-authored by
bot
meshcore-bot
parent
26a914274f
commit
f9cd43f06f
+135
-8
@@ -737,6 +737,7 @@
|
||||
// ===================== CHANNELS =====================
|
||||
var _channelSortState = null;
|
||||
var _channelData = null;
|
||||
var _channelRenderGen = 0;
|
||||
var CHANNEL_SORT_KEY = 'meshcore-channel-sort';
|
||||
|
||||
function loadChannelSort() {
|
||||
@@ -747,6 +748,18 @@
|
||||
return { col: 'lastActivity', dir: 'desc' };
|
||||
}
|
||||
|
||||
// True when the user has explicitly chosen a sort (saved in localStorage).
|
||||
// Used by the grouped analytics view to decide whether to apply its own
|
||||
// default ("messages desc") instead of the global flat-list default.
|
||||
function hasSavedChannelSort() {
|
||||
try {
|
||||
var s = localStorage.getItem(CHANNEL_SORT_KEY);
|
||||
if (!s) return false;
|
||||
var p = JSON.parse(s);
|
||||
return !!(p && p.col && p.dir);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
function saveChannelSort(state) {
|
||||
try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {}
|
||||
}
|
||||
@@ -781,20 +794,107 @@
|
||||
}
|
||||
|
||||
function channelRowHtml(c) {
|
||||
var name = c.displayName || c.name || 'Unknown';
|
||||
return '<tr class="clickable-row" data-action="navigate" data-value="#/channels?ch=' + c.hash + '" tabindex="0" role="row">' +
|
||||
'<td><strong>' + esc(c.name || 'Unknown') + '</strong></td>' +
|
||||
'<td><strong>' + esc(name) + '</strong></td>' +
|
||||
'<td class="mono">' + (typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash) + '</td>' +
|
||||
'<td>' + c.messages + '</td>' +
|
||||
'<td>' + c.senders + '</td>' +
|
||||
'<td>' + timeAgo(c.lastActivity) + '</td>' +
|
||||
'<td>' + (c.encrypted ? '🔒' : '✅') + '</td>' +
|
||||
'<td>' + (c.encrypted ? (c.group === 'mine' ? '🔑' : '🔒') : '✅') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function channelTbodyHtml(channels, col, dir) {
|
||||
// ── PSK-aware decoration ──────────────────────────────────────────────────
|
||||
// Server returns raw "chNNN" placeholder names for encrypted channels it
|
||||
// doesn't know. Decorate so the UI shows a useful display name and a
|
||||
// group bucket: mine / network / encrypted. Pure function for testability.
|
||||
function decorateAnalyticsChannels(channels, hashByteToKeyName, labels) {
|
||||
var keyMap = hashByteToKeyName || {};
|
||||
var lab = labels || {};
|
||||
var out = [];
|
||||
for (var i = 0; i < (channels || []).length; i++) {
|
||||
var c = channels[i];
|
||||
var copy = Object.assign({}, c);
|
||||
var hashNum = typeof c.hash === 'number' ? c.hash : parseInt(c.hash, 10);
|
||||
var rawName = String(c.name || '');
|
||||
var isPlaceholder = /^ch(\d+|\?)$/.test(rawName);
|
||||
if (c.encrypted) {
|
||||
var keyName = !isNaN(hashNum) ? keyMap[hashNum] : null;
|
||||
if (keyName) {
|
||||
copy.displayName = lab[keyName] || keyName;
|
||||
copy.group = 'mine';
|
||||
} else if (isPlaceholder || !rawName) {
|
||||
// Placeholder ("chNNN") or empty name → render as opaque encrypted.
|
||||
// Empty-name encrypted rows would otherwise leak through with an
|
||||
// empty <strong> in the row; force the placeholder rendering.
|
||||
copy.displayName = !isNaN(hashNum)
|
||||
? '🔒 Encrypted (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')'
|
||||
: '🔒 Encrypted';
|
||||
copy.group = 'encrypted';
|
||||
} else {
|
||||
// Server gave us a real name (rainbow table hit) for an encrypted ch.
|
||||
copy.displayName = rawName;
|
||||
copy.group = 'network';
|
||||
}
|
||||
} else {
|
||||
copy.displayName = rawName || 'Unknown';
|
||||
copy.group = 'network';
|
||||
}
|
||||
out.push(copy);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the (hash byte → key name) map from ChannelDecrypt's stored keys.
|
||||
// Async because computeChannelHash uses subtle.digest. Returns {} if the
|
||||
// module or its keys are unavailable (graceful fallback).
|
||||
async function buildHashKeyMap() {
|
||||
if (typeof ChannelDecrypt === 'undefined' || !ChannelDecrypt.getStoredKeys) return {};
|
||||
var keys = ChannelDecrypt.getStoredKeys();
|
||||
var map = {};
|
||||
var names = Object.keys(keys || {});
|
||||
for (var ni = 0; ni < names.length; ni++) {
|
||||
var name = names[ni];
|
||||
try {
|
||||
var bytes = ChannelDecrypt.hexToBytes(keys[name]);
|
||||
var hb = await ChannelDecrypt.computeChannelHash(bytes);
|
||||
if (typeof hb === 'number') map[hb] = name;
|
||||
} catch (e) { /* skip bad key */ }
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function channelTbodyHtml(channels, col, dir, opts) {
|
||||
var sorted = sortChannels(channels, col, dir);
|
||||
var parts = [];
|
||||
for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
|
||||
if (opts && opts.grouped) {
|
||||
// Group by .group: mine → network → encrypted. Inside each group keep
|
||||
// the active sort (caller passes col/dir; for the integration we sort
|
||||
// by messages desc by default).
|
||||
var groups = { mine: [], network: [], encrypted: [] };
|
||||
for (var gi = 0; gi < sorted.length; gi++) {
|
||||
var g = sorted[gi].group || (sorted[gi].encrypted ? 'encrypted' : 'network');
|
||||
(groups[g] || (groups[g] = [])).push(sorted[gi]);
|
||||
}
|
||||
var sections = [
|
||||
{ key: 'mine', label: '🔑 My Channels' },
|
||||
{ key: 'network', label: '📻 Network' },
|
||||
{ key: 'encrypted', label: '🔒 Encrypted' },
|
||||
];
|
||||
for (var si = 0; si < sections.length; si++) {
|
||||
var rows = groups[sections[si].key] || [];
|
||||
if (!rows.length) continue;
|
||||
parts.push(
|
||||
'<tr class="ch-section-row"><td colspan="6" class="ch-section-header">' +
|
||||
esc(sections[si].label) + ' <span class="text-muted">(' + rows.length + ')</span>' +
|
||||
'</td></tr>'
|
||||
);
|
||||
for (var ri = 0; ri < rows.length; ri++) parts.push(channelRowHtml(rows[ri]));
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
@@ -825,13 +925,39 @@
|
||||
var tbody = document.getElementById('channelsTbody');
|
||||
var thead = document.querySelector('#channelsTable thead');
|
||||
if (!tbody || !_channelData) return;
|
||||
tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir);
|
||||
tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true });
|
||||
if (thead) thead.outerHTML = channelTheadHtml(_channelSortState.col, _channelSortState.dir);
|
||||
}
|
||||
|
||||
function renderChannels(el, ch) {
|
||||
_channelData = ch.channels;
|
||||
if (!_channelSortState) _channelSortState = loadChannelSort();
|
||||
// Decorate first so grouping/display name reflect locally-stored PSK keys.
|
||||
// buildHashKeyMap is async; render once with a sync best-effort empty map,
|
||||
// then upgrade once keys resolve. That keeps first paint fast and avoids
|
||||
// blocking on subtle.digest in environments where it's slow.
|
||||
var rawChannels = ch.channels || [];
|
||||
// Resolve the persisted sort first so the default-fallback below doesn't
|
||||
// shadow what the user previously chose. Default for the grouped view is
|
||||
// messages desc (matches the PR description); only used when nothing saved.
|
||||
if (!_channelSortState) {
|
||||
_channelSortState = hasSavedChannelSort()
|
||||
? loadChannelSort()
|
||||
: { col: 'messages', dir: 'desc' };
|
||||
}
|
||||
var ranOnce = false;
|
||||
// Generation token: if renderChannels is called again before
|
||||
// buildHashKeyMap() resolves, the older promise must not clobber the
|
||||
// newer rawChannels / decoration with stale-key data.
|
||||
var myGen = ++_channelRenderGen;
|
||||
function applyDecorate(map) {
|
||||
if (myGen !== _channelRenderGen) return; // superseded
|
||||
var labels = (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt.getLabels)
|
||||
? ChannelDecrypt.getLabels() : {};
|
||||
_channelData = decorateAnalyticsChannels(rawChannels, map, labels);
|
||||
if (ranOnce) updateChannelTable();
|
||||
}
|
||||
applyDecorate({});
|
||||
ranOnce = true;
|
||||
buildHashKeyMap().then(applyDecorate).catch(function () { /* graceful */ });
|
||||
|
||||
var timelineHtml = renderChannelTimeline(ch.channelTimeline);
|
||||
var topSendersHtml = renderTopSenders(ch.topSenders);
|
||||
@@ -844,7 +970,7 @@
|
||||
'<table class="analytics-table" id="channelsTable">' +
|
||||
channelTheadHtml(_channelSortState.col, _channelSortState.dir) +
|
||||
'<tbody id="channelsTbody">' +
|
||||
channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir) +
|
||||
channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true }) +
|
||||
'</tbody>' +
|
||||
'</table>' +
|
||||
'</div>' +
|
||||
@@ -2055,6 +2181,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
|
||||
|
||||
// Expose for testing
|
||||
if (typeof window !== 'undefined') {
|
||||
window._analyticsDecorateChannels = decorateAnalyticsChannels;
|
||||
window._analyticsSortChannels = sortChannels;
|
||||
window._analyticsLoadChannelSort = loadChannelSort;
|
||||
window._analyticsSaveChannelSort = saveChannelSort;
|
||||
|
||||
@@ -634,6 +634,8 @@
|
||||
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
|
||||
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add Channel</button>
|
||||
</div>
|
||||
<a href="#/analytics" class="ch-analytics-link"
|
||||
title="Open the Analytics page to see channel activity stats">📊 Channel Analytics →</a>
|
||||
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
|
||||
<div id="chRegionFilter" class="region-filter-container" style="padding:0 8px"></div>
|
||||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||||
|
||||
@@ -1204,6 +1204,15 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.ch-add-btn:hover { opacity: 0.85; }
|
||||
.ch-add-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; line-height: 1.3; }
|
||||
.ch-add-status { font-size: 12px; margin-top: 4px; padding: 4px 6px; border-radius: 4px; }
|
||||
.ch-analytics-link {
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ch-analytics-link:hover { color: var(--accent); }
|
||||
.ch-add-status--loading { color: var(--text-muted); }
|
||||
.ch-add-status--success { color: var(--success, #22c55e); }
|
||||
.ch-add-status--warn { color: var(--warning, #eab308); }
|
||||
@@ -1273,6 +1282,18 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
|
||||
.analytics-table th.sort-active { color: var(--accent); }
|
||||
.analytics-table th .sort-arrow { font-size: 10px; margin-left: 4px; opacity: 0.7; }
|
||||
.analytics-table td { padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.analytics-table .ch-section-row { background: var(--card-bg); }
|
||||
.analytics-table .ch-section-row td.ch-section-header {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
padding: 10px 8px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
.analytics-table .ch-section-row:hover { background: var(--card-bg); cursor: default; }
|
||||
.hash-bars { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
||||
.hash-bar-row { display: flex; align-items: center; gap: 12px; }
|
||||
.hash-bar-label { min-width: 160px; font-size: 13px; }
|
||||
|
||||
@@ -18,6 +18,7 @@ node test-channel-sidebar-layout.js
|
||||
node test-channel-modal-ux.js
|
||||
node test-channel-decrypt-insecure-context.js
|
||||
node test-channel-qr.js
|
||||
node test-analytics-channels-integration.js
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Analytics → Channels section integration with PSK decrypt UX.
|
||||
*
|
||||
* Bug: the analytics channels list shows nonsense names like "ch185" for
|
||||
* every encrypted channel and ignores the user's locally-decrypted PSK
|
||||
* channels (from ChannelDecrypt.getStoredKeys() + label store).
|
||||
*
|
||||
* Fix:
|
||||
* 1. Replace "chNNN" raw names with "🔒 Encrypted (0xNN)" when the channel
|
||||
* is encrypted and the server only knows its hash byte.
|
||||
* 2. For channels matching a locally-stored PSK key, show the user's
|
||||
* label / key-name instead of the hash-byte placeholder.
|
||||
* 3. Group rendering: My Channels → Network → Encrypted, each sorted by
|
||||
* message count descending.
|
||||
* 4. Add a link from the Channels page to the Analytics page so users can
|
||||
* jump to channel activity stats.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function assert(cond, msg) {
|
||||
if (cond) { passed++; console.log(' ✓ ' + msg); }
|
||||
else { failed++; console.error(' ✗ ' + msg); }
|
||||
}
|
||||
|
||||
// ── Set up a tiny browser-ish global so analytics.js loads cleanly ──────────
|
||||
global.window = global;
|
||||
global.document = {
|
||||
documentElement: {},
|
||||
createElement: () => ({ style: {}, addEventListener() {} }),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
getElementById: () => null,
|
||||
};
|
||||
global.localStorage = {
|
||||
_s: {},
|
||||
getItem(k) { return this._s[k] || null; },
|
||||
setItem(k, v) { this._s[k] = String(v); },
|
||||
removeItem(k) { delete this._s[k]; },
|
||||
};
|
||||
global.getComputedStyle = () => ({ getPropertyValue: () => '' });
|
||||
global.registerPage = () => {};
|
||||
global.api = async () => ({});
|
||||
global.fetch = async () => ({ ok: true, json: async () => ({}) });
|
||||
global.CLIENT_TTL = {};
|
||||
global.RegionFilter = { getRegionParam: () => '' };
|
||||
global.Storage = function () {};
|
||||
global.timeAgo = () => '';
|
||||
global.histogram = () => ({ svg: '' });
|
||||
|
||||
// Load analytics.js — it self-registers global helpers we test.
|
||||
const analyticsSrc = fs.readFileSync(
|
||||
path.join(__dirname, 'public/analytics.js'),
|
||||
'utf8'
|
||||
);
|
||||
// Strip top-level `await` / module syntax — analytics.js is plain IIFE so it's
|
||||
// fine to eval as-is.
|
||||
// eslint-disable-next-line no-eval
|
||||
eval(analyticsSrc); // sets window._analyticsDecorateChannels etc.
|
||||
|
||||
console.log('\n=== Analytics channels: decorate with PSK keys ===');
|
||||
|
||||
const decorate = global._analyticsDecorateChannels;
|
||||
assert(typeof decorate === 'function',
|
||||
'_analyticsDecorateChannels exposed for testing');
|
||||
|
||||
// Server response sample — mix of cleartext, rainbow-known encrypted, raw "chNNN".
|
||||
const sampleChannels = [
|
||||
{ hash: 17, name: 'public', messages: 100, senders: 5, encrypted: false },
|
||||
{ hash: 217, name: '#test', messages: 200, senders: 8, encrypted: false },
|
||||
{ hash: 185, name: 'ch185', messages: 50, senders: 0, encrypted: true },
|
||||
{ hash: 64, name: 'ch64', messages: 300, senders: 0, encrypted: true },
|
||||
{ hash: 30, name: 'ch30', messages: 75, senders: 0, encrypted: true },
|
||||
{ hash: 99, name: '#earthquake', messages: 10, senders: 1, encrypted: false },
|
||||
// Rainbow-table hit on an ENCRYPTED channel: server resolved a real name.
|
||||
{ hash: 12, name: 'public-meshcore', messages: 40, senders: 2, encrypted: true },
|
||||
// Encrypted channel with empty name — must not render an empty <strong>.
|
||||
{ hash: 200, name: '', messages: 5, senders: 0, encrypted: true },
|
||||
];
|
||||
|
||||
// User has two PSK keys locally: one matches hash=185 (named "Levski"),
|
||||
// one matches hash=30 (named "secret-room", with label "Garage").
|
||||
const myKeyHashToName = { 185: 'Levski', 30: 'secret-room' };
|
||||
const labels = { 'secret-room': 'Garage' };
|
||||
|
||||
const out = decorate(sampleChannels, myKeyHashToName, labels);
|
||||
assert(Array.isArray(out), 'decorate returns an array');
|
||||
assert(out.length === sampleChannels.length, 'decorate keeps every channel');
|
||||
|
||||
// Find by original hash (and optionally original name) for assertions.
|
||||
// Decoration preserves c.name as-is and writes the user-facing string to
|
||||
// c.displayName, so matching on c.name is unambiguous.
|
||||
function find(hash, name) {
|
||||
return out.find(c => c.hash === hash && (name == null || c.name === name));
|
||||
}
|
||||
|
||||
const mine185 = find(185, 'ch185');
|
||||
assert(mine185 && mine185.displayName === 'Levski',
|
||||
'hash 185 + stored key → displayName = "Levski" (not "ch185")');
|
||||
assert(mine185 && mine185.group === 'mine',
|
||||
'hash 185 grouped as "mine"');
|
||||
|
||||
const mine30 = find(30, 'ch30');
|
||||
assert(mine30 && mine30.displayName === 'Garage',
|
||||
'hash 30 with stored key + label → displayName = "Garage" (label wins)');
|
||||
assert(mine30 && mine30.group === 'mine', 'hash 30 grouped as "mine"');
|
||||
|
||||
const ch64 = find(64, 'ch64');
|
||||
assert(ch64 && ch64.displayName === '🔒 Encrypted (0x40)',
|
||||
'unknown encrypted ch64 → "🔒 Encrypted (0x40)" (no nonsense "ch64")');
|
||||
assert(ch64 && ch64.group === 'encrypted', 'unknown encrypted grouped as "encrypted"');
|
||||
|
||||
const pub = find(17, 'public');
|
||||
assert(pub && pub.displayName === 'public', 'cleartext public name preserved');
|
||||
assert(pub && pub.group === 'network', 'cleartext public grouped as "network"');
|
||||
|
||||
const test = find(217, '#test');
|
||||
assert(test && test.group === 'network', 'rainbow-known #test grouped as "network"');
|
||||
|
||||
// Rainbow-table hit on an ENCRYPTED channel — actually exercises the
|
||||
// "encrypted but server has the real name" branch (was previously dead-untested).
|
||||
const rainbow = find(12, 'public-meshcore');
|
||||
assert(rainbow && rainbow.encrypted === true,
|
||||
'rainbow row preserves encrypted=true');
|
||||
assert(rainbow && rainbow.displayName === 'public-meshcore',
|
||||
'rainbow-decoded encrypted row → displayName = real name');
|
||||
assert(rainbow && rainbow.group === 'network',
|
||||
'rainbow-decoded encrypted row → group = "network"');
|
||||
|
||||
// Empty-name encrypted: must NOT leak through with displayName = ''.
|
||||
const empty = find(200, '');
|
||||
assert(empty && empty.displayName === '🔒 Encrypted (0xC8)',
|
||||
'encrypted with empty name → render as opaque encrypted placeholder');
|
||||
assert(empty && empty.group === 'encrypted',
|
||||
'encrypted with empty name → group = "encrypted"');
|
||||
|
||||
// No "chNNN" leaks into displayName for any row.
|
||||
const leak = out.find(c => /^ch(\d+|\?)$/.test(c.displayName));
|
||||
assert(!leak, 'no displayName matches the raw chNNN placeholder');
|
||||
|
||||
console.log('\n=== Grouped table render: order + sort ===');
|
||||
|
||||
const tbody = global._analyticsChannelTbodyHtml(out, 'messages', 'desc', {
|
||||
grouped: true,
|
||||
});
|
||||
assert(typeof tbody === 'string' && tbody.length > 0,
|
||||
'channelTbodyHtml accepts grouped option and returns html');
|
||||
|
||||
// Group headers must appear in order: My Channels, Network, Encrypted.
|
||||
const iMine = tbody.indexOf('My Channels');
|
||||
const iNet = tbody.indexOf('Network');
|
||||
const iEnc = tbody.indexOf('Encrypted');
|
||||
assert(iMine >= 0 && iNet > iMine && iEnc > iNet,
|
||||
'group headers render in order: My Channels → Network → Encrypted');
|
||||
|
||||
// Within "mine" section, hash=30 (75 msgs) > hash=185 (50 msgs).
|
||||
const i30 = tbody.indexOf('Garage');
|
||||
const i185 = tbody.indexOf('Levski');
|
||||
assert(i30 > 0 && i185 > i30,
|
||||
'within "My Channels" sort by messages desc (Garage 75 before Levski 50)');
|
||||
|
||||
// Within "network" section, #test (200) > public (100) > #earthquake (10).
|
||||
const iT = tbody.indexOf('#test');
|
||||
const iP = tbody.indexOf('public');
|
||||
const iE = tbody.indexOf('#earthquake');
|
||||
assert(iT > 0 && iP > iT && iE > iP,
|
||||
'within "Network" sort by messages desc (#test → public → #earthquake)');
|
||||
|
||||
// Within "encrypted" section, ch64 (300 msgs) appears (only one entry).
|
||||
assert(tbody.indexOf('0x40') > iEnc, 'encrypted section contains 0x40');
|
||||
|
||||
console.log('\n=== Channels page links to Analytics ===');
|
||||
|
||||
const channelsSrc = fs.readFileSync(
|
||||
path.join(__dirname, 'public/channels.js'),
|
||||
'utf8'
|
||||
);
|
||||
assert(/#\/analytics/.test(channelsSrc) &&
|
||||
/Channel Analytics|channel analytics/i.test(channelsSrc),
|
||||
'channels.js sidebar links to #/analytics with "Channel Analytics" text');
|
||||
|
||||
console.log('\n' + (failed ? '✗ ' + failed + ' failed, ' : '') + passed + ' passed');
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user