Files
meshcore-analyzer/test-issue-1147-section-order-e2e.js
T
Kpa-clawbot f27132e44e ui(node-detail): re-order sections so Recent Packets appears before Paths (#1147) (#1160)
Fixes #1147

## What

Re-orders the node-detail sections in **both** the side panel and the
full
node detail page. New sequence matches operator mental order
(identity → what this node SAID → who heard it → relay topology → meta):

1. Identity (name, role, badges)
2. Map + QR (full page) / Public key (side panel)
3. Overview (Last Heard, First Seen, Total Packets, etc.)
4. **Recent Packets** ← lifted from bottom
5. Heard By (observers)
6. Neighbors
7. Paths Through This Node
8. Clock Skew (hidden until populated)

## Why

"What did this node originate?" is the most-asked operator question at
the
node-detail surface. Previously Recent Packets was the LAST section in
both
views — operators had to scroll past Clock Skew, Heard By, Neighbors,
and
Paths just to see the node's own activity. Section B4 of the
node-analytics review flagged this as P1.

## Changes

- `public/nodes.js`: pure template re-order in two render paths
  (full-page `loadFullNode`, side-panel `renderDetail`). No data,
  styling, or behavior changes — same DOM ids, same CSS classes,
  same content per section.
- `test-issue-1147-section-order-e2e.js`: new Playwright test that
  loads a node detail page (and the side panel) against the fixture
  DB and asserts `Recent Packets` index in DOM order is **before**
  `Paths Through This Node`, `Heard By`, and `Neighbors` for both
  surfaces.
- `.github/workflows/deploy.yml`: wired the new E2E into the
  existing `e2e-test` job.

## TDD trail

- Red commit: `c0829fd` — adds failing E2E (Recent Packets is last).
- Green commit: `29cdb22` — re-orders the templates, test passes.

## Browser verified

E2E assertion added: `test-issue-1147-section-order-e2e.js:84` (full
page) and `:115` (side panel). Local Chromium can't run on this host
(libc reloc), so verification is via CI; server-side `grep` of rendered
`/nodes.js` confirms the new section order in both code paths.

## Preflight

All hard gates pass (PII, branch scope, red commit, CSS vars,
self-fallback, LIKE-on-JSON, sync migration). All warning gates pass.

---------

Co-authored-by: kpaclawbot <bot@kpaclawbot.local>
2026-05-07 06:50:03 -07:00

164 lines
7.5 KiB
JavaScript

/**
* #1147 — Side panel + full node detail page must render "Recent Packets"
* directly under Overview, BEFORE Paths/Neighbors/Heard By/Clock Skew.
*
* Operator mental order: identity → packets they originated → paths they
* relay → adverts → meta. Recent Packets currently appears LAST; this is
* the regression guard that proves the new ordering.
*
* Acceptance:
* - Full node detail page (#/nodes/<pk>): index of "Recent Packets"
* section header < index of "Paths Through This Node" header AND
* < index of "Heard By" header AND < index of "Neighbors" header
* AND < index of "Clock Skew" header (when present).
* - Side panel (open from /nodes list): same ordering.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1147-section-order-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'); }
// Pull all rendered <h4> headers (section titles in node detail) in DOM order
// from a given root. Returns array of trimmed text.
async function sectionHeaders(page, rootSelector) {
return await page.$$eval(`${rootSelector} h4`, els =>
els.map(e => (e.textContent || '').trim()));
}
// Find index of first header whose text starts with `prefix`. -1 if absent.
function indexOfStarts(headers, prefix) {
for (let i = 0; i < headers.length; i++) {
if (headers[i].startsWith(prefix)) return i;
}
return -1;
}
(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: 1000 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1147 section-order E2E against ${BASE} ===`);
// ─── Pick a node from the live API ───
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
const pubkey = await page.evaluate(async () => {
const r = await fetch('/api/nodes?limit=20');
const d = await r.json();
// Prefer a node with an advert_count > 0 so Recent Packets has content,
// but ANY node should render the section header (with an empty state).
const cand = (d.nodes || []).find(n => (n.advert_count || 0) > 0) ||
(d.nodes || [])[0];
return cand && cand.public_key;
});
assert(pubkey, 'No node returned from /api/nodes');
console.log(' → using probe pubkey: ' + pubkey.slice(0, 12) + '…');
// ─── Case 1: Full node detail page ───
await step('full page: Recent Packets appears before Paths/Heard By/Neighbors/Clock Skew', async () => {
await page.goto(BASE + '/#/nodes/' + encodeURIComponent(pubkey), { waitUntil: 'domcontentloaded' });
// Wait for the body container to render (the full detail uses .node-full-card).
await page.waitForSelector('.node-full-card', { timeout: 10000 });
// Wait until at least one "Recent Packets" header is in the DOM.
await page.waitForFunction(() => {
return Array.from(document.querySelectorAll('h4'))
.some(h => (h.textContent || '').trim().startsWith('Recent Packets'));
}, { timeout: 10000 });
const headers = await sectionHeaders(page, 'body');
console.log(' headers (full page): ' + JSON.stringify(headers));
const iRecent = indexOfStarts(headers, 'Recent Packets');
const iPaths = indexOfStarts(headers, 'Paths Through This Node');
const iHeard = indexOfStarts(headers, 'Heard By');
const iNeigh = indexOfStarts(headers, 'Neighbors');
// Clock Skew is hidden by default but rendered later; only enforce ordering
// when its container has visible content. Skip if absent.
assert(iRecent !== -1, 'Recent Packets header not found on full page');
assert(iPaths !== -1, 'Paths Through This Node header not found on full page');
assert(iRecent < iPaths,
`Recent Packets (idx ${iRecent}) must appear BEFORE Paths Through This Node (idx ${iPaths}) on full page`);
if (iHeard !== -1) {
assert(iRecent < iHeard,
`Recent Packets (idx ${iRecent}) must appear BEFORE Heard By (idx ${iHeard}) on full page`);
}
if (iNeigh !== -1) {
assert(iRecent < iNeigh,
`Recent Packets (idx ${iRecent}) must appear BEFORE Neighbors (idx ${iNeigh}) on full page`);
}
});
// ─── Case 2: Side panel (opened from /nodes list) ───
await step('side panel: Recent Packets appears before Paths/Heard By/Neighbors', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-loaded="true"]', { timeout: 15000 });
await page.waitForSelector('table tbody tr:not([id^=vscroll])', { timeout: 15000 });
// Click the row matching our probe pubkey if possible; fall back to first row.
const clicked = await page.evaluate((pk) => {
const rows = Array.from(document.querySelectorAll('table tbody tr'));
// Try data attributes first
let target = rows.find(r => (r.getAttribute('data-pubkey') || '').toLowerCase() === pk.toLowerCase());
// Fall back: any row whose text contains the pubkey prefix
if (!target) target = rows.find(r => r.textContent && r.textContent.toLowerCase().includes(pk.slice(0, 8).toLowerCase()));
// Last resort: first non-vscroll row
if (!target) target = rows.find(r => !(r.id || '').startsWith('vscroll'));
if (target) { target.click(); return true; }
return false;
}, pubkey);
assert(clicked, 'Could not click any node row to open side panel');
// Wait for side panel to render the detail container.
await page.waitForSelector('.node-detail', { timeout: 10000 });
// Wait until Recent Packets header lands in the side panel scope.
await page.waitForFunction(() => {
const root = document.querySelector('.node-detail');
if (!root) return false;
return Array.from(root.querySelectorAll('h4'))
.some(h => (h.textContent || '').trim().startsWith('Recent Packets'));
}, { timeout: 10000 });
const headers = await sectionHeaders(page, '.node-detail');
console.log(' headers (side panel): ' + JSON.stringify(headers));
const iRecent = indexOfStarts(headers, 'Recent Packets');
const iPaths = indexOfStarts(headers, 'Paths Through This Node');
const iHeard = indexOfStarts(headers, 'Heard By');
const iNeigh = indexOfStarts(headers, 'Neighbors');
assert(iRecent !== -1, 'Recent Packets header not found in side panel');
assert(iPaths !== -1, 'Paths Through This Node header not found in side panel');
assert(iRecent < iPaths,
`Recent Packets (idx ${iRecent}) must appear BEFORE Paths Through This Node (idx ${iPaths}) in side panel`);
if (iHeard !== -1) {
assert(iRecent < iHeard,
`Recent Packets (idx ${iRecent}) must appear BEFORE Heard By (idx ${iHeard}) in side panel`);
}
if (iNeigh !== -1) {
assert(iRecent < iNeigh,
`Recent Packets (idx ${iRecent}) must appear BEFORE Neighbors (idx ${iNeigh}) in side panel`);
}
});
await browser.close();
console.log(`\n${passed}/${passed + failed} passed`);
process.exit(failed === 0 ? 0 : 1);
})().catch(e => { console.error(e); process.exit(2); });