From 5c016a210ac116d071ea926862c59fb4ddffb8f6 Mon Sep 17 00:00:00 2001 From: efiten Date: Sun, 20 Sep 2026 17:04:37 +0200 Subject: [PATCH] fix(analytics): show a building state on the distance index's 202, and stop caching it (#2051) Closes #1997. ## What was wrong `/api/analytics/distance` answers `202 {status:"building", retry_after_seconds:5}` with no `summary` until the lazy index (#1011) has been built. 1. `renderDistanceTab` read `data.summary.totalHops` straight away. The TypeError is caught by the tab's own try/catch, so it never reaches `window.onerror`: it is painted into the tab as `Failed to load distance analytics: Cannot read properties of undefined (reading 'totalHops')`. 2. `api()` caches any `res.ok` body, and `res.ok` is true for 202, so the placeholder was stored for the `analyticsRF` TTL. Even a correct retry read the cached "building" body back. That is the half that made the broken state outlast the index build. ## What changed - `public/app.js:173` skips the cache write when `res.status === 202`. A 200 still caches, unchanged. - `public/analytics.js` renders a building notice and retries itself, honouring `retry_after_seconds` clamped to [1s, 30s]. The timer is cleared on tab switch and in `destroy()`, and a new render supersedes a pending retry, so two renders cannot write into the same tab. The notice uses `.text-center`/`.text-muted` rather than the `.spinner` class used at `analytics.js:1683`, because `.spinner` has no CSS anywhere in the repo and renders nothing. ## Tests `tests/unit/test-issue-1997-distance-building.js` (7 assertions, wired into `test-all.sh`) pins the two pure decisions the renderer makes and `api()`'s refusal to cache a 202 while still caching a 200. Red-run on the unfixed sources: 6 of 7 fail, and the "a 200 is still cached" control stays green. `tests/e2e/test-issue-1997-distance-building-e2e.js` (classified in `scripts/non-unit-tests.json`, invoked from `deploy.yml` with `CHROMIUM_REQUIRE=1`) serves both responses by route interception, so it does not depend on whether the server under test has an index built. It asserts: the building state appears, the tab does not paint the error text, a retry arrives with no interaction, the retry replaces the placeholder once the server answers 200, and no retry fires after leaving the tab. Check (2) deliberately asserts on the rendered text and not on `pageerror`: the TypeError is caught, so a `pageerror` assertion would pass on the broken build too. ## Verification No local cgo toolchain here since #1992, so I could not build a server to run the E2E against. Instead I ran its five steps in Playwright against a live instance with this branch's `public/app.js` and `public/analytics.js` injected in place of the deployed ones (both files are byte-identical between that instance and upstream master, so the injection is faithful): | check | deployed build | this branch | |---|---|---| | (1) building state shown | fail | pass | | (2) not rendered as data | fail | pass | | (3) retried on its own | fail (1 request) | pass (2 requests) | | (4) real payload after retry | fail | pass | | (5) no retry after leaving the tab | pass | pass | (5) passes on the broken build too: it schedules no retry at all, so it is a control and only means anything together with (3). The committed E2E suite has not been run as committed. CI is its first real run. ## Not done - The server still recomputes on every 202 poll rather than signalling readiness. - No other analytics tab was audited for the same assume-a-summary pattern. - One pre-existing unit suite (`test-preflight-xss-gate.js`) fails on this Windows machine with a cp1252 `UnicodeEncodeError` from its Python helper, on a clean tree as well as with this change. Unrelated, and green on Linux CI. Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/deploy.yml | 2 + public/analytics.js | 49 ++++- public/app.js | 7 +- scripts/non-unit-tests.json | 1 + test-all.sh | 1 + .../test-issue-1997-distance-building-e2e.js | 156 +++++++++++++++ .../unit/test-issue-1997-distance-building.js | 180 ++++++++++++++++++ 7 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/test-issue-1997-distance-building-e2e.js create mode 100644 tests/unit/test-issue-1997-distance-building.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 975c77da..a0d9b846 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -692,6 +692,8 @@ jobs: BASE_URL=http://localhost:13581 node tests/e2e/test-issue-1274-legend-coverage-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-issue-1648-m5-icons-e2e.js ===" CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-issue-1648-m5-icons-e2e.js 2>&1 | tee -a e2e-output.txt + echo "=== E2E SUITE: test-issue-1997-distance-building-e2e.js ===" + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-issue-1997-distance-building-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-live-dedup.js ===" BASE_URL=http://localhost:13581 node tests/e2e/test-live-dedup.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-nodes-export-e2e.js ===" diff --git a/public/analytics.js b/public/analytics.js index 070f9ce4..62c13565 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -32,6 +32,25 @@ if (_scopesRefreshTimer) { clearInterval(_scopesRefreshTimer); _scopesRefreshTimer = null; } } + // #1997 — the distance tab's lazy index answers 202 {status:"building"} + // until it is built. Retry on the server's own interval rather than + // rendering the placeholder as data, and stop the moment the tab changes + // or the page goes away, so a pending retry cannot render into a view the + // user has left. + var _distanceRetryTimer = null; + function _stopDistanceRetry() { + if (_distanceRetryTimer) { clearTimeout(_distanceRetryTimer); _distanceRetryTimer = null; } + } + // Exposed for tests: the two decisions worth pinning, kept pure. + function _distanceIsBuilding(data) { + return !!(data && data.status === 'building' && !data.summary); + } + function _distanceRetryDelayMs(data) { + var s = data && Number(data.retry_after_seconds); + if (!isFinite(s) || s <= 0) s = 5; // server default when it says nothing + return Math.min(Math.max(s, 1), 30) * 1000; // clamp: never hammer, never stall + } + // --- Status color helpers (read from CSS variables for theme support) --- function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } function statusGreen() { return cssVar('--status-green') || '#22c55e'; } @@ -178,6 +197,7 @@ // #1085 — Roles tab owns its own 60s auto-refresh; stop it on switch. if (_currentTab !== 'roles') _stopRolesRefresh(); if (_currentTab !== 'scopes') _stopScopesRefresh(); + if (_currentTab !== 'distance') _stopDistanceRetry(); _updateAnalyticsUrl(); renderTab(_currentTab); }); @@ -3023,10 +3043,33 @@ } async function renderDistanceTab(el) { + // A new render supersedes any retry still pending from an earlier one, + // so the two can never both write into the tab. + _stopDistanceRetry(); try { const rqs = RegionFilter.regionQueryString(); const sep = rqs ? '?' + rqs.slice(1) : ''; const data = await api('/analytics/distance' + sep, { ttl: CLIENT_TTL.analyticsRF }); + + // #1997: the lazy index (#1011) answers 202 {status:"building"} with no + // summary until it has been built. Rendering that as data is what threw + // "Cannot read properties of undefined (reading 'totalHops')". + if (_distanceIsBuilding(data)) { + const secs = Math.round(_distanceRetryDelayMs(data) / 1000); + el.innerHTML = '
' + + 'Building the distance index…' + + '
This runs once per instance. Retrying in ' + secs + 's.
'; + _distanceRetryTimer = setTimeout(function () { + _distanceRetryTimer = null; + // The user may have switched tabs or left while this was pending. + if (_currentTab !== 'distance') return; + const cur = document.getElementById('analyticsContent'); + if (!cur) return; + renderDistanceTab(cur); + }, _distanceRetryDelayMs(data)); + return; + } + const s = data.summary; let html = `
${s.totalHops.toLocaleString()}
Total Hops Analyzed
@@ -3112,11 +3155,13 @@ } } -function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _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(); _stopDistanceRetry(); _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') { - window._analyticsDecorateChannels = decorateAnalyticsChannels; + window._distanceIsBuilding = _distanceIsBuilding; + window._distanceRetryDelayMs = _distanceRetryDelayMs; + window._analyticsDecorateChannels = decorateAnalyticsChannels; window._analyticsSortChannels = sortChannels; window._analyticsLoadChannelSort = loadChannelSort; window._analyticsSaveChannelSort = saveChannelSort; diff --git a/public/app.js b/public/app.js index 5969dbed..9d9485bb 100644 --- a/public/app.js +++ b/public/app.js @@ -165,7 +165,12 @@ async function api(path, { ttl = 0, bust = false } = {}) { _apiPerf.log.push({ path, ms: Math.round(ms), time: Date.now() }); if (_apiPerf.log.length > 200) _apiPerf.log.shift(); if (ms > 500) console.warn(`[SLOW API] ${path} took ${Math.round(ms)}ms`); - if (ttl > 0) _apiCache.set(path, { data, expires: Date.now() + ttl }); + // #1997: never cache a "not ready yet" body. The lazy distance + // index answers 202 with {status:"building"} until it is built, and + // res.ok is true for 202, so caching it would serve that placeholder + // back to every retry for the whole TTL — the page would stay in its + // building state for minutes after the index was ready. + if (ttl > 0 && res.status !== 202) _apiCache.set(path, { data, expires: Date.now() + ttl }); return data; } } finally { diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index d89eb9cb..2c457e56 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -80,6 +80,7 @@ "tests/e2e/test-issue-1758-ng-filter-rerenders-e2e.js", "tests/e2e/test-issue-1799-label-vocab-e2e.js", "tests/e2e/test-issue-1833-legend-toggle-clickable-e2e.js", + "tests/e2e/test-issue-1997-distance-building-e2e.js", "tests/e2e/test-live-dedup.js", "tests/e2e/test-live-fullscreen-1572-e2e.js", "tests/e2e/test-live-layout-1178-1179-e2e.js", diff --git a/test-all.sh b/test-all.sh index 02165abf..f9622ad3 100755 --- a/test-all.sh +++ b/test-all.sh @@ -133,6 +133,7 @@ node tests/unit/test-issue-1868-control-decode.js node tests/unit/test-issue-1890-og-url.js node tests/unit/test-issue-1956-release-routing.js node tests/unit/test-issue-1979-scope-adverts-by-role.js +node tests/unit/test-issue-1997-distance-building.js node tests/unit/test-issue-2001-map-scope-state.js node tests/unit/test-issue-2012-clear-filters-selection.js node tests/unit/test-live-anims.js diff --git a/tests/e2e/test-issue-1997-distance-building-e2e.js b/tests/e2e/test-issue-1997-distance-building-e2e.js new file mode 100644 index 00000000..6feb6e16 --- /dev/null +++ b/tests/e2e/test-issue-1997-distance-building-e2e.js @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/* Issue #1997 — Analytics → Distance against the lazy index's 202. + * + * /api/analytics/distance answers 202 {status:"building", retry_after_seconds} + * with no summary until the distance index (#1011) has been built. The tab + * read data.summary.totalHops straight away and threw + * "Cannot read properties of undefined (reading 'totalHops')", and api() + * cached the placeholder (res.ok is true for 202), so every retry read the + * cached "building" body back and the page never recovered. + * + * The unit suite (tests/unit/test-issue-1997-distance-building.js) pins the + * two pure decisions and api()'s refusal to cache a 202. This one pins what + * the unit suite cannot: that a real browser, on a real render, shows the + * building state instead of an exception, retries on its own, replaces it + * with the data once the server is ready, and stops retrying when the user + * leaves the tab. + * + * Both responses are served by a route interception, so the suite does not + * depend on whether the server under test has an index built. + * + * CHROMIUM_REQUIRE=1 makes Chromium-launch failure a HARD FAIL. + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +let passes = 0, failures = 0; +function pass(msg) { console.log(` ✓ ${msg}`); passes++; } +function fail(msg) { console.error(` ✗ ${msg}`); failures++; } + +const READY = { + summary: { totalHops: 4242, totalPaths: 77, avgDist: 12.5, maxDist: 88.25 }, + catStats: { 'R↔R': { count: 10, avg: 12, median: 11, min: 1, max: 40 } }, + distHistogram: { bins: [{ x: 1.5, count: 3 }, { x: 2.5, count: 5 }] }, + distOverTime: [], + topHops: [], + topPaths: [], +}; + +async function main() { + const requireChromium = process.env.CHROMIUM_REQUIRE === '1'; + let browser; + try { + browser = await chromium.launch({ headless: true }); + } catch (err) { + if (requireChromium) { + console.error(`HARD FAIL — Chromium unavailable: ${err.message}`); + process.exit(1); + } + console.warn(`SKIP — Chromium unavailable: ${err.message}`); + process.exit(0); + } + + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await ctx.newPage(); + + // The server answers "building" until this flips, exactly like the lazy + // index does once its first build finishes. + let ready = false; + let distanceCalls = 0; + await page.route('**/api/analytics/distance*', async (route) => { + distanceCalls++; + if (ready) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(READY) }); + } else { + await route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ status: 'building', retry_after_seconds: 1, detail: 'distance index is being computed' }), + }); + } + }); + + // Any "reading 'totalHops' of undefined" would land here. + const pageErrors = []; + page.on('pageerror', (e) => pageErrors.push(e.message)); + + await page.goto(`${BASE}/#/analytics?tab=distance`, { waitUntil: 'domcontentloaded' }); + + // (1) The 202 renders the building state, not an exception and not a crash. + try { + await page.waitForSelector('#distanceBuilding', { timeout: 15000 }); + pass('(1) the 202 renders the building state'); + } catch { + const shown = await page.evaluate(() => { + const el = document.getElementById('analyticsContent'); + return el ? (el.innerText || '').slice(0, 300) : '(no #analyticsContent)'; + }); + fail(`(1) the building state never appeared within 15s; the tab showed: ${JSON.stringify(shown)}`); + } + + // (2) The reported symptom, asserted where it is actually visible. + // renderDistanceTab wraps its body in try/catch, so the TypeError never + // reaches window.onerror: it is caught and painted into the tab as + // "Failed to load distance analytics: Cannot read properties of undefined + // (reading 'totalHops')". Asserting on pageErrors here would pass on the + // broken build too, so assert on the text the user sees. + const buildingText = await page.evaluate(() => { + const el = document.getElementById('analyticsContent'); + return el ? (el.innerText || '') : ''; + }); + if (!/Failed to load distance analytics|Cannot read properties of undefined/.test(buildingText)) { + pass('(2) the building body is not rendered as data'); + } else { + fail(`(2) the building body was rendered as data: ${JSON.stringify(buildingText.slice(0, 160))}`); + } + + // (3) The tab retries on its own: a second request arrives without any + // interaction. retry_after_seconds is 1, so this is a short wait. + const callsAfterFirstRender = distanceCalls; + // The counter lives in Node (the route handler), so poll it here. + const deadline = Date.now() + 10000; + while (distanceCalls <= callsAfterFirstRender && Date.now() < deadline) { + await page.waitForTimeout(200); + } + if (distanceCalls > callsAfterFirstRender) pass(`(3) the tab retried on its own (${distanceCalls} requests)`); + else fail('(3) no retry arrived within 10s, so a stuck build never recovers'); + + // (4) Once the server is ready, the retry replaces the placeholder with + // the real numbers. This is the half that api()'s 202 caching broke: a + // cached placeholder would keep the building state on screen. + ready = true; + try { + await page.waitForFunction(() => { + const el = document.getElementById('analyticsContent'); + return !!el && /4,?242/.test(el.innerText || ''); + }, null, { timeout: 15000 }); + pass('(4) the retry replaces the placeholder with the real payload'); + } catch { + const shown = await page.evaluate(() => { + const el = document.getElementById('analyticsContent'); + return el ? (el.innerText || '').slice(0, 300) : '(no #analyticsContent)'; + }); + fail(`(4) the tab never showed the ready payload; it showed: ${JSON.stringify(shown)}`); + } + + // (5) Leaving the tab stops the retry. Go back to building, let one retry + // be scheduled, navigate away, and assert the request count stops moving. + ready = false; + await page.evaluate(() => { window.location.hash = '#/analytics?tab=overview'; }); + await page.waitForTimeout(500); + const before = distanceCalls; + await page.waitForTimeout(3000); // three retry intervals + if (distanceCalls === before) pass('(5) no retry fires after leaving the tab'); + else fail(`(5) the retry kept running after leaving the tab: ${distanceCalls - before} extra request(s)`); + + if (pageErrors.length) console.log(` (page errors seen: ${pageErrors.length}) ${pageErrors.slice(0, 3).join(' | ')}`); + + await browser.close(); + console.log(`\ntest-issue-1997-distance-building-e2e: ${passes} passed, ${failures} failed`); + process.exit(failures ? 1 : 0); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/tests/unit/test-issue-1997-distance-building.js b/tests/unit/test-issue-1997-distance-building.js new file mode 100644 index 00000000..1e8f6c7a --- /dev/null +++ b/tests/unit/test-issue-1997-distance-building.js @@ -0,0 +1,180 @@ +/* test-issue-1997-distance-building.js + * + * Issue #1997: the Distance tab crashed on the lazy index's first response. + * + * The backend answers 202 with {status:"building", retry_after_seconds:5} and + * no summary until the index (#1011) has been built. Two things went wrong: + * + * 1. renderDistanceTab read data.summary.totalHops straight away, so the + * page showed "Cannot read properties of undefined (reading 'totalHops')". + * 2. api() caches any res.ok body, and 202 is ok, so the placeholder was + * cached for the analyticsRF TTL. Even a correct retry would then read + * the cached "building" body back and stay stuck long after the index + * was ready. That is the half that makes the first one permanent. + * + * This pins both: the two pure decisions the renderer makes, and api()'s + * refusal to cache a 202 while still caching a 200. + */ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const assert = require('assert'); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +async function testAsync(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} + +// ── The renderer's two decisions, loaded from the real analytics.js ───────── +function loadAnalyticsHelpers() { + const sandbox = { + console, + window: null, + document: { + documentElement: {}, + createElement: () => ({ style: {}, addEventListener() {} }), + addEventListener() {}, removeEventListener() {}, + querySelector: () => null, querySelectorAll: () => [], getElementById: () => null, + }, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + registerPage: () => {}, + api: async () => ({}), + fetch: async () => ({ ok: true, json: async () => ({}) }), + CLIENT_TTL: {}, + RegionFilter: { getRegionParam: () => '', regionQueryString: () => '' }, + Storage: function () {}, + timeAgo: () => '', + histogram: () => ({ svg: '' }), + setTimeout, clearTimeout, setInterval, clearInterval, + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, + }; + sandbox.window = sandbox; + vm.createContext(sandbox); + vm.runInContext(fs.readFileSync(path.join(REPO_ROOT, 'public/analytics.js'), 'utf8'), sandbox); + return sandbox; +} + +console.log('\n=== #1997: the Distance tab and the lazy index\'s 202 ==='); + +const a = loadAnalyticsHelpers(); + +test('the helpers are exposed for testing', () => { + assert.strictEqual(typeof a._distanceIsBuilding, 'function', '_distanceIsBuilding'); + assert.strictEqual(typeof a._distanceRetryDelayMs, 'function', '_distanceRetryDelayMs'); +}); + +test('the 202 body is recognised as still building', () => { + const body = { status: 'building', retry_after_seconds: 5, detail: 'distance index is being computed' }; + assert.strictEqual(a._distanceIsBuilding(body), true); +}); + +test('a real payload is not treated as building', () => { + assert.strictEqual(a._distanceIsBuilding({ summary: { totalHops: 12 }, catStats: {} }), false); + assert.strictEqual(a._distanceIsBuilding({}), false); + assert.strictEqual(a._distanceIsBuilding(null), false); + assert.strictEqual(a._distanceIsBuilding(undefined), false); +}); + +test('a body carrying both a status and a summary is data, not a placeholder', () => { + // Defensive: if the contract ever grows a status field on real payloads, + // the presence of a summary decides, so the tab renders instead of looping. + assert.strictEqual(a._distanceIsBuilding({ status: 'building', summary: { totalHops: 1 } }), false); +}); + +test('the retry honours the server interval, and is clamped at both ends', () => { + assert.strictEqual(a._distanceRetryDelayMs({ retry_after_seconds: 5 }), 5000, 'uses what the server asked for'); + assert.strictEqual(a._distanceRetryDelayMs({}), 5000, 'defaults to the server default when absent'); + assert.strictEqual(a._distanceRetryDelayMs({ retry_after_seconds: 0 }), 5000, 'zero would be a hot loop'); + assert.strictEqual(a._distanceRetryDelayMs({ retry_after_seconds: -3 }), 5000, 'negative likewise'); + assert.strictEqual(a._distanceRetryDelayMs({ retry_after_seconds: 'soon' }), 5000, 'non-numeric likewise'); + assert.strictEqual(a._distanceRetryDelayMs({ retry_after_seconds: 0.2 }), 1000, 'clamped up to 1s'); + assert.strictEqual(a._distanceRetryDelayMs({ retry_after_seconds: 600 }), 30000, 'clamped down to 30s'); +}); + +// ── api() must not cache a 202 ────────────────────────────────────────────── +function loadApi(responses) { + const calls = []; + const sandbox = { + console, + window: null, + document: { + documentElement: { style: {}, setAttribute() {}, getAttribute: () => null, classList: { add() {}, remove() {}, contains: () => false } }, + createElement: () => ({ style: {}, classList: { add() {}, remove() {} }, appendChild() {}, addEventListener() {} }), + addEventListener() {}, removeEventListener() {}, + querySelector: () => null, querySelectorAll: () => [], getElementById: () => null, + body: { appendChild() {}, classList: { add() {}, remove() {} } }, + }, + localStorage: { getItem: () => null, setItem() {}, removeItem() {} }, + location: { hash: '', search: '', pathname: '/' }, + history: { replaceState() {}, pushState() {} }, + performance: { now: () => 0 }, + navigator: { userAgent: 'node' }, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + getComputedStyle: () => ({ getPropertyValue: () => '' }), + setTimeout: (fn) => { fn(); return 0; }, // no wall-clock waiting + clearTimeout() {}, setInterval: () => 0, clearInterval() {}, + requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + WebSocket: function () { this.close = function () {}; this.addEventListener = function () {}; }, + addEventListener() {}, removeEventListener() {}, dispatchEvent: () => true, + CustomEvent: function () {}, + URLSearchParams, + // app.js issues its own fetches while it loads (version banner, config, + // …). Only the distance path draws from the queued responses, and only + // it is counted: otherwise a load-time fetch consumes the 202 and the + // assertions below measure the wrong request. + fetch: async (url) => { + const mine = String(url).indexOf('/analytics/distance') !== -1; + if (!mine) return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({}) }; + calls.push(url); + const r = responses.shift() || responses[responses.length - 1]; + return { + ok: r.status >= 200 && r.status < 300, + status: r.status, + headers: { get: (h) => (h.toLowerCase() === 'retry-after' ? r.retryAfter || null : null) }, + json: async () => r.body, + }; + }, + }; + sandbox.window = sandbox; + vm.createContext(sandbox); + vm.runInContext(fs.readFileSync(path.join(REPO_ROOT, 'public/app.js'), 'utf8'), sandbox); + return { sandbox, calls }; +} + +(async () => { + await testAsync('a 202 body is returned but never cached, so the retry reaches the server', async () => { + const building = { status: 202, body: { status: 'building', retry_after_seconds: 5 } }; + const ready = { status: 200, body: { summary: { totalHops: 42 }, catStats: {} } }; + const { sandbox, calls } = loadApi([building, ready]); + + const first = await sandbox.api('/analytics/distance', { ttl: 300000 }); + assert.strictEqual(first.status, 'building', 'the placeholder is handed to the caller, not swallowed'); + + const second = await sandbox.api('/analytics/distance', { ttl: 300000 }); + assert.ok(second.summary, 'the retry must reach the server and get the real payload'); + assert.strictEqual(second.summary.totalHops, 42); + assert.strictEqual(calls.length, 2, 'the second call must not have been served from cache'); + }); + + await testAsync('a 200 body is still cached, so the fix does not disable caching', async () => { + const ready = { status: 200, body: { summary: { totalHops: 7 }, catStats: {} } }; + const { sandbox, calls } = loadApi([ready, ready]); + + await sandbox.api('/analytics/distance', { ttl: 300000 }); + await sandbox.api('/analytics/distance', { ttl: 300000 }); + assert.strictEqual(calls.length, 1, 'the second call should have been served from cache'); + }); + + console.log(`\nTotal: ${passed} passed, ${failed} failed`); + if (failed) process.exit(1); +})();