Files
meshcore-analyzer/tests/e2e/test-issue-1151-orphan-separators-e2e.js
n30nex dc4db17c48 fix(nodes): remove unsupported region fields from Heard By (#2077)
Fixes #2062.

The node health API does not emit observer region data, but Heard By
rendered a Region column containing only dashes and a Regions summary
that could never appear. Remove that column, its sort control, and the
unsupported region displays from the full node page and side panel.

Keep Observer, Packets, Avg SNR and Avg RSSI, along with existing
escaping, signal placeholders, relay counts and badges. No API,
dependency or configuration changes.

- Red commit `4396492` fails on the unwanted Region header and summary;
`a360711` removes the unsupported fields.
- Validation: 183 standalone suites and 7 focused browser checks passed.
Broader browser run: 131 passed, 3 fixture-dependent skips. Independent
reviews completed; the sorting-test finding was addressed in `1452981`.
- Unit coverage evaluates the real templates. The existing CI-selected
browser suite checks four-column alignment and sorting on desktop/mobile
using the actual API field names.
- Browser verified: local Chromium against a fixture-backed Go server;
screenshots recorded in `coverage/issue-2062-heard-by-1400.png` and
`coverage/issue-2062-heard-by-390.png`.
- E2E assertion added:
`tests/e2e/test-issue-1151-orphan-separators-e2e.js:125`, the two #2062
desktop/mobile cases.
- No added requests, loops or data structures.

## Preflight overrides

- External `run-all.sh` unavailable; repository syntax, whitespace,
CSS-variable, XSS and PII checks run directly.
- Local unit execution supplies UTF-8 settings and
`GITHUB_REF_NAME=local-validation`, required by the existing
release-routing test harness.


- Local browser navigation budget raised to 60s for slow local asset
responses; repository assertions and timeout settings unchanged.
2026-09-30 11:39:51 +02:00

176 lines
9.2 KiB
JavaScript

/**
* E2E (#1151): Side-panel "Heard By" rows must not render orphan separators
* when an observer's SNR and/or RSSI are null.
*
* Bug template (public/nodes.js):
* `${o.packetCount} pkts · ${snr ?: ''}${rssi ?: ''}`
* → "110 pkts · " (trailing dot when both null)
* → "110 pkts · · RSSI -52" (double dot when only SNR null)
*
* Fix: build a filtered parts array, then `.join(' · ')`.
*
* This test stubs /api/nodes/:pubkey/health via page.route() so we get
* deterministic observer rows with all three null/non-null permutations
* (the fixture DB has no real observers attached to a single node).
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1151-orphan-separators-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(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + 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 ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== #1151 orphan-separator E2E against ' + BASE + ' ===');
// Pick any node from the API to drive the test.
let pubkey = null;
await step('fetch a real node pubkey from /api/nodes', async () => {
const res = await page.request.get(BASE + '/api/nodes');
assert(res.ok(), '/api/nodes returned ' + res.status());
const body = await res.json();
const arr = Array.isArray(body) ? body : (body.nodes || []);
assert(arr.length > 0, 'fixture must contain at least one node');
pubkey = arr[0].public_key || arr[0].pubkey || arr[0].id;
assert(pubkey, 'first node must expose a public_key');
});
// Stub the health endpoint for this pubkey with three observer permutations:
// 1. both null → pre-fix: "110 pkts · · " (trailing orphan after pkts)
// 2. snr null only → pre-fix: "55 pkts · · RSSI -50" (orphan between pkts and RSSI)
// 3. rssi null only→ pre-fix: "22 pkts · SNR 5.5dB" (clean — control)
// 4. both present → pre-fix: "11 pkts · SNR 7.0dB · RSSI -42" (clean — control)
await page.route('**/api/nodes/' + encodeURIComponent(pubkey) + '/health', async (route) => {
const stubBody = {
node: { public_key: pubkey, name: 'TEST-NODE', role: 'repeater' },
stats: { totalPackets: 200, packetsToday: 10, avgSnr: null, avgHops: 2, lastHeard: new Date().toISOString() },
observers: [
{ observer_id: 'obs-both-null', observer_name: 'BothNull', avgSnr: null, avgRssi: null, packetCount: 110, can_relay: null },
{ observer_id: 'obs-snr-null', observer_name: 'SnrNull', avgSnr: null, avgRssi: -50, packetCount: 55, can_relay: false },
{ observer_id: 'obs-rssi-null', observer_name: 'RssiNull', avgSnr: 5.5, avgRssi: null, packetCount: 22, can_relay: true },
{ observer_id: 'obs-both-set', observer_name: 'BothSet', avgSnr: 7.0, avgRssi: -42, packetCount: 11, can_relay: null },
],
recentPackets: [],
};
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(stubBody) });
});
// Navigate to the nodes LIST page (not /#/nodes/<pubkey> — that opens the
// full-screen view, which uses a different, already null-safe template
// with separate <td> cells). The bug lives in the side-panel template
// that renders when you click a row on the list page.
await step('navigate /#/nodes (list view) and wait for rows', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#nodesBody tr[data-action="select"]', { timeout: 15000 });
});
await step('click target row to open side-panel detail', async () => {
await page.locator('#nodeSearch').fill(pubkey);
const sel = '#nodesBody tr[data-action="select"][data-value="' + pubkey + '"]';
await page.waitForSelector(sel, { timeout: 8000 });
await page.evaluate((s) => document.querySelector(s).scrollIntoView(), sel);
await page.click(sel);
await page.waitForSelector('#nodesRight .observer-row', { timeout: 15000 });
});
await step('side-panel "Heard By" rows render exactly 4 observers', async () => {
const count = await page.$$eval('#nodesRight .observer-row', els => els.length);
assert(count === 4, 'expected 4 observer rows, got ' + count);
});
await step('NO observer row contains an orphan separator (no "· ·" or trailing/leading " · ")', async () => {
const rows = await page.$$eval('#nodesRight .observer-row', els => els.map(el => {
// Read just the right-hand "stats" span (the suffix after the name).
// Two <span> children per row; second one is the stats.
const spans = el.querySelectorAll('span');
const last = spans[spans.length - 1];
// Normalize whitespace.
return (last.textContent || '').replace(/\s+/g, ' ').trim();
}));
const offences = [];
for (const text of rows) {
// Adjacent middle-dot separators with optional spaces between them.
if (/·\s*·/.test(text)) offences.push(['adjacent-dots', text]);
// Trailing separator (e.g. "110 pkts ·").
if (/·\s*$/.test(text)) offences.push(['trailing-dot', text]);
// Leading separator (e.g. "· SNR ...").
if (/^\s*·/.test(text)) offences.push(['leading-dot', text]);
}
if (offences.length) {
const detail = offences.map(o => `[${o[0]}] "${o[1]}"`).join('\n ');
throw new Error('Found ' + offences.length + ' orphan-separator row(s):\n ' + detail);
}
});
for (const width of [1400, 390]) {
await step('#2062 full Heard By table aligns and sorts supported fields at ' + width + 'px', async () => {
await page.setViewportSize({ width, height: 900 });
await page.goto(BASE + '/#/nodes/' + encodeURIComponent(pubkey), { waitUntil: 'domcontentloaded' });
await page.reload({ waitUntil: 'domcontentloaded' });
const card = page.locator('#node-observers');
const table = card.locator('table');
await table.waitFor({ state: 'visible' });
const headers = await table.locator('th').allTextContents();
assert(JSON.stringify(headers.map(s => s.trim())) === JSON.stringify(['Observer', 'Packets', 'Avg SNR', 'Avg RSSI']),
'expected four supported headers, got ' + JSON.stringify(headers));
assert(!/Regions:/.test(await card.innerText()), 'unsupported Regions summary must be absent');
assert(await table.locator('[data-sort-key="region"]').count() === 0, 'region sort must be absent');
const expected = {
BothNull: ['110', '—', '—'], SnrNull: ['55', '—', '-50 dBm'],
RssiNull: ['22', '5.5 dB', '—'], BothSet: ['11', '7.0 dB', '-42 dBm'],
};
for (const [name, values] of Object.entries(expected)) {
const row = table.locator('tbody tr').filter({ hasText: name });
const cells = await row.locator('td').allTextContents();
assert(cells.length === 4 && JSON.stringify(cells.slice(1).map(s => s.trim())) === JSON.stringify(values),
name + ' values must align under packet, SNR and RSSI headers: ' + JSON.stringify(cells));
}
for (const key of ['observer', 'packets', 'snr', 'rssi']) {
const header = table.locator('[data-sort-key="' + key + '"]');
const before = await header.getAttribute('aria-sort');
await header.click();
const direction = await header.getAttribute('aria-sort');
const expectedDirection = before === 'ascending' ? 'descending' :
(before === 'descending' || key === 'observer' ? 'ascending' : 'descending');
assert(direction === expectedDirection, key + ' click must set ' + expectedDirection + ', got ' + direction);
const values = await table.locator('tbody tr').evaluateAll((rows, sortKey) => {
const index = [...rows[0].closest('table').querySelectorAll('th')]
.findIndex(th => th.dataset.sortKey === sortKey);
return rows.map(row => row.cells[index].dataset.value).filter(value => value !== '');
}, key);
const sorted = values.slice().sort(key === 'observer' ? (a, b) => a.localeCompare(b) : (a, b) => Number(a) - Number(b));
if (direction === 'descending') sorted.reverse();
assert(JSON.stringify(values) === JSON.stringify(sorted), key + ' must sort its own column, got ' + JSON.stringify(values));
}
if (process.env.SCREENSHOT_DIR) {
await page.screenshot({ fullPage: true, path: require('path').join(process.env.SCREENSHOT_DIR, 'issue-2062-heard-by-' + width + '.png') });
}
});
}
await page.unroute('**/api/nodes/' + encodeURIComponent(pubkey) + '/health');
await browser.close();
console.log('\n=== #1151: ' + passed + ' passed, ' + failed + ' failed ===');
process.exit(failed ? 1 : 0);
})().catch((e) => { console.error('FATAL', e); process.exit(2); });