mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
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>
105 lines
4.8 KiB
JavaScript
105 lines
4.8 KiB
JavaScript
/**
|
|
* E2E (#2218): a Node Analytics response that lands after the user went back
|
|
* to Nodes must not replace the Nodes list.
|
|
*
|
|
* The /api/nodes/:key/analytics request is held with page.route(), the page
|
|
* navigates to #/nodes, and only then is the held request released (as a
|
|
* success, then as an error). The Nodes rows must still be there afterwards.
|
|
* A final step checks that an unheld visit still renders the analytics.
|
|
*
|
|
* Usage: BASE_URL=http://localhost:13581 node tests/e2e/test-issue-2218-node-analytics-stale-e2e.js
|
|
*/
|
|
'use strict';
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
const ANALYTICS_RE = /\/api\/nodes\/[^/]+\/analytics\?/;
|
|
const ROWS = 'table tbody tr:not([id^=vscroll])';
|
|
|
|
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 () => {
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: process.env.CHROMIUM_PATH || undefined,
|
|
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
|
});
|
|
const page = await (await browser.newContext({ viewport: { width: 1400, height: 900 } })).newPage();
|
|
page.setDefaultTimeout(15000);
|
|
const errors = [];
|
|
page.on('pageerror', (e) => errors.push(e.message));
|
|
|
|
console.log(`\n=== #2218 node analytics stale response E2E against ${BASE} ===`);
|
|
|
|
const nodes = (await (await page.request.get(`${BASE}/api/nodes?limit=50`)).json()).nodes || [];
|
|
assert(nodes.length > 0, 'fixture has no nodes');
|
|
|
|
let held = null;
|
|
let onHeld = () => {};
|
|
await page.route(ANALYTICS_RE, (route) => { held = route; onHeld(); });
|
|
|
|
// Opens a node's analytics with its response held, then goes back to Nodes.
|
|
async function leaveWhileHeld(pk) {
|
|
held = null;
|
|
const isHeld = new Promise((resolve) => { onHeld = resolve; });
|
|
await page.goto(`${BASE}/#/nodes/${encodeURIComponent(pk)}/analytics`, { waitUntil: 'domcontentloaded' });
|
|
await isHeld;
|
|
await page.waitForFunction(() => /Loading analytics/.test(document.getElementById('app').textContent));
|
|
await page.evaluate(() => { location.hash = '#/nodes'; });
|
|
await page.waitForSelector('[data-loaded="true"]');
|
|
await page.waitForSelector(ROWS);
|
|
return page.$$eval(ROWS, (rows) => rows.length);
|
|
}
|
|
async function assertNodesIntact(before) {
|
|
// Let the released response reach the page and any render run.
|
|
await page.waitForTimeout(500);
|
|
const hash = await page.evaluate(() => location.hash);
|
|
assert(hash === '#/nodes', 'hash changed to ' + hash);
|
|
const after = await page.$$eval(ROWS, (rows) => rows.length);
|
|
assert(after > 0 && after === before, `Nodes rows ${before} before release, ${after} after`);
|
|
const heading = await page.evaluate(() => /— Analytics/.test(document.getElementById('app').textContent));
|
|
assert(!heading, 'the Analytics heading replaced the Nodes list');
|
|
}
|
|
|
|
await step('a late success does not replace the Nodes list', async () => {
|
|
const before = await leaveWhileHeld(nodes[0].public_key);
|
|
const done = page.waitForEvent('requestfinished', (r) => ANALYTICS_RE.test(r.url()));
|
|
await held.continue();
|
|
await done;
|
|
await assertNodesIntact(before);
|
|
});
|
|
|
|
await step('a late error does not replace the Nodes list', async () => {
|
|
const pk = (nodes[1] || nodes[0]).public_key;
|
|
const before = await leaveWhileHeld(pk);
|
|
await held.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"held"}' });
|
|
await assertNodesIntact(before);
|
|
assert(!(await page.evaluate(() => /Failed to load analytics/.test(document.getElementById('app').textContent))),
|
|
'the error replaced the Nodes list');
|
|
});
|
|
|
|
await step('the current visit still renders its analytics', async () => {
|
|
await page.unroute(ANALYTICS_RE);
|
|
const pk = (nodes[2] || nodes[0]).public_key;
|
|
await page.goto(`${BASE}/#/nodes/${encodeURIComponent(pk)}/analytics`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('#timeRangeBtns');
|
|
assert(await page.evaluate(() => /— Analytics/.test(document.getElementById('app').textContent)), 'no Analytics heading');
|
|
});
|
|
|
|
// api() reports every failed request as an unhandled "API <status>:"
|
|
// rejection of its in-flight bookkeeping; the held 500 above is one of them.
|
|
await step('no page errors besides failed requests', async () => {
|
|
const other = errors.filter((m) => !/^API \d+: /.test(m));
|
|
assert(other.length === 0, other.join(' | '));
|
|
});
|
|
|
|
await browser.close();
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
})().catch((e) => { console.error(e); process.exit(1); });
|