Files
meshcore-analyzer/tests/unit/test-issue-2218-node-analytics-stale-render.js
T
efitenandClaude Opus 5.5 3960fadd96 fix(pages): drop late responses that belong to an earlier page visit (#2218, #2230, #2200) (#2248)
Fixes #2218, fixes #2230, fixes #2200.

Node analytics, the account page and analytics await their data, then
write into the shared `#app` container. A response that lands after the
user has navigated away replaces the new page. In #2218's reproduction,
the Nodes list, with 200 rows, became an Analytics heading.

## The shared part

`public/app.js` keeps one view generation, `viewGeneration()`:
- `navigate()` bumps it before the old page's `destroy()`, so every page
init counts as a new visit, including a switch to another node of the
same page type.
- A page captures the value at init, and after each `await` drops the
result when the value has moved on.

`.eslintrc.json` declares it as a global.

## Per page

**#2218, `node-analytics.js`**
- `loadAnalytics` and `loadBatteryChart` check the generation on both
success and error. A page-local `loadSeq` makes a time-range click a new
load on the same visit, so an older range can't land over a newer one.
- `NodeHopAnalytics.load` already checked `container.isConnected`, so it
is unchanged.

**#2230, `account.js`**
- The render after `CSAuth.ready()` runs only while the generation is
unchanged.
- `destroy()` was empty. It now clears `shown.app` and `shown.userId`.

**#2200, `analytics.js`**
- A page-local `_loadSeq` sits next to the generation. Results and
errors of an older window/region selection or an earlier visit are
dropped, so a late failure of an old load no longer shows an error over
newer results.
- The bulk load renders only the six tabs drawn from its data. A tab
that loads its own data (Prefix Tool, Nodes, Roles, ...) is no longer
rebuilt or replaced by the bulk result: a deep-linked one renders at
init, and a window, region or area change re-renders it directly.
- `renderTab` guards the six tabs drawn from the bulk data (overview,
rf, topology, channels, hashsizes, collisions). Until the data arrives
they show "Loading analytics…", or the current load's error. Overview no
longer dereferences an undefined `rf`.
- Three related fixes in the same lines:
  - The error message is now escaped with `esc()`; it was inserted raw.
- `destroy()` removes the RegionFilter and AreaFilter listeners. They
piled up on every visit and called `loadAnalytics` from other pages.
  - `destroy()` resets the stored error.

## Tests

| File | Assertions | Covers |
|---|---|---|
| `tests/unit/test-issue-2218-node-analytics-stale-render.js` | 12 |
Loads the real router: the generation rises on every init, including
another node. Late success and error after leaving, a stale visit of the
same node, an older range landing last, late battery success and error.
The current response, battery and error still render |
| `tests/unit/test-issue-2230-account-stale-render.js` | 6 | Leaving
before ready (login view and "not enabled"), re-entering before ready,
the current render, an auth change after `destroy()` |
| `tests/unit/test-issue-2200-analytics-stale-load.js` | 19 | An older
selection landing last, a late rejection of an older selection, leave
and re-enter (success and error), the current error rendered escaped and
kept on a tab click, Overview and RF clicked before the data |
| `tests/e2e/test-issue-2218-node-analytics-stale-e2e.js` | 4 steps |
Holds `/api/nodes/*/analytics` with `page.route`, returns to `#/nodes`,
then releases the response as a 200 and as a 500. The 200 rows stay and
no Analytics heading appears; a normal visit still renders |

How I checked them:
- With the production files reverted and the tests kept, 7 of 12, 4 of 6
and 7 of 12 unit assertions fail. With the fix, all pass.
- The E2E fails on the unfixed frontend with "Nodes rows 200 before
release, 0 after".
- Single mutations, each reverted:
- removing the success check in `loadAnalytics` (analytics) fails 2
assertions;
  - removing the check after `CSAuth.ready()` fails 3.

Other test changes:
- `test-analytics-theme-refresh-before-data.js` and
`test-user-management-ui.js` get a `viewGeneration: () => 0` stub.
- The new E2E is registered in `scripts/non-unit-tests.json` and
`deploy.yml` (shard 2, next to the #748 suite). The unit tests are in
`test-all.sh`.
- The broad suite's "Node side panel Details link navigates" test
selected an anchor that now opens Analytics, as #2218 notes. It now
clicks `#nodesRight .node-detail-btn`.

Results:
- `sh test-all.sh` passes, and `npx eslint public/*.js` reports 0
errors.
- E2E on a fixture seeded as in CI:

  | Suite | Result |
  |---|---|
  | `test-e2e-playwright.js` | 148 passed, 3 skipped |
  | `test-user-management-e2e.js` | 28/28 |
  | the new #2218 suite | 4/4 |
  | `test-issue-748-node-analytics-coverage-e2e.js` | 2/2 |
  | `test-analytics-fluid-charts.js` | 8/8 |
  | `test-issue-1657-analytics-channels-group-sprites-e2e.js` | 5/5 |

## Not done

- Two tabs that each fetch their own data can still overwrite each other
after a fast tab switch between them. #2200 does not ask for that.
- `nodes.js` `navigateToNode` calls its own `init` without the router,
so it does not bump the generation. None of the three pages fixed here
depends on that.
- I placed the E2E in shard 2 without measuring suite times, which the
`deploy.yml` comment asks for.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:39:15 +02:00

216 lines
8.8 KiB
JavaScript

/**
* #2218: a Node Analytics response that lands after the visit ended (or after
* a newer time range was picked) must not touch the page. Loads the real
* public/node-analytics.js in a vm sandbox with a held api().
*
* Usage: node tests/unit/test-issue-2218-node-analytics-stale-render.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'public/node-analytics.js'), 'utf8');
let passed = 0, failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const flush = () => new Promise((r) => setTimeout(r, 10));
const PK = 'ab'.repeat(32);
function analyticsData(days) {
return {
node: { public_key: PK, name: 'Relay One', role: 'repeater' },
computedStats: { totalTransmissions: days * 100, availabilityPct: 50, signalGrade: 'A', avgPacketsPerDay: 10,
uniqueObservers: 2, relayPct: 5, longestSilenceMs: 0 },
timeRange: null, peerInteractions: [], activityTimeline: [], snrTrend: [], packetTypeBreakdown: [],
observerCoverage: [], hopDistribution: [], uptimeHeatmap: [],
};
}
function load() {
const els = {};
const calls = [];
const nav = { gen: 0 };
const hop = { loads: 0 };
const mkEl = () => ({ style: {}, textContent: '', innerHTML: '' });
const ctx = {
console, Math, JSON, Date, Number, String, Array, Object, Promise, isNaN, setTimeout,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
Chart: Object.assign(function Chart() { this.destroy = () => {}; }, { defaults: {} }),
CLIENT_TTL: {}, escapeHtml: (s) => String(s), timeAgo: () => '',
viewGeneration: () => nav.gen,
api: (url) => new Promise((resolve, reject) => calls.push({ url, resolve, reject })),
NodeHopAnalytics: { load() { hop.loads++; }, destroy() {} },
};
let page = null;
ctx.registerPage = (name, p) => { if (name === 'node-analytics') page = p; };
ctx.window = ctx;
ctx.document = { documentElement: {}, getElementById: (id) => els[id] || (els[id] = mkEl()) };
// The battery card of whatever page is on screen.
['batteryStatusBadge', 'batteryEmpty', 'batteryChart'].forEach((id) => { els[id] = mkEl(); });
vm.createContext(ctx);
vm.runInContext(SRC, ctx);
// The shared #app container; time-range buttons are wired after a render.
const buttons = {};
const app = {
innerHTML: '',
querySelectorAll: (sel) => sel !== '#timeRangeBtns button' ? [] : ['1', '7', '30', '365'].map((d) => ({
dataset: { days: d }, addEventListener(t, fn) { buttons[d] = fn; },
})),
};
const call = (prefix) => calls.filter((c) => c.url.startsWith(prefix));
return { page, app, els, nav, hop, buttons, call };
}
// The real router in public/app.js, so the generation the pages read is the
// one navigate() bumps.
function loadRouter() {
const listeners = {};
const app = { innerHTML: '', classList: { toggle() {}, add() {}, remove() {} }, offsetWidth: 0,
querySelector: () => null, setAttribute() {}, focus() {} };
const ctx = {
console, Math, JSON, Date, Number, String, Array, Object, RegExp, Error, Map, Set, Promise, URLSearchParams,
parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent,
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
requestAnimationFrame: () => 0, performance: { now: () => 0 },
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
location: { hash: '' }, history: { replaceState() {} }, CustomEvent: class {},
document: {
readyState: 'complete', body: { classList: { toggle() {}, add() {}, remove() {} } },
createElement: () => ({}), head: { appendChild() {} },
getElementById: (id) => (id === 'app' ? app : null),
addEventListener() {}, querySelectorAll: () => [], querySelector: () => null,
},
};
ctx.window = ctx;
ctx.addEventListener = (t, fn) => { listeners[t] = fn; };
ctx.dispatchEvent = () => {};
vm.createContext(ctx);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public/roles.js'), 'utf8'), ctx);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', '..', 'public/app.js'), 'utf8'), ctx);
const go = (hash) => { ctx.location.hash = hash; listeners.hashchange(); };
return { ctx, go };
}
(async () => {
console.log('Router: viewGeneration()');
{
const r = loadRouter();
const seen = [];
r.ctx.registerPage('nodes', { init() { seen.push(['nodes', r.ctx.viewGeneration()]); }, destroy() {} });
r.ctx.registerPage('node-analytics', {
init() { seen.push(['init', r.ctx.viewGeneration()]); }, destroy() {},
});
r.go('#/nodes/' + PK + '/analytics');
r.go('#/nodes/' + 'cd'.repeat(32) + '/analytics');
r.go('#/nodes');
const gens = seen.map((e) => e[1]);
assert(gens.length === 3 && gens[0] < gens[1] && gens[1] < gens[2], 'every init sees a new generation, another node included (' + gens + ')');
}
console.log('Node Analytics: response after navigating away');
{
const t = load();
t.page.init(t.app, PK + '/analytics');
t.page.destroy();
t.nav.gen++;
t.app.innerHTML = 'NODES LIST';
t.call('/nodes/')[0].resolve(analyticsData(7));
await flush();
assert(t.app.innerHTML === 'NODES LIST', 'a late success leaves the next page alone');
assert(t.hop.loads === 0, 'and starts no nested hop-count load');
}
{
const t = load();
t.page.init(t.app, PK + '/analytics');
t.page.destroy();
t.nav.gen++;
t.app.innerHTML = 'NODES LIST';
t.call('/nodes/')[0].reject(new Error('timeout'));
await flush();
assert(t.app.innerHTML === 'NODES LIST', 'a late error leaves the next page alone');
}
console.log('Node Analytics: another node, then back');
{
const t = load();
t.page.init(t.app, PK + '/analytics');
const first = t.call('/nodes/')[0];
t.page.destroy(); t.nav.gen++;
t.page.init(t.app, 'cd'.repeat(32) + '/analytics');
t.page.destroy(); t.nav.gen++;
t.page.init(t.app, PK + '/analytics');
first.resolve(Object.assign(analyticsData(7), { node: { public_key: PK, name: 'STALE VISIT' } }));
await flush();
assert(!t.app.innerHTML.includes('STALE VISIT'), 'the first visit does not render into a later visit of the same node');
t.call('/nodes/')[2].resolve(analyticsData(7));
await flush();
assert(t.app.innerHTML.includes('Relay One') && t.app.innerHTML.includes('700 packets in 7d'), 'the current visit renders its response');
}
console.log('Node Analytics: an older time range landing last');
{
const t = load();
t.page.init(t.app, PK + '/analytics');
t.call('/nodes/')[0].resolve(analyticsData(7));
await flush();
t.buttons['1']();
t.buttons['30']();
const [r1, r30] = t.call('/nodes/' + PK + '/analytics').slice(1);
r30.resolve(analyticsData(30));
await flush();
r1.resolve(analyticsData(1));
await flush();
assert(t.app.innerHTML.includes('3000 packets in 30d'), 'the newest range stays on screen');
}
console.log('Node Analytics: nested battery work');
{
const t = load();
t.page.init(t.app, PK + '/analytics');
t.call('/nodes/')[0].resolve(analyticsData(7));
await flush();
const battery = t.call('/nodes/' + PK + '/battery')[0];
assert(!!battery, 'the current render starts the battery load');
t.page.destroy(); t.nav.gen++;
battery.resolve({ status: 'ok', latest_mv: 4100, samples: [] });
await flush();
assert(t.els.batteryStatusBadge.textContent === '' && t.els.batteryEmpty.style.display === undefined,
'a late battery answer does not touch the next page');
}
{
const t = load();
t.page.init(t.app, PK + '/analytics');
t.call('/nodes/')[0].resolve(analyticsData(7));
await flush();
t.page.destroy(); t.nav.gen++;
t.call('/nodes/' + PK + '/battery')[0].reject(new Error('gone'));
await flush();
assert(t.els.batteryEmpty.textContent === '', 'a late battery error does not touch the next page');
}
{
const t = load();
t.page.init(t.app, PK + '/analytics');
t.call('/nodes/')[0].resolve(analyticsData(7));
await flush();
t.call('/nodes/' + PK + '/battery')[0].resolve({ status: 'ok', latest_mv: 4100, samples: [] });
await flush();
assert(t.els.batteryStatusBadge.textContent.includes('4100 mV'), 'the current battery answer still renders');
}
console.log('Node Analytics: the current error still renders');
{
const t = load();
t.page.init(t.app, PK + '/analytics');
t.call('/nodes/')[0].reject(new Error('server down'));
await flush();
assert(t.app.innerHTML.includes('Failed to load analytics: server down'), 'the current rejection renders');
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
})();