Files
meshcore-analyzer/tests/e2e/test-map-nodes-pagination-e2e.js
liquidraverandClaude Opus 5 b695b979a2 fix(nodes): keep paginating past a page that post-LIMIT filtering shortened (#2061)
## Problem

`handleNodes` runs the geo-filter, `nodeBlacklist`, `hiddenNamePrefixes`
and area
passes **after** the SQL `LIMIT/OFFSET`, and rewrites `total` to the
filtered
length. A page that loses a row is therefore short **without being the
last
page**, and neither the page length nor `total` can tell a client
whether to ask
for another page.

#1606 added the pagination loop and chose the page length as the
canonical stop.
That is correct only where nothing is ever filtered. Everywhere else the
list
truncates at the first filtered page boundary and strands every node
behind it —
the #1598 symptom reached by a different route: a node that is relaying
right
now simply stops being in the list.

The comment at `app.js:240` rejects `total` for exactly the right
reason, then
picks the signal the same code path also breaks.

## Measured on a live 2346-node deployment

Page sizes for the query the map issues:

```
offset=0     returned=500     ← full, loop continues
offset=500   returned=499     ← one row filtered AFTER the LIMIT → loop STOPS
offset=1000  returned=500     ← never requested
offset=1500  returned=500     ← never requested
offset=2000  returned=345     ← never requested
```

| stop rule | requests | nodes reached |
|---|---:|---:|
| short page (master) | 2 | **999** |
| `has_more`, else empty page | 6 | **2344** |

**1341 nodes, 57%, unreachable through the UI.**

### One hidden node truncates the whole list

The deployment this came from has no `geoFilter`
(`/api/config/geo-filter`
returns `polygon: null`) and no `nodeBlacklist`. It has a single
`hiddenNamePrefixes` entry — a deliberate operator choice — and exactly
one node
whose name starts with it:

```
public_key   d4a46ea2…1054      (64 clean hex chars)
name         🚫🔥☀️
role         repeater
last_seen    2026-09-22T10:09:38Z
```

`handleNodes` drops that row in the `IsNameHidden` pass, which runs
after the SQL
`LIMIT`. The row is counted by the `LIMIT` and by `COUNT(*)`, so the
page it lands
in comes back exactly one short — and stops every client that treats a
short page
as the end.

Isolated against SQL on the same database, seconds apart:

```
SELECT lower(public_key) FROM nodes ORDER BY last_seen DESC LIMIT 500 OFFSET 500
  -> 500 rows
GET /api/nodes?limit=500&offset=500
  -> 499 rows
comm -23 sql.txt api.txt
  -> d4a46ea2e99cab132a3286ef3d9cce9099318790af7f25671fe83de453721054
```

Deterministic — `offset=500` returned 499 on three consecutive requests.
Not a
CDN artifact either: `cf-cache-status: DYNAMIC`, origin `cache-control:
no-store`, no `age` header, and four requests with deliberately unique
cache keys
all returned 499.

So **one deliberately hidden node makes 1341 of 2344 nodes
unreachable.** The
hiding feature does exactly what it was asked to do for that one node,
and takes
57% of the network with it, silently. A single `hiddenNamePrefixes`
entry is
enough; no geo-filter, blacklist or area filter is needed to reach this
state.

### The cutoff moves, which is why this reads as intermittent

The visible set is the sum of the pages up to and including the first
short one,
so the boundary sits wherever the unreturnable row currently sorts by
`last_seen`, and jumps a whole page as ingest reorders the list. Same
deployment, same code, same config, ~2h apart:

| dropped row's rank | first short page | nodes visible |
|---|---|---:|
| inside 0–499 | page 1 | 499 |
| inside 500–999 | page 2 | 999 |

A node is visible or invisible purely by where it lands relative to that
moving
line, so affected nodes appear to vanish and return on their own. Two
operators
on this deployment reported exactly that, independently, while I was
measuring.

### A named reproduction

`HU-ZA-Lentihegy` (`5287a33f…`), reported missing from the map by an
operator
whose companion had logged its advert at 04:20 local the same morning.

Ingest was fine. The row is in `nodes` with `last_seen`
`2026-09-22T02:20:35Z` — the same advert, to the second — valid GPS,
role
`repeater`, 1033 adverts, and `/api/nodes/search?q=lentihegy` returns
it.

```
rank by last_seen : 1081
cutoff at the time:  999
```

It missed by 82 positions. Walking the same live endpoint, same moment:

| stop rule | requests | nodes reached | Lentihegy |
|---|---:|---:|---|
| short page (master) | 2 | 999 | **not reached** |
| `has_more`, else empty page | 6 | 2340 | reached |

The practical shape of this on a busy mesh: 1081 nodes had been heard
more
recently than 9.4 hours, so on that deployment **anything last heard
more than
~9 hours ago was invisible**, alive or not.

`#/nodes` compounds it — its search box filters client-side over the
truncated
set, so the server-side `?search=` never runs and an operator cannot
find the
node by searching for it either, even though the endpoint would return
it.

## Change

**Server** — `NodeListResponse` gains `has_more`, computed from the raw
SQL page
against the real `COUNT(*)` before the filter passes run, so it survives
them:

```go
hasMore := offset+len(nodes) < total
```

Always emitted (no `omitempty`) so a client can tell `false` from an old
server.
No extra request in the fixed path: `has_more` ends the loop exactly,
where the
old rule needed a probe page.

**Clients** — `app.js` `fetchAllNodes`, `nodes.js` `loadNodes` and
`area-map.html`'s inline helper stop on `has_more`, falling back to a
zero-length
page against a server that predates it. An empty page always ends the
loop, so a
`has_more` against a concurrently-shrinking table cannot spin to
`safetyCap`.

Left alone: the three loops are still three copies. Collapsing them onto
`fetchAllNodes` is a bigger change than this fix needs, and `nodes.js`
has its
own inter-page progress UI. Happy to do it separately if you want it.

## Testing

- **Unit** (`tests/unit/test-fetch-all-nodes-pagination.js`): the
fixture now
models the real handler — a row counted by the LIMIT and by `COUNT(*)`,
then
removed from the page. Three new cases. Fails on the old rule at 499 of
1199.
- **E2E** (`tests/e2e/test-map-nodes-pagination-e2e.js`, already wired
into
  `deploy.yml`): the mock drops a page-1 row and emits `has_more`.
  Mutation-checked — restoring master's stop rule fails 3 of its steps.
- **Go** (`cmd/server/nodes_pagination_has_more_test.go`): asserts
`has_more`
stays true on a page filtering shortened. Mutation-checked — recomputing
it
  after the filter block fails the test.
- Full server suite `go test -race`: ok, 41.4s. `gofmt` clean, `go vet`
passes.
- **Against a real binary**, not just mocks: fixture DB migrated with
`corescope-migrate`, `hiddenNamePrefixes: ["SKCE"]`, `limit=3`. Page 1
returns
2 of 3 with `total` rewritten to 2 and `has_more=true`. Walking the real
server
with master's rule reaches 2 nodes; with `has_more`, all 199 visible of
200,
the hidden one still hidden. The real frontend against that server loads
199
  with no JS errors.

Two existing expectations changed, both deliberate:

1. `surfaces ALL nodes past the 500 server cap` — 3 → 4 requests. That
mock emits
no `has_more`, so the 200-row final page can no longer end the loop (a
short
page is exactly what a filtered page looks like) and a zero-length probe
   follows. Against a current server `has_more` still ends it at 3.
2. `rows missing public_key are NOT collapsed into one` — its stub
returned a
constant body, which would now be paged to `safetyCap`. It serves one
page
   then empties.

Local `test-all.sh` exits 1 on two XSS-gate self-tests
(`good-2-tested.js`, `good-4-tested.js`) via a `UnicodeEncodeError`
printing an
emoji under Windows cp1252. Identical on clean `origin/master` in a
scratch
worktree, so it is pre-existing and platform-local, not this branch.

There is a second identical filter block further down `routes.go` on
another list
endpoint. Likely the same class; not touched here.

If you would rather land your own version of this, say so and I will
close mine.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:45:18 +02:00

236 lines
11 KiB
JavaScript

/**
* E2E: the Map view must paginate /api/nodes past the server's per-request cap.
*
* Bug (this PR): map.js issued a single `/api/nodes?limit=10000` fetch. The
* server clamps ?limit to listLimits.nodesMax (default 2000; originally 500 in
* PR #1540) and orders by last_seen DESC, so on a mesh larger than the cap the
* map silently dropped every node whose last self-advert fell outside the top
* window — even ones relaying constantly. The node still appeared in the
* (paginated, #1606) Nodes list but vanished from the map.
*
* This test mocks /api/nodes with a 500-per-page cap (the client page size) and
* a 501st node ("PAGE2 RP") reachable only on the second page. After the map
* loads, the app's node set (window.__mc_nodes, populated by map.js loadNodes()
* → fetchAllNodes()) must contain all 501 nodes including the page-2 node, and a
* marker for it must exist on the map. Pre-fix, __mc_nodes would hold 500 and
* the page-2 node would be absent.
*
* Backend-independent: every /api/* call the map makes at load is mocked via
* page.route, so it runs against any static host at BASE_URL (the CI fixture
* server, or a plain static server locally).
*
* Run: BASE_URL=http://localhost:13581 node test-map-nodes-pagination-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const PAGE_CAP = 500; // client page size; a node sits past it on page 2
const FILTERED_INDEX = 450; // a page-1 row the server drops AFTER the SQL LIMIT
const PAGE2_KEY = 'page2deadbeef00000000000000000000000000000000000000000000000beef02';
const PAGE2_NAME = 'PAGE2 RP';
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'); }
// Build the full fixture: 500 "page 1" repeaters + 1 page-2 repeater.
function buildFixture() {
const nodes = [];
for (let i = 0; i < PAGE_CAP; i++) {
nodes.push({
public_key: 'p1' + String(i).padStart(62, '0'),
name: 'P1-' + i,
role: 'repeater',
lat: 51 + (i % 100) * 0.001,
lon: 5 + Math.floor(i / 100) * 0.001,
last_seen: '2026-06-09T07:40:00Z',
hash_size: 1,
});
}
nodes.push({
public_key: PAGE2_KEY,
name: PAGE2_NAME,
role: 'repeater',
lat: 50.5,
lon: 4.5,
last_seen: '2026-06-08T13:55:14Z', // older advert → would be cut by the 500 cap
hash_size: 2,
});
return nodes;
}
// #2030: release an old map's asynchronous response only after teardown,
// including after a replacement map has already finished loading.
async function checkMapTeardown(browser, stage, revisit) {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
page.on('console', message => {
if (message.type() === 'error' && message.text().includes('Map load error')) errors.push(message.text());
});
await page.route('**/api/**', route => route.fulfill({ json: {} }));
await page.goto(BASE + '/#/tools');
await page.waitForFunction(() => typeof fetchAllNodes === 'function');
await page.evaluate(stage => {
window.__pendingMapResponse = null;
let first = true;
let load = 0;
const defer = value => new Promise(resolve => { window.__pendingMapResponse = () => resolve(value); });
const originalApi = window.api;
window.api = async function (path, options) {
if (path === '/config/regions' || path === '/observers') {
const value = path === '/observers' ? { observers: [] } : {};
if (first && path === stage) { first = false; return defer(value); }
return value;
}
return originalApi(path, options);
};
window.fetchAllNodes = async function () {
const name = ++load === 1 ? 'OLD MAP' : 'CURRENT MAP';
const value = { nodes: [{ public_key: name === 'OLD MAP' ? 'aa'.repeat(32) : 'bb'.repeat(32), name, role: 'repeater', lat: 1, lon: 1, last_seen: new Date().toISOString() }], counts: { repeaters: 1 } };
if (first && stage === 'nodes') { first = false; return defer(value); }
return value;
};
location.hash = '#/map';
}, stage);
await page.waitForFunction(() => typeof window.__pendingMapResponse === 'function');
await page.evaluate(() => { location.hash = '#/tools'; });
await page.waitForSelector('#leaflet-map', { state: 'detached' });
if (revisit) {
await page.evaluate(() => { location.hash = '#/map'; });
await page.waitForSelector('#leaflet-map[data-loaded="true"]');
}
const before = await page.evaluate(() => ({ nodes: JSON.stringify(window.__mc_nodes), html: document.getElementById('app').innerHTML }));
await page.evaluate(async () => {
window.__pendingMapResponse();
// A rendering turn drains the released async chain; no race-prone sleep.
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
});
const after = await page.evaluate(() => ({ nodes: JSON.stringify(window.__mc_nodes), html: document.getElementById('app').innerHTML }));
try {
assert(errors.length === 0, 'teardown must not log errors: ' + errors.join('; '));
assert(after.nodes === before.nodes, 'old response must not overwrite the map node set');
if (!revisit) assert(after.html === before.html, 'old response must not change the destination page');
else {
// A stale completion must not attach a second pane listener either.
await page.locator('#mapControlsToggle').click();
await page.locator('#mapPaneToggle').click();
assert(await page.locator('#mapSidePane').evaluate(el => el.classList.contains('expanded')), 'replacement inspector must toggle exactly once');
}
} finally {
await page.close();
}
}
(async () => {
const launchOpts = {
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
};
const browser = await chromium.launch(launchOpts);
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== Map /api/nodes pagination E2E against ${BASE} ===`);
const fixture = buildFixture();
let nodesRequests = 0;
// Mock every /api/* call the map makes at load. /api/nodes?... is paginated
// with the same 500-row clamp + offset semantics as the real server; all
// other endpoints get harmless stubs so the test is backend-independent.
await page.route('**/api/**', (route) => {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === '/api/nodes') {
nodesRequests++;
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50', 10), PAGE_CAP);
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const raw = fixture.slice(offset, offset + limit);
// Model handleNodes exactly: the blacklist / hidden-prefix / geo-filter
// passes run AFTER the SQL LIMIT, so FILTERED_INDEX is counted by the
// limit and by COUNT(*) but removed from the page. Page 1 comes back a
// row short WITHOUT being the last page — the shape that stranded every
// node behind it once a client stopped on a short page.
const slice = raw.filter((_n, i) => offset + i !== FILTERED_INDEX);
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
nodes: slice,
total: slice.length, // deliberately wrong per-page total; the helper must ignore it
has_more: offset + raw.length < fixture.length, // decided pre-filter, as the server does
counts: { repeaters: fixture.length, rooms: 0, companions: 0, sensors: 0 },
}),
});
}
if (path === '/api/observers') {
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ observers: [] }) });
}
// Generic stub for config/regions/map/etc. — empty object is safe.
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
await page.goto(BASE + '/#/map', { waitUntil: 'load', timeout: 60000 });
await page.waitForSelector('#leaflet-map', { timeout: 15000 });
await step('a page shortened by post-LIMIT filtering does not end pagination', async () => {
// 500 = 501 fixture nodes minus the one the mock filters out of page 1.
// Pre-fix the run stops on that 499-row page and __mc_nodes holds 499.
await page.waitForFunction(
() => Array.isArray(window.__mc_nodes) && window.__mc_nodes.length >= 500,
{ timeout: 15000 }
);
const len = await page.evaluate(() => window.__mc_nodes.length);
assert(len === 500, 'expected 500 nodes in __mc_nodes (501 minus the filtered row), got ' + len);
assert(nodesRequests >= 2, 'expected ≥2 /api/nodes page requests, got ' + nodesRequests);
const filteredGone = await page.evaluate(
(k) => !window.__mc_nodes.some((n) => n.public_key === k),
'p1' + String(FILTERED_INDEX).padStart(62, '0')
);
assert(filteredGone, 'the filtered row must stay filtered, not reappear');
});
await step('the page-2 node (cut by the cap pre-fix) is present in the node set', async () => {
const found = await page.evaluate(
(key) => window.__mc_nodes.some((n) => n.public_key === key),
PAGE2_KEY
);
assert(found, 'page-2 node ' + PAGE2_NAME + ' missing from __mc_nodes');
});
await step('a marker for the page-2 node is rendered on the map', async () => {
const hasMarker = await page.evaluate((key) => {
let found = false;
const scan = (layer) => {
if (found || !layer || !layer.eachLayer) return;
layer.eachLayer((m) => {
if (found) return;
if (m._nodeKey === key) { found = true; return; }
if (m.eachLayer) scan(m); // cluster groups nest their markers
});
};
// markerLayer + clusterGroup are internal; reach them via the map's layers.
if (window.__mc_map && window.__mc_map.eachLayer) window.__mc_map.eachLayer(scan);
return found;
}, PAGE2_KEY);
assert(hasMarker, 'no marker with _nodeKey for the page-2 node was rendered');
});
for (const stage of ['/config/regions', 'nodes', '/observers']) {
for (const revisit of [false, true]) {
await step('late ' + stage + ' response after ' + (revisit ? 'map replacement' : 'map teardown'), () => checkMapTeardown(browser, stage, revisit));
}
}
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(1); });