From c3e95e61a6779ef8b27075ad9d81af852ac51935 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:13:31 +0200 Subject: [PATCH 01/13] test(analytics): reproduce the error during a warm-up longer than 30 s (#172) Part #172 of test-1659-analytics-warmup.js runs the real app.js api() and the real analytics page in one vm with a fake fetch and a fake clock. The server answers 503 + Retry-After: 5 on rf/topology/channels until its background load is done (up to the 60 s force-open). - a 45 s warm-up must end with data, with a "still loading" state and no error on the way; - a permanent 503 must keep retrying for at least 90 s and give up by about 120 s; - destroy() during the warm-up must stop the retries and write nothing; - a new load during the warm-up must leave one retry timer and render the data once. Red on master: the error shows at 31 s (api() stops after 6 attempts), retries keep running after destroy(), and three retry timers are pending. Relates to #172 Co-Authored-By: Claude Opus 5.5 --- test-1659-analytics-warmup.js | 182 ++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/test-1659-analytics-warmup.js b/test-1659-analytics-warmup.js index b0db50ab3..5e2ad796d 100644 --- a/test-1659-analytics-warmup.js +++ b/test-1659-analytics-warmup.js @@ -61,6 +61,119 @@ function makeCtx(fetchImpl) { return ctx; } +// ---------------------------------------------------------------- #172 + +// A fake clock: setTimeout only queues; advance(ms) runs what is due, in +// time order, then lets the promise chains settle. +function fakeClock() { + let now = 0, nextId = 1; + const timers = new Map(); + const flush = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); }; + return { + now: () => now, + setTimeout: (fn, ms) => { const id = nextId++; timers.set(id, { fn, at: now + (Number(ms) || 0) }); return id; }, + clearTimeout: (id) => { timers.delete(id); }, + pending: () => [...timers.values()], + async advance(ms) { + const end = now + ms; + for (;;) { + let next = null; + for (const [id, t] of timers) if (t.at <= end && (!next || t.at < next[1].at)) next = [id, t]; + if (!next) break; + timers.delete(next[0]); + now = Math.max(now, next[1].at); + next[1].fn(); + await flush(); + } + now = end; + await flush(); + }, + }; +} + +const WARM_RF = { + totalTransmissions: 4321, totalPackets: 1234, packetsPerHour: [{ hour: '2026-10-02T12:00:00Z', count: 5 }], + snr: { avg: 5, min: -10, max: 12 }, rssi: { avg: -90, min: -120, max: -40 }, + avgPacketSize: 40, minPacketSize: 10, maxPacketSize: 200, timeSpanHours: 24, payloadTypes: [], +}; +const WARM_TOPO = { uniqueNodes: 42, avgHops: 1.5, maxHops: 5, hopDistribution: [] }; +const WARM_CHAN = { activeChannels: 3, decryptable: 1 }; + +function fakeEl(id) { + let html = '', dataWrites = 0; + return { + id, value: '', style: {}, dataset: {}, parentElement: null, + get innerHTML() { return html; }, + set innerHTML(v) { html = String(v); if (/Total Transmissions/.test(html)) dataWrites++; }, + dataWrites: () => dataWrites, + classList: { add() {}, remove() {}, contains: () => false, toggle() {} }, + addEventListener() {}, removeEventListener() {}, + querySelector: () => null, querySelectorAll: () => [], + appendChild: (c) => c, insertBefore: (c) => c, setAttribute() {}, getAttribute: () => null, + }; +} + +// The analytics page on a server whose rf/topology/channels answer 503 + +// Retry-After: 5 until warmMs on the fake clock. +function pageEnv(warmMs) { + const clock = fakeClock(); + const els = {}; + const el = (id) => els[id] || (els[id] = fakeEl(id)); + const fetchLog = []; + const respond = (status, body, retryAfter) => ({ + ok: status >= 200 && status < 300, status, + headers: { get: (k) => (String(k).toLowerCase() === 'retry-after' ? retryAfter || null : null) }, + json: async () => JSON.parse(JSON.stringify(body)), + }); + const fetchImpl = async (url) => { + fetchLog.push(url); + if (/\/api\/analytics\/(rf|topology|channels)\b/.test(url) && clock.now() < warmMs) { + return respond(503, { error: 'analytics warming up', retry_after_s: 5 }, '5'); + } + if (/\/api\/analytics\/rf\b/.test(url)) return respond(200, WARM_RF); + if (/\/api\/analytics\/topology\b/.test(url)) return respond(200, WARM_TOPO); + if (/\/api\/analytics\/channels\b/.test(url)) return respond(200, WARM_CHAN); + if (/relay-airtime-share/.test(url)) return respond(200, { rows: [] }); + return respond(200, {}); + }; + class FakeDate extends Date { static now() { return 1790000000000 + clock.now(); } } + let regionCb = null; + const pages = {}; + const ctx = { + window: { addEventListener() {}, removeEventListener() {}, dispatchEvent() {} }, + document: { + readyState: 'complete', body: { appendChild() {} }, head: { appendChild() {} }, + createElement: () => fakeEl(''), getElementById: el, addEventListener() {}, + querySelectorAll: () => [], querySelector: () => null, documentElement: fakeEl('html'), + }, + console: { log() {}, warn() {}, error: console.error }, Date: FakeDate, Infinity, Math, Array, Object, String, Number, + JSON, RegExp, Error, TypeError, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, + setTimeout: clock.setTimeout, clearTimeout: clock.clearTimeout, setInterval: () => 0, clearInterval() {}, + requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + fetch: fetchImpl, performance: { now: () => clock.now() }, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + location: { hash: '#/analytics' }, history: { replaceState() {} }, + CustomEvent: class CustomEvent {}, Map, Set, Promise, URLSearchParams, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + timeAgo: () => 'x ago', initTabBar() {}, makeColumnsResizable() {}, + onWS() {}, offWS() {}, connectWS() {}, invalidateApiCache() {}, IATA_COORDS_GEO: {}, + RegionFilter: { init() {}, onChange: (fn) => { regionCb = fn; }, regionQueryString: () => '' }, + AreaFilter: { init() {}, onChange() {}, areaQueryString: () => '' }, + }; + vm.createContext(ctx); + const load = (f) => { vm.runInContext(fs.readFileSync(f, 'utf8'), ctx); for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; }; + load('public/payload-labels.js'); + load('public/roles.js'); + try { load('public/app.js'); } catch (e) { /* DOM-only tail */ } + ctx.registerPage = (name, obj) => { pages[name] = obj; }; // app.js defines its own + load('public/analytics.js'); + return { + clock, el, page: pages.analytics, + regionChanged: () => regionCb(), + analyticsFetches: () => fetchLog.filter((u) => /\/api\/analytics\/(rf|topology|channels)\b/.test(u)).length, + }; +} + (async () => { console.log('\n=== #1659: api() retries on 503 with Retry-After ==='); @@ -212,6 +325,75 @@ function makeCtx(fetchImpl) { assert.strictEqual(events[events.length - 1], false, 'banner must end hidden'); }); + console.log('\n=== #172: the analytics page outlasts the server warm-up ==='); + + // The server answers 503 + Retry-After: 5 on rf/topology/channels until + // its background load is done, for up to its 60 s force-open + // (cmd/server/analytics_warmup_1659.go). These run the REAL app.js api() + // and the REAL analytics page in one vm, with a fake fetch and a fake + // clock that drives setTimeout and Date.now. + + await test('a warm-up longer than 30 s ends with data and never shows an error', async () => { + const env = pageEnv(45000); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + let sawLoading = false; + for (let t = 0; t < 60; t++) { + await env.clock.advance(1000); + assert.ok(!/Failed to load/.test(content.innerHTML), 'error shown at ' + env.clock.now() + ' ms: ' + content.innerHTML); + if (env.clock.now() < 45000 && /still loading/i.test(content.innerHTML)) sawLoading = true; + } + assert.ok(sawLoading, 'no "still loading" state while the server warmed up'); + assert.ok(/Total Transmissions/.test(content.innerHTML) && /4,321|4321/.test(content.innerHTML), + 'no data after the warm-up: ' + content.innerHTML.slice(0, 200)); + }); + + await test('a permanent 503 keeps retrying past the 60 s force-open, then shows the error', async () => { + const env = pageEnv(Infinity); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + let errorAt = null; + for (let t = 0; t < 200 && errorAt === null; t++) { + await env.clock.advance(1000); + if (/Failed to load/.test(content.innerHTML)) errorAt = env.clock.now(); + } + assert.ok(errorAt !== null, 'no error after 200 s of 503s: the retry loop is unbounded'); + assert.ok(errorAt >= 90000, 'gave up after ' + errorAt + ' ms, before the 90 s floor (server force-open is 60 s)'); + assert.ok(errorAt <= 125000, 'gave up only after ' + errorAt + ' ms, past the 120 s cap'); + const before = env.analyticsFetches(); + await env.clock.advance(60000); + assert.strictEqual(env.analyticsFetches(), before, 'still fetching after giving up'); + }); + + await test('leaving the page during the warm-up stops the retries and writes nothing', async () => { + const env = pageEnv(45000); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + await env.clock.advance(12000); + env.page.destroy(); + const fetches = env.analyticsFetches(); + const html = content.innerHTML; + for (let t = 0; t < 150; t++) await env.clock.advance(1000); + assert.strictEqual(env.analyticsFetches(), fetches, 'warm-up fetches after destroy()'); + assert.strictEqual(content.innerHTML, html, 'the left page was written after destroy()'); + assert.strictEqual(env.clock.pending().length, 0, 'retry timers left after destroy(): ' + env.clock.pending().length); + }); + + await test('a new load during the warm-up replaces the old one: one retry timer, one render', async () => { + const env = pageEnv(45000); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + await env.clock.advance(7000); + env.regionChanged(); // e.g. a region filter change starts a new load + let maxTimers = 0; + for (let t = 0; t < 60; t++) { + await env.clock.advance(1000); + maxTimers = Math.max(maxTimers, env.clock.pending().length); + } + assert.ok(maxTimers <= 1, maxTimers + ' retry timers pending at once'); + assert.strictEqual(content.dataWrites(), 1, 'the data was rendered ' + content.dataWrites() + ' times (the superseded load rendered too)'); + }); + console.log('\n' + passed + ' passed, ' + failed + ' failed'); if (failed > 0) process.exit(1); })(); From 0a1acd26023b2330d7e491f5c7364d0b841b429b Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:15:21 +0200 Subject: [PATCH 02/13] fix(analytics): keep loading through the server warm-up, up to 120 s (#172) Since #145 the server answers 503 + Retry-After on rf/topology/channels until its whole background load is done, for up to its 60 s force-open. api() gave up after 6 attempts (about 30 s at Retry-After: 5) and the page showed "Failed to load". - api() takes retry503:false for a caller that retries on its own. Its errors now carry the HTTP status, and a 503's valid Retry-After as retryAfterSeconds. Other callers keep the old 6-attempt loop. - loadAnalytics() retries a 503 on the server's interval while the next attempt starts within 120 s of the load's start, and shows a "still loading" status meanwhile; only then does it show the error. - Like the distance tab (#120), each load and destroy() bump _loadGen and clear the one retry timer, so a superseded load or a left page never renders. The retry delay helper is shared with the distance tab. Also adds a test that a slow response of a superseded load does not render over the newer one (red on master; it was added here because a mutant without the success-path generation check survived the first four). Relates to #172 Co-Authored-By: Claude Opus 5.5 --- public/analytics.js | 59 +++++++++++++++++++++++++++-------- public/app.js | 17 ++++++++-- test-1659-analytics-warmup.js | 29 +++++++++++++---- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 7d55addad..8de22de37 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -50,14 +50,28 @@ function _distanceIsBuilding(data) { return !!(data && data.status === 'building' && !data.summary); } - // Retry-After header (passed on by api()), else the body's - // retry_after_seconds, else 5s; clamped to 1..30s. - function _distanceRetryDelayMs(data) { + // Retry-After header (passed on by api() on a 202 body or a 503 error), + // else the body's retry_after_seconds, else 5s; clamped to 1..30s. + function _retryAfterDelayMs(data) { var s = Number(data && data.retryAfterSeconds); if (!(isFinite(s) && s > 0)) s = Number(data && data.retry_after_seconds); if (!(isFinite(s) && s > 0)) s = 5; return Math.min(Math.max(s, 1), 30) * 1000; } + // #172 — rf/topology/channels answer 503 + Retry-After while the server + // warms up after a restart (#1659), for up to its 60s force-open. The + // page shows a "still loading" state and retries on the server's + // interval until ANALYTICS_WARMUP_MAX_MS has passed since the load + // began, and only then shows the error. Like the distance tab (#120), + // every load and destroy() bump _loadGen, so a response or retry from an + // older load never renders, and at most one retry timer exists. + var ANALYTICS_WARMUP_MAX_MS = 120000; + var _loadRetryTimer = null; + var _loadGen = 0; + function _cancelLoadRetry() { + _loadGen++; + if (_loadRetryTimer) { clearTimeout(_loadRetryTimer); _loadRetryTimer = null; } + } var _wardrivingRefreshTimer = null; function _stopWardrivingRefresh() { if (_wardrivingRefreshTimer) { clearInterval(_wardrivingRefreshTimer); _wardrivingRefreshTimer = null; } @@ -302,7 +316,10 @@ var _themeRefreshHandler = null; let _currentTab = 'overview'; - async function loadAnalytics() { + async function loadAnalytics(startedAt) { + _cancelLoadRetry(); + const gen = _loadGen; + if (startedAt === undefined) startedAt = Date.now(); try { _analyticsData = {}; const rqs = RegionFilter.regionQueryString(); // "®ion=..." or "" @@ -323,19 +340,35 @@ // channels: region + window (no area per original PR intent) const chanQS = (rqs + tws).slice(1); const sepChan = chanQS ? '?' + chanQS : ''; + // This load retries 503s itself (retry503:false), see _loadGen. + const opts = { ttl: CLIENT_TTL.analyticsRF, retry503: false }; const [hashData, rfData, topoData, chanData, collisionData, airtimeData] = await Promise.all([ - api('/analytics/hash-sizes' + sepBase, { ttl: CLIENT_TTL.analyticsRF }), - api('/analytics/rf' + sepWin, { ttl: CLIENT_TTL.analyticsRF }), - api('/analytics/topology' + sepWin, { ttl: CLIENT_TTL.analyticsRF }), - api('/analytics/channels' + sepChan, { ttl: CLIENT_TTL.analyticsRF }), - api('/analytics/hash-collisions' + sepBase, { ttl: CLIENT_TTL.analyticsRF }), + api('/analytics/hash-sizes' + sepBase, opts), + api('/analytics/rf' + sepWin, opts), + api('/analytics/topology' + sepWin, opts), + api('/analytics/channels' + sepChan, opts), + api('/analytics/hash-collisions' + sepBase, opts), api('/analytics/relay-airtime-share' + sepWin, { ttl: CLIENT_TTL.analyticsRF }).catch(() => ({ rows: [] })), ]); + if (gen !== _loadGen) return; _analyticsData = { hashData, rfData, topoData, chanData, collisionData, airtimeData }; renderTab(_currentTab); } catch (e) { - document.getElementById('analyticsContent').innerHTML = - ``; + if (gen !== _loadGen) return; + const el = document.getElementById('analyticsContent'); + const ms = _retryAfterDelayMs(e); + if (e && e.status === 503 && Date.now() - startedAt + ms <= ANALYTICS_WARMUP_MAX_MS) { + if (el) el.innerHTML = '
' + + 'Analytics are still loading on the server after a restart.' + + '
Retrying in ' + Math.round(ms / 1000) + 's.
'; + _loadRetryTimer = setTimeout(function () { + _loadRetryTimer = null; + if (gen === _loadGen) loadAnalytics(startedAt); + }, ms); + return; + } + if (el) el.innerHTML = + ``; } } @@ -3010,7 +3043,7 @@ const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF }); if (gen !== _distanceGen) return; // re-rendered, switched tab or left meanwhile if (_distanceIsBuilding(data)) { - const ms = _distanceRetryDelayMs(data); + const ms = _retryAfterDelayMs(data); el.innerHTML = '
' + 'Building the distance index…' + '
This runs once after the server starts. Retrying in ' + Math.round(ms / 1000) + 's.
'; @@ -3101,7 +3134,7 @@ } } -function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _stopWardrivingRefresh(); _stopAreasRefresh(); _leaveDistanceTab(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } +function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTrafficRefresh(); _stopWardrivingRefresh(); _stopAreasRefresh(); _leaveDistanceTab(); _cancelLoadRetry(); _analyticsData = {}; _channelData = null; if (_ngState && _ngState.animId) { cancelAnimationFrame(_ngState.animId); } _ngState = null; if (_themeRefreshHandler) { window.removeEventListener('theme-refresh', _themeRefreshHandler); _themeRefreshHandler = null; } } // Expose for testing if (typeof window !== 'undefined') { diff --git a/public/app.js b/public/app.js index 7b9fef991..1d206899e 100644 --- a/public/app.js +++ b/public/app.js @@ -138,7 +138,7 @@ fetch('/api/config/cache').then(r => r.json()).then(cfg => { if (k in CLIENT_TTL && typeof v === 'number') CLIENT_TTL[k] = v * 1000; } }).catch(() => {}); -async function api(path, { ttl = 0, bust = false } = {}) { +async function api(path, { ttl = 0, bust = false, retry503 = true } = {}) { const t0 = performance.now(); if (!bust && ttl > 0) { const cached = _apiCache.get(path); @@ -166,6 +166,11 @@ async function api(path, { ttl = 0, bust = false } = {}) { // per attempt, decremented at most once) and exhausted-retries // threw without decrementing at all — banner stuck across three // analytics endpoints, multiplied. + // + // #172: retry503:false skips this loop for a caller that retries on + // its own (analytics.js outlasts the server's 60s warm-up and cancels + // its retries on navigation). Every error carries the HTTP status, and + // a 503's valid Retry-After as retryAfterSeconds. let attempt = 0; let delay = 1000; const maxAttempts = 6; @@ -173,7 +178,7 @@ async function api(path, { ttl = 0, bust = false } = {}) { try { while (true) { const res = await fetch('/api' + path); - if (res.status === 503 && attempt < maxAttempts) { + if (res.status === 503 && retry503 && attempt < maxAttempts) { const ra = parseInt(res.headers.get('Retry-After'), 10); const wait = isFinite(ra) && ra > 0 ? ra * 1000 : delay; if (!notified) { _warmupNotify_1659(true); notified = true; } @@ -182,7 +187,13 @@ async function api(path, { ttl = 0, bust = false } = {}) { attempt++; continue; } - if (!res.ok) throw new Error(`API ${res.status}: ${path}`); + if (!res.ok) { + const err = new Error(`API ${res.status}: ${path}`); + err.status = res.status; + const ra = parseInt(res.headers.get('Retry-After'), 10); + if (res.status === 503 && isFinite(ra) && ra > 0) err.retryAfterSeconds = ra; + throw err; + } const data = await res.json(); const ms = performance.now() - t0; _apiPerf.calls++; diff --git a/test-1659-analytics-warmup.js b/test-1659-analytics-warmup.js index 5e2ad796d..d39e9bd37 100644 --- a/test-1659-analytics-warmup.js +++ b/test-1659-analytics-warmup.js @@ -114,8 +114,9 @@ function fakeEl(id) { } // The analytics page on a server whose rf/topology/channels answer 503 + -// Retry-After: 5 until warmMs on the fake clock. -function pageEnv(warmMs) { +// Retry-After: 5 until warmMs on the fake clock, each response after +// latencyMs (0: at once). +function pageEnv(warmMs, latencyMs) { const clock = fakeClock(); const els = {}; const el = (id) => els[id] || (els[id] = fakeEl(id)); @@ -127,17 +128,20 @@ function pageEnv(warmMs) { }); const fetchImpl = async (url) => { fetchLog.push(url); + if (latencyMs) await new Promise((r) => clock.setTimeout(r, latencyMs)); if (/\/api\/analytics\/(rf|topology|channels)\b/.test(url) && clock.now() < warmMs) { return respond(503, { error: 'analytics warming up', retry_after_s: 5 }, '5'); } - if (/\/api\/analytics\/rf\b/.test(url)) return respond(200, WARM_RF); + if (/\/api\/analytics\/rf\b/.test(url)) { + return respond(200, Object.assign({}, WARM_RF, /region=CPH/.test(url) ? { totalTransmissions: 8765 } : {})); + } if (/\/api\/analytics\/topology\b/.test(url)) return respond(200, WARM_TOPO); if (/\/api\/analytics\/channels\b/.test(url)) return respond(200, WARM_CHAN); if (/relay-airtime-share/.test(url)) return respond(200, { rows: [] }); return respond(200, {}); }; class FakeDate extends Date { static now() { return 1790000000000 + clock.now(); } } - let regionCb = null; + let regionCb = null, region = ''; const pages = {}; const ctx = { window: { addEventListener() {}, removeEventListener() {}, dispatchEvent() {} }, @@ -157,7 +161,7 @@ function pageEnv(warmMs) { getComputedStyle: () => ({ getPropertyValue: () => '' }), timeAgo: () => 'x ago', initTabBar() {}, makeColumnsResizable() {}, onWS() {}, offWS() {}, connectWS() {}, invalidateApiCache() {}, IATA_COORDS_GEO: {}, - RegionFilter: { init() {}, onChange: (fn) => { regionCb = fn; }, regionQueryString: () => '' }, + RegionFilter: { init() {}, onChange: (fn) => { regionCb = fn; }, regionQueryString: () => region }, AreaFilter: { init() {}, onChange() {}, areaQueryString: () => '' }, }; vm.createContext(ctx); @@ -169,7 +173,7 @@ function pageEnv(warmMs) { load('public/analytics.js'); return { clock, el, page: pages.analytics, - regionChanged: () => regionCb(), + regionChanged: (r) => { region = r ? '®ion=' + r : ''; regionCb(); }, analyticsFetches: () => fetchLog.filter((u) => /\/api\/analytics\/(rf|topology|channels)\b/.test(u)).length, }; } @@ -394,6 +398,19 @@ function pageEnv(warmMs) { assert.strictEqual(content.dataWrites(), 1, 'the data was rendered ' + content.dataWrites() + ' times (the superseded load rendered too)'); }); + await test('a slow response of a superseded load does not render over the newer one', async () => { + const env = pageEnv(0, 3000); + env.page.init(env.el('app')); // all regions: answers at 3 s + const content = env.el('analyticsContent'); + await env.clock.advance(1000); + env.regionChanged('CPH'); // region CPH: answers at 4 s + await env.clock.advance(2500); + assert.strictEqual(content.dataWrites(), 0, 'the superseded all-regions load rendered: ' + content.innerHTML.slice(0, 120)); + await env.clock.advance(2000); + assert.strictEqual(content.dataWrites(), 1, 'the CPH load did not render once: ' + content.dataWrites()); + assert.ok(/8,765|8765/.test(content.innerHTML), 'not the CPH data: ' + content.innerHTML.slice(0, 200)); + }); + console.log('\n' + passed + ' passed, ' + failed + ' failed'); if (failed > 0) process.exit(1); })(); From 3d13f69bea51f8bfa50e8e09b2f0664c9288a8da Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:17:45 +0200 Subject: [PATCH 03/13] test(nav): require 4.5:1 for the drawer close button in every preset (#172) Step (k) of test-nav-drawer-1064-e2e.js now walks every customizer preset (read from the preset buttons) in the light and the dark theme, checks that the preset's --nav-bg2 is applied, waits for the colour transitions to settle, and requires 4.5:1 on the header background for both the title and the close button (the close button used to need 3:1). Red on master: the close button's --nav-text-muted measures 3.38:1 (forest/light), 4.05:1 (forest/dark), 3.03:1 (sunset/dark) and 3.67:1 (mono/dark). Relates to #172 Co-Authored-By: Claude Opus 5.5 --- test-nav-drawer-1064-e2e.js | 55 +++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/test-nav-drawer-1064-e2e.js b/test-nav-drawer-1064-e2e.js index 414985b9e..86e35138d 100644 --- a/test-nav-drawer-1064-e2e.js +++ b/test-nav-drawer-1064-e2e.js @@ -273,20 +273,44 @@ async function edgeSwipe(page, x0, y0, x1, y1, steps) { await wideCtx.close(); - // (k) #150: the header title (and close button) must be readable on the - // header background in both themes. The light theme used to paint the - // header with --surface-2 (white) under the white --nav-text title. + // (k) #150, #172: the header title and the close button must be readable + // (4.5:1) on the header background in both themes and with every + // customizer preset. The light theme used to paint the header with + // --surface-2 (white) under the white --nav-text title (#150), and the + // close button's --nav-text-muted fell below 4.5:1 on --nav-bg2 in the + // forest, sunset and mono presets (#172). for (const theme of ['light', 'dark']) { - await step(`(k) ${theme} theme: header title ≥ 4.5:1 and close button ≥ 3:1 on the header background`, async () => { - const ctx = await browser.newContext({ viewport: { width: 1024, height: 800 }, colorScheme: theme }); - const p = await ctx.newPage(); - p.setDefaultTimeout(10000); - await p.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); } catch (_) {} }, theme); - try { - await p.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); - await p.waitForFunction(() => !!(window.__navDrawer && window.__navDrawer.open)); + const ctx = await browser.newContext({ viewport: { width: 1024, height: 800 }, colorScheme: theme }); + const p = await ctx.newPage(); + p.setDefaultTimeout(10000); + await p.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); localStorage.removeItem('cs-theme-overrides'); } catch (_) {} }, theme); + let presets = []; + await step(`(k) ${theme} theme: list the customizer presets`, async () => { + await p.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await p.waitForFunction(() => !!(window.__navDrawer && window.__navDrawer.open && window._customizerV2 && window._customizerV2.initDone)); + await p.click('#customizeToggle'); + await p.waitForSelector('.cust-overlay:not(.hidden)'); + const tabBtn = await p.$('.cust-tab[data-tab="theme"]'); + if (tabBtn) await tabBtn.click(); + presets = await p.$$eval('.cust-preset-btn[data-preset]', (els) => els.map((e) => e.getAttribute('data-preset'))); + for (const id of ['default', 'forest', 'sunset', 'mono']) assert(presets.includes(id), 'preset ' + id + ' missing: ' + presets.join(',')); + }); + for (const id of presets) { + await step(`(k) ${theme} / ${id}: header title and close button ≥ 4.5:1 on the header background`, async () => { + await p.$eval(`.cust-preset-btn[data-preset="${id}"]`, (b) => b.click()); + // the preset is applied: --nav-bg2 is the preset's (default: no overrides) + await p.waitForFunction((t) => { + const raw = localStorage.getItem('cs-theme-overrides'); + if (!raw) return true; + const o = JSON.parse(raw); + const want = ((t === 'dark' ? o.themeDark : o.theme) || {}).navBg2; + const got = getComputedStyle(document.documentElement).getPropertyValue('--nav-bg2').trim(); + return !want || got.toLowerCase() === want.toLowerCase(); + }, theme); await p.evaluate(() => window.__navDrawer.open()); await p.waitForSelector('[data-nav-drawer] .nav-drawer-title', { state: 'visible' }); + // measure the settled colours, not a colour transition's midpoint + await p.waitForFunction(() => document.getAnimations().every((a) => a.playState !== 'running')); const r = await p.evaluate(() => { function rgba(s) { const m = /rgba?\(([^)]+)\)/.exec(s); @@ -326,15 +350,18 @@ async function edgeSwipe(page, x0, y0, x1, y1, steps) { theme: document.documentElement.getAttribute('data-theme'), bg: getComputedStyle(header).backgroundColor, title: getComputedStyle(title).color, + close: getComputedStyle(close).color, titleRatio: ratio(rgba(getComputedStyle(title).color), bg), closeRatio: ratio(rgba(getComputedStyle(close).color), backdrop(close)), }; }); + await p.evaluate(() => window.__navDrawer.close()); assert(r.theme === theme, 'data-theme is ' + r.theme + ', want ' + theme); assert(r.titleRatio >= 4.5, `title ${r.title} on header ${r.bg}: ${r.titleRatio.toFixed(2)}:1 < 4.5:1`); - assert(r.closeRatio >= 3, `close button on header ${r.bg}: ${r.closeRatio.toFixed(2)}:1 < 3:1`); - } finally { await ctx.close(); } - }); + assert(r.closeRatio >= 4.5, `close button ${r.close} on header ${r.bg}: ${r.closeRatio.toFixed(2)}:1 < 4.5:1`); + }); + } + await ctx.close(); } // ── Narrow viewport (Option A): drawer disabled ── From 97ff03d580ae0b346b5ea6985e9faa2ec98bd5ac Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:18:33 +0200 Subject: [PATCH 04/13] fix(css): paint the drawer close button with --nav-text (#172) #169 put the drawer header on --nav-bg2. The close button kept --nav-text-muted, which is under 4.5:1 on --nav-bg2 in four presets: forest/light 3.38, forest/dark 4.05, sunset/dark 3.03, mono/dark 3.67. The close button now uses --nav-text, the colour of the title beside it, which passes on the same background in every preset and theme. This is the least invasive fix: one declaration on one control. Changing the four presets' --nav-text-muted instead would also recolour every inactive navbar link and drawer item, and would not protect an operator's own theme. Hover and focus stay visible through the hover background and the focus outline. Relates to #172 Co-Authored-By: Claude Opus 5.5 --- public/nav-drawer.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/public/nav-drawer.css b/public/nav-drawer.css index dd556b299..4decb15a2 100644 --- a/public/nav-drawer.css +++ b/public/nav-drawer.css @@ -91,7 +91,9 @@ background: transparent; border: none; border-radius: 6px; - color: var(--nav-text-muted); + /* --nav-text like the title: --nav-text-muted on --nav-bg2 is under + 4.5:1 in the forest, sunset and mono presets (#172). */ + color: var(--nav-text); font-size: 20px; line-height: 1; cursor: pointer; From 0fe8eb5d051b5f18c0e8529403d85ab384ba875d Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:19:13 +0200 Subject: [PATCH 05/13] test(ingestor): pin the oldest refresh after a partial IATA sweep (#172) TestIATADropThrottleNoResweepAfterPartialSweep fills the throttle table with entries at +0 and +30m, lets a new region at +1h sweep out the +0 half, refills the table, and then sends 100 new regions between +1h and +1h29m. None of them may sweep again: the oldest kept entry (+30m) cannot expire before +1h30m, where exactly one more sweep must run. Green on master, which is correct. Without `t.oldest = oldest` in shouldWarn it fails with 101 sweeps instead of 1; every other IATA test stays green with that line removed. Test only: no production change. Relates to #172 Co-Authored-By: Claude Opus 5.5 --- cmd/ingestor/iata_drop_warn_test.go | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/cmd/ingestor/iata_drop_warn_test.go b/cmd/ingestor/iata_drop_warn_test.go index 8d4b1617c..af57537ac 100644 --- a/cmd/ingestor/iata_drop_warn_test.go +++ b/cmd/ingestor/iata_drop_warn_test.go @@ -251,6 +251,48 @@ func TestIATADropThrottleOldestTracksStaggeredEntries(t *testing.T) { } } +// A sweep that frees only part of the table must leave oldest at the +// oldest entry it kept (#172). Left at the swept entries' time, oldest +// stays expired, and every later drop on the refilled table sweeps the +// whole table again although nothing can expire before the kept entries +// do. +func TestIATADropThrottleNoResweepAfterPartialSweep(t *testing.T) { + var th iataDropThrottle + iv := time.Hour + half := iataWarnMaxTracked / 2 + fillIATA(t, &th, "A", half, t0IATA, iv) + fillIATA(t, &th, "B", iataWarnMaxTracked-half, t0IATA.Add(30*time.Minute), iv) + + // +1h: a partial sweep frees the A half; refill to the cap + if w, o := th.shouldWarn("N1", t0IATA.Add(time.Hour), iv); !w || o { + t.Fatalf("+1h: warn=%v overflow=%v, want an own slot (A entries expired)", w, o) + } + fillIATA(t, &th, "C", half-1, t0IATA.Add(time.Hour), iv) + if len(th.last) != iataWarnMaxTracked || th.sweeps != 1 { + t.Fatalf("after the refill: %d entries after %d sweeps, want %d after 1", len(th.last), th.sweeps, iataWarnMaxTracked) + } + + // +1h..+1h29m: the oldest kept entry (+30m) has not expired, so new + // regions overflow without another sweep + for i := 0; i < 100; i++ { + at := t0IATA.Add(time.Hour + time.Duration(i)*17*time.Second) + if _, o := th.shouldWarn(fmt.Sprintf("X%04d", i), at, iv); !o { + t.Fatalf("X%04d at %v: own slot in a full table of fresh entries", i, at.Sub(t0IATA)) + } + } + if th.sweeps != 1 { + t.Fatalf("%d sweeps before +1h30m, want 1: oldest was not refreshed after the partial sweep", th.sweeps) + } + + // +1h30m: the B half expires; the next new region sweeps once more + if w, o := th.shouldWarn("N2", t0IATA.Add(90*time.Minute), iv); !w || o { + t.Fatalf("+1h30m: warn=%v overflow=%v, want an own slot (B entries expired)", w, o) + } + if th.sweeps != 2 { + t.Fatalf("+1h30m: %d sweeps, want 2", th.sweeps) + } +} + // A drop can carry an earlier time than the current oldest entry (the // caller takes the time before the lock). oldest must drop to it, or that // entry outlives its interval in a full table. From 318a1733ead4826bdca45f4f10bd1ecba9fd4c03 Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:20:20 +0200 Subject: [PATCH 06/13] test(rx-coverage): reproduce a stale observer fit moving the map (#172) Tests 14a-c in test-issue-124-rx-coverage-viewport.js open an observer-only link, so fitToObserver() requests the observer's extent for days=7, and keep that response pending. They then switch days to 30 (14a), pick All (14b) or pick another observer (14c), release the stale response, and require that fitBounds is not called for it. 14c also requires that the newer observer's extent still fits the map. Red on master: in all three the stale extent calls fitBounds. Relates to #172 Co-Authored-By: Claude Opus 5.5 --- test-issue-124-rx-coverage-viewport.js | 62 +++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/test-issue-124-rx-coverage-viewport.js b/test-issue-124-rx-coverage-viewport.js index 147b26d73..8c01b8cd0 100644 --- a/test-issue-124-rx-coverage-viewport.js +++ b/test-issue-124-rx-coverage-viewport.js @@ -11,7 +11,9 @@ * through. Only `rx-coverage-view` is read or written, never the main map's * `map-view`. days/rx stay in the URL as the viewport changes, an * observer-only rx= link still fits that observer, and delayed responses - * never touch a destroyed or replaced page. + * never touch a destroyed or replaced page. A slow response for an earlier + * days, observer or All never overwrites newer data or moves the map + * (#150, #172). */ 'use strict'; const vm = require('vm'); @@ -335,6 +337,64 @@ function assertView(env, want, tag) { assert(/NEWEST/.test(board.innerHTML), 'a failed older leaderboard request replaced the newest one: ' + board.innerHTML); }); + // #172: an observer-only link fits that observer once the map settles; + // the extent request for it (days=7) stays pending. The page's days bar, + // All button and leaderboard rows are wired so the tests can click them. + const extent = (lat) => ({ features: [{ geometry: { coordinates: [[[10, lat], [11, lat + 1]]] } }] }); + async function startFit() { + const env = makeEnv({ hash: '#/rx-coverage?rx=abcdef' }); + const bar = env.sandbox.document.getElementById('rxDays'); + bar.addEventListener = (ev, fn) => { if (ev === 'click') bar.onclick = fn; }; + env.pickDays = (d) => bar.onclick({ target: { closest: () => ({ dataset: { days: String(d) } }) } }); + const all = env.sandbox.document.getElementById('rxAll'); + all.addEventListener = (ev, fn) => { if (ev === 'click') env.clickAll = fn; }; + const rows = ['abcdef', 'bbbbbb'].map((rx) => { + const r = { dataset: { rx, name: rx }, addEventListener(ev, fn) { if (ev === 'click') r.click = fn; } }; + return r; + }); + env.sandbox.document.getElementById('rxBoard').querySelectorAll = (sel) => (/rxb-row/.test(sel) ? rows : []); + env.clickRow = (rx) => rows.find((r) => r.dataset.rx === rx).click(); + await mount(env, { center: [55.68, 12.57], zoom: 9 }); + env.runTimers(); + // the leaderboard renders, which wires All and the rows + const obs = (pubkey) => ({ pubkey, name: pubkey, score: 1, cells: 1, nodes: 1, receptions: 1 }); + assert(env.respond(/rx-leaderboard/, { observers: [obs('abcdef'), obs('bbbbbb')] }), 'no leaderboard request'); + await flush(); + assert(env.pending.some((p) => /bbox=-90,-180,90,180.*days=7&rx=abcdef/.test(p.url)), 'no days=7 extent request for the observer'); + return env; + } + + await test('14a. #172: a pending observer fit is dropped when days changes', async () => { + const env = await startFit(); + env.pickDays(30); + assert(env.respond(/bbox=-90,-180,90,180.*days=7&rx=abcdef/, extent(55))); + await flush(); + assert.strictEqual(env.maps[0].fits.length, 0, 'the stale days=7 extent moved the map after the switch to days=30'); + }); + + await test('14b. #172: a pending observer fit is dropped when All is picked', async () => { + const env = await startFit(); + assert(typeof env.clickAll === 'function', 'All was not wired'); + env.clickAll(); + assert(env.respond(/bbox=-90,-180,90,180.*rx=abcdef/, extent(55))); + await flush(); + assert.strictEqual(env.maps[0].fits.length, 0, 'the stale observer extent moved the map after All'); + }); + + await test('14c. #172: a pending observer fit is dropped when another observer is picked; the newer one fits', async () => { + const env = await startFit(); + env.clickRow('bbbbbb'); + await flush(); + assert(env.pending.some((p) => /bbox=-90,-180,90,180.*rx=bbbbbb/.test(p.url)), 'no extent request for the new observer'); + assert(env.respond(/bbox=-90,-180,90,180.*rx=abcdef/, extent(55))); + await flush(); + assert.strictEqual(env.maps[0].fits.length, 0, 'the stale abcdef extent moved the map after picking bbbbbb'); + assert(env.respond(/bbox=-90,-180,90,180.*rx=bbbbbb/, extent(60))); + await flush(); + assert.strictEqual(env.maps[0].fits.length, 1, 'the bbbbbb extent did not fit the map'); + assert.strictEqual(env.maps[0].fits[0][0][0], 60, 'fitted to the wrong extent: ' + JSON.stringify(env.maps[0].fits)); + }); + await test('11. coverage filtering and leaderboard requests are unchanged', async () => { const env = makeEnv({ hash: '#/rx-coverage?days=14' }); await mount(env, { center: [55.68, 12.57], zoom: 9 }); From 1c510d48ed298bea9561cbbcad2625220341942a Mon Sep 17 00:00:00 2001 From: dborup Date: Fri, 2 Oct 2026 18:20:48 +0200 Subject: [PATCH 07/13] fix(rx-coverage): drop a stale observer fit (#172) fitToObserver() fetches the observer's extent and then calls fitBounds. Only isLive(gen) guarded the response, so switching days, picking All or picking another observer while it was in flight still moved the map to the old request's extent. Same class of bug as item 3 of #150. A fit request now takes a sequence number like coverage and leaderboard (#169) and applies only while it is the latest fit (isLatestFit). A newer fit, a days switch and All bump the sequence, so the stale response (and its error path) does nothing. Relates to #172 Co-Authored-By: Claude Opus 5.5 --- public/rx-coverage.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/public/rx-coverage.js b/public/rx-coverage.js index da1ba9df2..3936a126d 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -14,8 +14,9 @@ // Request sequence per data stream (#150): a coverage or leaderboard // response renders only if no newer request of its kind has started since, // so a slow response for an earlier days (or rx, or viewport) cannot - // overwrite newer data. - var coverageSeq = 0, boardSeq = 0; + // overwrite newer data. An observer fit is dropped the same way when a + // newer fit starts, or when days or All changes what it was for (#172). + var coverageSeq = 0, boardSeq = 0, fitSeq = 0; // Initial viewport (#124), first valid one wins: explicit URL lat/lon/zoom, // this page's own saved view, /api/config/map, then this offline fallback @@ -29,6 +30,7 @@ function isLive(gen) { return !destroyed && gen === generation; } function isLatestCoverage(gen, seq) { return isLive(gen) && seq === coverageSeq; } function isLatestBoard(gen, seq) { return isLive(gen) && seq === boardSeq; } + function isLatestFit(gen, seq) { return isLive(gen) && seq === fitSeq; } // validView returns {lat, lon, zoom} when all three are present, numeric and // in range; anything invalid, partial or out of range gives null. @@ -228,7 +230,7 @@ }); }); var all = document.getElementById('rxAll'); - if (all) all.addEventListener('click', function () { selectedRx = ''; selectedName = ''; renderBoard(); drawCoverage(); syncHash(); }); + if (all) all.addEventListener('click', function () { selectedRx = ''; selectedName = ''; fitSeq++; renderBoard(); drawCoverage(); syncHash(); }); } // fitToObserver zooms the map to the selected observer's full coverage extent @@ -236,10 +238,10 @@ // resulting moveend redraws the hexes at the fitted resolution. function fitToObserver() { if (!map || !selectedRx) { drawCoverage(); return; } - var gen = generation; + var gen = generation, seq = ++fitSeq; var url = '/api/rx-coverage?bbox=-90,-180,90,180&z=' + Math.max(8, map.getZoom()) + '&days=' + days + '&rx=' + encodeURIComponent(selectedRx); fetch(url).then(function (r) { return r.json(); }).then(function (fc) { - if (!isLive(gen) || !map) return; + if (!isLatestFit(gen, seq) || !map) return; var minLat = 90, minLon = 180, maxLat = -90, maxLon = -180, any = false; (fc.features || []).forEach(function (f) { (f.geometry.coordinates[0] || []).forEach(function (c) { @@ -252,7 +254,7 @@ map.fitBounds([[minLat, minLon], [maxLat, maxLon]], { padding: [30, 30], maxZoom: 15 }); drawCoverage(); // fitBounds may not fire moveend if the view is unchanged }).catch(function (e) { - if (!isLive(gen)) return; + if (!isLatestFit(gen, seq)) return; console.warn('rx-coverage: observer extent fetch failed', e); drawCoverage(); }); } @@ -271,6 +273,7 @@ function setDays(d) { days = d; + fitSeq++; // a pending observer fit was for the old days var bar = document.getElementById('rxDays'); if (bar) bar.querySelectorAll('button').forEach(function (b) { b.classList.toggle('active', +b.dataset.days === d); }); loadBoard(); drawCoverage(); syncHash(); From ae27ab350eff64f97b1640b80f4211ed5c1696c0 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 06:02:29 +0000 Subject: [PATCH 08/13] test(analytics): reproduce in-flight sharing across retry503 and tab clicks during warm-up (#172) - api() shares one in-flight promise per path, so a retry503:false caller and a default caller get each other's behaviour (3 tests). - Clicking a data tab while the analytics are still loading throws a TypeError instead of showing the loading state, and the warm-up retries overwrite a tab that fetches its own data (7 tests). - Pin what was untested: a 500/404 fails at once without retries, the retry follows Retry-After clamped to 1..30 s, and the loading state is role="status" (6 tests, green on the current code). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vwXk6z7Bcv1AmN2qd6WSZ --- test-1659-analytics-warmup.js | 184 ++++++++++++++++++++++++++++++++-- 1 file changed, 177 insertions(+), 7 deletions(-) diff --git a/test-1659-analytics-warmup.js b/test-1659-analytics-warmup.js index d39e9bd37..f4c72445f 100644 --- a/test-1659-analytics-warmup.js +++ b/test-1659-analytics-warmup.js @@ -20,10 +20,13 @@ const assert = require('assert'); // Some tests intentionally cause api() to throw; the inflight tracker // inside app.js may surface that as an unhandled rejection a tick later // even after the test has caught it. Swallow them — they're expected -// for the "cap retries" test path. +// for the "cap retries" test path. Any other unhandled rejection (e.g. a +// TypeError from an analytics tab rendered without data, #172) is kept +// so the test that caused it can fail on it. +const unexpectedRejections = []; process.on('unhandledRejection', (e) => { if (e && /API \d+/.test(e.message || '')) return; - throw e; + unexpectedRejections.push(e); }); let passed = 0, failed = 0; @@ -34,7 +37,7 @@ function test(name, fn) { .catch(e => { failed++; console.log(' ❌ ' + name + ': ' + (e && e.message || e)); }); } -function makeCtx(fetchImpl) { +function makeCtx(fetchImpl, clock) { const ctx = { console, Date, Math, Promise, Error, isFinite, parseInt, JSON, performance: { now: () => 0 }, @@ -46,6 +49,8 @@ function makeCtx(fetchImpl) { clearInterval: () => {}, Map, Set, }; + // #172: with a fake clock, backoff sleeps only run when it advances. + if (clock) { ctx.setTimeout = clock.setTimeout; ctx.clearTimeout = clock.clearTimeout; } vm.createContext(ctx); // Provide the no-op fetch('/api/config/cache') hit that app.js makes // on module load by returning a thenable that ignores. @@ -101,13 +106,16 @@ const WARM_CHAN = { activeChannels: 3, decryptable: 1 }; function fakeEl(id) { let html = '', dataWrites = 0; + const listeners = {}; return { id, value: '', style: {}, dataset: {}, parentElement: null, get innerHTML() { return html; }, set innerHTML(v) { html = String(v); if (/Total Transmissions/.test(html)) dataWrites++; }, dataWrites: () => dataWrites, classList: { add() {}, remove() {}, contains: () => false, toggle() {} }, - addEventListener() {}, removeEventListener() {}, + addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, removeEventListener() {}, + dispatch(type, ev) { for (const fn of listeners[type] || []) fn(ev); }, + contains: () => true, querySelector: () => null, querySelectorAll: () => [], appendChild: (c) => c, insertBefore: (c) => c, setAttribute() {}, getAttribute: () => null, }; @@ -115,8 +123,11 @@ function fakeEl(id) { // The analytics page on a server whose rf/topology/channels answer 503 + // Retry-After: 5 until warmMs on the fake clock, each response after -// latencyMs (0: at once). -function pageEnv(warmMs, latencyMs) { +// latencyMs (0: at once). opts.status and opts.retryAfter replace the +// 503 and its Retry-After value. +function pageEnv(warmMs, latencyMs, opts) { + const errStatus = (opts && opts.status) || 503; + const errRetryAfter = opts && 'retryAfter' in opts ? opts.retryAfter : '5'; const clock = fakeClock(); const els = {}; const el = (id) => els[id] || (els[id] = fakeEl(id)); @@ -130,7 +141,7 @@ function pageEnv(warmMs, latencyMs) { fetchLog.push(url); if (latencyMs) await new Promise((r) => clock.setTimeout(r, latencyMs)); if (/\/api\/analytics\/(rf|topology|channels)\b/.test(url) && clock.now() < warmMs) { - return respond(503, { error: 'analytics warming up', retry_after_s: 5 }, '5'); + return respond(errStatus, { error: 'analytics warming up', retry_after_s: 5 }, errRetryAfter); } if (/\/api\/analytics\/rf\b/.test(url)) { return respond(200, Object.assign({}, WARM_RF, /region=CPH/.test(url) ? { totalTransmissions: 8765 } : {})); @@ -174,6 +185,11 @@ function pageEnv(warmMs, latencyMs) { return { clock, el, page: pages.analytics, regionChanged: (r) => { region = r ? '®ion=' + r : ''; regionCb(); }, + // A click on the analytics tab button for `tab`, as the browser sends it. + clickTab: (tab) => { + const btn = { dataset: { tab }, classList: { add() {}, remove() {} }, setAttribute() {} }; + el('analyticsTabs').dispatch('click', { target: { closest: () => btn } }); + }, analyticsFetches: () => fetchLog.filter((u) => /\/api\/analytics\/(rf|topology|channels)\b/.test(u)).length, }; } @@ -411,6 +427,160 @@ function pageEnv(warmMs, latencyMs) { assert.ok(/8,765|8765/.test(content.innerHTML), 'not the CPH data: ' + content.innerHTML.slice(0, 200)); }); + console.log('\n=== #172 r2: in-flight sharing respects retry503 ==='); + + // One request per path is shared while in flight, but a retry503:false + // caller (analytics.js) and a default caller want different promises: + // the first must see the 503 at once, the second must ride out the + // warm-up. rf answers 503 + Retry-After: 5 until 10 s on the fake clock. + function inflightEnv() { + const clock = fakeClock(); + let rfFetches = 0; + const ctx = makeCtx(async (url) => { + if (!/analytics\/rf/.test(url || '')) { + return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) }; + } + rfFetches++; + if (clock.now() < 10000) { + return { ok: false, status: 503, headers: { get: (k) => (k.toLowerCase() === 'retry-after' ? '5' : null) }, json: async () => ({}) }; + } + return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ totalPackets: 7 }) }; + }, clock); + const track = (p) => { + const r = { settled: false, value: undefined, error: undefined }; + p.then((v) => { r.settled = true; r.value = v; }, (e) => { r.settled = true; r.error = e; }); + return r; + }; + return { clock, ctx, track, rfFetches: () => rfFetches }; + } + + await test('a default caller does not join a retry503:false request and gets the data', async () => { + const env = inflightEnv(); + const noRetry = env.track(env.ctx.api('/analytics/rf', { retry503: false })); + const dflt = env.track(env.ctx.api('/analytics/rf')); + await env.clock.advance(1000); + assert.ok(noRetry.settled && noRetry.error && noRetry.error.status === 503, + 'the retry503:false caller did not get the 503 at once'); + assert.ok(!dflt.settled, 'the default caller settled after 1 s: ' + (dflt.error ? dflt.error.message : JSON.stringify(dflt.value))); + await env.clock.advance(30000); + assert.ok(dflt.settled && !dflt.error, 'the default caller failed: ' + (dflt.error && dflt.error.message)); + assert.deepStrictEqual(dflt.value, { totalPackets: 7 }); + }); + + await test('a retry503:false caller does not join a retrying request and gets the 503 at once', async () => { + const env = inflightEnv(); + const dflt = env.track(env.ctx.api('/analytics/rf')); + await env.clock.advance(100); // the default call is now sleeping on Retry-After + const noRetry = env.track(env.ctx.api('/analytics/rf', { retry503: false })); + await env.clock.advance(100); + assert.ok(noRetry.settled, 'the retry503:false caller is waiting on the retrying request'); + assert.ok(noRetry.error && noRetry.error.status === 503, 'not a 503 error: ' + (noRetry.error && noRetry.error.message)); + await env.clock.advance(30000); + assert.ok(dflt.settled && !dflt.error, 'the default caller failed: ' + (dflt.error && dflt.error.message)); + }); + + await test('callers with the same retry503 still share one request', async () => { + const env = inflightEnv(); + await env.clock.advance(10000); // warm: one 200 + const a = env.ctx.api('/analytics/rf', { retry503: false }); + const b = env.ctx.api('/analytics/rf', { retry503: false }); + const c = env.ctx.api('/analytics/rf'); + const d = env.ctx.api('/analytics/rf'); + await env.clock.advance(100); + await Promise.all([a, b, c, d]); + assert.strictEqual(env.rfFetches(), 2, 'expected one fetch per retry503 value, got ' + env.rfFetches()); + }); + + console.log('\n=== #172 r2: what the analytics page does with an error ==='); + + for (const status of [500, 404]) { + await test('a ' + status + ' shows the error at once and is not retried', async () => { + const env = pageEnv(Infinity, 0, { status, retryAfter: null }); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + await env.clock.advance(1000); + assert.ok(/Failed to load/.test(content.innerHTML), 'no error after 1 s: ' + content.innerHTML.slice(0, 200)); + assert.ok(!/still loading/i.test(content.innerHTML), 'a ' + status + ' is shown as "still loading"'); + const fetches = env.analyticsFetches(); + await env.clock.advance(130000); + assert.strictEqual(env.analyticsFetches(), fetches, 'a ' + status + ' was retried'); + assert.strictEqual(env.clock.pending().length, 0, 'retry timers pending after a ' + status); + }); + } + + // The retry follows the server's Retry-After, clamped to 1..30 s, and the + // status line says so (role="status", not an alert). + for (const [ra, wantMs] of [['1', 1000], ['12', 12000], ['60', 30000], ['abc', 5000]]) { + await test('a 503 with Retry-After: ' + ra + ' retries after ' + wantMs / 1000 + ' s', async () => { + const env = pageEnv(Infinity, 0, { retryAfter: ra }); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + await env.clock.advance(1); + const timers = env.clock.pending(); + assert.strictEqual(timers.length, 1, timers.length + ' timers pending after the first 503'); + assert.strictEqual(timers[0].at - env.clock.now(), wantMs, 'retry timer'); + assert.ok(/role="status"/.test(content.innerHTML), 'the loading state is not role="status": ' + content.innerHTML.slice(0, 200)); + assert.ok(!/role="alert"/.test(content.innerHTML), 'the loading state is an alert'); + assert.ok(content.innerHTML.includes('Retrying in ' + wantMs / 1000 + 's'), 'status text: ' + content.innerHTML.slice(0, 300)); + const fetches = env.analyticsFetches(); + await env.clock.advance(wantMs - 1); + assert.strictEqual(env.analyticsFetches(), fetches, 'retried before ' + wantMs + ' ms'); + await env.clock.advance(1); + assert.ok(env.analyticsFetches() > fetches, 'not retried at ' + wantMs + ' ms'); + }); + } + + console.log('\n=== #172 r2: tab clicks while the analytics are still loading ==='); + + // These six tabs render from the shared load (_analyticsData). The other + // tabs fetch their own data and are covered by the browser check. + const DATA_TABS = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']; + for (const tab of DATA_TABS) { + await test('clicking "' + tab + '" during the warm-up shows the loading state, then that tab\'s data', async () => { + const env = pageEnv(45000); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + await env.clock.advance(6000); + const before = unexpectedRejections.length; + env.clickTab(tab); + await env.clock.advance(100); + const errs = unexpectedRejections.splice(before); + assert.strictEqual(errs.length, 0, 'the click threw: ' + errs.map((e) => e && e.message).join('; ')); + assert.ok(/still loading/i.test(content.innerHTML) && /role="status"/.test(content.innerHTML), + 'no loading state after the click: ' + content.innerHTML.slice(0, 200)); + await env.clock.advance(15000); // a retry while the tab is shown + assert.ok(/still loading/i.test(content.innerHTML), 'loading state lost on a retry: ' + content.innerHTML.slice(0, 200)); + await env.clock.advance(40000); + const late = unexpectedRejections.splice(before); + assert.strictEqual(late.length, 0, 'rendering threw: ' + late.map((e) => e && e.message).join('; ')); + assert.ok(!/still loading|Failed to load/i.test(content.innerHTML), 'not rendered after the warm-up: ' + content.innerHTML.slice(0, 200)); + if (tab === 'overview') assert.ok(/Total Transmissions/.test(content.innerHTML), 'not the overview'); + if (tab !== 'overview') assert.ok(!/Total Transmissions/.test(content.innerHTML), 'the overview was rendered instead of ' + tab); + }); + } + + await test('a tab that fetches its own data is not overwritten by the loading state', async () => { + const env = pageEnv(45000); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + await env.clock.advance(6000); + // Route patterns fetches its own data; a click makes it the current + // tab. What it renders in this fake DOM does not matter: a marker + // stands in for it, and the warm-up retries must leave it alone. + const before = unexpectedRejections.length; + env.clickTab('subpaths'); + await env.clock.advance(100); + unexpectedRejections.splice(before); + content.innerHTML = '
own data
'; + await env.clock.advance(20000); // several 503 retries + assert.ok(/own-tab/.test(content.innerHTML), 'the loading state replaced the selected tab: ' + content.innerHTML.slice(0, 200)); + }); + + if (unexpectedRejections.length) { + failed++; + console.log(' ❌ unexpected unhandled rejections: ' + unexpectedRejections.map((e) => e && e.message).join('; ')); + } + console.log('\n' + passed + ' passed, ' + failed + ' failed'); if (failed > 0) process.exit(1); })(); From 45d9e85534863ac1ff9ad4d52ec55f9ed387779d Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 06:05:04 +0000 Subject: [PATCH 09/13] test(analytics): render each data tab from real response shapes (#172) The tab-click tests ran the tabs on the overview's minimal stub, so a correct guard still failed inside renderRF/renderTopology/... on data those tabs never get from the server. They now use the CI fixture server's real responses for the five shared endpoints (arrays cut to two items, node names and keys anonymised) and check that the clicked tab, not the overview, renders after the warm-up. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vwXk6z7Bcv1AmN2qd6WSZ --- test-1659-analytics-warmup.js | 23 ++++++++++++++++++----- test-fixtures/analytics-tabs-172.json | 1 + 2 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 test-fixtures/analytics-tabs-172.json diff --git a/test-1659-analytics-warmup.js b/test-1659-analytics-warmup.js index f4c72445f..0e151c8b6 100644 --- a/test-1659-analytics-warmup.js +++ b/test-1659-analytics-warmup.js @@ -103,6 +103,10 @@ const WARM_RF = { }; const WARM_TOPO = { uniqueNodes: 42, avgHops: 1.5, maxHops: 5, hopDistribution: [] }; const WARM_CHAN = { activeChannels: 3, decryptable: 1 }; +// #172 r2: real responses of the CI fixture server for the five shared +// endpoints, arrays cut to two items, names and keys anonymised. The +// tab tests need every tab's real shape, not just the overview's. +const REAL = JSON.parse(fs.readFileSync('test-fixtures/analytics-tabs-172.json', 'utf8')); function fakeEl(id) { let html = '', dataWrites = 0; @@ -124,10 +128,11 @@ function fakeEl(id) { // The analytics page on a server whose rf/topology/channels answer 503 + // Retry-After: 5 until warmMs on the fake clock, each response after // latencyMs (0: at once). opts.status and opts.retryAfter replace the -// 503 and its Retry-After value. +// 503 and its Retry-After value; opts.realData answers with REAL. function pageEnv(warmMs, latencyMs, opts) { const errStatus = (opts && opts.status) || 503; const errRetryAfter = opts && 'retryAfter' in opts ? opts.retryAfter : '5'; + const realData = !!(opts && opts.realData); const clock = fakeClock(); const els = {}; const el = (id) => els[id] || (els[id] = fakeEl(id)); @@ -143,6 +148,11 @@ function pageEnv(warmMs, latencyMs, opts) { if (/\/api\/analytics\/(rf|topology|channels)\b/.test(url) && clock.now() < warmMs) { return respond(errStatus, { error: 'analytics warming up', retry_after_s: 5 }, errRetryAfter); } + if (realData) { + const m = /\/api\/analytics\/(rf|topology|channels|hash-sizes|hash-collisions)\b/.exec(url); + const key = m && { rf: 'rfData', topology: 'topoData', channels: 'chanData', 'hash-sizes': 'hashData', 'hash-collisions': 'collisionData' }[m[1]]; + if (key) return respond(200, REAL[key]); + } if (/\/api\/analytics\/rf\b/.test(url)) { return respond(200, Object.assign({}, WARM_RF, /region=CPH/.test(url) ? { totalTransmissions: 8765 } : {})); } @@ -534,10 +544,13 @@ function pageEnv(warmMs, latencyMs, opts) { // These six tabs render from the shared load (_analyticsData). The other // tabs fetch their own data and are covered by the browser check. - const DATA_TABS = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']; - for (const tab of DATA_TABS) { + const DATA_TABS = { + overview: /Total Transmissions/, rf: /SNR Distribution/, topology: /Hop Count Distribution/, + channels: /Channel Activity/, hashsizes: /Hash Size Distribution/, collisions: /hashMatrixTitle/, + }; + for (const [tab, marker] of Object.entries(DATA_TABS)) { await test('clicking "' + tab + '" during the warm-up shows the loading state, then that tab\'s data', async () => { - const env = pageEnv(45000); + const env = pageEnv(45000, 0, { realData: true }); env.page.init(env.el('app')); const content = env.el('analyticsContent'); await env.clock.advance(6000); @@ -554,7 +567,7 @@ function pageEnv(warmMs, latencyMs, opts) { const late = unexpectedRejections.splice(before); assert.strictEqual(late.length, 0, 'rendering threw: ' + late.map((e) => e && e.message).join('; ')); assert.ok(!/still loading|Failed to load/i.test(content.innerHTML), 'not rendered after the warm-up: ' + content.innerHTML.slice(0, 200)); - if (tab === 'overview') assert.ok(/Total Transmissions/.test(content.innerHTML), 'not the overview'); + assert.ok(marker.test(content.innerHTML), 'not the ' + tab + ' tab: ' + content.innerHTML.slice(0, 200)); if (tab !== 'overview') assert.ok(!/Total Transmissions/.test(content.innerHTML), 'the overview was rendered instead of ' + tab); }); } diff --git a/test-fixtures/analytics-tabs-172.json b/test-fixtures/analytics-tabs-172.json new file mode 100644 index 000000000..b9d368f24 --- /dev/null +++ b/test-fixtures/analytics-tabs-172.json @@ -0,0 +1 @@ +{"rfData":{"avgPacketSize":64,"maxPacketSize":242,"minPacketSize":12,"packetSizes":{"bins":[{"count":9,"w":9.2,"x":12},{"count":157,"w":9.2,"x":21.2}],"max":242,"min":12},"packetsPerHour":[{"hour":"2026-05-15T00","count":1},{"hour":"2026-10-03T03","count":14}],"payloadTypes":[{"type":5,"name":"Node 1","count":167},{"type":4,"name":"Node 2","count":102}],"rssi":{"avg":-92.33333333333333,"max":-90,"median":-92,"min":-95,"stddev":2.0548046676563256},"rssiValues":{"bins":[{"count":1,"w":0.25,"x":-95},{"count":0,"w":0.25,"x":-94.75}],"max":-90,"min":-95},"scatterData":[{"snr":5,"rssi":-95},{"snr":5.5,"rssi":-92}],"signalOverTime":[{"hour":"2026-05-15T00","count":3,"avgSnr":5.5}],"snr":{"avg":5.5,"max":6,"median":5.5,"min":5,"stddev":0.408248290463863},"snrByType":[{"name":"Node 1","count":3,"avg":5.5,"min":5,"max":6}],"snrValues":{"bins":[{"count":1,"w":0.05,"x":5},{"count":0,"w":0.05,"x":5.05}],"max":6,"min":5},"timeSpanHours":3390.0602777777776,"totalAllPackets":502,"totalPackets":3,"totalTransmissions":500},"topoData":{"avgHops":7.3375,"bestPathList":[{"hop":"3957","minDist":1,"name":"Node 3","observer_id":"63538D0B8F8607168388DAF7FAB21468C36193D95DA7646F66E78FCFE407AC93","observer_name":"Node 4","pubkey":"39cfb3d583eee85c367aee5318cedecc1b88c86b96ebf8827864a08e77f6eddf"},{"hop":"04","minDist":1,"name":"Node 5","observer_id":"B71892E2BE5EE20F27B6CF8D1014F09C5E9C278CF3033F6A28D6F3430CE04523","observer_name":"Node 6","pubkey":"bbcbb8b3071f79fb869bf7e31403eda1dd0c040236386fc0f72ff83fa61f5246"}],"hopDistribution":[{"count":29,"hops":1},{"count":9,"hops":2}],"hopsVsSnr":[{"avgSnr":5,"count":1,"hops":1}],"maxHops":37,"medianHops":7,"multiObsNodes":[{"hop":"10","name":"Node 7","observers":[{"count":4,"minDist":1,"observer_id":"5162D84286F5226AE22F0CB0B015C2D4AFC5D59DEB388B45491AEF7C7B6010FF","observer_name":"Node 8"},{"count":1,"minDist":1,"observer_id":"D6D6FD7889A0EC8756CBF0EC74CD0CD4CC756CBA709439835DA730C34EFA5B20","observer_name":"Node 9"}],"pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da"},{"hop":"20","name":"Node 10","observers":[{"count":3,"minDist":1,"observer_id":"5162D84286F5226AE22F0CB0B015C2D4AFC5D59DEB388B45491AEF7C7B6010FF","observer_name":"Node 8"},{"count":8,"minDist":1,"observer_id":"753BCF9C2B7F1E40CA7DA215FCF077AB63547982B68FBA3E143381C105D7381F","observer_name":"Node 11"}],"pubkey":"31ec23e3dcde0d51fa936d86e1530cbc263bc4f3dd2a71b4c1232973631322d0"}],"observers":[{"id":"7D31532B52A5EA780B80359916B469E9CCA1D9FA2D0D015D3DD67E36CB35E88A","name":"Node 12"},{"id":"A7656605AC8F0AE03E6D219E84AC6690F7BAF7F0EE8E444A70898646BC58CB1F","name":"Node 13"}],"perObserverReach":{"0EA31493D2EBABFA472EEAC745923AFB241869038BB048E432C3BDE7F4CC2C0E":{"observer_name":"Node 14","rings":[{"hops":1,"nodes":[{"count":7,"distRange":"1-3","hop":"30","name":"Node 15","pubkey":"03363cdac563771cdfe37340213e46515911efeee712a4db50f6fba6ab608071"},{"count":4,"distRange":"1-6","hop":"1C","name":null,"pubkey":null}]},{"hops":2,"nodes":[{"count":4,"distRange":"2-4","hop":"9B","name":"Node 16","pubkey":"96a32f6dd9386af934894f123b9a560da004e857fc09942446178ccedafcf3a3"},{"count":4,"distRange":"2-3","hop":"57","name":"Node 17","pubkey":"9044c66c0a61da8a7c714b85a065014df81191351b2e4a1d4184b4f209b76d63"}]}]},"1E598D0F11783A33C04D701FF3151FD8D8C0A96686D88267DF70993F59EF760C":{"observer_name":"Node 18","rings":[{"hops":1,"nodes":[{"count":6,"distRange":"1-4","hop":"A3","name":"Node 19","pubkey":"6091d74d0644c60b4341f0018f1cdcc6346c41154a2a5defc5987ae0bfd56b34"},{"count":4,"distRange":"1-2","hop":"F8","name":"Node 20","pubkey":"8b5334f07a400a08cda720c7b2a27ad562f2e6312de9bcde5dfe940da802d807"}]},{"hops":2,"nodes":[{"count":2,"distRange":"2-3","hop":"EC","name":"Node 12","pubkey":"7d31532b52a5ea780b80359916b469e9cca1d9fa2d0d015d3dd67e36cb35e88a"},{"count":2,"distRange":"2-3","hop":"BE","name":null,"pubkey":null}]}]}},"topPairs":[{"count":38,"hopA":"33","hopB":"8A","nameA":"Node 21","nameB":null,"pubkeyA":"83a248387ffe78a3b2388e1bb62094e2546e359e11a470acf99350442500fc58","pubkeyB":null},{"count":38,"hopA":"33","hopB":"D6","nameA":"Node 21","nameB":null,"pubkeyA":"83a248387ffe78a3b2388e1bb62094e2546e359e11a470acf99350442500fc58","pubkeyB":null}],"topRepeaters":[{"count":220,"hop":"1000","name":"Node 7","pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da"},{"count":94,"hop":"D6","name":null,"pubkey":null}],"uniqueNodes":202},"chanData":{"activeChannels":20,"channelTimeline":[{"channel":"ch?","count":1,"hour":"2026-05-15T00"},{"channel":"#test","count":2,"hour":"2026-10-03T03"}],"channels":[{"encrypted":false,"hash":"217","lastActivity":"2026-10-03T03:56:07Z","messages":37,"name":"#test","senders":8},{"encrypted":true,"hash":"195","lastActivity":"2026-10-03T04:01:47Z","messages":26,"name":"Node 22","senders":0}],"decryptable":5,"msgLengths":[13,51],"topSenders":[{"count":26,"name":"Node 23"},{"count":13,"name":"Node 24"}]},"hashData":{"attributionMethod":"observer","distribution":{"1":365,"2":34,"3":1},"distributionByRepeaters":{"0":10,"1":46,"2":20,"3":0},"hourly":[{"1":1,"2":0,"3":0,"hour":"2026-05-15T00"},{"1":13,"2":1,"3":0,"hour":"2026-10-03T03"}],"multiByteCapability":[{"pubkey":"e828bfc7bdee3c1662d12104288bfbdd180fb2a8f11339e69d616a2410fb191a","name":"Node 25","role":"repeater","status":"confirmed","evidence":"advert","maxHashSize":2,"lastSeen":"2026-10-03T05:23:33Z"},{"pubkey":"4819920bf01f889c17af1d5297e3870d029cfda6cbdf8f15bcb08d1dbc2b2e7e","name":"Node 26","role":"repeater","status":"confirmed","evidence":"advert","maxHashSize":2,"lastSeen":"2026-10-03T05:27:31Z"}],"multiByteNodes":[{"hashSize":2,"lastSeen":"2026-10-03T05:25:39Z","name":"Node 20","packets":2,"pubkey":"8b5334f07a400a08cda720c7b2a27ad562f2e6312de9bcde5dfe940da802d807","role":"repeater"},{"hashSize":2,"lastSeen":"2026-10-03T04:52:26Z","name":"Node 27","packets":1,"pubkey":"5c23812892dc17ca7a4910845f7bb69dba26b2568ff73476c1d2515e44dbc8d6","role":"repeater"}],"topHops":[{"count":195,"hex":"10","name":"Node 7","pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da","size":1},{"count":94,"hex":"D6","name":null,"pubkey":null,"size":1}],"total":400},"collisionData":{"by_size":{"1":{"collisions":[],"one_byte_cells":{"00":[],"01":[]},"stats":{"collision_count":0,"nodes_for_byte":46,"pct_used":17.96875,"space_size":256,"total_nodes":202,"unique_prefixes":46,"using_this_size":59}},"2":{"collisions":[],"stats":{"collision_count":0,"nodes_for_byte":20,"pct_used":0.030517578125,"space_size":65536,"total_nodes":202,"unique_prefixes":20,"using_this_size":21},"two_byte_cells":{"00":{"group_nodes":[],"two_byte_map":{},"max_collision":0,"collision_count":0},"01":{"group_nodes":[],"two_byte_map":{},"max_collision":0,"collision_count":0}}},"3":{"collisions":[],"stats":{"collision_count":0,"nodes_for_byte":0,"pct_used":0,"space_size":16777216,"total_nodes":202,"unique_prefixes":0,"using_this_size":0}}},"inconsistent_nodes":[]}} \ No newline at end of file From fe88e842cfd2ac73575821c91ce76eb31711f2d1 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 06:05:04 +0000 Subject: [PATCH 10/13] fix(app): share an in-flight request only between callers with the same retry503 (#172) _inflight was keyed on the path alone. A default caller that joined a retry503:false request got its 503 after one attempt, and a retry503:false caller that joined a retrying request waited out the whole retry loop (up to ~31 s) without the cancellation analytics.js relies on. The key now includes the flag; default callers keep the bare path as their key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vwXk6z7Bcv1AmN2qd6WSZ --- public/app.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/public/app.js b/public/app.js index 1d206899e..960be42b3 100644 --- a/public/app.js +++ b/public/app.js @@ -150,8 +150,11 @@ async function api(path, { ttl = 0, bust = false, retry503 = true } = {}) { return cached.data; } } - // Deduplicate in-flight requests - if (_inflight.has(path)) return _inflight.get(path); + // Deduplicate in-flight requests. #172: per retry503 too, so a caller + // that retries 503s itself never waits on the retry loop below, and a + // default caller never gets a 503 that loop would have ridden out. + const inflightKey = retry503 ? path : path + '\n#no-retry503'; + if (_inflight.has(inflightKey)) return _inflight.get(inflightKey); const promise = (async () => { // Issue #1659: 503 with Retry-After indicates server-side warm-up // (analytics recomputer first-pass, index build, etc.). Retry with @@ -222,14 +225,14 @@ async function api(path, { ttl = 0, bust = false, retry503 = true } = {}) { if (notified) _warmupNotify_1659(false); } })(); - _inflight.set(path, promise); + _inflight.set(inflightKey, promise); // `.finally()` returns its own derived promise that mirrors `promise`'s // outcome; discarding it uncaught leaves the real caller's rejection // (delivered via the returned `promise` below, unaffected by this) // duplicated as a second, unobserved rejection on this derived one. // The `.catch()` here only silences that duplicate -- it does not // touch `promise` itself or its resolution to callers. - promise.finally(() => _inflight.delete(path)).catch(() => {}); + promise.finally(() => _inflight.delete(inflightKey)).catch(() => {}); return promise; } From d7cd59b2b363ab529ab354129869d3ff58b6bf11 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 06:05:04 +0000 Subject: [PATCH 11/13] fix(analytics): show the load status on a data tab clicked during loading (#172) While the shared load had no data, a click on Overview, RF, Topology, Channels, Hash Stats or Hash Issues rendered from an empty _analyticsData and threw an uncaught TypeError (snrValues, ...). The window is now up to 120 s instead of ~30 s. renderTab() now shows the load's current status (loading, still loading or the error) on those six tabs until the data is there, and the load renders the selected tab when it is. The load writes its status only while one of those tabs is shown, so its 503 retries no longer overwrite a tab that fetches its own data. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vwXk6z7Bcv1AmN2qd6WSZ --- public/analytics.js | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 8de22de37..bc8b1dcff 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -68,6 +68,18 @@ var ANALYTICS_WARMUP_MAX_MS = 120000; var _loadRetryTimer = null; var _loadGen = 0; + // #172: the tabs that render from that load's _analyticsData. Until it + // has data, they show the load's status instead (a click during the + // warm-up threw a TypeError), and the status is written only while one + // of them is shown, never over a tab that fetches its own data. + var LOAD_TABS = new Set(['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions']); + var LOADING_HTML = '
Loading analytics…
'; + var _loadStatusHtml = LOADING_HTML; + function _showLoadStatus(html) { + _loadStatusHtml = html; + var el = LOAD_TABS.has(_currentTab) && document.getElementById('analyticsContent'); + if (el) el.innerHTML = html; + } function _cancelLoadRetry() { _loadGen++; if (_loadRetryTimer) { clearTimeout(_loadRetryTimer); _loadRetryTimer = null; } @@ -187,7 +199,7 @@
-
Loading analytics…
+ ${LOADING_HTML}
`; @@ -319,7 +331,7 @@ async function loadAnalytics(startedAt) { _cancelLoadRetry(); const gen = _loadGen; - if (startedAt === undefined) startedAt = Date.now(); + if (startedAt === undefined) { startedAt = Date.now(); _loadStatusHtml = LOADING_HTML; } try { _analyticsData = {}; const rqs = RegionFilter.regionQueryString(); // "®ion=..." or "" @@ -355,20 +367,18 @@ renderTab(_currentTab); } catch (e) { if (gen !== _loadGen) return; - const el = document.getElementById('analyticsContent'); const ms = _retryAfterDelayMs(e); if (e && e.status === 503 && Date.now() - startedAt + ms <= ANALYTICS_WARMUP_MAX_MS) { - if (el) el.innerHTML = '
' + + _showLoadStatus('
' + 'Analytics are still loading on the server after a restart.' + - '
Retrying in ' + Math.round(ms / 1000) + 's.
'; + '
Retrying in ' + Math.round(ms / 1000) + 's.
'); _loadRetryTimer = setTimeout(function () { _loadRetryTimer = null; if (gen === _loadGen) loadAnalytics(startedAt); }, ms); return; } - if (el) el.innerHTML = - ``; + _showLoadStatus(``); } } @@ -389,6 +399,7 @@ async function renderTab(tab) { const el = document.getElementById('analyticsContent'); const d = _analyticsData; + if (LOAD_TABS.has(tab) && !d.rfData) { el.innerHTML = _loadStatusHtml; return; } switch (tab) { case 'overview': renderOverview(el, d); break; case 'rf': renderRF(el, d.rfData); break; From 837abe98ae55fb22b4db2b840803497baabf974a Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 08:05:27 +0000 Subject: [PATCH 12/13] test(analytics): make the two perObserverReach keys in the tab fixture synthetic (#172) The fixture still had two real observer pubkeys from e2e-fixture.db as object keys in topoData.perObserverReach. They are now synthetic 64-hex keys like the other ids. The values, the key order and the shape are unchanged. A scan of every key and value against nodes, observers, transmissions (hash, from_pubkey, decoded_json) and neighbor_edges finds no real ids or names left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdKwzUPntKQHjQEKpMXefc --- test-fixtures/analytics-tabs-172.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-fixtures/analytics-tabs-172.json b/test-fixtures/analytics-tabs-172.json index b9d368f24..60bb9a0db 100644 --- a/test-fixtures/analytics-tabs-172.json +++ b/test-fixtures/analytics-tabs-172.json @@ -1 +1 @@ -{"rfData":{"avgPacketSize":64,"maxPacketSize":242,"minPacketSize":12,"packetSizes":{"bins":[{"count":9,"w":9.2,"x":12},{"count":157,"w":9.2,"x":21.2}],"max":242,"min":12},"packetsPerHour":[{"hour":"2026-05-15T00","count":1},{"hour":"2026-10-03T03","count":14}],"payloadTypes":[{"type":5,"name":"Node 1","count":167},{"type":4,"name":"Node 2","count":102}],"rssi":{"avg":-92.33333333333333,"max":-90,"median":-92,"min":-95,"stddev":2.0548046676563256},"rssiValues":{"bins":[{"count":1,"w":0.25,"x":-95},{"count":0,"w":0.25,"x":-94.75}],"max":-90,"min":-95},"scatterData":[{"snr":5,"rssi":-95},{"snr":5.5,"rssi":-92}],"signalOverTime":[{"hour":"2026-05-15T00","count":3,"avgSnr":5.5}],"snr":{"avg":5.5,"max":6,"median":5.5,"min":5,"stddev":0.408248290463863},"snrByType":[{"name":"Node 1","count":3,"avg":5.5,"min":5,"max":6}],"snrValues":{"bins":[{"count":1,"w":0.05,"x":5},{"count":0,"w":0.05,"x":5.05}],"max":6,"min":5},"timeSpanHours":3390.0602777777776,"totalAllPackets":502,"totalPackets":3,"totalTransmissions":500},"topoData":{"avgHops":7.3375,"bestPathList":[{"hop":"3957","minDist":1,"name":"Node 3","observer_id":"63538D0B8F8607168388DAF7FAB21468C36193D95DA7646F66E78FCFE407AC93","observer_name":"Node 4","pubkey":"39cfb3d583eee85c367aee5318cedecc1b88c86b96ebf8827864a08e77f6eddf"},{"hop":"04","minDist":1,"name":"Node 5","observer_id":"B71892E2BE5EE20F27B6CF8D1014F09C5E9C278CF3033F6A28D6F3430CE04523","observer_name":"Node 6","pubkey":"bbcbb8b3071f79fb869bf7e31403eda1dd0c040236386fc0f72ff83fa61f5246"}],"hopDistribution":[{"count":29,"hops":1},{"count":9,"hops":2}],"hopsVsSnr":[{"avgSnr":5,"count":1,"hops":1}],"maxHops":37,"medianHops":7,"multiObsNodes":[{"hop":"10","name":"Node 7","observers":[{"count":4,"minDist":1,"observer_id":"5162D84286F5226AE22F0CB0B015C2D4AFC5D59DEB388B45491AEF7C7B6010FF","observer_name":"Node 8"},{"count":1,"minDist":1,"observer_id":"D6D6FD7889A0EC8756CBF0EC74CD0CD4CC756CBA709439835DA730C34EFA5B20","observer_name":"Node 9"}],"pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da"},{"hop":"20","name":"Node 10","observers":[{"count":3,"minDist":1,"observer_id":"5162D84286F5226AE22F0CB0B015C2D4AFC5D59DEB388B45491AEF7C7B6010FF","observer_name":"Node 8"},{"count":8,"minDist":1,"observer_id":"753BCF9C2B7F1E40CA7DA215FCF077AB63547982B68FBA3E143381C105D7381F","observer_name":"Node 11"}],"pubkey":"31ec23e3dcde0d51fa936d86e1530cbc263bc4f3dd2a71b4c1232973631322d0"}],"observers":[{"id":"7D31532B52A5EA780B80359916B469E9CCA1D9FA2D0D015D3DD67E36CB35E88A","name":"Node 12"},{"id":"A7656605AC8F0AE03E6D219E84AC6690F7BAF7F0EE8E444A70898646BC58CB1F","name":"Node 13"}],"perObserverReach":{"0EA31493D2EBABFA472EEAC745923AFB241869038BB048E432C3BDE7F4CC2C0E":{"observer_name":"Node 14","rings":[{"hops":1,"nodes":[{"count":7,"distRange":"1-3","hop":"30","name":"Node 15","pubkey":"03363cdac563771cdfe37340213e46515911efeee712a4db50f6fba6ab608071"},{"count":4,"distRange":"1-6","hop":"1C","name":null,"pubkey":null}]},{"hops":2,"nodes":[{"count":4,"distRange":"2-4","hop":"9B","name":"Node 16","pubkey":"96a32f6dd9386af934894f123b9a560da004e857fc09942446178ccedafcf3a3"},{"count":4,"distRange":"2-3","hop":"57","name":"Node 17","pubkey":"9044c66c0a61da8a7c714b85a065014df81191351b2e4a1d4184b4f209b76d63"}]}]},"1E598D0F11783A33C04D701FF3151FD8D8C0A96686D88267DF70993F59EF760C":{"observer_name":"Node 18","rings":[{"hops":1,"nodes":[{"count":6,"distRange":"1-4","hop":"A3","name":"Node 19","pubkey":"6091d74d0644c60b4341f0018f1cdcc6346c41154a2a5defc5987ae0bfd56b34"},{"count":4,"distRange":"1-2","hop":"F8","name":"Node 20","pubkey":"8b5334f07a400a08cda720c7b2a27ad562f2e6312de9bcde5dfe940da802d807"}]},{"hops":2,"nodes":[{"count":2,"distRange":"2-3","hop":"EC","name":"Node 12","pubkey":"7d31532b52a5ea780b80359916b469e9cca1d9fa2d0d015d3dd67e36cb35e88a"},{"count":2,"distRange":"2-3","hop":"BE","name":null,"pubkey":null}]}]}},"topPairs":[{"count":38,"hopA":"33","hopB":"8A","nameA":"Node 21","nameB":null,"pubkeyA":"83a248387ffe78a3b2388e1bb62094e2546e359e11a470acf99350442500fc58","pubkeyB":null},{"count":38,"hopA":"33","hopB":"D6","nameA":"Node 21","nameB":null,"pubkeyA":"83a248387ffe78a3b2388e1bb62094e2546e359e11a470acf99350442500fc58","pubkeyB":null}],"topRepeaters":[{"count":220,"hop":"1000","name":"Node 7","pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da"},{"count":94,"hop":"D6","name":null,"pubkey":null}],"uniqueNodes":202},"chanData":{"activeChannels":20,"channelTimeline":[{"channel":"ch?","count":1,"hour":"2026-05-15T00"},{"channel":"#test","count":2,"hour":"2026-10-03T03"}],"channels":[{"encrypted":false,"hash":"217","lastActivity":"2026-10-03T03:56:07Z","messages":37,"name":"#test","senders":8},{"encrypted":true,"hash":"195","lastActivity":"2026-10-03T04:01:47Z","messages":26,"name":"Node 22","senders":0}],"decryptable":5,"msgLengths":[13,51],"topSenders":[{"count":26,"name":"Node 23"},{"count":13,"name":"Node 24"}]},"hashData":{"attributionMethod":"observer","distribution":{"1":365,"2":34,"3":1},"distributionByRepeaters":{"0":10,"1":46,"2":20,"3":0},"hourly":[{"1":1,"2":0,"3":0,"hour":"2026-05-15T00"},{"1":13,"2":1,"3":0,"hour":"2026-10-03T03"}],"multiByteCapability":[{"pubkey":"e828bfc7bdee3c1662d12104288bfbdd180fb2a8f11339e69d616a2410fb191a","name":"Node 25","role":"repeater","status":"confirmed","evidence":"advert","maxHashSize":2,"lastSeen":"2026-10-03T05:23:33Z"},{"pubkey":"4819920bf01f889c17af1d5297e3870d029cfda6cbdf8f15bcb08d1dbc2b2e7e","name":"Node 26","role":"repeater","status":"confirmed","evidence":"advert","maxHashSize":2,"lastSeen":"2026-10-03T05:27:31Z"}],"multiByteNodes":[{"hashSize":2,"lastSeen":"2026-10-03T05:25:39Z","name":"Node 20","packets":2,"pubkey":"8b5334f07a400a08cda720c7b2a27ad562f2e6312de9bcde5dfe940da802d807","role":"repeater"},{"hashSize":2,"lastSeen":"2026-10-03T04:52:26Z","name":"Node 27","packets":1,"pubkey":"5c23812892dc17ca7a4910845f7bb69dba26b2568ff73476c1d2515e44dbc8d6","role":"repeater"}],"topHops":[{"count":195,"hex":"10","name":"Node 7","pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da","size":1},{"count":94,"hex":"D6","name":null,"pubkey":null,"size":1}],"total":400},"collisionData":{"by_size":{"1":{"collisions":[],"one_byte_cells":{"00":[],"01":[]},"stats":{"collision_count":0,"nodes_for_byte":46,"pct_used":17.96875,"space_size":256,"total_nodes":202,"unique_prefixes":46,"using_this_size":59}},"2":{"collisions":[],"stats":{"collision_count":0,"nodes_for_byte":20,"pct_used":0.030517578125,"space_size":65536,"total_nodes":202,"unique_prefixes":20,"using_this_size":21},"two_byte_cells":{"00":{"group_nodes":[],"two_byte_map":{},"max_collision":0,"collision_count":0},"01":{"group_nodes":[],"two_byte_map":{},"max_collision":0,"collision_count":0}}},"3":{"collisions":[],"stats":{"collision_count":0,"nodes_for_byte":0,"pct_used":0,"space_size":16777216,"total_nodes":202,"unique_prefixes":0,"using_this_size":0}}},"inconsistent_nodes":[]}} \ No newline at end of file +{"rfData":{"avgPacketSize":64,"maxPacketSize":242,"minPacketSize":12,"packetSizes":{"bins":[{"count":9,"w":9.2,"x":12},{"count":157,"w":9.2,"x":21.2}],"max":242,"min":12},"packetsPerHour":[{"hour":"2026-05-15T00","count":1},{"hour":"2026-10-03T03","count":14}],"payloadTypes":[{"type":5,"name":"Node 1","count":167},{"type":4,"name":"Node 2","count":102}],"rssi":{"avg":-92.33333333333333,"max":-90,"median":-92,"min":-95,"stddev":2.0548046676563256},"rssiValues":{"bins":[{"count":1,"w":0.25,"x":-95},{"count":0,"w":0.25,"x":-94.75}],"max":-90,"min":-95},"scatterData":[{"snr":5,"rssi":-95},{"snr":5.5,"rssi":-92}],"signalOverTime":[{"hour":"2026-05-15T00","count":3,"avgSnr":5.5}],"snr":{"avg":5.5,"max":6,"median":5.5,"min":5,"stddev":0.408248290463863},"snrByType":[{"name":"Node 1","count":3,"avg":5.5,"min":5,"max":6}],"snrValues":{"bins":[{"count":1,"w":0.05,"x":5},{"count":0,"w":0.05,"x":5.05}],"max":6,"min":5},"timeSpanHours":3390.0602777777776,"totalAllPackets":502,"totalPackets":3,"totalTransmissions":500},"topoData":{"avgHops":7.3375,"bestPathList":[{"hop":"3957","minDist":1,"name":"Node 3","observer_id":"63538D0B8F8607168388DAF7FAB21468C36193D95DA7646F66E78FCFE407AC93","observer_name":"Node 4","pubkey":"39cfb3d583eee85c367aee5318cedecc1b88c86b96ebf8827864a08e77f6eddf"},{"hop":"04","minDist":1,"name":"Node 5","observer_id":"B71892E2BE5EE20F27B6CF8D1014F09C5E9C278CF3033F6A28D6F3430CE04523","observer_name":"Node 6","pubkey":"bbcbb8b3071f79fb869bf7e31403eda1dd0c040236386fc0f72ff83fa61f5246"}],"hopDistribution":[{"count":29,"hops":1},{"count":9,"hops":2}],"hopsVsSnr":[{"avgSnr":5,"count":1,"hops":1}],"maxHops":37,"medianHops":7,"multiObsNodes":[{"hop":"10","name":"Node 7","observers":[{"count":4,"minDist":1,"observer_id":"5162D84286F5226AE22F0CB0B015C2D4AFC5D59DEB388B45491AEF7C7B6010FF","observer_name":"Node 8"},{"count":1,"minDist":1,"observer_id":"D6D6FD7889A0EC8756CBF0EC74CD0CD4CC756CBA709439835DA730C34EFA5B20","observer_name":"Node 9"}],"pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da"},{"hop":"20","name":"Node 10","observers":[{"count":3,"minDist":1,"observer_id":"5162D84286F5226AE22F0CB0B015C2D4AFC5D59DEB388B45491AEF7C7B6010FF","observer_name":"Node 8"},{"count":8,"minDist":1,"observer_id":"753BCF9C2B7F1E40CA7DA215FCF077AB63547982B68FBA3E143381C105D7381F","observer_name":"Node 11"}],"pubkey":"31ec23e3dcde0d51fa936d86e1530cbc263bc4f3dd2a71b4c1232973631322d0"}],"observers":[{"id":"7D31532B52A5EA780B80359916B469E9CCA1D9FA2D0D015D3DD67E36CB35E88A","name":"Node 12"},{"id":"A7656605AC8F0AE03E6D219E84AC6690F7BAF7F0EE8E444A70898646BC58CB1F","name":"Node 13"}],"perObserverReach":{"74D71204F174AED6A9104EA4EA2148B093ED58E83CECCE3E1BDEFFCCEAAAFE7D":{"observer_name":"Node 14","rings":[{"hops":1,"nodes":[{"count":7,"distRange":"1-3","hop":"30","name":"Node 15","pubkey":"03363cdac563771cdfe37340213e46515911efeee712a4db50f6fba6ab608071"},{"count":4,"distRange":"1-6","hop":"1C","name":null,"pubkey":null}]},{"hops":2,"nodes":[{"count":4,"distRange":"2-4","hop":"9B","name":"Node 16","pubkey":"96a32f6dd9386af934894f123b9a560da004e857fc09942446178ccedafcf3a3"},{"count":4,"distRange":"2-3","hop":"57","name":"Node 17","pubkey":"9044c66c0a61da8a7c714b85a065014df81191351b2e4a1d4184b4f209b76d63"}]}]},"BAF0043FCCDDEDF29D105CDC6B37519096D6D1BEA2A30C54F7C3A12493880BCC":{"observer_name":"Node 18","rings":[{"hops":1,"nodes":[{"count":6,"distRange":"1-4","hop":"A3","name":"Node 19","pubkey":"6091d74d0644c60b4341f0018f1cdcc6346c41154a2a5defc5987ae0bfd56b34"},{"count":4,"distRange":"1-2","hop":"F8","name":"Node 20","pubkey":"8b5334f07a400a08cda720c7b2a27ad562f2e6312de9bcde5dfe940da802d807"}]},{"hops":2,"nodes":[{"count":2,"distRange":"2-3","hop":"EC","name":"Node 12","pubkey":"7d31532b52a5ea780b80359916b469e9cca1d9fa2d0d015d3dd67e36cb35e88a"},{"count":2,"distRange":"2-3","hop":"BE","name":null,"pubkey":null}]}]}},"topPairs":[{"count":38,"hopA":"33","hopB":"8A","nameA":"Node 21","nameB":null,"pubkeyA":"83a248387ffe78a3b2388e1bb62094e2546e359e11a470acf99350442500fc58","pubkeyB":null},{"count":38,"hopA":"33","hopB":"D6","nameA":"Node 21","nameB":null,"pubkeyA":"83a248387ffe78a3b2388e1bb62094e2546e359e11a470acf99350442500fc58","pubkeyB":null}],"topRepeaters":[{"count":220,"hop":"1000","name":"Node 7","pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da"},{"count":94,"hop":"D6","name":null,"pubkey":null}],"uniqueNodes":202},"chanData":{"activeChannels":20,"channelTimeline":[{"channel":"ch?","count":1,"hour":"2026-05-15T00"},{"channel":"#test","count":2,"hour":"2026-10-03T03"}],"channels":[{"encrypted":false,"hash":"217","lastActivity":"2026-10-03T03:56:07Z","messages":37,"name":"#test","senders":8},{"encrypted":true,"hash":"195","lastActivity":"2026-10-03T04:01:47Z","messages":26,"name":"Node 22","senders":0}],"decryptable":5,"msgLengths":[13,51],"topSenders":[{"count":26,"name":"Node 23"},{"count":13,"name":"Node 24"}]},"hashData":{"attributionMethod":"observer","distribution":{"1":365,"2":34,"3":1},"distributionByRepeaters":{"0":10,"1":46,"2":20,"3":0},"hourly":[{"1":1,"2":0,"3":0,"hour":"2026-05-15T00"},{"1":13,"2":1,"3":0,"hour":"2026-10-03T03"}],"multiByteCapability":[{"pubkey":"e828bfc7bdee3c1662d12104288bfbdd180fb2a8f11339e69d616a2410fb191a","name":"Node 25","role":"repeater","status":"confirmed","evidence":"advert","maxHashSize":2,"lastSeen":"2026-10-03T05:23:33Z"},{"pubkey":"4819920bf01f889c17af1d5297e3870d029cfda6cbdf8f15bcb08d1dbc2b2e7e","name":"Node 26","role":"repeater","status":"confirmed","evidence":"advert","maxHashSize":2,"lastSeen":"2026-10-03T05:27:31Z"}],"multiByteNodes":[{"hashSize":2,"lastSeen":"2026-10-03T05:25:39Z","name":"Node 20","packets":2,"pubkey":"8b5334f07a400a08cda720c7b2a27ad562f2e6312de9bcde5dfe940da802d807","role":"repeater"},{"hashSize":2,"lastSeen":"2026-10-03T04:52:26Z","name":"Node 27","packets":1,"pubkey":"5c23812892dc17ca7a4910845f7bb69dba26b2568ff73476c1d2515e44dbc8d6","role":"repeater"}],"topHops":[{"count":195,"hex":"10","name":"Node 7","pubkey":"936983eb45bdaa8e8b672e30bf488b201651c2b1a0cb3d9170c5a485e0a112da","size":1},{"count":94,"hex":"D6","name":null,"pubkey":null,"size":1}],"total":400},"collisionData":{"by_size":{"1":{"collisions":[],"one_byte_cells":{"00":[],"01":[]},"stats":{"collision_count":0,"nodes_for_byte":46,"pct_used":17.96875,"space_size":256,"total_nodes":202,"unique_prefixes":46,"using_this_size":59}},"2":{"collisions":[],"stats":{"collision_count":0,"nodes_for_byte":20,"pct_used":0.030517578125,"space_size":65536,"total_nodes":202,"unique_prefixes":20,"using_this_size":21},"two_byte_cells":{"00":{"group_nodes":[],"two_byte_map":{},"max_collision":0,"collision_count":0},"01":{"group_nodes":[],"two_byte_map":{},"max_collision":0,"collision_count":0}}},"3":{"collisions":[],"stats":{"collision_count":0,"nodes_for_byte":0,"pct_used":0,"space_size":16777216,"total_nodes":202,"unique_prefixes":0,"using_this_size":0}}},"inconsistent_nodes":[]}} \ No newline at end of file From 7ad6defd07749c00d943226eea138b902b25df30 Mon Sep 17 00:00:00 2001 From: dborup Date: Sat, 3 Oct 2026 08:05:39 +0000 Subject: [PATCH 13/13] test(analytics): prove self-fetching tabs render, a new load resets the status, theme-refresh during a load (#172) Re-review gaps in test-1659-analytics-warmup.js (34 tests, was 28): - The self-fetching-tab test wrote its own marker after the click, so it passed even when renderTab skipped the tab's render function (guard without LOAD_TABS.has(tab)). It now runs for Route Patterns, Roles and Nodes and requires that tab's own fetch and its own output during the warm-up, and that the 503 retries leave the output alone. - After a failed load and a region change, a data tab clicked during the new load must show "Loading analytics", not the old "Failed to load". - theme-refresh on Overview and RF during the warm-up must not throw and must keep the loading state; the data renders after the warm-up. The page's window listener is captured and called like app.js does. - A slow 500 of a superseded load must not be shown during the newer load (the gen check in the error path had no test). Test only. On 1c510d48 the theme-refresh tests fail with the reported TypeError (totalTransmissions, snrValues). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdKwzUPntKQHjQEKpMXefc --- test-1659-analytics-warmup.js | 126 ++++++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 14 deletions(-) diff --git a/test-1659-analytics-warmup.js b/test-1659-analytics-warmup.js index 0e151c8b6..94cff61d7 100644 --- a/test-1659-analytics-warmup.js +++ b/test-1659-analytics-warmup.js @@ -128,7 +128,8 @@ function fakeEl(id) { // The analytics page on a server whose rf/topology/channels answer 503 + // Retry-After: 5 until warmMs on the fake clock, each response after // latencyMs (0: at once). opts.status and opts.retryAfter replace the -// 503 and its Retry-After value; opts.realData answers with REAL. +// 503 and its Retry-After value; opts.realData answers with REAL. The +// tabs that fetch their own data get a small answer of their own (OWN_TABS). function pageEnv(warmMs, latencyMs, opts) { const errStatus = (opts && opts.status) || 503; const errRetryAfter = opts && 'retryAfter' in opts ? opts.retryAfter : '5'; @@ -159,13 +160,19 @@ function pageEnv(warmMs, latencyMs, opts) { if (/\/api\/analytics\/topology\b/.test(url)) return respond(200, WARM_TOPO); if (/\/api\/analytics\/channels\b/.test(url)) return respond(200, WARM_CHAN); if (/relay-airtime-share/.test(url)) return respond(200, { rows: [] }); + for (const t of Object.values(OWN_TABS)) for (const [re, body] of t.responses) if (re.test(url)) return respond(200, body); return respond(200, {}); }; class FakeDate extends Date { static now() { return 1790000000000 + clock.now(); } } let regionCb = null, region = ''; const pages = {}; + const winListeners = {}; const ctx = { - window: { addEventListener() {}, removeEventListener() {}, dispatchEvent() {} }, + window: { + addEventListener(type, fn) { (winListeners[type] = winListeners[type] || []).push(fn); }, + removeEventListener(type, fn) { winListeners[type] = (winListeners[type] || []).filter((f) => f !== fn); }, + dispatchEvent() {}, + }, document: { readyState: 'complete', body: { appendChild() {} }, head: { appendChild() {} }, createElement: () => fakeEl(''), getElementById: el, addEventListener() {}, @@ -201,9 +208,37 @@ function pageEnv(warmMs, latencyMs, opts) { el('analyticsTabs').dispatch('click', { target: { closest: () => btn } }); }, analyticsFetches: () => fetchLog.filter((u) => /\/api\/analytics\/(rf|topology|channels)\b/.test(u)).length, + fetchesOf: (re) => fetchLog.filter((u) => re.test(u)).length, + // What app.js's theme debounce dispatches on window (#1925). + themeRefresh: () => { for (const fn of winListeners['theme-refresh'] || []) fn(); }, + themeListeners: () => (winListeners['theme-refresh'] || []).length, }; } +// #172 r3: three tabs that fetch their own data, each with the endpoint it +// fetches, a small answer for it and a marker that only its own render +// function writes. +const OWN_TABS = { + subpaths: { + fetches: /\/api\/analytics\/subpaths-bulk\b/, + responses: [[/\/api\/analytics\/subpaths-bulk\b/, { results: [0, 1, 2, 3].map(() => ({ subpaths: [], totalPaths: 0 })) }]], + marker: /Route Pattern Analysis/, + }, + roles: { + fetches: /\/api\/analytics\/roles\b/, + responses: [[/\/api\/analytics\/roles\b/, { totalNodes: 3, roles: [{ role: 'repeater', nodeCount: 3, okCount: 3 }] }]], + marker: /data-role="repeater"/, + }, + nodes: { + fetches: /\/api\/nodes\/bulk-health\b/, + responses: [ + [/\/api\/nodes\/bulk-health\b/, [{ public_key: 'aa'.repeat(32), stats: { totalTransmissions: 9, avgSnr: 4 }, observers: [] }]], + [/\/api\/nodes\?/, { nodes: [{ public_key: 'aa'.repeat(32), name: 'Node A', role: 'repeater' }], total: 1, counts: { repeater: 1 } }], + ], + marker: /Node A/, + }, +}; + (async () => { console.log('\n=== #1659: api() retries on 503 with Retry-After ==='); @@ -437,6 +472,19 @@ function pageEnv(warmMs, latencyMs, opts) { assert.ok(/8,765|8765/.test(content.innerHTML), 'not the CPH data: ' + content.innerHTML.slice(0, 200)); }); + await test('a slow error of a superseded load is not shown during the newer one', async () => { + // rf answers 500 until 3.5 s, every response after 3 s. + const env = pageEnv(3500, 3000, { status: 500, retryAfter: null }); + env.page.init(env.el('app')); // all regions: the 500 at 3 s + const content = env.el('analyticsContent'); + await env.clock.advance(1000); + env.regionChanged('CPH'); // region CPH: the data at 4 s + await env.clock.advance(2500); + assert.ok(!/Failed to load/.test(content.innerHTML), 'the superseded load showed its error: ' + content.innerHTML.slice(0, 200)); + await env.clock.advance(1000); + assert.ok(/8,765|8765/.test(content.innerHTML), 'not the CPH data: ' + content.innerHTML.slice(0, 200)); + }); + console.log('\n=== #172 r2: in-flight sharing respects retry503 ==='); // One request per path is shared while in flight, but a retry503:false @@ -572,23 +620,73 @@ function pageEnv(warmMs, latencyMs, opts) { }); } - await test('a tab that fetches its own data is not overwritten by the loading state', async () => { - const env = pageEnv(45000); + // #172 r3: the tab's OWN render function must run during the warm-up + // (its fetch goes out and its output is shown), and the load's 503 + // retries must then leave that output alone. + for (const [tab, own] of Object.entries(OWN_TABS)) { + await test('clicking "' + tab + '" during the warm-up renders that tab, and the retries leave it alone', async () => { + const env = pageEnv(45000); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + await env.clock.advance(6000); + assert.ok(/still loading/i.test(content.innerHTML), 'not in the warm-up: ' + content.innerHTML.slice(0, 200)); + const before = unexpectedRejections.length; + env.clickTab(tab); + await env.clock.advance(100); + const errs = unexpectedRejections.splice(before); + assert.strictEqual(errs.length, 0, 'the click threw: ' + errs.map((e) => e && e.message).join('; ')); + assert.ok(env.fetchesOf(own.fetches) > 0, 'the ' + tab + ' tab did not fetch its own data'); + assert.ok(own.marker.test(content.innerHTML), 'the ' + tab + ' tab was not rendered: ' + content.innerHTML.slice(0, 200)); + assert.ok(!/still loading|Loading analytics/i.test(content.innerHTML), 'the load status is shown on ' + tab); + await env.clock.advance(20000); // several 503 retries + assert.ok(own.marker.test(content.innerHTML), 'the loading state replaced the ' + tab + ' tab: ' + content.innerHTML.slice(0, 200)); + }); + } + + console.log('\n=== #172 r3: a new load starts from "Loading", and theme-refresh during a load ==='); + + await test('after a failed load, a data tab clicked during the next load shows "Loading", not the old error', async () => { + // rf answers 500 until 5 s, every response after 3 s. + const env = pageEnv(5000, 3000, { status: 500, retryAfter: null, realData: true }); env.page.init(env.el('app')); const content = env.el('analyticsContent'); - await env.clock.advance(6000); - // Route patterns fetches its own data; a click makes it the current - // tab. What it renders in this fake DOM does not matter: a marker - // stands in for it, and the warm-up retries must leave it alone. - const before = unexpectedRejections.length; - env.clickTab('subpaths'); + await env.clock.advance(4000); + assert.ok(/Failed to load/.test(content.innerHTML), 'no error from the first load: ' + content.innerHTML.slice(0, 200)); + env.regionChanged('CPH'); // a new load; its answers come at 9 s + await env.clock.advance(2000); + env.clickTab('rf'); await env.clock.advance(100); - unexpectedRejections.splice(before); - content.innerHTML = '
own data
'; - await env.clock.advance(20000); // several 503 retries - assert.ok(/own-tab/.test(content.innerHTML), 'the loading state replaced the selected tab: ' + content.innerHTML.slice(0, 200)); + assert.ok(!/Failed to load/.test(content.innerHTML), 'the old error is shown during the new load: ' + content.innerHTML.slice(0, 200)); + assert.ok(/Loading analytics/.test(content.innerHTML), 'no loading state during the new load: ' + content.innerHTML.slice(0, 200)); + await env.clock.advance(3000); + assert.ok(/SNR Distribution/.test(content.innerHTML), 'the new load did not render the rf tab: ' + content.innerHTML.slice(0, 200)); }); + for (const tab of ['overview', 'rf']) { + await test('theme-refresh on "' + tab + '" during the warm-up keeps the loading state, then the data renders', async () => { + const env = pageEnv(45000, 0, { realData: true }); + env.page.init(env.el('app')); + const content = env.el('analyticsContent'); + assert.strictEqual(env.themeListeners(), 1, 'the page did not listen for theme-refresh'); + await env.clock.advance(6000); + if (tab !== 'overview') env.clickTab(tab); + await env.clock.advance(100); + const before = unexpectedRejections.length; + env.themeRefresh(); + await env.clock.advance(100); + const errs = unexpectedRejections.splice(before); + assert.strictEqual(errs.length, 0, 'theme-refresh threw: ' + errs.map((e) => e && e.message).join('; ')); + assert.ok(/still loading/i.test(content.innerHTML) && /role="status"/.test(content.innerHTML), + 'no loading state after theme-refresh: ' + content.innerHTML.slice(0, 200)); + await env.clock.advance(45000); + env.themeRefresh(); // once more, now with data + await env.clock.advance(100); + const late = unexpectedRejections.splice(before); + assert.strictEqual(late.length, 0, 'rendering threw: ' + late.map((e) => e && e.message).join('; ')); + assert.ok(DATA_TABS[tab].test(content.innerHTML), 'not the ' + tab + ' data: ' + content.innerHTML.slice(0, 200)); + }); + } + if (unexpectedRejections.length) { failed++; console.log(' ❌ unexpected unhandled rejections: ' + unexpectedRejections.map((e) => e && e.message).join('; '));