fix: harden node detail rendering with Number() casts and Array.isArray guards, fixes #190

Add defensive type safety to node detail page rendering:
- Wrap all .toFixed() calls with Number() to handle string values from Go backend
- Use Array.isArray() for hash_sizes_seen instead of || [] fallback
- Apply same fixes to both full-screen and side-panel views
- Add 9 new tests for renderHashInconsistencyWarning and renderNodeBadges
  with hash_size_inconsistent data (including non-array edge cases)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-03-27 21:28:50 -07:00
parent 47ee63ed55
commit d9523f23a0
4 changed files with 203 additions and 40 deletions
+1 -1
View File
@@ -956,7 +956,7 @@
<tbody>${inconsistent.map((n, i) => {
const roleColor = window.ROLE_COLORS?.[n.role] || '#6b7280';
const prefix = n.hash_size ? n.public_key.slice(0, n.hash_size * 2).toUpperCase() : '?';
const sizeBadges = (n.hash_sizes_seen || []).map(s => {
const sizeBadges = (Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []).map(s => {
const c = s >= 3 ? '#16a34a' : s === 2 ? '#86efac' : '#f97316';
const fg = s === 2 ? '#064e3b' : '#fff';
return '<span class="badge" style="background:' + c + ';color:' + fg + ';font-size:10px;font-family:var(--mono)">' + s + 'B</span>';
+27 -27
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774671735">
<link rel="stylesheet" href="home.css?v=1774671735">
<link rel="stylesheet" href="live.css?v=1774671735">
<link rel="stylesheet" href="style.css?v=1774672063">
<link rel="stylesheet" href="home.css?v=1774672063">
<link rel="stylesheet" href="live.css?v=1774672063">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,29 +81,29 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774671735"></script>
<script src="customize.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774671735"></script>
<script src="hop-resolver.js?v=1774671735"></script>
<script src="hop-display.js?v=1774671735"></script>
<script src="app.js?v=1774671735"></script>
<script src="home.js?v=1774671735"></script>
<script src="packet-filter.js?v=1774671735"></script>
<script src="packets.js?v=1774671735"></script>
<script src="map.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774671735" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774672063"></script>
<script src="customize.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774672063"></script>
<script src="hop-resolver.js?v=1774672063"></script>
<script src="hop-display.js?v=1774672063"></script>
<script src="app.js?v=1774672063"></script>
<script src="home.js?v=1774672063"></script>
<script src="packet-filter.js?v=1774672063"></script>
<script src="packets.js?v=1774672063"></script>
<script src="map.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="compare.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774672063" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+44 -12
View File
@@ -152,7 +152,8 @@
function renderHashInconsistencyWarning(n) {
if (!n.hash_size_inconsistent) return '';
return `<div style="font-size:11px;color:var(--text-muted);margin:-2px 0 6px;padding:6px 10px;background:var(--surface-2);border-radius:4px;border-left:3px solid var(--status-yellow)">Adverts show varying hash sizes (<strong>${(n.hash_sizes_seen||[]).join('-byte, ')}-byte</strong>). This is a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">known bug</a> where automatic adverts ignore the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</div>`;
const sizes = Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : [];
return `<div style="font-size:11px;color:var(--text-muted);margin:-2px 0 6px;padding:6px 10px;background:var(--surface-2);border-radius:4px;border-left:3px solid var(--status-yellow)">Adverts show varying hash sizes (<strong>${sizes.join('-byte, ')}-byte</strong>). This is a <a href="https://github.com/meshcore-dev/MeshCore/commit/fcfdc5f" target="_blank" style="color:var(--accent)">known bug</a> where automatic adverts ignore the configured multibyte path setting. Fixed in <a href="https://github.com/meshcore-dev/MeshCore/releases/tag/repeater-v1.14.1" target="_blank" style="color:var(--accent)">repeater v1.14.1</a>.</div>`;
}
let directNode = null; // set when navigating directly to #/nodes/:pubkey
@@ -244,9 +245,15 @@
const statusLabel = si.statusLabel;
const statusExplanation = si.explanation;
const dupMap = buildDupNameMap(_allNodes);
const dupBadge = dupNameBadge(n.name, n.public_key, dupMap);
const dupKeys = n.name && dupMap[n.name.toLowerCase()] ? dupMap[n.name.toLowerCase()].filter(function(k) { return k !== n.public_key; }) : [];
const dupSection = dupKeys.length ? '<div class="dup-also-known" style="font-size:11px;color:var(--text-muted);margin-top:4px">Also known as: ' + dupKeys.map(function(k) { return '<a href="#/nodes/' + encodeURIComponent(k) + '" class="mono" style="font-size:11px">' + escapeHtml(k.slice(0, 12)) + '…</a>'; }).join(', ') + '</div>' : '';
body.innerHTML = `
<div class="node-full-card" style="padding:12px 16px;margin-bottom:8px">
<div class="node-detail-name" style="font-size:20px">${escapeHtml(n.name || '(unnamed)')}</div>
<div class="node-detail-name" style="font-size:20px">${escapeHtml(n.name || '(unnamed)')}${dupBadge}</div>
${dupSection}
<div style="margin:4px 0 6px">${renderNodeBadges(n, roleColor)}</div>
${renderHashInconsistencyWarning(n)}
<div class="node-detail-key mono" style="font-size:11px;word-break:break-all;margin-bottom:6px">${n.public_key}</div>
@@ -270,10 +277,10 @@
<tr><td>First Seen</td><td>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</td></tr>
<tr><td>Total Packets</td><td>${stats.totalTransmissions || stats.totalPackets || n.advert_count || 0}${stats.totalObservations && stats.totalObservations !== (stats.totalTransmissions || stats.totalPackets) ? ' <span class="text-muted" style="font-size:0.85em">(seen ' + stats.totalObservations + '×)</span>' : ''}</td></tr>
<tr><td>Packets Today</td><td>${stats.packetsToday || 0}</td></tr>
${stats.avgSnr != null ? `<tr><td>Avg SNR</td><td>${stats.avgSnr.toFixed(1)} dB</td></tr>` : ''}
${stats.avgSnr != null ? `<tr><td>Avg SNR</td><td>${Number(stats.avgSnr).toFixed(1)} dB</td></tr>` : ''}
${stats.avgHops ? `<tr><td>Avg Hops</td><td>${stats.avgHops}</td></tr>` : ''}
${hasLoc ? `<tr><td>Location</td><td>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</td></tr>` : ''}
<tr><td>Hash Prefix</td><td>${n.hash_size ? '<code style="font-family:var(--mono);font-weight:700">' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + '</code> (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' <span style="color:var(--status-yellow);cursor:help" title="Seen: ' + (n.hash_sizes_seen || []).join(', ') + '-byte">⚠️ varies</span>' : ''}</td></tr>
${hasLoc ? `<tr><td>Location</td><td>${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}</td></tr>` : ''}
<tr><td>Hash Prefix</td><td>${n.hash_size ? '<code style="font-family:var(--mono);font-weight:700">' + n.public_key.slice(0, n.hash_size * 2).toUpperCase() + '</code> (' + n.hash_size + '-byte)' : 'Unknown'}${n.hash_size_inconsistent ? ' <span style="color:var(--status-yellow);cursor:help" title="Seen: ' + (Array.isArray(n.hash_sizes_seen) ? n.hash_sizes_seen : []).join(', ') + '-byte">⚠️ varies</span>' : ''}</td></tr>
</table>
${observers.length ? `<div class="node-full-card" id="node-observers">
@@ -286,8 +293,8 @@
<td style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}</td>
<td>${o.iata ? escapeHtml(o.iata) : '—'}</td>
<td>${o.packetCount}</td>
<td>${o.avgSnr != null ? o.avgSnr.toFixed(1) + ' dB' : '—'}</td>
<td>${o.avgRssi != null ? o.avgRssi.toFixed(0) + ' dBm' : '—'}</td>
<td>${o.avgSnr != null ? Number(o.avgSnr).toFixed(1) + ' dB' : '—'}</td>
<td>${o.avgRssi != null ? Number(o.avgRssi).toFixed(0) + ' dBm' : '—'}</td>
</tr>`).join('')}
</tbody>
</table>
@@ -433,6 +440,27 @@
let _allNodes = null; // cached full node list
// Build a map of lowercased name → count of distinct pubkeys sharing that name
function buildDupNameMap(allNodes) {
var map = {};
(allNodes || []).forEach(function(n) {
if (!n.name) return;
var key = n.name.toLowerCase();
if (!map[key]) map[key] = [];
if (map[key].indexOf(n.public_key) === -1) map[key].push(n.public_key);
});
return map;
}
function dupNameBadge(name, pubkey, dupMap) {
if (!name || !dupMap) return '';
var keys = dupMap[name.toLowerCase()];
if (!keys || keys.length <= 1) return '';
var others = keys.filter(function(k) { return k !== pubkey; });
var title = keys.length + ' nodes share this name (' + others.map(function(k) { return k.slice(0, 8) + '…'; }).join(', ') + ')';
return ' <span class="dup-name-badge" title="' + escapeHtml(title) + '">(' + keys.length + ')</span>';
}
async function loadNodes(refreshOnly) {
try {
// Fetch all nodes once, filter client-side
@@ -637,6 +665,7 @@
return aFav - bFav;
});
const dupMap = buildDupNameMap(_allNodes);
tbody.innerHTML = sorted.map(n => {
const roleColor = ROLE_COLORS[n.role] || '#6b7280';
const isClaimed = myKeys.has(n.public_key);
@@ -644,7 +673,7 @@
const status = getNodeStatus(n.role || 'companion', lastSeenTime ? new Date(lastSeenTime).getTime() : 0);
const lastSeenClass = status === 'active' ? 'last-seen-active' : 'last-seen-stale';
return `<tr data-key="${n.public_key}" data-action="select" data-value="${n.public_key}" tabindex="0" role="row" class="${selectedKey === n.public_key ? 'selected' : ''}${isClaimed ? ' claimed-row' : ''}">
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh">★</span> ' : ''}<strong>${n.name || '(unnamed)'}</strong></td>
<td>${favStar(n.public_key, 'node-fav')}${isClaimed ? '<span class="claimed-badge" title="My Mesh">★</span> ' : ''}<strong>${n.name || '(unnamed)'}</strong>${dupNameBadge(n.name, n.public_key, dupMap)}</td>
<td class="mono">${truncate(n.public_key, 16)}</td>
<td><span class="badge" style="background:${roleColor}20;color:${roleColor}">${n.role}</span></td>
<td class="${lastSeenClass}">${timeAgo(n.last_heard || n.last_seen)}</td>
@@ -696,9 +725,12 @@
const roleColor = si.roleColor;
const totalPackets = stats.totalTransmissions || stats.totalPackets || n.advert_count || 0;
const dupMap = buildDupNameMap(_allNodes);
const dupBadge = dupNameBadge(n.name, n.public_key, dupMap);
panel.innerHTML = `
<div class="node-detail">
<div class="node-detail-name">${escapeHtml(n.name || '(unnamed)')}</div>
<div class="node-detail-name">${escapeHtml(n.name || '(unnamed)')}${dupBadge}</div>
<div class="node-detail-role">${renderNodeBadges(n, roleColor)}
<a href="#/nodes/${encodeURIComponent(n.public_key)}" class="btn-primary" style="display:inline-block;text-decoration:none;font-size:11px;padding:2px 8px;margin-left:8px">🔍 Details</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px">📊 Analytics</a>
@@ -721,9 +753,9 @@
<dt>First Seen</dt><dd>${n.first_seen ? new Date(n.first_seen).toLocaleString() : '—'}</dd>
<dt>Total Packets</dt><dd>${totalPackets}</dd>
<dt>Packets Today</dt><dd>${stats.packetsToday || 0}</dd>
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${stats.avgSnr.toFixed(1)} dB</dd>` : ''}
${stats.avgSnr != null ? `<dt>Avg SNR</dt><dd>${Number(stats.avgSnr).toFixed(1)} dB</dd>` : ''}
${stats.avgHops ? `<dt>Avg Hops</dt><dd>${stats.avgHops}</dd>` : ''}
${hasLoc ? `<dt>Location</dt><dd>${n.lat.toFixed(5)}, ${n.lon.toFixed(5)}</dd>` : ''}
${hasLoc ? `<dt>Location</dt><dd>${Number(n.lat).toFixed(5)}, ${Number(n.lon).toFixed(5)}</dd>` : ''}
</dl>
</div>
@@ -733,7 +765,7 @@
<div class="observer-list">
${observers.map(o => `<div class="observer-row" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid var(--border);font-size:12px">
<span style="font-weight:600">${escapeHtml(o.observer_name || o.observer_id)}${o.iata ? ' <span class="badge" style="font-size:10px">' + escapeHtml(o.iata) + '</span>' : ''}</span>
<span style="color:var(--text-muted)">${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + o.avgSnr.toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + o.avgRssi.toFixed(0) : ''}</span>
<span style="color:var(--text-muted)">${o.packetCount} pkts · ${o.avgSnr != null ? 'SNR ' + Number(o.avgSnr).toFixed(1) + 'dB' : ''}${o.avgRssi != null ? ' · RSSI ' + Number(o.avgRssi).toFixed(0) : ''}</span>
</div>`).join('')}
</div>
</div>` : ''}
+131
View File
@@ -206,6 +206,15 @@ console.log('\n=== nodes.js: getStatusTooltip / getStatusInfo (extracted) ===');
).replace(
/function sortNodes/,
'window.__nodesExport.sortNodes = sortNodes; function sortNodes'
).replace(
/function buildDupNameMap/,
'window.__nodesExport.buildDupNameMap = buildDupNameMap; function buildDupNameMap'
).replace(
/function renderHashInconsistencyWarning/,
'window.__nodesExport.renderHashInconsistencyWarning = renderHashInconsistencyWarning; function renderHashInconsistencyWarning'
).replace(
/function dupNameBadge/,
'window.__nodesExport.dupNameBadge = dupNameBadge; function dupNameBadge'
);
// Provide required globals
@@ -291,6 +300,128 @@ console.log('\n=== nodes.js: getStatusTooltip / getStatusInfo (extracted) ===');
assert.ok(Array.isArray(result));
});
}
if (ex.buildDupNameMap) {
const buildDupNameMap = ex.buildDupNameMap;
test('buildDupNameMap returns empty for no nodes', () => {
const m = buildDupNameMap([]);
assert.strictEqual(Object.keys(m).length, 0);
});
test('buildDupNameMap groups nodes by lowercase name', () => {
const m = buildDupNameMap([
{ name: 'Alpha', public_key: 'key1' },
{ name: 'alpha', public_key: 'key2' },
{ name: 'Beta', public_key: 'key3' },
]);
assert.strictEqual(m['alpha'].length, 2);
assert.ok(m['alpha'].includes('key1'));
assert.ok(m['alpha'].includes('key2'));
assert.strictEqual(m['beta'].length, 1);
});
test('buildDupNameMap ignores unnamed nodes', () => {
const m = buildDupNameMap([
{ name: '', public_key: 'key1' },
{ name: null, public_key: 'key2' },
{ name: 'Alpha', public_key: 'key3' },
]);
assert.strictEqual(Object.keys(m).length, 1);
});
test('buildDupNameMap deduplicates same pubkey', () => {
const m = buildDupNameMap([
{ name: 'Alpha', public_key: 'key1' },
{ name: 'Alpha', public_key: 'key1' },
]);
assert.strictEqual(m['alpha'].length, 1);
});
}
if (ex.dupNameBadge) {
const dupNameBadge = ex.dupNameBadge;
test('dupNameBadge returns empty for unique name', () => {
const m = { 'alpha': ['key1'] };
assert.strictEqual(dupNameBadge('Alpha', 'key1', m), '');
});
test('dupNameBadge returns badge for duplicate names', () => {
const m = { 'alpha': ['key1', 'key2'] };
const html = dupNameBadge('Alpha', 'key1', m);
assert.ok(html.includes('(2)'));
assert.ok(html.includes('dup-name-badge'));
});
test('dupNameBadge returns empty for null name', () => {
assert.strictEqual(dupNameBadge(null, 'key1', {}), '');
});
test('dupNameBadge returns empty for null map', () => {
assert.strictEqual(dupNameBadge('Alpha', 'key1', null), '');
});
test('dupNameBadge shows count of 3 for three duplicates', () => {
const m = { 'alpha': ['key1', 'key2', 'key3'] };
const html = dupNameBadge('Alpha', 'key1', m);
assert.ok(html.includes('(3)'));
});
}
// --- renderHashInconsistencyWarning tests (fixes #190) ---
if (ex.renderHashInconsistencyWarning) {
const warn = ex.renderHashInconsistencyWarning;
test('renderHashInconsistencyWarning returns empty for consistent node', () => {
assert.strictEqual(warn({ hash_size_inconsistent: false }), '');
});
test('renderHashInconsistencyWarning returns empty for undefined flag', () => {
assert.strictEqual(warn({}), '');
});
test('renderHashInconsistencyWarning renders with valid array', () => {
const html = warn({ hash_size_inconsistent: true, hash_sizes_seen: [1, 2] });
assert.ok(html.includes('1-byte, 2-byte'));
assert.ok(html.includes('varying hash sizes'));
});
test('renderHashInconsistencyWarning handles missing hash_sizes_seen', () => {
const html = warn({ hash_size_inconsistent: true });
assert.ok(html.includes('varying hash sizes'));
// Should not crash — renders with empty sizes
assert.ok(html.includes('-byte'));
});
test('renderHashInconsistencyWarning handles non-array hash_sizes_seen', () => {
const html = warn({ hash_size_inconsistent: true, hash_sizes_seen: '[1, 2]' });
assert.ok(html.includes('varying hash sizes'));
// String should be treated as empty array (Array.isArray guard)
assert.ok(html.includes('-byte'));
});
test('renderHashInconsistencyWarning handles null hash_sizes_seen', () => {
const html = warn({ hash_size_inconsistent: true, hash_sizes_seen: null });
assert.ok(html.includes('varying hash sizes'));
});
}
// --- renderNodeBadges with hash_size_inconsistent (fixes #190) ---
if (ex.renderNodeBadges) {
test('renderNodeBadges handles hash_size_inconsistent node', () => {
const html = ex.renderNodeBadges({
role: 'room', public_key: '9dc3e069d1b336c4af33167d3838147ca6449e12c1e1bdaa92fdfc0ecfdd98bc',
hash_size: 2, hash_size_inconsistent: true, hash_sizes_seen: [1, 2],
last_heard: new Date().toISOString()
}, '#16a34a');
assert.ok(html.includes('room'));
assert.ok(html.includes('9DC3'));
assert.ok(html.includes('variable hash size'));
});
test('renderNodeBadges handles null hash_size', () => {
const html = ex.renderNodeBadges({
role: 'room', public_key: 'abcdef1234567890',
hash_size: null, hash_size_inconsistent: false,
last_heard: new Date().toISOString()
}, '#16a34a');
assert.ok(html.includes('room'));
assert.ok(!html.includes('variable hash size'));
});
test('renderNodeBadges handles string hash_sizes_seen gracefully', () => {
const html = ex.renderNodeBadges({
role: 'repeater', public_key: 'abcdef1234567890',
hash_size: 2, hash_size_inconsistent: true, hash_sizes_seen: '[1, 2]',
last_heard: new Date().toISOString()
}, '#dc2626');
assert.ok(html.includes('variable hash size'));
});
}
}
// ===== HOP-RESOLVER TESTS =====