diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index c80b6688b..1357fda7b 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -767,6 +767,7 @@ jobs:
suite 3 test-observer-iata-1188-e2e.js BASE_URL=$B
suite 2 test-issue-1639-observers-sort-e2e.js BASE_URL=$B
suite 2 test-issue-1758-ng-filter-rerenders-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B
+ suite 2 test-analytics-loading-e2e.js BASE_URL=$B
suite 2 test-nav-fluid-1055-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B
suite 3 test-nav-priority-1102-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B
suite 3 test-nav-priority-1311-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B
diff --git a/public/analytics.js b/public/analytics.js
index 7851bdb2c..ec17474ee 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -2,10 +2,28 @@
'use strict';
(function () {
- let _analyticsData = {};
+ let _analyticsData = null;
+ let _analyticsLoadId = 0;
+ let _analyticsRenderId = 0;
+ const _analyticsRequests = new WeakMap();
+ let _analyticsLoadError = '';
+ const _coreAnalyticsTabs = new Set(['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']);
const sf = (v, d) => (v != null ? v.toFixed(d) : '–'); // safe toFixed
function esc(s) { return s ? String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''') : ''; }
+ // Capture the current page/filter/tab render. A target also makes requests
+ // within that view latest-wins (detail panels, range picks, auto-refresh).
+ // Weak keys let replaced DOM nodes and their request tokens be collected.
+ function analyticsGuard(target) {
+ const el = document.getElementById('analyticsContent');
+ const loadId = _analyticsLoadId, renderId = _analyticsRenderId;
+ const request = {};
+ if (target) _analyticsRequests.set(target, request);
+ return () => loadId === _analyticsLoadId && renderId === _analyticsRenderId &&
+ document.getElementById('analyticsContent') === el &&
+ (!target || _analyticsRequests.get(target) === request);
+ }
+
// #1085 — Roles tab helpers (hoisted from renderRolesTab so they're not
// re-allocated per render).
function _rolesEmoji(role) {
@@ -259,7 +277,10 @@
// still picks up the new theme: node colors are read live per frame
// from window.ROLE_COLORS, role swatches use CSS tokens, and the one
// cached value (_labelColor) is re-read on restart.
- if (_currentTab === 'neighbor-graph' && _ngState) { startGraphRenderer(); return; }
+ if (_currentTab === 'neighbor-graph') {
+ if (_ngState) startGraphRenderer();
+ return;
+ }
renderTab(_currentTab);
};
window.addEventListener('theme-refresh', _themeRefreshHandler);
@@ -270,9 +291,21 @@
var _themeRefreshHandler = null;
let _currentTab = 'overview';
+ function renderAnalyticsLoadState(el) {
+ el.innerHTML = _analyticsLoadError
+ ? `
This runs once per instance. Retrying in ' + secs + 's.
';
_distanceRetryTimer = setTimeout(function () {
+ if (!isCurrent()) return;
_distanceRetryTimer = null;
- // The user may have switched tabs or left while this was pending.
- if (_currentTab !== 'distance') return;
- const cur = document.getElementById('analyticsContent');
- if (!cur) return;
- renderDistanceTab(cur);
+ renderDistanceTab(el);
}, _distanceRetryDelayMs(data));
return;
}
@@ -3160,11 +3238,12 @@
});
});
} catch (e) {
+ if (!isCurrent()) return;
el.innerHTML = `
Neighbor Graph
@@ -3243,10 +3323,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
try {
graphData = await api('/analytics/neighbor-graph' + sep + (sep ? '&' : '?') + 'min_count=1&min_score=0', { ttl: CLIENT_TTL.analyticsRF });
} catch (e) {
+ if (!isCurrent()) return;
el.innerHTML = `
Failed to load neighbor graph: ${esc(e.message)}
`;
return;
}
+ if (!isCurrent()) return;
_ngState = createGraphState(graphData);
// Render through the filter path so the first paint already respects the
// default filters (observers unchecked, saved min-score) and the
@@ -3270,7 +3352,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// Debounce the O(N+E) refilter so dragging the slider doesn't rebuild the
// node set on every pixel (review of #1758).
clearTimeout(ngScoreDebounce);
- ngScoreDebounce = setTimeout(applyNGFilters, 50);
+ ngScoreDebounce = setTimeout(() => { if (isCurrent()) applyNGFilters(); }, 50);
});
document.getElementById('ngConfidence').addEventListener('change', applyNGFilters);
rcEl.addEventListener('change', applyNGFilters);
@@ -3522,6 +3604,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
function startGraphRenderer() {
if (!_ngState) return;
+ const state = _ngState;
// Re-entrant: the filter path calls this to restart against the new node
// set, so cancel any loop already running before deciding what to do.
@@ -3591,8 +3674,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// Performance: 500 nodes brute-force repulsion: avg ~4ms/frame = 250fps headroom (measured Chrome 120, M1)
var _perfFrameTimes = [], _perfLastTime = 0;
function tick() {
- if (_ngState._runEpoch !== myEpoch) { return; } // a newer startGraphRenderer run superseded this loop
- if (!document.getElementById('ngCanvas')) { _ngState.animId = null; return; }
+ if (_ngState !== state || state._runEpoch !== myEpoch) return;
+ if (document.getElementById('ngCanvas') !== canvas) { state.animId = null; return; }
var now = performance.now();
if (_perfLastTime) _perfFrameTimes.push(now - _perfLastTime);
_perfLastTime = now;
@@ -3708,6 +3791,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// --- Prefix Tool ---
async function renderPrefixTool(el) {
+ const isCurrent = analyticsGuard(el);
el.innerHTML = '
Loading prefix data…
';
const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString();
@@ -3723,10 +3807,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
api('/analytics/hash-sizes' + rq, { ttl: CLIENT_TTL.analyticsRF }).catch(() => null),
]);
} catch (e) {
+ if (!isCurrent()) return;
el.innerHTML = `
Failed to load: ${esc(e.message)}
`;
return;
}
+ if (!isCurrent()) return;
// Deduplicate by public_key, require at least 6 hex chars to build all 3 tiers
const nodeMap = new Map();
(nodesResp.nodes || nodesResp).forEach(n => {
@@ -4179,10 +4265,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// Auto-run from URL params
if (initPrefix) {
doCheck(initPrefix);
- setTimeout(() => { document.getElementById('ptChecker')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150);
+ setTimeout(() => { if (isCurrent()) document.getElementById('ptChecker')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150);
} else if (initGenerate) {
doGenerate();
- setTimeout(() => { document.getElementById('ptGenerator')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150);
+ setTimeout(() => { if (isCurrent()) document.getElementById('ptGenerator')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 150);
}
}
@@ -4218,6 +4304,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
}
async function renderRFHealthTab(el) {
+ const isCurrent = analyticsGuard();
// Restore state from URL
const hashParams = new URLSearchParams((location.hash.split('?')[1] || ''));
if (hashParams.get('range')) _rfHealthState.range = hashParams.get('range');
@@ -4255,8 +4342,11 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// Range button handlers
el.querySelectorAll('.rf-range-btn[data-range]').forEach(btn => {
btn.addEventListener('click', () => {
+ if (!isCurrent()) return;
const range = btn.dataset.range;
_rfHealthState.range = range;
+ _analyticsRequests.delete(document.getElementById('rfHealthGrid'));
+ _analyticsRequests.delete(document.getElementById('rfHealthDetail'));
el.querySelectorAll('.rf-range-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const customInputs = el.querySelector('.rf-custom-inputs');
@@ -4271,6 +4361,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
const applyBtn = document.getElementById('rfCustomApply');
if (applyBtn) {
applyBtn.addEventListener('click', () => {
+ if (!isCurrent()) return;
_rfHealthState.customFrom = document.getElementById('rfFrom').value;
_rfHealthState.customTo = document.getElementById('rfTo').value;
rfHealthUpdateHash();
@@ -4284,12 +4375,17 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
async function loadRFHealthData(el) {
const grid = document.getElementById('rfHealthGrid');
const detail = document.getElementById('rfHealthDetail');
+ const isCurrent = analyticsGuard(grid);
+ // A range change invalidates any detail already in flight, even while
+ // its new summary is still loading or no longer contains that observer.
+ if (detail) _analyticsRequests.delete(detail);
try {
// Compute window string for summary endpoint
const windowMap = { '1h':'1h', '3h':'3h', '6h':'6h', '12h':'12h', '24h':'24h', '3d':'3d', '7d':'7d', '30d':'30d' };
const window = windowMap[_rfHealthState.range] || '24h';
const summaryData = await api('/observers/metrics/summary?window=' + window + (RegionFilter.regionQueryString() || ''));
+ if (!isCurrent()) return;
const observers = summaryData.observers || [];
// Filter to observers with sufficient sparkline data (≥2 non-null noise_floor values)
@@ -4337,6 +4433,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// Click handler for cells
grid.querySelectorAll('.rf-cell').forEach(cell => {
cell.addEventListener('click', () => {
+ if (!isCurrent()) return;
const obsId = cell.dataset.observer;
grid.querySelectorAll('.rf-cell').forEach(c => c.classList.remove('rf-cell-selected'));
cell.classList.add('rf-cell-selected');
@@ -4367,17 +4464,21 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
}
}
} catch (e) {
+ if (!isCurrent()) return;
grid.innerHTML = `
Failed to load RF health data: ${esc(e.message)}
`;
}
}
async function loadRFSparkline(observerId) {
+ const container = document.getElementById(`rf-spark-${observerId}`);
+ if (!container) return;
+ const isCurrent = analyticsGuard(container);
const { since, until } = rfHealthTimeRangeToParams(_rfHealthState.range, _rfHealthState.customFrom, _rfHealthState.customTo);
try {
const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}`);
+ if (!isCurrent()) return;
const metrics = data.metrics || [];
const nfValues = metrics.map(m => m.noise_floor).filter(v => v != null);
- const container = document.getElementById(`rf-spark-${observerId}`);
if (container && nfValues.length > 1) {
container.innerHTML = rfNFSparkline(nfValues, 140, 24);
} else if (container) {
@@ -4412,6 +4513,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
}
async function loadRFHealthDetail(observerId, container) {
+ const isCurrent = analyticsGuard(container);
container.classList.remove('rf-panel-empty');
container.innerHTML = '
Loading detail…
';
@@ -4423,6 +4525,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
try {
const data = await api(`/observers/${observerId}/metrics?since=${encodeURIComponent(since)}&until=${encodeURIComponent(until)}&resolution=${resolution}`);
+ if (!isCurrent()) return;
const metrics = data.metrics || [];
const reboots = (data.reboots || []).map(r => new Date(r).getTime());
const name = data.observer_name || observerId.substring(0, 8);
@@ -4472,6 +4575,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// Close button
container.querySelector('.rf-detail-close').addEventListener('click', () => {
+ _analyticsRequests.delete(container);
container.classList.add('rf-panel-empty');
container.innerHTML = '
Select an observer to view details ';
_rfHealthState.selectedObserver = null;
@@ -4516,6 +4620,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
}
}
} catch (e) {
+ if (!isCurrent()) return;
container.innerHTML = `
Failed to load detail: ${esc(e.message)}
`;
}
}
@@ -4845,10 +4950,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// #690 — Clock Health fleet view (M3)
async function renderClockHealthTab(el) {
+ const isCurrent = analyticsGuard(el);
el.innerHTML = '
Loading clock health data…
';
try {
const aqs = AreaFilter.areaQueryString();
var data = await (await fetch('/api/nodes/clock-skew' + (aqs ? '?' + aqs.slice(1) : ''))).json();
+ if (!isCurrent()) return;
if (!Array.isArray(data) || !data.length) {
el.innerHTML = '
No clock skew data available. Nodes need recent adverts for clock analysis.
';
return;
@@ -4947,6 +5054,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
render();
} catch (err) {
+ if (!isCurrent()) return;
el.innerHTML = '
Failed to load clock health data: ' + esc(String(err)) + '
';
}
}
@@ -4980,6 +5088,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
}
async function renderScopesTab(el) {
+ const isCurrent = analyticsGuard();
var winKey = 'scopes_window';
var selectedWindow = (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(winKey)) || '24h';
@@ -5004,28 +5113,34 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
'
' +
'
';
- // Attach window-button click listeners (once)
- el.querySelectorAll('[data-win]').forEach(function(btn) {
- btn.addEventListener('click', function() {
- selectedWindow = btn.dataset.win;
- if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(winKey, selectedWindow);
- el.querySelectorAll('[data-win]').forEach(function(b) { b.classList.toggle('active', b.dataset.win === selectedWindow); });
- load(selectedWindow);
- });
- });
}
+ // The frame survives theme/filter refreshes; replace its handlers so
+ // window picks and the refresh timer share the current render's state.
+ el.querySelectorAll('[data-win]').forEach(function(btn) {
+ btn.onclick = function() {
+ if (!isCurrent()) return;
+ selectedWindow = btn.dataset.win;
+ if (typeof sessionStorage !== 'undefined') sessionStorage.setItem(winKey, selectedWindow);
+ el.querySelectorAll('[data-win]').forEach(function(b) { b.classList.toggle('active', b.dataset.win === selectedWindow); });
+ load(selectedWindow);
+ };
+ });
+
function pct(n, total) {
if (!total) return '—';
return (n / total * 100).toFixed(1) + '%';
}
async function load(w) {
+ if (!isCurrent()) return;
+ const requestCurrent = analyticsGuard(document.getElementById('scopes-cards'));
var loadingEl = document.getElementById('scopes-loading');
if (loadingEl) loadingEl.style.display = '';
try {
// Fix 4: use api() instead of raw fetch()
var data = await api('/scope-stats?window=' + encodeURIComponent(w), { ttl: 30000 });
+ if (!requestCurrent()) return;
if (loadingEl) loadingEl.style.display = 'none';
if (data.error) {
var cardsEl2 = document.getElementById('scopes-cards');
@@ -5034,6 +5149,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
}
updateData(data, w);
} catch (err) {
+ if (!requestCurrent()) return;
if (loadingEl) loadingEl.style.display = 'none';
var cardsEl3 = document.getElementById('scopes-cards');
if (cardsEl3) cardsEl3.innerHTML = '
Failed to load scope stats: ' + esc(String(err)) + '
';
@@ -5141,9 +5257,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// Fix 6: auto-refresh every 60s
_stopScopesRefresh();
_scopesRefreshTimer = setInterval(function() {
- if (_currentTab !== 'scopes') { _stopScopesRefresh(); return; }
- var cur = document.getElementById('analyticsContent');
- if (!cur) { _stopScopesRefresh(); return; }
+ if (!isCurrent()) return;
load(selectedWindow);
}, 60000);
}
@@ -5153,22 +5267,23 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
// Auto-refreshes every 60s while the Roles tab is active (matches the
// behavior of the former standalone roles-page.js).
async function renderRolesTab(el) {
+ const isCurrent = analyticsGuard();
el.innerHTML = '
Loading roles…
';
await _renderRolesTabBody(el);
+ if (!isCurrent()) return;
// (Re)start the 60s auto-refresh.
_stopRolesRefresh();
_rolesRefreshTimer = setInterval(function () {
- // Bail if the user navigated away from the Roles tab.
- if (_currentTab !== 'roles') { _stopRolesRefresh(); return; }
- var cur = document.getElementById('analyticsContent');
- if (!cur) { _stopRolesRefresh(); return; }
- _renderRolesTabBody(cur);
+ if (!isCurrent()) return;
+ _renderRolesTabBody(el);
}, 60000);
}
async function _renderRolesTabBody(el) {
+ const isCurrent = analyticsGuard(el);
try {
var data = await api('/analytics/roles', { ttl: CLIENT_TTL.analyticsRF });
+ if (!isCurrent()) return;
var roles = (data && data.roles) || [];
var total = (data && data.totalNodes) || 0;
if (!roles.length) {
@@ -5220,6 +5335,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
'
' + rows + ' ' +
'';
} catch (err) {
+ if (!isCurrent()) return;
el.innerHTML = '
Failed to load roles: ' + esc(String(err.message || err)) + '
';
}
}
diff --git a/public/app.js b/public/app.js
index 7a04414f0..a68060653 100644
--- a/public/app.js
+++ b/public/app.js
@@ -203,7 +203,9 @@ async function api(path, { ttl = 0, bust = false } = {}) {
}
})();
_inflight.set(path, promise);
- promise.finally(() => _inflight.delete(path));
+ // Handle both outcomes so cleanup does not create an unobserved rejection.
+ const cleanup = () => _inflight.delete(path);
+ promise.then(cleanup, cleanup);
return promise;
}
diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json
index 0a1c87d1b..9b4594a84 100644
--- a/scripts/non-unit-tests.json
+++ b/scripts/non-unit-tests.json
@@ -5,6 +5,7 @@
"tests/e2e/test-1110-live-filter.js",
"tests/e2e/test-a11y-axe-1668.js",
"tests/e2e/test-analytics-fluid-charts.js",
+ "tests/e2e/test-analytics-loading-e2e.js",
"tests/e2e/test-audio-lab-1297-e2e.js",
"tests/e2e/test-audio-live-1297-e2e.js",
"tests/e2e/test-bottom-nav-1061-e2e.js",
diff --git a/test-all.sh b/test-all.sh
index d765d4225..83a67df29 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -18,7 +18,9 @@ node tests/unit/test-a11y-axe-routes-coverage.js
node tests/unit/test-admin-dashboard-ui.js
node tests/unit/test-aging.js
node tests/unit/test-analytics-channels-integration.js
+node tests/unit/test-analytics-loading.js
node tests/unit/test-anl1-tooltip-render.js
+node tests/unit/test-api-inflight-cleanup.js
node tests/unit/test-area-filter.js
node tests/unit/test-canadaverse-branding.js
node tests/unit/test-channel-color-picker.js
diff --git a/tests/e2e/test-analytics-loading-e2e.js b/tests/e2e/test-analytics-loading-e2e.js
new file mode 100644
index 000000000..57689648f
--- /dev/null
+++ b/tests/e2e/test-analytics-loading-e2e.js
@@ -0,0 +1,204 @@
+#!/usr/bin/env node
+/* Local browser regression: delayed analytics, theme refreshes and filter races. */
+'use strict';
+const assert = require('assert');
+const { chromium } = require('playwright');
+const BASE = process.env.BASE_URL || 'http://localhost:13581';
+assert.ok(['localhost', '127.0.0.1', '[::1]'].includes(new URL(BASE).hostname), 'Use a local fixture server, never production');
+
+function deferred() {
+ let resolve;
+ const promise = new Promise(r => { resolve = r; });
+ return { promise, resolve };
+}
+
+const FIXTURES = {
+ 'hash-sizes': { total: 0, distribution: {}, hourly: [], topHops: [], multiByteNodes: [] },
+ topology: { uniqueNodes: 4, avgHops: 1, maxHops: 1, hopDistribution: [] },
+ channels: { activeChannels: 0, decryptable: 0 },
+ 'hash-collisions': {},
+ 'relay-airtime-share': { rows: [] },
+ 'subpaths-bulk': { results: Array.from({ length: 4 }, () => ({ subpaths: [] })) },
+ 'neighbor-graph': {
+ nodes: [
+ { pubkey: 'aa'.repeat(32), role: 'companion', neighbor_count: 1 },
+ { pubkey: 'bb'.repeat(32), role: 'companion', neighbor_count: 1 },
+ { pubkey: 'cc'.repeat(32), role: 'repeater', neighbor_count: 1 },
+ { pubkey: 'dd'.repeat(32), role: 'repeater', neighbor_count: 1 },
+ ],
+ edges: [
+ { source: 'aa'.repeat(32), target: 'bb'.repeat(32), score: 0.9, ambiguous: false },
+ { source: 'cc'.repeat(32), target: 'dd'.repeat(32), score: 0.9, ambiguous: false },
+ ],
+ stats: {},
+ },
+};
+
+(async () => {
+ const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined });
+ try {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
+ page.setDefaultTimeout(15000);
+ const errors = [];
+ page.on('pageerror', error => errors.push(error.message));
+ const gates = new Map(['', '1h', '24h', '7d'].map(window => [window, { response: deferred(), arrived: deferred() }]));
+ const lazyGates = new Map(['E2E-OLD', 'E2E-NEW', 'E2E-LATE', 'E2E-ERROR', 'E2E-FAIL', 'E2E-LEFT'].map(region => [region, { response: deferred(), arrived: deferred() }]));
+ await page.route('**/api/analytics/*', async route => {
+ const url = new URL(route.request().url());
+ const endpoint = url.pathname.split('/').pop();
+ let body = FIXTURES[endpoint];
+ if (endpoint === 'rf') {
+ const gate = gates.get(url.searchParams.get('window') || '');
+ gate.arrived.resolve(url.pathname.slice('/api'.length) + url.search);
+ const total = await gate.response.promise;
+ body = {
+ totalTransmissions: total, totalPackets: total, packetsPerHour: [], payloadTypes: [],
+ snr: { avg: 0, min: 0, max: 0 }, rssi: { avg: -100, min: -100, max: -100 },
+ avgPacketSize: 20, minPacketSize: 20, maxPacketSize: 20, timeSpanHours: 1,
+ };
+ }
+ if (body === undefined) return route.continue();
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
+ });
+ // Use synthetic nodes and bulk health; hold the lazy response independently
+ // of the six core requests so these exercise the actual renderer race.
+ await page.route('**/api/nodes**', async route => {
+ const url = new URL(route.request().url());
+ const region = url.searchParams.get('region');
+ const gate = lazyGates.get(region);
+ if (!gate || !['/api/nodes', '/api/nodes/bulk-health'].includes(url.pathname)) return route.continue();
+ const publicKey = 'ab'.repeat(32);
+ let status = 200;
+ let body = { nodes: [{ public_key: publicKey, name: region + ' synthetic node', role: 'repeater', last_seen: new Date().toISOString() }], has_more: false, counts: { repeater: 1 }, total: 1 };
+ if (url.pathname.endsWith('/bulk-health')) {
+ gate.arrived.resolve(url.pathname.slice('/api'.length) + url.search);
+ status = await gate.response.promise;
+ body = [{ public_key: publicKey, stats: { totalPackets: 22, packetsToday: 2, avgSnr: 3 }, observers: [] }];
+ }
+ await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
+ });
+
+ async function release(window, total) {
+ const gate = gates.get(window);
+ const requestPath = await gate.arrived.promise;
+ gate.response.resolve(total);
+ // Wait until the real api() has consumed the response, not just until
+ // the server has sent it; then its Promise.all continuation has run.
+ await page.waitForFunction(path => _apiPerf.log.some(entry => entry.path === path), requestPath);
+ }
+ async function theme() {
+ await page.evaluate(() => window.dispatchEvent(new CustomEvent('theme-refresh')));
+ }
+ async function selectWindow(window) {
+ const request = page.waitForRequest(req => {
+ const url = new URL(req.url());
+ return url.pathname === '/api/analytics/rf' && url.searchParams.get('window') === window;
+ });
+ await page.locator('#analyticsTimeWindow').selectOption(window);
+ await request;
+ }
+ async function selectRegion(region, window) {
+ await page.evaluate(region => RegionFilter.setSelected([region]), region);
+ await selectWindow(window);
+ }
+ async function releaseLazy(region, status = 200) {
+ const gate = lazyGates.get(region);
+ const requestPath = await gate.arrived.promise;
+ gate.response.resolve(status);
+ // Errors do not enter _apiPerf.log. Wait for the real api() cleanup for
+ // both success and rejection, then inspect the renderer's settled view.
+ await page.waitForFunction(path => !_inflight.has(path), requestPath);
+ }
+
+ await page.goto(BASE + '/#/analytics', { waitUntil: 'domcontentloaded' });
+ await page.locator('#analyticsTimeWindow').waitFor();
+ await theme();
+ await page.locator('#analyticsTabs [data-tab="hashsizes"]').click();
+ await theme();
+ assert.match(await page.locator('#analyticsContent').innerText(), /Loading analytics/);
+ assert.deepStrictEqual(errors, []);
+ console.log(' ✓ pending core tabs and theme refresh retain the loading state');
+
+ await page.locator('#analyticsTabs [data-tab="neighbor-graph"]').click();
+ const companion = page.locator('#ngRoleChecks input[data-role="companion"]');
+ await page.waitForFunction(() => document.querySelector('#ngStats .stat-value')?.textContent === '4');
+ await companion.uncheck();
+ const canvas = await page.locator('#ngCanvas').elementHandle();
+ await release('', 111);
+ await theme();
+ assert.strictEqual(await companion.isChecked(), false, 'bulk completion and theme refresh must preserve graph filters');
+ assert.strictEqual(await canvas.evaluate(el => el.isConnected), true, 'the graph DOM must not be rebuilt');
+ console.log(' ✓ a lazy graph retains its filters and canvas after core data and theme refresh');
+
+ await page.locator('#analyticsTabs [data-tab="overview"]').click();
+ assert.strictEqual(await page.locator('#analyticsContent .stat-value').first().innerText(), '111');
+ await selectWindow('1h');
+ await theme();
+ assert.match(await page.locator('#analyticsContent').innerText(), /Loading analytics/);
+ await selectWindow('24h');
+ await release('24h', 222);
+ await page.waitForFunction(() => document.querySelector('#analyticsContent .stat-value')?.textContent === '222');
+ await release('1h', 999);
+ await theme();
+ assert.strictEqual(await page.locator('#analyticsContent .stat-value').first().innerText(), '222');
+ assert.strictEqual(await page.locator('#analyticsTimeWindow').inputValue(), '24h');
+ console.log(' ✓ the newest time window wins when responses complete out of order');
+
+ await selectWindow('7d');
+ await page.evaluate(() => { location.hash = '#/home'; });
+ await page.locator('#analyticsContent').waitFor({ state: 'detached' });
+ await release('7d', 333);
+ assert.strictEqual(await page.locator('#analyticsContent').count(), 0);
+ assert.deepStrictEqual(errors, [], 'analytics must not throw during loading, refresh or page leave');
+ console.log(' ✓ late completion after page leave is harmless');
+
+ await page.evaluate(() => {
+ RegionFilter.setSelected(['E2E-OLD']);
+ location.hash = '#/analytics?tab=nodes';
+ });
+ await lazyGates.get('E2E-OLD').arrived.promise;
+ assert.match(await page.locator('#analyticsContent').innerText(), /Loading node analytics/);
+ await selectRegion('E2E-NEW', '24h');
+ await releaseLazy('E2E-NEW');
+ await page.getByRole('link', { name: 'E2E-NEW synthetic node', exact: true }).first().waitFor();
+ await releaseLazy('E2E-OLD');
+ assert.match(await page.locator('#analyticsContent').innerText(), /E2E-NEW synthetic node/);
+ assert.doesNotMatch(await page.locator('#analyticsContent').innerText(), /E2E-OLD synthetic node/);
+ console.log(' ✓ lazy Nodes keeps the newest filter when the old response arrives last');
+
+ for (const [region, status] of [['E2E-LATE', 200], ['E2E-ERROR', 500]]) {
+ await page.evaluate(region => RegionFilter.setSelected([region]), region);
+ await page.locator('#analyticsTabs [data-tab="nodes"]').click();
+ await lazyGates.get(region).arrived.promise;
+ await page.locator('#analyticsTabs [data-tab="subpaths"]').click();
+ await page.getByRole('heading', { name: 'Route Pattern Analysis' }).waitFor();
+ await releaseLazy(region, status);
+ assert.match(await page.locator('#analyticsContent').innerText(), /Route Pattern Analysis/);
+ assert.doesNotMatch(await page.locator('#analyticsContent').innerText(), /Failed to load node|synthetic node/);
+ }
+ console.log(' ✓ late lazy success and HTTP 500 failure preserve the newly selected tab');
+
+ await page.evaluate(() => RegionFilter.setSelected(['E2E-FAIL']));
+ await page.locator('#analyticsTabs [data-tab="nodes"]').click();
+ await releaseLazy('E2E-FAIL', 500);
+ await page.getByText(/Failed to load node analytics: API 500/).waitFor();
+ assert.deepStrictEqual(errors, [], 'handled lazy HTTP failures must not become unhandled rejections');
+ lazyGates.set('E2E-FAIL', { response: deferred(), arrived: deferred() });
+ await theme();
+ await releaseLazy('E2E-FAIL');
+ await page.getByRole('link', { name: 'E2E-FAIL synthetic node', exact: true }).first().waitFor();
+ console.log(' ✓ a current lazy failure is visible and a subsequent refresh recovers');
+
+ await selectRegion('E2E-LEFT', '1h');
+ await lazyGates.get('E2E-LEFT').arrived.promise;
+ await page.evaluate(() => { location.hash = '#/home'; });
+ await page.locator('#analyticsContent').waitFor({ state: 'detached' });
+ await releaseLazy('E2E-LEFT');
+ assert.strictEqual(await page.locator('#analyticsContent').count(), 0);
+ assert.deepStrictEqual(errors, [], 'lazy completion after destroy must not throw or restart rendering');
+ console.log(' ✓ a delayed lazy response after page leave is harmless');
+ console.log('Analytics loading browser regression: 8 passed');
+ } finally {
+ await browser.close();
+ }
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/unit/test-analytics-loading.js b/tests/unit/test-analytics-loading.js
new file mode 100644
index 000000000..b671b782a
--- /dev/null
+++ b/tests/unit/test-analytics-loading.js
@@ -0,0 +1,471 @@
+/* Exercise the real analytics page lifecycle with controllable API promises. */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const source = fs.readFileSync(path.join(__dirname, '../../public/analytics.js'), 'utf8');
+const settle = () => new Promise(resolve => setImmediate(resolve));
+const unhandled = [];
+process.on('unhandledRejection', error => unhandled.push(error.message));
+
+function fixture(url, total) {
+ switch (url.split('?')[0]) {
+ case '/analytics/rf': return { totalTransmissions: total, totalPackets: total, packetsPerHour: [], payloadTypes: [], snr: {}, rssi: {} };
+ case '/analytics/topology': return { uniqueNodes: 0, hopDistribution: [] };
+ case '/analytics/hash-sizes': return { total: 0, distribution: {}, hourly: [], topHops: [], multiByteNodes: [] };
+ case '/analytics/channels': return { activeChannels: 0, decryptable: 0 };
+ case '/nodes': return { nodes: [], total };
+ case '/nodes/bulk-health':
+ case '/api/nodes/clock-skew':
+ case '/nodes/clock-skew': return [];
+ case '/analytics/subpaths-bulk': return { results: Array.from({ length: 4 }, () => ({ subpaths: [] })) };
+ case '/analytics/neighbor-graph': return { nodes: [], edges: [], stats: {} };
+ case '/analytics/distance': return { status: 'building', retry_after_seconds: 5 };
+ case '/observers/metrics/summary': return { observers: [] };
+ case '/analytics/roles': return { roles: [], totalNodes: 0 };
+ case '/scope-stats': return { summary: { transportTotal: total, scoped: 0, unscoped: total, unknownScope: 0 } };
+ default: return { rows: [] };
+ }
+}
+
+async function harness(initialTab = '') {
+ let page;
+ const requests = [], events = new Map(), timers = new Map(), frames = [];
+ let timerId = 0;
+ function element() {
+ let html = '';
+ return {
+ get innerHTML() { return html; },
+ set innerHTML(value) { html = value; this.writes++; },
+ writes: 0, value: '', style: {}, listeners: {},
+ classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
+ addEventListener(name, fn) { this.listeners[name] = fn; },
+ setAttribute() {},
+ querySelectorAll() { return []; },
+ querySelector(selector) { return elements[selector.slice(1)] || null; },
+ getContext() { return { scale() {} }; },
+ };
+ }
+ const elements = Object.fromEntries(['analyticsContent', 'analyticsTabs', 'analyticsTimeWindow', 'analyticsRegionFilter', 'analyticsAreaFilter'].map(id => [id, element()]));
+ elements.analyticsTabs.querySelector = () => ({ classList: { add() {} } });
+ function filter() {
+ return { init() {}, onChange(fn) { this.change = fn; }, regionQueryString: () => '', areaQueryString: () => '' };
+ }
+ const sandbox = {
+ window: null, console, URLSearchParams,
+ document: { getElementById: id => elements[id] || null, querySelectorAll: () => [], documentElement: {} },
+ location: { hash: '#/analytics' + (initialTab ? '?tab=' + initialTab : '') },
+ history: { replaceState() {} },
+ localStorage: { getItem: () => null, setItem() {} },
+ sessionStorage: { getItem: () => null, setItem() {} },
+ RegionFilter: filter(), AreaFilter: filter(),
+ CLIENT_TTL: { analyticsRF: 1000 },
+ initTabBar() {},
+ registerPage(name, handlers) { assert.strictEqual(name, 'analytics'); page = handlers; },
+ addEventListener(name, fn) { events.set(name, fn); },
+ removeEventListener(name) { events.delete(name); },
+ requestAnimationFrame(fn) { frames.push(fn); return frames.length; }, cancelAnimationFrame() {},
+ setInterval(fn) { timers.set(++timerId, fn); return timerId; },
+ setTimeout(fn) { const id = ++timerId; timers.set(id, () => { timers.delete(id); fn(); }); return id; },
+ clearInterval(id) { timers.delete(id); }, clearTimeout(id) { timers.delete(id); },
+ getComputedStyle: () => ({ getPropertyValue: () => '' }),
+ getFavorites: () => [],
+ api(url) {
+ return new Promise((resolve, reject) => requests.push({ url, resolve, reject }));
+ },
+ };
+ sandbox.fetchAllNodes = query => sandbox.api('/nodes?' + query);
+ sandbox.fetch = url => sandbox.api(url).then(data => ({ ok: true, json: async () => data }));
+ sandbox.window = sandbox;
+ vm.runInNewContext(source, sandbox, { filename: 'analytics.js' });
+ async function init() {
+ elements.analyticsContent = element();
+ elements.analyticsContent.innerHTML = 'Loading analytics…';
+ await page.init(element());
+ }
+ await init();
+ return {
+ page, elements, requests, init, timers, frames,
+ element,
+ add(...ids) { ids.forEach(id => { elements[id] = element(); }); },
+ favorites() { sandbox.getFavorites = () => ['aa'.repeat(32)]; },
+ html: () => elements.analyticsContent.innerHTML,
+ theme: () => events.get('theme-refresh')?.(),
+ tab(tab) {
+ const button = { dataset: { tab }, classList: { add() {} } };
+ elements.analyticsTabs.listeners.click({ target: { closest: () => button } });
+ },
+ window(value) {
+ elements.analyticsTimeWindow.value = value;
+ elements.analyticsTimeWindow.listeners.change();
+ },
+ refresh: () => sandbox.RegionFilter.change(),
+ async resolve(batch, total) {
+ requests.slice(batch * 6, batch * 6 + 6).forEach(r => r.resolve(fixture(r.url, total)));
+ await settle();
+ },
+ async reject(batch, message) {
+ requests[batch * 6].reject(new Error(message));
+ await settle();
+ },
+ };
+}
+
+let passed = 0, failed = 0;
+async function test(name, fn) {
+ const before = unhandled.length;
+ try {
+ try { await fn(); } finally { await settle(); }
+ assert.deepStrictEqual(unhandled.slice(before), [], 'page lifecycle must not reject in the background');
+ passed++;
+ console.log(' ✓ ' + name);
+ } catch (error) {
+ failed++;
+ console.error(' ✗ ' + name + ': ' + error.message.split('\n')[0]);
+ }
+}
+
+(async () => {
+ await test('theme refresh and every core tab wait for complete analytics', async () => {
+ const h = await harness();
+ h.theme();
+ for (const tab of ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']) {
+ h.tab(tab);
+ h.theme();
+ assert.match(h.html(), /Loading analytics/);
+ }
+ assert.strictEqual(h.requests.length, 6, 'refreshes must not issue extra requests');
+ });
+
+ await test('ready data still renders and tabs and theme refresh keep working', async () => {
+ const h = await harness();
+ await h.resolve(0, 111);
+ assert.match(h.html(), /stat-value">111);
+ h.tab('hashsizes');
+ assert.match(h.html(), /Hash Size Distribution/);
+ h.theme();
+ assert.match(h.html(), /Hash Size Distribution/);
+ h.tab('overview');
+ h.theme();
+ assert.match(h.html(), /stat-value">111);
+ assert.strictEqual(h.requests.length, 6);
+ });
+
+ await test('newer filter results win even when older requests finish last', async () => {
+ const h = await harness();
+ h.window('1h');
+ assert.match(h.requests[7].url, /window=1h/);
+ await h.resolve(1, 222);
+ assert.match(h.html(), /stat-value">222);
+ await h.resolve(0, 111);
+ h.theme();
+ assert.match(h.html(), /stat-value">222);
+ });
+
+ await test('an old failure cannot replace newer results', async () => {
+ const h = await harness();
+ h.refresh();
+ await h.resolve(1, 222);
+ await h.reject(0, 'stale failure');
+ assert.match(h.html(), /stat-value">222);
+ h.theme();
+ assert.match(h.html(), /stat-value">222);
+ assert.doesNotMatch(h.html(), /stale failure/);
+ });
+
+ await test('current errors are escaped and survive theme and core tab changes until retry', async () => {
+ const h = await harness();
+ await h.reject(0, '
');
+ for (const tab of ['overview', 'hashsizes']) {
+ h.tab(tab);
+ h.theme();
+ assert.match(h.html(), /Failed to load: <img/);
+ assert.doesNotMatch(h.html(), /
333);
+ });
+
+ await test('a refresh replaces the previous core view with a loading state', async () => {
+ const h = await harness();
+ await h.resolve(0, 111);
+ h.window('7d');
+ h.theme();
+ assert.match(h.html(), /Loading analytics/);
+ assert.doesNotMatch(h.html(), /stat-value">111);
+ });
+
+ await test('lazy tabs still render while the core requests are pending', async () => {
+ const h = await harness();
+ h.tab('my-repeaters');
+ assert.doesNotMatch(h.html(), /Loading analytics/);
+ assert.match(h.html(), /repeater/i);
+ h.tab('overview');
+ assert.match(h.html(), /Loading analytics/);
+ });
+
+ await test('deep-linked lazy tabs do not wait for the core requests', async () => {
+ const h = await harness('my-repeaters');
+ assert.match(h.html(), /repeater/i);
+ assert.doesNotMatch(h.html(), /Loading analytics/);
+ assert.strictEqual(h.requests.length, 6);
+ });
+
+ await test('core success or failure cannot rebuild an interactive lazy tab', async () => {
+ for (const reject of [false, true]) {
+ const h = await harness();
+ h.tab('my-repeaters');
+ const writes = h.elements.analyticsContent.writes;
+ if (reject) await h.reject(0, 'core failure');
+ else await h.resolve(0, 111);
+ assert.strictEqual(h.elements.analyticsContent.writes, writes);
+ h.tab('overview');
+ assert.match(h.html(), reject ? /Failed to load: core failure/ : /stat-value">111);
+ }
+ });
+
+ await test('completion and rejection after destroy cannot render or throw', async () => {
+ for (const reject of [false, true]) {
+ const h = await harness();
+ h.page.destroy();
+ delete h.elements.analyticsContent;
+ if (reject) await h.reject(0, 'left page');
+ else await h.resolve(0, 111);
+ }
+ });
+
+ await test('old results cannot overwrite a new analytics visit', async () => {
+ const h = await harness();
+ h.page.destroy();
+ await h.init();
+ await h.resolve(0, 111);
+ assert.match(h.html(), /Loading analytics/);
+ await h.resolve(1, 222);
+ assert.match(h.html(), /stat-value">222);
+ });
+
+ const lazyTabs = ['nodes', 'subpaths', 'my-repeaters', 'repeater-metrics', 'distance', 'neighbor-graph', 'rf-health', 'clock-health', 'prefix-tool', 'roles', 'scopes'];
+ function addLazyElements(h) {
+ h.favorites();
+ h.add('hideCollisions', 'subpathDetail', 'ngRoleChecks', 'ngMinScore', 'ngMinScoreVal', 'ngConfidence', 'ngStats', 'ngCanvas', 'ngTextList',
+ 'rfHealthGrid', 'rfHealthDetail', 'scopes-cards', 'scopes-loading', 'scopes-tbody', 'scopes-chart', 'scopes-adverts-by-role');
+ }
+ async function complete(requests, reject = false, total = 1) {
+ requests.forEach(r => reject ? r.reject(new Error('obsolete response')) : r.resolve(fixture(r.url, total)));
+ await settle();
+ }
+
+ for (const tab of lazyTabs) {
+ for (const reject of [false, true]) {
+ await test(`${tab}: stale ${reject ? 'failure' : 'success'} cannot write or restart work after a tab switch`, async () => {
+ const h = await harness();
+ addLazyElements(h);
+ const start = h.requests.length;
+ h.tab(tab);
+ const pending = h.requests.slice(start);
+ assert.ok(pending.length, 'exercise the real lazy request');
+ h.tab('overview');
+ const writes = Object.values(h.elements).reduce((n, el) => n + el.writes, 0);
+ const frames = h.frames.length;
+ await complete(pending, reject);
+ assert.match(h.html(), /Loading analytics/);
+ assert.strictEqual(Object.values(h.elements).reduce((n, el) => n + el.writes, 0), writes, 'stale result must not mutate any view');
+ assert.strictEqual(h.frames.length, frames, 'stale completion must not schedule layout or graph work');
+ assert.strictEqual(h.timers.size, 0, 'stale completion must not restart retries or refresh timers');
+ });
+ }
+ }
+
+ await test('Nodes uses the newest filter response and ignores an older failure', async () => {
+ for (const reject of [false, true]) {
+ const h = await harness();
+ h.tab('nodes');
+ const old = h.requests.slice(6);
+ const next = h.requests.length;
+ h.refresh();
+ await complete(h.requests.slice(next, next + 2), false, 222);
+ assert.match(h.html(), />222, h.html());
+ await complete(old, reject, 111);
+ assert.match(h.html(), />222);
+ assert.doesNotMatch(h.html(), /obsolete response|>111);
+ }
+ });
+
+ await test('destroyed lazy requests cannot write into detached content or a new visit', async () => {
+ for (const tab of lazyTabs) {
+ const h = await harness();
+ addLazyElements(h);
+ h.tab(tab);
+ const pending = h.requests.slice(6);
+ const previous = h.elements.analyticsContent;
+ h.page.destroy();
+ const writes = previous.writes;
+ await h.init();
+ const currentWrites = h.elements.analyticsContent.writes;
+ await complete(pending);
+ assert.strictEqual(previous.writes, writes, tab + ' must not write into the detached page');
+ assert.strictEqual(h.elements.analyticsContent.writes, currentWrites, tab + ' must not write into the new page');
+ }
+ });
+
+ await test('current lazy responses render normally and distance retries only while current', async () => {
+ for (const tab of lazyTabs) {
+ const h = await harness();
+ addLazyElements(h);
+ h.tab(tab);
+ await complete(h.requests.slice(6));
+ assert.doesNotMatch(h.html(), /Failed to load|Error loading|Loading node|Loading roles/);
+ if (tab === 'distance') {
+ assert.match(h.html(), /Building the distance index/);
+ const retry = [...h.timers.values()][0];
+ const before = h.requests.length;
+ retry();
+ assert.strictEqual(h.requests.length, before + 1);
+ h.tab('overview');
+ retry();
+ assert.strictEqual(h.requests.length, before + 1, 'a queued stale retry cannot fetch again');
+ await complete(h.requests.slice(before));
+ assert.strictEqual(h.timers.size, 0);
+ }
+ }
+ });
+
+ await test('Roles refreshes are latest-wins and obsolete timer callbacks cannot restart work', async () => {
+ const h = await harness('roles');
+ await complete(h.requests.slice(0, 1));
+ const refresh = [...h.timers.values()][0];
+ assert.ok(refresh, 'current response starts the refresh timer');
+ refresh();
+ const old = h.requests.at(-1);
+ refresh();
+ const current = h.requests.at(-1);
+ current.resolve({ totalNodes: 222, roles: [{ role: 'repeater', nodeCount: 222 }] });
+ await settle();
+ old.resolve({ totalNodes: 111, roles: [{ role: 'repeater', nodeCount: 111 }] });
+ await settle();
+ assert.match(h.html(), /
222<\/strong>/);
+ h.tab('overview');
+ const before = h.requests.length;
+ refresh();
+ assert.strictEqual(h.requests.length, before);
+ assert.strictEqual(h.timers.size, 0);
+ });
+
+ await test('subpath detail selections are latest-wins and stop after a filter change', async () => {
+ const h = await harness();
+ addLazyElements(h);
+ h.tab('subpaths');
+ await complete(h.requests.slice(6));
+ function select(hops) {
+ const row = h.element();
+ row.dataset = { hops };
+ h.elements.analyticsContent.onclick({ target: { closest: () => row } });
+ return h.requests.at(-1);
+ }
+ function detail(count) {
+ return { nodes: [], hops: ['aa', 'bb'], totalMatches: count, hourDistribution: [], signal: {}, observers: [], parentPaths: [] };
+ }
+ const old = select('aa,bb'), current = select('cc,dd');
+ current.resolve(detail(222));
+ await settle();
+ old.reject(new Error('obsolete detail'));
+ await settle();
+ assert.match(h.elements.subpathDetail.innerHTML, /222 occurrences/);
+ const pending = select('ee,ff');
+ h.refresh();
+ const writes = h.elements.subpathDetail.writes;
+ pending.resolve(detail(111));
+ await settle();
+ assert.strictEqual(h.elements.subpathDetail.writes, writes);
+ });
+
+ await test('RF range and observer selections reject stale successes and failures', async () => {
+ const h = await harness();
+ addLazyElements(h);
+ const range = h.element();
+ range.dataset = { range: '1h' };
+ h.elements.analyticsContent.querySelectorAll = selector => selector.includes('data-range') ? [range] : [];
+ const cells = ['observer-a', 'observer-b'].map(id => Object.assign(h.element(), { dataset: { observer: id } }));
+ h.elements.rfHealthGrid.querySelectorAll = () => cells;
+ const summary = { observers: cells.map(c => ({ observer_id: c.dataset.observer, sparkline: [-110, -111], sample_count: 2 })) };
+ h.tab('rf-health');
+ const old = h.requests.at(-1);
+ range.listeners.click();
+ const current = h.requests.at(-1);
+ current.resolve(summary);
+ await settle();
+ const gridWrites = h.elements.rfHealthGrid.writes;
+ old.reject(new Error('obsolete summary'));
+ await settle();
+ assert.strictEqual(h.elements.rfHealthGrid.writes, gridWrites);
+ cells[0].listeners.click();
+ const firstDetail = h.requests.at(-1);
+ cells[1].listeners.click();
+ const secondDetail = h.requests.at(-1);
+ secondDetail.resolve({ observer_name: 'Newest observer', metrics: [] });
+ await settle();
+ firstDetail.resolve({ observer_name: 'Old observer', metrics: [] });
+ await settle();
+ assert.match(h.elements.rfHealthDetail.innerHTML, /Newest observer/);
+ cells[0].listeners.click();
+ const pending = h.requests.at(-1);
+ range.dataset.range = 'custom';
+ range.listeners.click();
+ const detailWrites = h.elements.rfHealthDetail.writes;
+ pending.reject(new Error('obsolete range detail'));
+ await settle();
+ assert.strictEqual(h.elements.rfHealthDetail.writes, detailWrites);
+ });
+
+ await test('Scopes keeps current window handlers after refresh and ignores overlapping requests', async () => {
+ const h = await harness();
+ addLazyElements(h);
+ const button = Object.assign(h.element(), { dataset: { win: '1h' } });
+ h.elements.analyticsContent.querySelectorAll = selector => selector === '[data-win]' ? [button] : [];
+ h.tab('scopes');
+ const old = h.requests.at(-1);
+ h.theme();
+ button.onclick();
+ const current = h.requests.at(-1);
+ assert.match(current.url, /window=1h/);
+ current.resolve(fixture(current.url, 222));
+ await settle();
+ old.reject(new Error('obsolete scopes'));
+ await settle();
+ assert.match(h.elements['scopes-cards'].innerHTML, />222);
+ assert.strictEqual(h.timers.size, 1);
+ const timer = [...h.timers.values()][0];
+ timer();
+ assert.match(h.requests.at(-1).url, /window=1h/, 'timer uses the selected window from the current handler');
+ h.page.destroy();
+ const before = h.requests.length;
+ timer();
+ assert.strictEqual(h.requests.length, before);
+ });
+
+ await test('queued graph debounce and animation callbacks are harmless after leaving', async () => {
+ const h = await harness();
+ addLazyElements(h);
+ h.tab('neighbor-graph');
+ await complete(h.requests.slice(6));
+ const slider = h.elements.ngMinScore;
+ slider.value = '80';
+ slider.listeners.input.call(slider);
+ const debounce = [...h.timers.values()][0];
+ const frames = h.frames.slice();
+ h.page.destroy();
+ delete h.elements.analyticsContent;
+ delete h.elements.ngCanvas;
+ debounce();
+ frames.forEach(fn => fn());
+ assert.strictEqual(h.frames.length, frames.length, 'old callbacks cannot schedule more animation or layout');
+ });
+
+ console.log(`Analytics loading: ${passed} passed, ${failed} failed`);
+ process.exitCode = failed ? 1 : 0;
+})();
diff --git a/tests/unit/test-api-inflight-cleanup.js b/tests/unit/test-api-inflight-cleanup.js
new file mode 100644
index 000000000..ade3592ad
--- /dev/null
+++ b/tests/unit/test-api-inflight-cleanup.js
@@ -0,0 +1,145 @@
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const source = fs.readFileSync(path.join(__dirname, '../../public/app.js'), 'utf8');
+const start = source.indexOf('// --- Utilities ---');
+const end = source.indexOf('// Fetch the COMPLETE /api/nodes set', start);
+assert.ok(start >= 0 && end > start, 'locate the real API/cache/warmup helpers');
+
+function response(status, data = {}) {
+ return { status, ok: status >= 200 && status < 300, headers: { get: () => '1' }, json: async () => data };
+}
+
+function makeContext(fetchImpl) {
+ const events = [];
+ const clock = { now: 1000 };
+ const context = vm.createContext({
+ console,
+ Date: { now: () => clock.now },
+ performance: { now: () => 0 },
+ window: { onWarmup_1659: visible => events.push(visible) },
+ fetch: url => url === '/api/config/cache' ? Promise.resolve(response(200)) : fetchImpl(url),
+ setTimeout: fn => { fn(); return 0; },
+ });
+ // Execute the actual helpers, including their maps and warmup counter, without SPA startup.
+ vm.runInContext(source.slice(start, end), context, { filename: 'public/app.js' });
+ return { context, events, clock };
+}
+
+function assertSettled(context) {
+ assert.strictEqual(vm.runInContext('_inflight.size', context), 0, 'settled requests leave the inflight map');
+ assert.strictEqual(vm.runInContext('_warmupInflight_1659', context), 0, 'warmup counter stays balanced');
+}
+
+let passed = 0;
+let failed = 0;
+async function test(name, fn) {
+ const unhandled = [];
+ const capture = reason => unhandled.push(reason);
+ process.on('unhandledRejection', capture);
+ let failure;
+ try { await fn(); } catch (error) { failure = error; }
+ // Node reports unhandled rejections after the promise microtasks have drained.
+ await new Promise(setImmediate);
+ process.removeListener('unhandledRejection', capture);
+ try {
+ if (failure) throw failure;
+ assert.strictEqual(unhandled.length, 0,
+ 'handled API requests must not create unhandled rejections: ' + unhandled.map(error => error.message).join(', '));
+ passed++;
+ console.log(' PASS ' + name);
+ } catch (error) {
+ failed++;
+ console.error(' FAIL ' + name + ': ' + error.message);
+ }
+}
+
+(async () => {
+ console.log('\n=== API inflight cleanup ===');
+
+ await test('success shares one request, clears inflight, and preserves cache TTL and bust', async () => {
+ let release;
+ const pending = new Promise(resolve => { release = resolve; });
+ let calls = 0;
+ const { context, clock } = makeContext(async () => {
+ calls++;
+ return calls === 1 ? pending : response(200, { call: calls });
+ });
+ const first = context.api('/cleanup', { ttl: 100 });
+ const second = context.api('/cleanup', { ttl: 100 });
+ assert.strictEqual(calls, 1, 'concurrent callers share one fetch');
+ assert.strictEqual(vm.runInContext('_inflight.size', context), 1);
+ const body = { call: 1 };
+ release(response(200, body));
+ const results = await Promise.all([first, second]);
+ results.forEach(result => assert.strictEqual(result, body));
+ assertSettled(context);
+ clock.now += 99;
+ assert.strictEqual(await context.api('/cleanup', { ttl: 100 }), body);
+ assert.strictEqual(calls, 1, 'unexpired cached data avoids another fetch');
+ clock.now++;
+ assert.deepStrictEqual(await context.api('/cleanup', { ttl: 100 }), { call: 2 });
+ assert.deepStrictEqual(await context.api('/cleanup', { ttl: 100, bust: true }), { call: 3 });
+ assert.strictEqual(calls, 3, 'expiry and bust each start a fresh request');
+ assertSettled(context);
+ });
+
+ const networkError = new TypeError('Network unavailable');
+ const jsonError = new SyntaxError('Invalid JSON');
+ const failures = [
+ { name: 'HTTP failure', fetch: () => response(500), message: /^API 500: \/cleanup$/, calls: 1 },
+ { name: 'network failure', fetch: () => { throw networkError; }, error: networkError, calls: 1 },
+ { name: 'JSON parse failure', fetch: () => ({ ...response(200), json: async () => { throw jsonError; } }), error: jsonError, calls: 1 },
+ { name: 'exhausted warmup retries', fetch: () => response(503), message: /^API 503: \/cleanup$/, calls: 7, warmup: true },
+ { name: 'network failure after warmup', fetch: call => {
+ if (call === 1) return response(503);
+ throw networkError;
+ }, error: networkError, calls: 2, warmup: true },
+ ];
+
+ for (const fixture of failures) {
+ await test(fixture.name + ' reaches both callers without an extra rejection and permits retry', async () => {
+ let calls = 0;
+ let failing = true;
+ const body = { recovered: true };
+ const { context, events } = makeContext(async () => {
+ calls++;
+ return failing ? fixture.fetch(calls) : response(200, body);
+ });
+ const results = await Promise.allSettled([
+ context.api('/cleanup', { ttl: 100 }),
+ context.api('/cleanup', { ttl: 100 }),
+ ]);
+ assert.strictEqual(calls, fixture.calls, 'concurrent callers share one request/retry sequence');
+ results.forEach(result => assert.strictEqual(result.status, 'rejected', 'the caller still receives the failure'));
+ assert.strictEqual(results[0].reason, results[1].reason, 'both callers receive the original error');
+ if (fixture.error) assert.strictEqual(results[0].reason, fixture.error);
+ else assert.match(results[0].reason.message, fixture.message);
+ assertSettled(context);
+ assert.strictEqual(vm.runInContext('_apiCache.size', context), 0, 'failures are not cached');
+ assert.deepStrictEqual(events, fixture.warmup ? [true, false] : []);
+ failing = false;
+ assert.strictEqual(await context.api('/cleanup', { ttl: 100 }), body);
+ assert.strictEqual(calls, fixture.calls + 1, 'a failed request does not block a fresh fetch');
+ assertSettled(context);
+ });
+ }
+
+ await test('successful warmup retries share one sequence and balance the banner', async () => {
+ let calls = 0;
+ const body = { ready: true };
+ const { context, events } = makeContext(async () => ++calls < 3 ? response(503) : response(200, body));
+ const results = await Promise.all([context.api('/cleanup'), context.api('/cleanup')]);
+ results.forEach(result => assert.strictEqual(result, body));
+ assert.strictEqual(calls, 3);
+ assert.deepStrictEqual(events, [true, false]);
+ assertSettled(context);
+ });
+
+ console.log('\n' + passed + ' passed, ' + failed + ' failed');
+ if (failed) process.exitCode = 1;
+})().catch(error => { console.error(error); process.exitCode = 1; });
diff --git a/tests/unit/test-my-repeaters-dashboard.js b/tests/unit/test-my-repeaters-dashboard.js
index 30664a579..98d35ef86 100644
--- a/tests/unit/test-my-repeaters-dashboard.js
+++ b/tests/unit/test-my-repeaters-dashboard.js
@@ -67,10 +67,10 @@ const window = { ROLE_COLORS: { repeater: '#3b82f6', room: '#a855f7' }, aaBadgeS
const M = new Function(
'esc', 'getFavorites', 'RegionFilter', 'AreaFilter', 'fetchAllNodes', 'api', 'CLIENT_TTL',
- 'getHealthThresholds', 'renderSkewBadge', 'currentSkewValue', 'favStar', 'timeAgo', 'bindFavStars', 'window',
+ 'getHealthThresholds', 'renderSkewBadge', 'currentSkewValue', 'favStar', 'timeAgo', 'bindFavStars', 'window', 'analyticsGuard',
block + '\nreturn { renderMyRepeatersTab, _mrStatusCell, _mrSummaryCard };')(
esc, getFavorites, RegionFilter, AreaFilter, fetchAllNodes, api, CLIENT_TTL,
- getHealthThresholds, renderSkewBadge, currentSkewValue, favStar, timeAgo, bindFavStars, window);
+ getHealthThresholds, renderSkewBadge, currentSkewValue, favStar, timeAgo, bindFavStars, window, () => () => true);
function makeEl() { return { _h: '', set innerHTML(v) { this._h = v; }, get innerHTML() { return this._h; } }; }
const tick = () => new Promise(r => setTimeout(r, 0));