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); +})();