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. diff --git a/public/analytics.js b/public/analytics.js index 7d55addad..bc8b1dcff 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -50,14 +50,40 @@ 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; + // #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; } + } var _wardrivingRefreshTimer = null; function _stopWardrivingRefresh() { if (_wardrivingRefreshTimer) { clearInterval(_wardrivingRefreshTimer); _wardrivingRefreshTimer = null; } @@ -173,7 +199,7 @@
-
Loading analytics…
+ ${LOADING_HTML}
`; @@ -302,7 +328,10 @@ var _themeRefreshHandler = null; let _currentTab = 'overview'; - async function loadAnalytics() { + async function loadAnalytics(startedAt) { + _cancelLoadRetry(); + const gen = _loadGen; + if (startedAt === undefined) { startedAt = Date.now(); _loadStatusHtml = LOADING_HTML; } try { _analyticsData = {}; const rqs = RegionFilter.regionQueryString(); // "®ion=..." or "" @@ -323,19 +352,33 @@ // 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 ms = _retryAfterDelayMs(e); + if (e && e.status === 503 && Date.now() - startedAt + ms <= ANALYTICS_WARMUP_MAX_MS) { + _showLoadStatus('
' + + '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; + } + _showLoadStatus(``); } } @@ -356,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; @@ -3010,7 +3054,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 +3145,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..960be42b3 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); @@ -150,8 +150,11 @@ async function api(path, { ttl = 0, bust = false } = {}) { 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 @@ -166,6 +169,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 +181,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 +190,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++; @@ -211,14 +225,14 @@ async function api(path, { ttl = 0, bust = false } = {}) { 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; } 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; 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(); diff --git a/test-1659-analytics-warmup.js b/test-1659-analytics-warmup.js index b0db50ab3..94cff61d7 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. @@ -61,6 +66,179 @@ 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 }; +// #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; + 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(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, + }; +} + +// 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. 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'; + const realData = !!(opts && opts.realData); + 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 (latencyMs) await new Promise((r) => clock.setTimeout(r, latencyMs)); + 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 } : {})); + } + 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(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() {}, + 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: () => region }, + 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: (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, + 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 ==='); @@ -212,6 +390,308 @@ 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)'); + }); + + 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)); + }); + + 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 + // 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: /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, 0, { realData: true }); + 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)); + 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); + }); + } + + // #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(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); + 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('; ')); + } + console.log('\n' + passed + ' passed, ' + failed + ' failed'); if (failed > 0) process.exit(1); })(); diff --git a/test-fixtures/analytics-tabs-172.json b/test-fixtures/analytics-tabs-172.json new file mode 100644 index 000000000..60bb9a0db --- /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":{"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 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 }); 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 ──