Files
meshcore-analyzer/test-node-reach-e2e.js
efiten e2212f5015 feat(nodes): per-node Reach page + GET /api/nodes/{pubkey}/reach (v2, review-complete) (#1627)
Re-submission of #1625 (which was merged early, then reverted in #1626)
— now with **all three round-1 reviews addressed** so it lands in one
hardened state instead of as post-merge follow-ups.

## What

Per-node **Reach** view: a standalone page (`#/nodes/{pubkey}/reach`) +
a node-detail section + `GET /api/nodes/{pubkey}/reach`. It shows which
nodes a node has a **stable two-way RF link** with, derived from raw
`path_json` adjacency (a path travels origin→observer, so `[A,B]` ⇒ B
heard A). A link is bidirectional when both directions have
observations; the **bottleneck** (weaker direction) rates two-way
reliability. Nodes are identified only by **unique 2–3 byte** path
prefixes (1-byte collides → excluded).

## Review fixes folded in vs #1625

**Performance (Carmack):** hard scan LIMIT (200k) + modest prealloc;
`json.Unmarshal` replaced by a single-pass `parsePathTokens` (100k-row
scan 2.2M→1.3M allocs, 344→203ms); memoized resolver; size-hinted maps
(attribution over 100k rows: 102 allocs); `context.Context` plumbed;
cache `RWMutex` + evict-oldest (no full wipe); singleflight dedup;
degree/rank from a 60s shared snapshot; bench rewritten (ReportAllocs,
1k/10k/100k, mixed-payload, isolated attribution).

**Correctness/safety + tests (Independent + Kent Beck):** pubkey
validation → 400; error logging instead of silent swallow (first_seen /
degree / marshal→500 / discarded rows); `public_key=?` index use;
canonical `PayloadADVERT`; `min()` builtin; documented cache-slice
immutability; mux ordering comment. New tests: scanReachRows decode,
3-byte token branch, non-advert first-hop guard, observer SNR
aggregation across rows, HTTP-level attribution (asserts non-zero
we_hear/they_hear), 400/404/blacklist/cache-hit.

**UI / a11y / Tufte:** in-map legend (tiers + thresholds); dropped the
colour+width double-encoding (constant width, colour-only); colour-blind
glyphs (●●●/●●/●) + tier title beside the bottleneck number; dark-theme
`--link-*`; lighter table (horizontal rules, sentence-case headers); map
built once + link layer updated in place on toggle (no flicker);
time-range no longer flashes a loader; `destroy()` generation guard;
statCard escaping; scoped `@media print` to `#nq-report`;
`fieldset/legend` + `for/id` toggles; `aria-pressed` / `aria-live` /
back-link `aria-label`; "distance (km)" + bottleneck tooltip + no-GPS
note; inline styles → CSS; decorative emoji removed.

**Docs:** api-spec documents the 5-min cache, 200k scan cap, and 400.

## Testing
- `cmd/server` full suite green; reach unit + endpoint + bench all pass.
- `eslint public/*.js` (no-undef) and the XSS-sink gate clean.
- E2E updated: request status checks + exact (non-tautological) toggle
assertions + hard map-render assert.

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


---

## TDD-history note (Kent Beck gate)

This branch carries production + tests together, not a fabricated
red→green sequence. That's deliberate: the branch was rebased onto
upstream and the intermediate SHAs were squashed, so reconstructing a
"failing-test-first" commit after the fact would be theatre, not
evidence — and rewriting history to stage it would be dishonest. The
behaviour is instead covered by a comprehensive, anti-tautological suite
(directional attribution edges, 3-byte token branch, non-advert
first-hop guard, observer SNR aggregation, HTTP-level attribution
asserting non-zero counts, scan-cap truncation, zero-reach 200-not-404,
companion mis-attribution, cache eviction). Requesting maintainer
acceptance of the work on test *substance* rather than commit
*choreography*; the net-new-UI exemption is not claimed for the server
endpoint.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-08 22:13:02 -07:00

77 lines
3.4 KiB
JavaScript

// E2E for the per-node Reach page (#/nodes/<pubkey>/reach).
// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL.
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
async function getJson(page, url) {
const resp = await page.request.get(url);
if (!resp.ok()) throw new Error('GET ' + url + ' → HTTP ' + resp.status());
return resp.json();
}
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
// A repeater is most likely to have reach data (it relays).
const nodes = await getJson(page, BASE + '/api/nodes?role=repeater&limit=1');
if (!nodes.nodes || !nodes.nodes.length) {
console.log('node-reach E2E SKIP (no repeater in dataset)');
await browser.close();
return;
}
const pk = nodes.nodes[0].public_key;
// 1. The endpoint returns the documented shape.
const reach = await getJson(page, BASE + '/api/nodes/' + pk + '/reach?days=7');
for (const k of ['node', 'window', 'reliable_tokens', 'importance', 'links', 'direct_observers']) {
if (!(k in reach)) throw new Error('reach response missing key: ' + k);
}
if (!Array.isArray(reach.links)) throw new Error('reach.links must be an array');
// 2. The page renders.
await page.goto(BASE + '/#/nodes/' + pk + '/reach');
await page.waitForSelector('.nq-head', { timeout: 20000 });
if (!(await page.locator('h2', { hasText: 'Reach' }).count())) {
throw new Error('Reach header missing');
}
// 3. If this node is identifiable, exercise the table, toggles and links.
if (reach.reliable_tokens.length && (await page.locator('#nqRows').count())) {
await page.waitForSelector('#nqIncoming');
await page.waitForSelector('#nqOutgoing');
// Derive the EXACT expected row counts from the API so the toggles are
// verified, not just "didn't shrink" (tautology). Base shows two-way only;
// incoming adds we-only links; +outgoing adds the rest (= all links).
const twoWayExp = reach.links.filter(l => l.bidir).length;
const weOnlyExp = reach.links.filter(l => !l.bidir && l.we_hear > 0 && l.they_hear === 0).length;
const allExp = reach.links.length;
const base = await page.locator('#nqRows tr').count();
if (base !== twoWayExp) throw new Error(`base rows ${base} != two-way ${twoWayExp}`);
await page.check('#nqIncoming');
const withIncoming = await page.locator('#nqRows tr').count();
if (withIncoming !== twoWayExp + weOnlyExp) {
throw new Error(`incoming rows ${withIncoming} != two-way+we-only ${twoWayExp + weOnlyExp}`);
}
await page.check('#nqOutgoing');
const withBoth = await page.locator('#nqRows tr').count();
if (withBoth !== allExp) throw new Error(`both-toggles rows ${withBoth} != all links ${allExp}`);
// Neighbour rows link to a node detail page.
if (await page.locator('#nqRows a.nq-link').count()) {
const href = await page.locator('#nqRows a.nq-link').first().getAttribute('href');
if (!href || !href.startsWith('#/nodes/')) throw new Error('neighbour link malformed: ' + href);
}
// Map must render whenever at least one link has GPS (no swallowed failure).
if (reach.links.some(l => l.lat != null && l.lon != null)) {
await page.waitForSelector('#nqMap .leaflet-container', { timeout: 10000 });
}
}
console.log('node-reach E2E OK');
await browser.close();
})().catch((e) => { console.error(e); process.exit(1); });