Files
meshcore-analyzer/tests/e2e/test-issue-1146-path-link-contrast-e2e.js
efitenandClaude Opus 5.5 415362c5af fix(packets): resolve path hops with the observer that heard them (#2097) (#2099)
Fixes #2097.

Every 1-byte path prefix on this network is shared. Measured on the live
deployment: 254 prefixes cover all 2043 nodes, nine of them on a single
prefix, and not one node has a prefix to itself. The packets page
printed one of those nine as a certainty.

It could already have said otherwise. `hop-resolver.js` marks such a hop
`ambiguous` and returns its full candidate list; `hop-display.js`
renders a warning badge with a count and a candidate popover;
`renderHop(h, observerId)` reads a per-observer cache key. None of it
fired, because `HopResolver.resolve()` takes six parameters and
`packets.js` passed one.

Without `observerId`, `packetIata` is null, `nodeInRegion()` never runs,
no candidate is flagged `regional`, `globalFallback` stays false, and
`hop-display.js` computes a badge count of 0. The whole chain stayed
silent while the data said it was a guess.

## What this changes

**Resolution carries the observer.** `resolveHops()` takes it and writes
the per-observer cache key that `renderHop()` has always read and
nothing ever wrote. `resolveHopsForPackets()` groups a multi-packet call
by observer so each group is filtered on its own. One site was the bug
in miniature: it wrote its result under `hopNameCache[k + ':' +
pkt.observer_id]` after resolving without that observer, so the key
promised something the value was not.

**The observer's position is the anchor.** `resolve()` has always
accepted `observerLat`/`observerLon` as the anchor at the receiving end:
when nothing later in the path is resolved, the observer is the next
known position, which is what `pickByAffinity` needs.
`observerPosition()` is the single lookup, used by both resolve paths.

This matters more than the IATA route, which turns out to be dead on
this network. `nodeInRegion()` looks the observer's code up in
`/api/iata-coords`; **0 of 42 observers have their code in that table**
(`ANR BRU GNE HEP KJK LGG MST NRW OBL OST` are all missing, while its 54
entries are `AMS`, `APC`, `ATL` and the like). So the 300 km filter has
never excluded anything here. 27 of 42 observers report lat/lon
directly, and that works. Filed separately as #2098, since it is a
network-wide lookup that resolves nothing and hop resolution may not be
its only consumer.

**Each badge belongs to its pill.** The badge is a sibling after the
pill, so a path read `A [8] → B [6] → C` with nothing to say which name
the 8 qualified. Pill and badge now render inside one `white-space:
nowrap` `.hop-group`, leaving the separator outside the pair. Only a hop
that carries a badge is wrapped; wrapping every hop would change the
layout of every path for nothing. That markup predates this work, but
these commits are what made it visible — before them the badge
essentially never appeared on this page.

**The list summarises, the detail pane does not.** Every 1-byte hop
getting its own badge turned a table row into a line of warning
triangles. `renderPath()` takes `{ summary: true }` for the three list
call sites: names with no per-hop badge, and one indicator reading "N of
M hops have more than one candidate". The two detail-pane sites are
unchanged and keep a badge per hop, because that is where the candidates
actually get read. `HopDisplay` gains `opts.badge === false` for it, and
the hop keeps its `hop-ambiguous` class either way so CSS can still mark
it.

## Scope

Deliberately not included: chaining the next resolved hop to narrow a
candidate set to one. `pickByAffinity` already scores on graph edges and
would do it; it needs neighbouring context this call does not yet
supply, and that belongs in its own change to `hop-resolver.js`. What is
here makes the uncertainty visible and picks a defensible candidate
rather than the first in index order.

## Verification

30 unit tests in `tests/unit/test-issue-2097-hop-ambiguity-badge.js`,
registered in `test-all.sh`. Full frontend suite exits 0.

Four of them are structural guards over `packets.js`, and each was
mutation-checked by reverting the change it protects:

- no `HopResolver.resolve()` call passes the hops alone
- no call passes an observer id and drops its position
- one helper does the observer lookup
- the detail pane calls `renderPath` without `summary`

The behavioural test for the anchor runs with `iataCoords` deliberately
empty, which is the live state, and lists the distant candidate first:
without an anchor the resolver keeps candidate order and picks it, with
one it does not, and the hop stays reported as ambiguous with all
candidates listed.

**Browser validated** on a staging deployment of this branch, against
live data:

| | list | detail pane |
|---|---|---|
| per-hop badges | 0 | 24 |
| path indicators | 1 | 0 |
| badges outside a `.hop-group` | — | 0 |

On the packet that prompted the issue, the first hop now resolves to a
repeater 10.6 km from the transmitter instead of one 126 km away, and
the second hop resolves to the only candidate that has a recorded
neighbour edge matching the next hop in the path.

## Note for reviewers

The last commit exists because staging disagreed with the tests. After
the anchor landed, the detail pane still picked the distant candidate:
it resolves through its own `HopResolver.resolve` call, which had the
observer id but a null position. The guard that now fails on exactly
that shape is what would have caught it before the deploy.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:35:40 +02:00

276 lines
13 KiB
JavaScript

/**
* #1146 — "Paths Through This Node" path-link contrast E2E.
*
* Bug: Path entries inside the node-detail "Paths Through This Node"
* section are rendered as <div> blocks, not a <table>. The existing
* `.node-detail-section .data-table td a { color: var(--accent) }`
* rule (style.css:1231) doesn't apply, so the path-hop <a> elements
* fall back to UA-default `rgb(0,0,238)` blue. On dark theme, that
* blue against `var(--card-bg)` (#1a1a2e) computes to ~3.0:1 — a
* WCAG AA failure (4.5:1 required for body text).
*
* This test loads a node detail page, mocks the /paths API to return
* a deterministic chain with at least one named hop, switches to dark
* theme, then asserts the computed link colour vs. its background
* yields a contrast ratio ≥ 4.5:1.
*
* #1153 also checks that the selected node has a visible enclosing marker
* in an 18-hop chain, without marking a different node with the same prefix.
* Covers the desktop side/full views and mobile full view in both themes.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js
* Optional SCREENSHOT_DIR saves PNG evidence; use an ignored output directory.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
assert(['localhost', '127.0.0.1', '[::1]'].includes(new URL(BASE).hostname),
'Browser regression tests must use a local server');
const SCREENSHOT_DIR = process.env.SCREENSHOT_DIR;
if (SCREENSHOT_DIR) require('fs').mkdirSync(SCREENSHOT_DIR, { recursive: true });
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'); }
// WCAG 2.1 relative luminance + contrast ratio.
function srgbToLin(c) {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function lum(rgb) {
return 0.2126 * srgbToLin(rgb[0]) + 0.7152 * srgbToLin(rgb[1]) + 0.0722 * srgbToLin(rgb[2]);
}
function contrast(fg, bg) {
const L1 = lum(fg), L2 = lum(bg);
const hi = Math.max(L1, L2), lo = Math.min(L1, L2);
return (hi + 0.05) / (lo + 0.05);
}
function parseRgb(s) {
// Accept "rgb(r, g, b)" or "rgba(r, g, b, a)".
const m = s.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!m) throw new Error('Cannot parse colour: ' + s);
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
}
// Walk up parent chain to find the first non-transparent backgroundColor.
async function effectiveBgFor(page, selector) {
return await page.evaluate((sel) => {
let el = document.querySelector(sel);
if (!el) return null;
while (el) {
const cs = getComputedStyle(el);
const bg = cs.backgroundColor;
const m = bg && bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (m) {
const a = m[4] !== undefined ? parseFloat(m[4]) : 1;
if (a > 0.01) return bg;
}
el = el.parentElement;
}
// Fallback: html background.
return getComputedStyle(document.documentElement).backgroundColor || 'rgb(255,255,255)';
}, selector);
}
(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: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== #1146 path-link contrast E2E against ${BASE} ===`);
const hopPubkey = 'a1'.repeat(32);
const targetName = 'Selected <node> & relay';
let selectedPubkey;
let targetHopKey;
let siblingKey;
// Mock paths API for ANY node so test is deterministic.
await page.route('**/api/nodes/*/paths*', (route) => {
selectedPubkey = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]);
// Full public-key identity must survive hex casing; a shared prefix alone
// must never qualify. Neither fixture node requires resolver changes.
targetHopKey = selectedPubkey === selectedPubkey.toUpperCase()
? selectedPubkey.toLowerCase() : selectedPubkey.toUpperCase();
siblingKey = targetHopKey.slice(0, -1) + (targetHopKey.endsWith('0') ? '1' : '0');
const hops = Array.from({ length: 18 }, (_, i) => ({
pubkey: hopPubkey.slice(0, -2) + i.toString(16).padStart(2, '0'),
prefix: 'a1', name: 'Relay ' + (i + 1),
}));
hops[8] = { pubkey: targetHopKey, prefix: targetHopKey.slice(0, 2), name: targetName, unreliable: true, ambiguous: true };
hops[9] = {
pubkey: siblingKey, prefix: targetHopKey.slice(0, 2), name: 'Same-prefix sibling', ambiguous: true,
conflicts: [
{ pubkey: targetHopKey, name: targetName, regional: true },
{ pubkey: siblingKey, name: 'Same-prefix sibling', regional: true },
],
};
hops[10] = { prefix: targetHopKey.slice(0, 2), name: 'Unresolved prefix' };
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
totalPaths: 1,
totalTransmissions: 5,
paths: [{
hops,
count: 5,
lastSeen: new Date().toISOString(),
sampleHash: 'deadbeef00',
}],
}),
});
});
await step('Load nodes page and force dark theme', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#nodesBody tr[data-key]', { timeout: 15000 });
if (await page.locator('html').getAttribute('data-theme') !== 'dark') {
await page.locator('#darkModeToggle').click();
}
});
await step('Open side panel for first node and wait for paths', async () => {
await page.click('#nodesBody tr[data-key]');
await page.waitForSelector('#pathsContent', { timeout: 10000 });
await page.waitForFunction(
() => {
const el = document.getElementById('pathsContent');
return el && el.querySelector('a[href^="#/nodes/"]');
},
null, { timeout: 15000 }
);
});
await step('Path link contrast (#pathsContent a) ≥ 4.5:1 in dark mode', async () => {
// Use page.evaluate (single CDP call) so querySelector and getComputedStyle
// are atomic — page.$eval splits them across two calls, leaving a window
// where a concurrent re-render can detach the element before getComputedStyle
// runs, causing Chromium to return '' for color.
const linkColor = await page.evaluate(() => {
const el = document.querySelector('#pathsContent a[href^="#/nodes/"]');
return el ? getComputedStyle(el).color : '';
});
const bgColor = await effectiveBgFor(page, '#pathsContent a[href^="#/nodes/"]');
const fg = parseRgb(linkColor);
const bg = parseRgb(bgColor);
const ratio = contrast(fg, bg);
console.log(` link=${linkColor} bg=${bgColor} ratio=${ratio.toFixed(2)}:1`);
assert(ratio >= 4.5,
`Expected contrast ≥ 4.5:1 (WCAG AA), got ${ratio.toFixed(2)}:1 ` +
`(link ${linkColor} on ${bgColor}). The path-link <a> elements are not ` +
`covered by the .data-table td a rule and inherit UA blue.`);
});
async function assertCurrentHop(selector, hasHopDisplay) {
await page.waitForFunction((sel) => document.querySelector(sel)?.textContent.includes('Relay 18'), selector);
const targetSelector = selector + ' a[href="#/nodes/' + targetHopKey + '"]';
const result = await page.evaluate(({ selector, targetHopKey, siblingKey }) => {
const container = document.querySelector(selector);
const chain = container.firstElementChild.firstElementChild;
const target = chain.querySelector('a[href="#/nodes/' + targetHopKey + '"]');
const sibling = chain.querySelector('a[href="#/nodes/' + siblingKey + '"]');
target.scrollIntoView({ block: 'center' });
const style = getComputedStyle(target);
const rect = target.getBoundingClientRect();
const chainRect = chain.getBoundingClientRect();
const outline = parseFloat(style.outlineWidth) >= 1 && style.outlineStyle === 'solid';
const border = ['Top', 'Right', 'Bottom', 'Left'].every(side =>
parseFloat(style['border' + side + 'Width']) >= 1 && style['border' + side + 'Style'] === 'solid');
return {
marked: Array.from(chain.querySelectorAll('.hop-current')).map(el => el.getAttribute('href')),
ring: outline || border, ringColor: outline ? style.outlineColor : style.borderTopColor,
outline, dashedBottom: style.borderBottomStyle === 'dashed' && parseFloat(style.borderBottomWidth) >= 1,
textColor: style.color, padding: parseFloat(style.paddingLeft),
text: target.textContent, injectedElements: target.childElementCount,
siblingMarked: sibling.classList.contains('hop-current'),
fits: rect.left >= chainRect.left - 1 && rect.right <= chainRect.right + 1,
fragments: target.getClientRects().length,
warning: !!chain.querySelector('.hop-unreliable-btn'),
conflict: !!chain.querySelector('.hop-conflict-btn'),
siblingAmbiguous: sibling.classList.contains('hop-ambiguous'),
hopCount: chain.children.length - chain.querySelectorAll(':scope > button').length,
};
}, { selector, targetHopKey, siblingKey });
assert(result.hopCount === 18, 'Expected all 18 hops in the rendered chain');
assert(result.ring, 'Selected hop must have a visible enclosing border or outline, beyond bold/color');
assert(result.marked.length === 1 && result.marked[0] === '#/nodes/' + targetHopKey,
'Exactly the selected full public key must be marked, including mixed-case hex');
assert(!result.siblingMarked, 'Same-prefix sibling must not be marked as this node');
assert(result.padding > 0 && result.fits && result.fragments === 1,
'Selected hop marker must enclose the name and fit within the wrapping chain');
assert(result.text === targetName && result.injectedElements === 0, 'Selected hop name must stay escaped');
const bg = parseRgb(await effectiveBgFor(page, targetSelector));
assert(contrast(parseRgb(result.ringColor), bg) >= 3, 'Selected hop ring must contrast with the active theme');
assert(contrast(parseRgb(result.textColor), bg) >= 4.5, 'Selected hop text must remain readable');
if (hasHopDisplay) {
assert(result.warning && result.conflict && result.siblingAmbiguous,
'Shared hop rendering must preserve unreliable and ambiguous indicators');
assert(result.outline && result.dashedBottom,
'The selected ambiguous hop must keep its dashed bottom border alongside the enclosing outline');
}
if (SCREENSHOT_DIR) {
const theme = await page.locator('html').getAttribute('data-theme');
const filename = `${selector.slice(1)}-${page.viewportSize().width}-${theme}${hasHopDisplay ? '' : '-plain'}.png`;
await page.locator(selector).screenshot({ path: require('path').join(SCREENSHOT_DIR, filename) });
}
}
await step('#1153 desktop side panel: selected hop stands out in an 18-hop dark chain',
() => assertCurrentHop('#pathsContent', false));
for (const theme of ['light', 'dark']) {
if (await page.locator('html').getAttribute('data-theme') !== theme) {
await page.locator('#darkModeToggle').click();
}
await step('#1153 desktop side panel: ' + theme + ' theme', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
assert(await page.locator('html').getAttribute('data-theme') === theme, 'Expected the selected theme');
await page.locator('#nodesBody tr[data-key]').first().click();
await assertCurrentHop('#pathsContent', false);
});
await step('#1153 desktop full view: ' + theme + ' theme', async () => {
await page.locator('#nodesRight .node-detail-btn').click();
await assertCurrentHop('#fullPathsContent', true);
});
}
for (const theme of ['light', 'dark']) {
// The theme toggle lives in the desktop header; exercise the same theme
// at mobile width without depending on the unrelated navigation drawer.
await page.setViewportSize({ width: 1280, height: 900 });
if (await page.locator('html').getAttribute('data-theme') !== theme) {
await page.locator('#darkModeToggle').click();
}
await page.setViewportSize({ width: 390, height: 844 });
await step('#1153 mobile row click opens a readable full chain: ' + theme + ' theme', async () => {
await page.goto(BASE + '/#/nodes', { waitUntil: 'domcontentloaded' });
assert(await page.locator('html').getAttribute('data-theme') === theme, 'Expected the selected theme');
await page.locator('#nodesBody tr[data-key]').first().click();
await assertCurrentHop('#fullPathsContent', true);
});
}
await step('#1153 full-view fallback also marks only the selected node', async () => {
await page.route('**/hop-display.js*', route => route.fulfill({ contentType: 'text/javascript', body: '' }));
await page.reload({ waitUntil: 'domcontentloaded' });
await assertCurrentHop('#fullPathsContent', false);
assert(await page.evaluate(() => !window.HopDisplay), 'Expected the real full-view fallback');
});
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
})().catch((e) => { console.error(e); process.exit(1); });