From 56156ae29072f51d14c0343d80d01d745606f8ff Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 25 Oct 2026 04:45:23 +0100 Subject: [PATCH 1/2] fix(analytics): derive role breakdown from filtered node rows Analytics -> Nodes' Role Breakdown read /api/nodes' global `counts` (cmd/server/db.go GetAllRoleCounts, an unconditional COUNT(*) over the whole nodes table), while the Network Status total above it already came from the loaded, region/area-filtered rows. Picking a region changed one number and not the other. Compute the breakdown with a single bounded pass over the rows the page already has (same role set and pluralized keys as GetAllRoleCounts, so an unfiltered load is unchanged), add a generation guard so a late response from a superseded filter change can't overwrite a newer render, and add unit + E2E coverage. Relates to #560 Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 30 +++- scripts/e2e-shards.json | 8 + test-all.sh | 1 + test-issue-560-role-breakdown-e2e.js | 154 ++++++++++++++++ test-issue-560-role-breakdown.js | 254 +++++++++++++++++++++++++++ 5 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 test-issue-560-role-breakdown-e2e.js create mode 100644 test-issue-560-role-breakdown.js diff --git a/public/analytics.js b/public/analytics.js index 78a9bac73..2d441e289 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2587,7 +2587,31 @@ } } + // #560 — mirrors the role set and pluralized keys of GetAllRoleCounts + // (cmd/server/db.go), but tallies the rows the caller already loaded + // instead of querying the whole `nodes` table, so the breakdown describes + // the same region/area-filtered population as the Network Status counts + // rendered above it. Single bounded O(n) pass; no network call. Roles + // outside the known set (including "observer" and missing/unrecognized + // values) are not tallied, matching GetAllRoleCounts' existing omission of + // them from the breakdown today. + const NODE_ROLE_BREAKDOWN_KEYS = { repeater: 'repeaters', room: 'rooms', companion: 'companions', sensor: 'sensors' }; + function nodeRoleBreakdownCounts(nodes) { + const counts = { repeaters: 0, rooms: 0, companions: 0, sensors: 0 }; + (nodes || []).forEach(n => { + const key = NODE_ROLE_BREAKDOWN_KEYS[n && n.role]; + if (key) counts[key]++; + }); + return counts; + } + + // Bumped on every call and checked after the await below so a late + // response from a superseded filter/tab change can never overwrite a + // newer render (mirrors the _channelRenderGen / _distanceGen pattern + // already used elsewhere in this file). + let _nodesTabGen = 0; async function renderNodesTab(el) { + const myGen = ++_nodesTabGen; el.innerHTML = '
Loading node analytics…
'; try { const rq = RegionFilter.regionQueryString() + AreaFilter.areaQueryString(); @@ -2595,6 +2619,7 @@ fetchAllNodes('&sortBy=lastSeen' + rq, { ttl: CLIENT_TTL.nodeList }), api('/nodes/bulk-health?limit=50' + rq, { ttl: CLIENT_TTL.analyticsRF }) ]); + if (myGen !== _nodesTabGen) return; // superseded by a newer filter/tab change const nodes = nodesResp.nodes || nodesResp; const myNodes = JSON.parse(localStorage.getItem('meshcore-my-nodes') || '[]'); const myKeys = new Set(myNodes.map(n => n.pubkey)); @@ -2621,7 +2646,7 @@ else silent++; }); const totalNodes = nodesResp.total || nodes.length; - const roleCounts = nodesResp.counts || {}; + const roleCounts = nodeRoleBreakdownCounts(nodes); function nodeLink(n) { return `${esc(n.name || n.public_key.slice(0, 12))}`; @@ -2742,6 +2767,7 @@ `; } catch (e) { + if (myGen !== _nodesTabGen) return; el.innerHTML = `
Failed to load node analytics: ${esc(e.message)}
`; } } @@ -3403,6 +3429,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopScopeAudit( window._analyticsHopDepthTimeSeriesChartHtml = hopDepthTimeSeriesChartHtml; window._analyticsHopDepthLookupByPubkey = hopDepthLookupByPubkey; window._analyticsBridgeRepeaterPubkeySet = bridgeRepeaterPubkeySet; + window._analyticsNodeRoleBreakdownCounts = nodeRoleBreakdownCounts; + window._analyticsRenderNodesTab = renderNodesTab; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json index 48eed756b..e782c55f4 100644 --- a/scripts/e2e-shards.json +++ b/scripts/e2e-shards.json @@ -33,6 +33,14 @@ "meanSeconds": 0, "measurementNote": "New suite; fresh and SPA ?region= links across five views, listener counts, back/forward, the hash ?region= after a control change on six views, and ?region=All. About 2.5 minutes locally; no CI duration measured yet." }, + { + "file": "test-issue-560-role-breakdown-e2e.js", + "shard": 2, + "env": { "BASE_URL": "$BASE_URL" }, + "output": "e2e", + "meanSeconds": 0, + "measurementNote": "New suite; #560 Analytics -> Nodes Role Breakdown computed from the filtered rows, not the server's global /api/nodes counts -- an MRY region filter's role-breakdown sum must equal the Network Status total, and must differ from the unfiltered repeaters count. About 5s locally; no CI duration measured yet; assigned to shard 2, the lowest measured total." + }, { "file": "test-page-lifecycle-2248-e2e.js", "shard": 2, diff --git a/test-all.sh b/test-all.sh index 9a534b11c..f189e3c0c 100755 --- a/test-all.sh +++ b/test-all.sh @@ -92,6 +92,7 @@ run test-analytics-channels-integration.js run test-analytics-foreign-traffic-tab.js run test-scope-audit.js run test-analytics-nodes-without-scope.js +run test-issue-560-role-breakdown.js run test-analytics-table-ids-unique.js run test-analytics-relay-airtime-dumbbell.js run test-live-vcr-mode-contrast.js diff --git a/test-issue-560-role-breakdown-e2e.js b/test-issue-560-role-breakdown-e2e.js new file mode 100644 index 000000000..15fc97350 --- /dev/null +++ b/test-issue-560-role-breakdown-e2e.js @@ -0,0 +1,154 @@ +/** + * E2E (Relates to #560): Analytics -> Nodes' Role Breakdown must describe + * the SAME population as that view's own Network Status once a region + * filter is active, not the server's global /api/nodes `counts` metadata + * (cmd/server/db.go GetAllRoleCounts, an unconditional COUNT(*) over the + * whole `nodes` table regardless of the region/area filter on the page). + * + * Against a real browser and the Go server on the CI fixture (regions MRY, + * OAK, SFO, SJC; 200 nodes total: 168 repeaters, 23 rooms, 9 companions, 0 + * sensors). MRY holds exactly 13 nodes, all repeaters -- a region small + * enough, and lopsided enough, that the pre-fix bug is unmistakable: every + * badge kept showing the unfiltered global counts (168/23/9/0) no matter + * which region was picked, while the Network Status "Total Nodes" card + * (already computed from the filtered rows before this fix) correctly + * dropped to 13 -- two numbers for what is supposed to be one population. + * + * Usage: BASE_URL=http://localhost:13820 node test-issue-560-role-breakdown-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function openPage(browser) { + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.__errors = []; + page.on('pageerror', (e) => page.__errors.push(e.message)); + page.__reqs = []; + page.__open = new Set(); + page.on('request', (r) => { if (r.url().includes('/api/')) { page.__reqs.push(r.url()); page.__open.add(r); } }); + page.on('requestfinished', (r) => page.__open.delete(r)); + page.on('requestfailed', (r) => page.__open.delete(r)); + return page; +} + +// Until no /api/ request is open and none started for QUIET_MS (bounded). +// A fixed settle window alone is unreliable on a loaded machine. +const QUIET_MS = 800; +async function apiIdle(page) { + const deadline = Date.now() + 20000; + let n = page.__reqs.length, quietFrom = Date.now(); + while (Date.now() < deadline) { + await page.waitForTimeout(100); + if (page.__reqs.length !== n || page.__open.size > 0) { n = page.__reqs.length; quietFrom = Date.now(); continue; } + if (Date.now() - quietFrom >= QUIET_MS) return; + } +} + +function roleBreakdown(page) { + return page.evaluate(() => { + const h3 = Array.from(document.querySelectorAll('#analyticsContent h3')).find((h) => h.textContent.includes('Role Breakdown')); + if (!h3) return null; + const row = h3.nextElementSibling; + const counts = {}; + Array.from(row.querySelectorAll('.badge')).forEach((b) => { + const m = /^(\w+):\s*(\d+)$/.exec(b.textContent.trim()); + if (m) counts[m[1]] = Number(m[2]); + }); + return counts; + }); +} + +function totalNodesStat(page) { + return page.evaluate(() => { + const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent)); + if (!card) return null; + const m = /(\d+)/.exec(card.textContent); + return m ? Number(m[1]) : null; + }); +} + +async function pickRegion(page, code) { + await page.waitForSelector('#analyticsRegionFilter [data-region="' + code + '"]', { state: 'attached', timeout: 15000 }); + await page.evaluate((c) => document.querySelector('#analyticsRegionFilter [data-region="' + c + '"]').click(), code); +} + +function sumOf(counts) { + return Object.keys(counts).reduce((a, k) => a + counts[k], 0); +} + +(async () => { + const browser = await chromium.launch(); + try { + const page = await openPage(browser); + + let unfilteredTotal, unfilteredBreakdown; + await step('loads the Nodes analytics tab unfiltered', async () => { + await page.goto(BASE + '/#/analytics?tab=nodes', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#analyticsContent h3:has-text("Role Breakdown")', { timeout: 15000 }); + await apiIdle(page); + unfilteredTotal = await totalNodesStat(page); + unfilteredBreakdown = await roleBreakdown(page); + assert(unfilteredTotal > 0, 'Total Nodes must render a positive count, got ' + unfilteredTotal); + assert(sumOf(unfilteredBreakdown) === unfilteredTotal, + 'unfiltered role-breakdown sum (' + sumOf(unfilteredBreakdown) + ') must equal Total Nodes (' + unfilteredTotal + ')'); + }); + + await step('an MRY region filter: the role breakdown sum equals the Network Status total', async () => { + await pickRegion(page, 'MRY'); + await apiIdle(page); + await page.waitForFunction(() => { + const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent)); + return !!card && !/^\s*$/.test(card.textContent); + }, { timeout: 15000 }); + + const total = await totalNodesStat(page); + const breakdown = await roleBreakdown(page); + + assert(total < unfilteredTotal, 'MRY must narrow Total Nodes below the unfiltered ' + unfilteredTotal + ', got ' + total); + const sum = sumOf(breakdown); + assert(sum === total, 'MRY-filtered role-breakdown sum (' + sum + ') must equal the Network Status total (' + total + ')'); + + // The regression this closes: before the fix every badge showed the + // server's global, filter-blind counts no matter which region was + // active, so the filtered breakdown was identical to the unfiltered + // one. MRY's own repeater count must differ from the unfiltered one. + assert(breakdown.repeaters !== unfilteredBreakdown.repeaters, + 'MRY repeaters (' + breakdown.repeaters + ') must differ from the unfiltered global count (' + unfilteredBreakdown.repeaters + ')'); + }); + + await step('clearing the region filter restores the unfiltered numbers (no leftover stale state)', async () => { + await pickRegion(page, '__all__'); + await apiIdle(page); + await page.waitForFunction((want) => { + const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent)); + const m = card && /(\d+)/.exec(card.textContent); + return !!m && Number(m[1]) === want; + }, unfilteredTotal, { timeout: 15000 }); + + const total = await totalNodesStat(page); + const breakdown = await roleBreakdown(page); + assert(total === unfilteredTotal, 'clearing the filter should restore Total Nodes to ' + unfilteredTotal + ', got ' + total); + assert(sumOf(breakdown) === total, 'post-clear role-breakdown sum must again equal Total Nodes'); + assert(breakdown.repeaters === unfilteredBreakdown.repeaters, 'post-clear repeaters count must match the original unfiltered count'); + }); + + await step('no page errors were raised', async () => { + assert(page.__errors.length === 0, 'page errors: ' + page.__errors.join('; ')); + }); + } finally { + await browser.close(); + } + + console.log('\n' + passed + ' passed, ' + failed + ' failed'); + process.exit(failed === 0 ? 0 : 1); +})(); diff --git a/test-issue-560-role-breakdown.js b/test-issue-560-role-breakdown.js new file mode 100644 index 000000000..d74012881 --- /dev/null +++ b/test-issue-560-role-breakdown.js @@ -0,0 +1,254 @@ +/** + * Unit + integration tests for #560 (Relates to #560): Analytics -> Nodes' + * Role Breakdown must be computed from the already-loaded, FILTERED node + * rows (region + area), not from /api/nodes' global `counts` metadata + * (cmd/server/db.go GetAllRoleCounts, unconditional over the whole `nodes` + * table). That global count still backs other consumers unchanged. + * + * Section A: the pure counting function nodeRoleBreakdownCounts + * (window._analyticsNodeRoleBreakdownCounts) -- known roles, an unknown + * role, and an empty row set. + * Section B: renderNodesTab (window._analyticsRenderNodesTab) wired end to + * end against stub fetchAllNodes/api -- synthetic global counts that + * disagree with the filtered rows, exactly one network round trip per + * render, and a late (superseded) response that must not clobber a + * newer render's output. + */ +'use strict'; + +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { + fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} +async function testAsync(name, fn) { + try { + await fn(); + passed++; + console.log(` ✅ ${name}`); + } catch (e) { + failed++; + console.log(` ❌ ${name}: ${e.message}`); + } +} + +// Same sandbox shape as test-analytics-nodes-without-scope.js. +function makeSandbox() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {} }, + document: { + readyState: 'complete', + createElement: () => ({ id: '', textContent: '', innerHTML: '' }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], + querySelector: () => null, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout, clearTimeout, setInterval: () => {}, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + localStorage: (() => { const s = {}; return { getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '' }, + CustomEvent: class CustomEvent {}, + Map, Promise, URLSearchParams, + addEventListener: () => {}, + dispatchEvent: () => {}, + requestAnimationFrame: (cb) => setTimeout(cb, 0), + }; + vm.createContext(ctx); + return ctx; +} + +function loadInCtx(ctx, file) { + if (!ctx.__payloadLabelsLoaded && file !== 'public/payload-labels.js') { + ctx.__payloadLabelsLoaded = true; + vm.runInContext(fs.readFileSync('public/payload-labels.js', 'utf8'), ctx); + } + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; +} + +// --- fetchAllNodes/api stubs recorded per test so each case can assert on +// exactly how many times the network was touched and with what the render +// saw as its synthetic "server" response. +function makeAnalyticsSandbox() { + const ctx = makeSandbox(); + ctx.getComputedStyle = () => ({ getPropertyValue: () => '' }); + ctx.registerPage = () => {}; + ctx.RegionFilter = { init: () => {}, onChange: () => {}, regionQueryString: () => '' }; + ctx.AreaFilter = { init: () => {}, onChange: () => {}, areaQueryString: () => '' }; + ctx.onWS = () => {}; + ctx.offWS = () => {}; + ctx.connectWS = () => {}; + ctx.invalidateApiCache = () => {}; + ctx.makeColumnsResizable = () => {}; + ctx.initTabBar = () => {}; + ctx.IATA_COORDS_GEO = {}; + loadInCtx(ctx, 'public/roles.js'); + loadInCtx(ctx, 'public/app.js'); + loadInCtx(ctx, 'public/heard-by.js'); + try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) { + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; + } + return ctx; +} + +const ctx = makeAnalyticsSandbox(); +const nodeRoleBreakdownCounts = ctx.window._analyticsNodeRoleBreakdownCounts; +const renderNodesTab = ctx.window._analyticsRenderNodesTab; + +console.log('\n=== analytics.js: nodeRoleBreakdownCounts (unit) ==='); + +test('is exported for testing', () => { + assert.strictEqual(typeof nodeRoleBreakdownCounts, 'function'); +}); + +test('counts each known role, pluralized -- same shape as GetAllRoleCounts', () => { + const nodes = [ + { public_key: 'p1', role: 'repeater' }, + { public_key: 'p2', role: 'repeater' }, + { public_key: 'p3', role: 'room' }, + { public_key: 'p4', role: 'companion' }, + { public_key: 'p5', role: 'sensor' }, + ]; + assert.strictEqual(JSON.stringify(nodeRoleBreakdownCounts(nodes)), JSON.stringify({ repeaters: 2, rooms: 1, companions: 1, sensors: 1 })); +}); + +test('an unknown/unrecognized role is not tallied into any bucket and does not throw', () => { + const nodes = [ + { public_key: 'p1', role: 'repeater' }, + { public_key: 'p2', role: 'observer' }, // a real role, but not one GetAllRoleCounts tracks today + { public_key: 'p3', role: 'gateway' }, // never a known role at all + { public_key: 'p4', role: null }, + { public_key: 'p5' }, // role field missing entirely + ]; + const result = nodeRoleBreakdownCounts(nodes); + assert.strictEqual(JSON.stringify(result), JSON.stringify({ repeaters: 1, rooms: 0, companions: 0, sensors: 0 })); + const sum = Object.keys(result).reduce((a, k) => a + result[k], 0); + assert.ok(sum < nodes.length, 'unknown-role rows must not inflate any known bucket'); +}); + +test('an empty row set renders zero for every known role, not an absent/undefined count', () => { + assert.strictEqual(JSON.stringify(nodeRoleBreakdownCounts([])), JSON.stringify({ repeaters: 0, rooms: 0, companions: 0, sensors: 0 })); +}); + +test('a non-array input is treated as empty rather than throwing', () => { + const zero = JSON.stringify({ repeaters: 0, rooms: 0, companions: 0, sensors: 0 }); + assert.strictEqual(JSON.stringify(nodeRoleBreakdownCounts(undefined)), zero); + assert.strictEqual(JSON.stringify(nodeRoleBreakdownCounts(null)), zero); +}); + +// ===================================================================== +// Section B -- renderNodesTab wired end to end. +// ===================================================================== + +function makeEl() { + return { _h: '', set innerHTML(v) { this._h = v; }, get innerHTML() { return this._h; } }; +} + +function extractBadge(html, key) { + const m = new RegExp(key + ':\\s*(\\d+)').exec(html); + return m ? Number(m[1]) : null; +} + +console.log('\n=== analytics.js: renderNodesTab (integration) ==='); + +(async () => { + await testAsync('renders the Role Breakdown from the filtered rows, not from a disagreeing global `counts`', async () => { + let fetchCalls = 0, apiCalls = 0; + const filteredNodes = [ + { public_key: 'p1', role: 'repeater', last_seen: new Date().toISOString() }, + { public_key: 'p2', role: 'repeater', last_seen: new Date().toISOString() }, + { public_key: 'p3', role: 'room', last_seen: new Date().toISOString() }, + ]; + ctx.fetchAllNodes = async () => { + fetchCalls++; + // Deliberately disagreeing global counts (as if the region filter + // returned 3 rows while the unfiltered mesh has far more of each role). + return { nodes: filteredNodes, counts: { repeaters: 999, rooms: 999, companions: 999, sensors: 999 }, total: filteredNodes.length }; + }; + ctx.api = async () => { apiCalls++; return []; }; + + const el = makeEl(); + await renderNodesTab(el); + + assert.strictEqual(fetchCalls, 1, 'exactly one fetchAllNodes call -- no extra request added by the fix'); + assert.strictEqual(apiCalls, 1, 'exactly one bulk-health call -- no extra request added by the fix'); + assert.strictEqual(extractBadge(el._h, 'repeaters'), 2, 'repeaters badge must come from the 2 filtered rows, not the global 999'); + assert.strictEqual(extractBadge(el._h, 'rooms'), 1, 'rooms badge must come from the 1 filtered row, not the global 999'); + assert.strictEqual(extractBadge(el._h, 'companions'), 0); + assert.strictEqual(extractBadge(el._h, 'sensors'), 0); + assert.ok(!/999/.test(el._h), 'the disagreeing global count must never reach the page'); + + // Network Status total and the role-breakdown sum describe the same + // (filtered) population, since every row above has a known role. + const totalMatch = /font-weight:700">(\d+)<\/div>\s*]*>Total Nodes/.exec(el._h); + assert.ok(totalMatch, 'Total Nodes stat must render'); + const total = Number(totalMatch[1]); + const sum = ['repeaters', 'rooms', 'companions', 'sensors'].reduce((a, k) => a + extractBadge(el._h, k), 0); + assert.strictEqual(sum, total, 'role-breakdown sum must equal the Network Status total for an all-known-role population'); + }); + + await testAsync('an empty filtered result renders zero for every role, not a fallback to any global count', async () => { + ctx.fetchAllNodes = async () => ({ nodes: [], counts: { repeaters: 42, rooms: 7, companions: 3, sensors: 1 }, total: 0 }); + ctx.api = async () => []; + + const el = makeEl(); + await renderNodesTab(el); + + assert.strictEqual(extractBadge(el._h, 'repeaters'), 0); + assert.strictEqual(extractBadge(el._h, 'rooms'), 0); + assert.strictEqual(extractBadge(el._h, 'companions'), 0); + assert.strictEqual(extractBadge(el._h, 'sensors'), 0); + assert.ok(!/42|>7<|>3<|>1 { + // First call's fetch is held open; second call (a simulated filter + // change while the first is still pending) resolves immediately. The + // first call's response must be discarded once it finally arrives. + let resolveFirst; + let call = 0; + ctx.fetchAllNodes = async () => { + call++; + if (call === 1) { + return new Promise((resolve) => { resolveFirst = resolve; }); + } + return { nodes: [{ public_key: 'new1', role: 'companion', last_seen: new Date().toISOString() }], counts: {}, total: 1 }; + }; + ctx.api = async () => []; + + const el = makeEl(); + const firstRender = renderNodesTab(el); // not awaited -- still pending + await renderNodesTab(el); // second, newer render resolves first + + assert.strictEqual(extractBadge(el._h, 'companions'), 1, 'the newer render\'s single companion must be showing'); + assert.strictEqual(extractBadge(el._h, 'repeaters'), 0); + + // Now let the stale first request resolve with different data. + resolveFirst({ nodes: [{ public_key: 'old1', role: 'repeater' }, { public_key: 'old2', role: 'repeater' }], counts: {}, total: 2 }); + await firstRender; + + assert.strictEqual(extractBadge(el._h, 'companions'), 1, 'the stale response must not have overwritten the newer render'); + assert.strictEqual(extractBadge(el._h, 'repeaters'), 0, 'the stale response\'s repeater rows must never have reached the page'); + }); + + console.log('\n════════════════════════════════════════'); + console.log(` Role Breakdown (#560): ${passed} passed, ${failed} failed`); + console.log('════════════════════════════════════════'); + process.exit(failed === 0 ? 0 : 1); +})(); From 31aa66a03ced8c20f9a4a0e0f3f926d4c3e869a7 Mon Sep 17 00:00:00 2001 From: dborup Date: Sun, 25 Oct 2026 06:31:08 +0100 Subject: [PATCH 2/2] reviewfix(analytics): close #560 round-2 review findings 1-4 CS-Minimax PR#580 round-2 (REQUEST CHANGES): 1. Area-only and combined region+area filters had no test coverage, so mutant M7 (dropping AreaFilter.areaQueryString() from the request) survived. Added unit tests that record the outgoing fetchAllNodes query for both cases, plus an area-only and a combined region+area E2E scenario against a private, disposable server with one test-only area (the shared BASE_URL fixture configures none). 2. fetchAllNodes (public/app.js) silently sliced to its safetyCap with no way for a caller to tell. It now returns `truncated`, and renderNodesTab (analytics.js) shows a visible notice when the loaded node list may be incomplete, rather than presenting Total Nodes/Role Breakdown as whole regardless. 3. Added an E2E assertion that /api/nodes' global `counts` stays the unconditional per-role total under a region filter -- the contract this PR's Go-free diff relies on holding. 4. Added an E2E assertion that the unfiltered Role Breakdown equals /api/nodes' own `counts` directly, not just its own internal sum. Relates to #560. Co-Authored-By: Claude Sonnet 5 --- public/analytics.js | 11 ++ public/app.js | 17 ++- scripts/e2e-shards.json | 4 +- test-fetch-all-nodes-pagination.js | 32 +++++ test-issue-560-role-breakdown-e2e.js | 181 +++++++++++++++++++++++++++ test-issue-560-role-breakdown.js | 65 ++++++++++ 6 files changed, 304 insertions(+), 6 deletions(-) diff --git a/public/analytics.js b/public/analytics.js index 2d441e289..b413ba0c3 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -2647,6 +2647,16 @@ }); const totalNodes = nodesResp.total || nodes.length; const roleCounts = nodeRoleBreakdownCounts(nodes); + // #560 follow-up: fetchAllNodes silently slices to its safetyCap + // (10000 nodes by default, public/app.js). Total Nodes and the Role + // Breakdown below both come from this same `nodes` array, so they stay + // mutually consistent even above the cap — but presenting a truncated + // population as complete would still be misleading. Surface it rather + // than guess whether any given deployment can reach it. + const nodesTruncatedNotice = nodesResp.truncated ? ` +
+ Node list truncated at ${nodes.length.toLocaleString()} rows — Total Nodes and Role Breakdown below may not reflect the complete filtered population. +
` : ''; function nodeLink(n) { return `${esc(n.name || n.public_key.slice(0, 12))}`; @@ -2658,6 +2668,7 @@ // ROLE_COLORS from shared roles.js el.innerHTML = ` + ${nodesTruncatedNotice}

Network Status

diff --git a/public/app.js b/public/app.js index 015ae2c9e..7084d0e04 100644 --- a/public/app.js +++ b/public/app.js @@ -411,11 +411,19 @@ function _warmupNotify_1659(active) { // (e.g. '&lastHeard=30d&area=x', '&sortBy=lastSeen®ion=y'); pass '' for none. // safetyCap: hard ceiling on BOTH pages fetched and nodes returned — the result // is sliced to it (callers like live.js pass their render ceiling here). -// Returns { nodes, counts, total }: counts is from the first page; total is the -// real deduped/capped node count (NOT the server's per-query `total`). +// Returns { nodes, counts, total, truncated }: counts is from the first page; +// total is the real deduped/capped node count (NOT the server's per-query +// `total`). truncated is true when `nodes` may not be the complete +// population for the query — either the page loop never saw a short page +// before hitting safetyCap (so more rows may exist beyond it), or dedup +// left more unique rows than safetyCap and the final slice below dropped +// some. A caller that presents `nodes` as a complete population (e.g. +// analytics.js's Role Breakdown, #560) must check this rather than +// silently showing a partial count as whole. async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyCap = 10000 } = {}) { const accumulated = []; let counts = {}; + let stoppedShort = false; for (let offset = 0; offset < safetyCap; offset += pageSize) { const data = await api(`/nodes?limit=${pageSize}&offset=${offset}${extraQuery}`, { ttl }); const page = data && Array.isArray(data.nodes) ? data.nodes @@ -426,7 +434,7 @@ async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyC // COUNT(*) for the query, but the handler overwrites it with the filtered // length under area/geo/blacklist filtering — so we never loop on it, nor // surface it; a short page is the reliable end-of-data signal. See #1606. - if (page.length < pageSize) break; + if (page.length < pageSize) { stoppedShort = true; break; } } // Dedup by public_key: the sort window (last_seen DESC by default) can shift // under concurrent ingest, repeating a row across a page boundary. Rows @@ -440,7 +448,8 @@ async function fetchAllNodes(extraQuery = '', { ttl = 0, pageSize = 500, safetyC // it to the next pageSize multiple), so e.g. live.js's LIVE_MAP_MAX_NODES is // honored exactly rather than overshooting by up to pageSize-1. const nodes = Array.from(seen.values()).slice(0, safetyCap); - return { nodes, counts, total: nodes.length }; + const truncated = !stoppedShort || seen.size > nodes.length; + return { nodes, counts, total: nodes.length, truncated }; } function invalidateApiCache(prefix) { diff --git a/scripts/e2e-shards.json b/scripts/e2e-shards.json index e782c55f4..f9a73907b 100644 --- a/scripts/e2e-shards.json +++ b/scripts/e2e-shards.json @@ -36,10 +36,10 @@ { "file": "test-issue-560-role-breakdown-e2e.js", "shard": 2, - "env": { "BASE_URL": "$BASE_URL" }, + "env": { "BASE_URL": "$BASE_URL", "CORESCOPE_SERVER_BIN": "./corescope-server", "CORESCOPE_MIGRATE_BIN": "./corescope-migrate" }, "output": "e2e", "meanSeconds": 0, - "measurementNote": "New suite; #560 Analytics -> Nodes Role Breakdown computed from the filtered rows, not the server's global /api/nodes counts -- an MRY region filter's role-breakdown sum must equal the Network Status total, and must differ from the unfiltered repeaters count. About 5s locally; no CI duration measured yet; assigned to shard 2, the lowest measured total." + "measurementNote": "New suite; #560 Analytics -> Nodes Role Breakdown computed from the filtered rows, not the server's global /api/nodes counts -- an MRY region filter's role-breakdown sum must equal the Network Status total, and must differ from the unfiltered repeaters count; also an unfiltered-vs-server counts-contract check, and (round-2 review) an area-only and combined region+area check against a private, disposable server instance with one test-only area configured via -config-dir, since the shared BASE_URL server/fixture has none. About 15s locally; no CI duration measured yet; assigned to shard 2, the lowest measured total." }, { "file": "test-page-lifecycle-2248-e2e.js", diff --git a/test-fetch-all-nodes-pagination.js b/test-fetch-all-nodes-pagination.js index b53689498..0b2db07b9 100644 --- a/test-fetch-all-nodes-pagination.js +++ b/test-fetch-all-nodes-pagination.js @@ -109,6 +109,7 @@ test('surfaces ALL nodes past the 500 server cap (1200 > 500)', async () => { assert.strictEqual(out.nodes.length, 1200, 'expected all 1200 nodes, got ' + out.nodes.length); assert.strictEqual(out.total, 1200, 'total must be the real deduped count, not the clamped per-page total'); assert.strictEqual(m.calls.length, 3, 'expected 3 pages (500+500+200), got ' + m.calls.length); + assert.strictEqual(out.truncated, false, 'a short final page means the population is complete, not truncated'); }); test('stops on a short page rather than the unreliable server total', async () => { @@ -120,6 +121,7 @@ test('stops on a short page rather than the unreliable server total', async () = const out = await ctx.fetchAllNodes(''); assert.strictEqual(out.nodes.length, 1000); assert.strictEqual(m.calls.length, 3, 'expected one extra empty page after two full pages'); + assert.strictEqual(out.truncated, false, 'a trailing empty page means the population is complete, not truncated'); }); test('dedups a public_key repeated across a page boundary', async () => { @@ -165,6 +167,36 @@ test('safetyCap is a hard node-count ceiling (no pageSize overshoot)', async () const out = await ctx.fetchAllNodes('', { safetyCap: 800 }); assert.strictEqual(out.nodes.length, 800, 'safetyCap must cap returned nodes exactly, got ' + out.nodes.length); assert.strictEqual(out.total, 800, 'total must equal the capped count'); + assert.strictEqual(out.truncated, true, 'the real 5000-node population exceeds the 800 safetyCap, so the result must say so'); +}); + +// #560 follow-up: a caller presenting `nodes` as the complete filtered +// population (analytics.js's Role Breakdown) must be able to tell when it +// is not — see the two `truncated` assertions above for the "complete" +// cases. This covers the two ways `truncated` must come back true. +test('truncated is true when the page loop hits safetyCap without ever seeing a short page', async () => { + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/app.js'); + // 10000 nodes, safetyCap=1000, pageSize=500: every fetched page (0, 500) + // is a full 500-row page, so the loop never learns the real population is + // far larger than what it returned. + const m = makeNodesFetch(10000, 500); + ctx.fetch = m.fetch; + const out = await ctx.fetchAllNodes('', { safetyCap: 1000 }); + assert.strictEqual(out.nodes.length, 1000); + assert.strictEqual(out.truncated, true, 'every page fetched was full, so more rows may exist beyond safetyCap'); +}); + +test('truncated is false when the real population exactly fits under safetyCap', async () => { + const ctx = makeSandbox(); + loadInCtx(ctx, 'public/app.js'); + // 1000 nodes, safetyCap well above it: the loop sees a short (0-length) + // page before ever approaching the cap, so the population is complete. + const m = makeNodesFetch(1000, 500); + ctx.fetch = m.fetch; + const out = await ctx.fetchAllNodes('', { safetyCap: 10000 }); + assert.strictEqual(out.nodes.length, 1000); + assert.strictEqual(out.truncated, false, 'a short page was seen well under safetyCap, so the population is complete'); }); test('rows missing public_key are NOT collapsed into one', async () => { diff --git a/test-issue-560-role-breakdown-e2e.js b/test-issue-560-role-breakdown-e2e.js index 15fc97350..0d764fec4 100644 --- a/test-issue-560-role-breakdown-e2e.js +++ b/test-issue-560-role-breakdown-e2e.js @@ -18,6 +18,11 @@ */ 'use strict'; const { chromium } = require('playwright'); +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const net = require('net'); const BASE = process.env.BASE_URL || 'http://localhost:13581'; @@ -86,6 +91,99 @@ function sumOf(counts) { return Object.keys(counts).reduce((a, k) => a + counts[k], 0); } +// Order-independent equality for the four known-role count objects -- +// /api/nodes' `counts` and the DOM-parsed Role Breakdown enumerate their +// keys in different orders, so a JSON.stringify comparison is not safe. +function sameCounts(a, b) { + const keys = ['repeaters', 'rooms', 'companions', 'sensors']; + return keys.every((k) => (a[k] || 0) === (b[k] || 0)); +} + +// ===== area-only / combined region+area: CS-Minimax PR#580 round-2 review, +// finding 1. The shared BASE_URL server (and the committed e2e-fixture.db) +// has no `areas` configured at all, so an area filter cannot be exercised +// against it. Spin up a SEPARATE, disposable server instance -- a copy of +// the same migrated fixture, with one test-only area added via -config-dir +// -- mirroring the private-server pattern already used by +// test-issue-315-estimated-positions-e2e.js. Requires CORESCOPE_SERVER_BIN +// and CORESCOPE_MIGRATE_BIN (already set for that suite in e2e-shards.json); +// skips gracefully when they are not set, e.g. for a quick local run of just +// this file against BASE_URL. +async function freePort() { + const socket = net.createServer(); + await new Promise((resolve, reject) => { socket.once('error', reject); socket.listen(0, '127.0.0.1', resolve); }); + const port = socket.address().port; + await new Promise((resolve) => socket.close(resolve)); + return port; +} + +async function stopChild(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + const ended = new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + const timer = setTimeout(() => child.kill('SIGKILL'), 5000); + await ended; + clearTimeout(timer); +} + +async function waitServerHealthy(base, child, getLog) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + assert(child.exitCode === null && child.signalCode === null, 'private server exited early: ' + getLog()); + try { + const r = await fetch(base + '/api/healthz'); + if (r.ok) return; + } catch (_) { /* not listening yet */ } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('private server did not become healthy in time: ' + getLog()); +} + +// A bounding box covering 6 of the fixture's 13 MRY-region nodes (all +// repeaters) plus 43 further nodes outside MRY (38 repeaters, 4 rooms, 1 +// companion) -- chosen by inspecting the committed fixture's real node +// coordinates, so area-only (49), region-only (13) and the MRY∩SFBAY +// combination (6) are three genuinely different, non-overlapping-with- +// unfiltered populations. +const SFBAY_AREA_CONFIG = { label: 'SF Bay (test)', latMin: 37.7, latMax: 38.0, lonMin: -122.6, lonMax: -122.0 }; + +async function withPrivateAreaServer(fn) { + const serverBin = process.env.CORESCOPE_SERVER_BIN; + const migrateBin = process.env.CORESCOPE_MIGRATE_BIN; + if (!serverBin || !migrateBin) { + console.log(' … SKIP area-only / combined region+area checks (set CORESCOPE_SERVER_BIN and CORESCOPE_MIGRATE_BIN to run them)'); + return; + } + const { execFileSync } = require('child_process'); + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'corescope-560-e2e-')); + let child = null; + try { + const database = path.join(scratch, 'fixture.db'); + fs.copyFileSync(path.join(__dirname, 'test-fixtures/e2e-fixture.db'), database); + execFileSync(migrateBin, ['-db', database], { stdio: 'pipe' }); + fs.writeFileSync(path.join(scratch, 'config.json'), JSON.stringify({ areas: { SFBAY: SFBAY_AREA_CONFIG } })); + const port = await freePort(); + const base = 'http://127.0.0.1:' + port; + let log = ''; + child = spawn(serverBin, ['-config-dir', scratch, '-db', database, '-public', path.join(__dirname, 'public'), '-port', String(port)], + { cwd: __dirname, stdio: ['ignore', 'pipe', 'pipe'] }); + child.stdout.on('data', (c) => { log = (log + c).slice(-20000); }); + child.stderr.on('data', (c) => { log = (log + c).slice(-20000); }); + await waitServerHealthy(base, child, () => log); + await fn(base); + } finally { + await stopChild(child); + fs.rmSync(scratch, { recursive: true, force: true }); + } +} + +async function pickArea(page, key) { + await page.waitForSelector('#analyticsAreaFilter .region-dropdown-trigger', { state: 'attached', timeout: 15000 }); + await page.click('#analyticsAreaFilter .region-dropdown-trigger'); + await page.waitForSelector('#analyticsAreaFilter [data-area="' + key + '"]', { state: 'attached', timeout: 15000 }); + await page.click('#analyticsAreaFilter [data-area="' + key + '"]'); +} + (async () => { const browser = await chromium.launch(); try { @@ -103,6 +201,34 @@ function sumOf(counts) { 'unfiltered role-breakdown sum (' + sumOf(unfilteredBreakdown) + ') must equal Total Nodes (' + unfilteredTotal + ')'); }); + // CS-Minimax PR#580 round-2 review, finding 4: the suite checked the + // breakdown's own internal sum against Total Nodes, but never against + // the server's /api/nodes `counts` directly. Unfiltered, these two MUST + // agree -- that is the baseline #560 preserves. + await step('unfiltered Role Breakdown equals /api/nodes\' own global counts', async () => { + const serverCounts = await page.evaluate(() => fetch('/api/nodes?limit=1').then((r) => r.json()).then((d) => d.counts)); + assert(sameCounts(unfilteredBreakdown, serverCounts), + 'unfiltered breakdown ' + JSON.stringify(unfilteredBreakdown) + ' must equal /api/nodes counts ' + JSON.stringify(serverCounts)); + }); + + // CS-Minimax PR#580 round-2 review, finding 3: this PR touches no Go, + // so the global /api/nodes `counts` contract (an unconditional COUNT(*) + // per role, unaffected by region/area filters -- GetAllRoleCounts in + // cmd/server/db.go) must still hold. Make that explicit rather than + // leaving it implicit in "no Go changed". + await step('/api/nodes global counts contract is unaffected by a region filter', async () => { + const [plain, mry] = await Promise.all([ + page.evaluate(() => fetch('/api/nodes?limit=1').then((r) => r.json())), + page.evaluate(() => fetch('/api/nodes?limit=1®ion=MRY').then((r) => r.json())), + ]); + for (const key of ['repeaters', 'rooms', 'companions', 'sensors']) { + assert(typeof plain.counts[key] === 'number', 'counts.' + key + ' must be a number, got: ' + JSON.stringify(plain.counts)); + } + assert(sameCounts(mry.counts, plain.counts), + 'global counts must stay the server-wide total under a region filter (this PR only changed the CLIENT-side breakdown): ' + + JSON.stringify(mry.counts) + ' vs ' + JSON.stringify(plain.counts)); + }); + await step('an MRY region filter: the role breakdown sum equals the Network Status total', async () => { await pickRegion(page, 'MRY'); await apiIdle(page); @@ -145,6 +271,61 @@ function sumOf(counts) { await step('no page errors were raised', async () => { assert(page.__errors.length === 0, 'page errors: ' + page.__errors.join('; ')); }); + + // CS-Minimax PR#580 round-2 review, finding 1: area-only and combined + // region+area coverage, against a private server with one test area. + await withPrivateAreaServer(async (base) => { + const areaPage = await openPage(browser); + let baseTotal; + await step('loads the Nodes analytics tab unfiltered on the private area server', async () => { + await areaPage.goto(base + '/#/analytics?tab=nodes', { waitUntil: 'domcontentloaded' }); + await areaPage.waitForSelector('#analyticsContent h3:has-text("Role Breakdown")', { timeout: 15000 }); + await apiIdle(areaPage); + baseTotal = await totalNodesStat(areaPage); + assert(baseTotal > 0, 'Total Nodes must render a positive count, got ' + baseTotal); + }); + + await step('an area-only filter: the role breakdown sum equals the Network Status total, and narrows the unfiltered total', async () => { + await pickArea(areaPage, 'SFBAY'); + await apiIdle(areaPage); + await areaPage.waitForFunction((want) => { + const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent)); + const m = card && /(\d+)/.exec(card.textContent); + return !!m && Number(m[1]) !== want; + }, baseTotal, { timeout: 15000 }); + + const total = await totalNodesStat(areaPage); + const breakdown = await roleBreakdown(areaPage); + assert(total !== baseTotal, 'the SFBAY area must narrow Total Nodes from the unfiltered ' + baseTotal + ', got ' + total); + assert(sumOf(breakdown) === total, + 'area-only role-breakdown sum (' + sumOf(breakdown) + ') must equal the Network Status total (' + total + ')'); + }); + + await step('a combined region+area filter narrows further, and the breakdown sum still equals the total', async () => { + await pickRegion(areaPage, 'MRY'); + await apiIdle(areaPage); + await areaPage.waitForFunction(() => { + const card = Array.from(document.querySelectorAll('.analytics-stat-card')).find((c) => /Total Nodes/.test(c.textContent)); + return !!card && !/^\s*$/.test(card.textContent); + }, { timeout: 15000 }); + + const total = await totalNodesStat(areaPage); + const breakdown = await roleBreakdown(areaPage); + // MRY ∩ SFBAY is exactly 6 of the fixture's known nodes, all + // repeaters (verified directly against the Go server's own + // /api/nodes?area=SFBAY®ion=MRY response while building this + // fixture area -- see SFBAY_AREA_CONFIG above). + assert(total === 6, 'MRY ∩ SFBAY must be exactly 6 nodes in this fixture, got ' + total); + assert(sumOf(breakdown) === total, + 'combined region+area role-breakdown sum (' + sumOf(breakdown) + ') must equal the Network Status total (' + total + ')'); + assert(breakdown.repeaters === 6, 'all 6 MRY ∩ SFBAY nodes are repeaters in this fixture, got ' + breakdown.repeaters); + }); + + await step('no page errors were raised on the private area server', async () => { + assert(areaPage.__errors.length === 0, 'page errors: ' + areaPage.__errors.join('; ')); + }); + await areaPage.close(); + }); } finally { await browser.close(); } diff --git a/test-issue-560-role-breakdown.js b/test-issue-560-role-breakdown.js index d74012881..053e7f119 100644 --- a/test-issue-560-role-breakdown.js +++ b/test-issue-560-role-breakdown.js @@ -247,6 +247,71 @@ console.log('\n=== analytics.js: renderNodesTab (integration) ==='); assert.strictEqual(extractBadge(el._h, 'repeaters'), 0, 'the stale response\'s repeater rows must never have reached the page'); }); + // ===================================================================== + // Section C -- CS-Minimax PR#580 round-2 review, finding 2: fetchAllNodes + // silently slices to its safetyCap (public/app.js). A truncated node list + // must not be presented to the user as a complete population. + // ===================================================================== + console.log('\n=== analytics.js: renderNodesTab surfaces a truncated node list ==='); + + await testAsync('shows a truncation notice when fetchAllNodes reports truncated: true', async () => { + ctx.fetchAllNodes = async () => ({ + nodes: [{ public_key: 'p1', role: 'repeater', last_seen: new Date().toISOString() }], + counts: {}, total: 1, truncated: true, + }); + ctx.api = async () => []; + const el = makeEl(); + await renderNodesTab(el); + assert.ok(/truncat/i.test(el._h), 'a truncated node list must render a visible notice, got: ' + el._h.slice(0, 200)); + }); + + await testAsync('shows no truncation notice when fetchAllNodes reports truncated: false', async () => { + ctx.fetchAllNodes = async () => ({ + nodes: [{ public_key: 'p1', role: 'repeater', last_seen: new Date().toISOString() }], + counts: {}, total: 1, truncated: false, + }); + ctx.api = async () => []; + const el = makeEl(); + await renderNodesTab(el); + assert.ok(!/truncat/i.test(el._h), 'a complete node list must not render a truncation notice, got: ' + el._h.slice(0, 200)); + }); + + // ===================================================================== + // Section D -- CS-Minimax PR#580 round-2 review, finding 1: an area-only + // filter and a combined region+area filter must both reach fetchAllNodes + // -- the unit sandbox previously stubbed AreaFilter.areaQueryString() to + // '' and never varied it, so a regression dropping AreaFilter's + // contribution to the request (mutant M7) passed the whole suite. + // ===================================================================== + console.log('\n=== analytics.js: renderNodesTab passes area/region+area query to fetchAllNodes ==='); + + await testAsync('an area-only filter is included in the fetchAllNodes query', async () => { + let recordedQuery = null; + ctx.RegionFilter.regionQueryString = () => ''; + ctx.AreaFilter.areaQueryString = () => '&area=SFBAY'; + ctx.fetchAllNodes = async (q) => { recordedQuery = q; return { nodes: [], counts: {}, total: 0 }; }; + ctx.api = async () => []; + const el = makeEl(); + await renderNodesTab(el); + assert.ok(recordedQuery && recordedQuery.indexOf('&area=SFBAY') !== -1, + 'an area-only filter must reach fetchAllNodes\' query, got: ' + recordedQuery); + ctx.AreaFilter.areaQueryString = () => ''; + }); + + await testAsync('a combined region+area filter includes BOTH pieces in the fetchAllNodes query', async () => { + let recordedQuery = null; + ctx.RegionFilter.regionQueryString = () => '®ion=MRY'; + ctx.AreaFilter.areaQueryString = () => '&area=SFBAY'; + ctx.fetchAllNodes = async (q) => { recordedQuery = q; return { nodes: [], counts: {}, total: 0 }; }; + ctx.api = async () => []; + const el = makeEl(); + await renderNodesTab(el); + assert.ok(recordedQuery && recordedQuery.indexOf('®ion=MRY') !== -1 && recordedQuery.indexOf('&area=SFBAY') !== -1, + 'a combined region+area filter must pass BOTH pieces to fetchAllNodes\' query, got: ' + recordedQuery); + ctx.RegionFilter.regionQueryString = () => ''; + ctx.AreaFilter.areaQueryString = () => ''; + }); + console.log('\n════════════════════════════════════════'); console.log(` Role Breakdown (#560): ${passed} passed, ${failed} failed`); console.log('════════════════════════════════════════');