Files
meshcore-analyzer/tests/e2e/test-path-inspector-e2e.js
efitenandClaude Opus 5.5 ef12535364 test(map): wait for the new route sidebar before measuring it (#2109)
Step (9) of `tests/e2e/test-path-inspector-e2e.js` fails intermittently
on master with `(3-9) fixture route coverage: 0 !== 700`: 3 of the 18
runs that executed it since 2026-09-30, including master runs
[37120155356](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37120155356)
and
[37156362836](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37156362836).
A red master run skips the image build, so `:edge` is not rebuilt.

## Cause

The step clicks a candidate and then waits for any `.mc-rt-sidebar`. The
previous route's sidebar survives the hash-only `goto` to `#/map`, and
`drawPacketRoute` (`public/map.js:1080`) replaces it only after
`/api/resolve-hops` answers. So the wait matched the old sidebar, and
the checks that follow measured either the old sidebar or, if the
replacement landed between the locator resolving and the evaluation, a
detached element, whose `getBoundingClientRect()` width is 0.

This is a test defect. The app keeps the previous route on screen while
the next one loads, which is intended.

## Fix

Remember the sidebar before the click and wait until a different one is
in the DOM. Test-only, one file.

## Evidence

Locally against the fixture, with `/api/resolve-hops` delayed through
`page.route` in a throwaway copy of the test, measuring which sidebar
the step-9 assertion sees:

| Delay | Before | After |
|---|---|---|
| 0 ms | new sidebar, pass | new sidebar, pass |
| 80 to 100 ms | old sidebar, `isConnected: false`, width 0 (3 of 5
runs) | new sidebar, pass (5 of 5) |
| 1500 ms | old sidebar, pass: the step checked the wrong page | new
sidebar, pass |

The unmodified fixed test then passed 3 of 3 runs.

## Not done

- I did not look for the same wait pattern in other suites.
- The delay probe is not committed. It needs a timing window to be
useful, and that window would be a fixed sleep in CI.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:27:02 +02:00

301 lines
16 KiB
JavaScript

#!/usr/bin/env node
/* Path Inspector — real fixture API, map route drawing/replacement, side pane,
* legacy trace redirect and tools landing. Seed test-fixtures/path-inspector.sql
* after migrating the fixture and BEFORE starting the server (see #2060).
* Missing candidates are failures, never skips or mocked responses.
*/
'use strict';
const assert = require('node:assert/strict');
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// Three-byte prefixes meet the default path-trust policy. The middle prefix
// deliberately matches two repeaters, giving two distinct routes to draw.
const PREFIXES = 'f20601,f20602,f20603';
const key = (prefix, suffix = '0') => prefix + suffix.repeat(64 - prefix.length);
const POSITIONS = {
[key('f20601')]: [1, 1],
[key('f20602', '1')]: [1.01, 1.01],
[key('f20602', '2')]: [0.99, 1.01],
[key('f20603')]: [1, 1.02],
};
let passes = 0, failures = 0;
function pass(msg) { console.log(` ✓ ${msg}`); passes++; }
function fail(msg) { console.error(` ✗ ${msg}`); failures++; }
// The toggle ships in the page template, but its click handler is attached by
// initMapSidePane(), which public/map.js calls at the END of loadNodes()
// (map.js:1795). So the button exists for seconds before it does anything, and
// a single click on sight is a race that silently no-ops. Measured at ~3s
// against a populated instance.
//
// Clicking for real each attempt rather than dispatching in-page on purpose: a
// click that cannot land (an overlay eating it, as in #2049) has to fail here,
// which an element.click() from evaluate would hide.
async function expandPane(page) {
await page.waitForSelector('#mapPaneToggle', { timeout: 15000 });
for (let i = 0; i < 20; i++) {
const expanded = await page.evaluate(() => {
const el = document.getElementById('mapSidePane');
return !!el && /\bexpanded\b/.test(el.className);
});
if (expanded) return;
try {
await page.click('#mapPaneToggle', { timeout: 1500 });
} catch { /* not clickable yet; the next round re-checks */ }
await page.waitForTimeout(500);
}
throw new Error('the pane never expanded after 20 clicks over ~20s');
}
async function openPaneAndSubmit(page) {
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
await expandPane(page);
await page.fill('#mapPiInput', PREFIXES);
await page.click('#mapPiSubmit');
await page.waitForSelector('#mapPiResults button[data-idx="1"]', { timeout: 10000 });
}
async function assertDesktopLayout(page) {
const layout = await page.evaluate(() => ({
sidebar: document.querySelector('.mc-rt-sidebar').getBoundingClientRect().toJSON(),
map: document.querySelector('#leaflet-map').getBoundingClientRect().toJSON(),
inspector: document.querySelector('#mapSidePane').getBoundingClientRect().toJSON(),
viewport: innerWidth,
}));
assert.equal(layout.sidebar.left, 0, 'route sidebar stays on the left');
assert.ok(layout.sidebar.right <= layout.map.left + 1, 'map must reserve the route sidebar width');
assert.ok(layout.map.right <= layout.inspector.left + 1, 'map must not cover the Path Inspector');
assert.ok(layout.map.width >= 160, 'map retains at least 160px of usable space');
assert.ok(layout.inspector.right <= layout.viewport + 1, 'inspector stays within the viewport');
}
async function resizeSidebar(page, width) {
const current = await page.locator('.mc-rt-sidebar').boundingBox();
const handle = await page.locator('.mc-rt-resize-handle').boundingBox();
// The collapse button overlaps the handle's midpoint; drag its upper half.
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 4);
await page.mouse.down();
await page.mouse.move(handle.x + handle.width / 2 + width - current.width, handle.y + handle.height / 4, { steps: 4 });
await page.mouse.up();
}
async function main() {
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
let browser;
try {
browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined });
} catch (err) {
if (requireChromium) {
console.error(`HARD FAIL — Chromium unavailable: ${err.message}`);
process.exit(1);
}
console.warn(`SKIP — Chromium unavailable: ${err.message}`);
process.exit(0);
}
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
// (1) The side pane exists and starts collapsed.
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
try {
await page.waitForSelector('#mapSidePane', { timeout: 15000 });
const cls = await page.getAttribute('#mapSidePane', 'class');
if (cls && /\bexpanded\b/.test(cls)) fail(`(1) the side pane starts expanded (class="${cls}")`);
else pass('(1) the side pane is present and collapsed by default');
} catch {
fail('(1) #mapSidePane never appeared within 15s');
}
// (2) The toggle expands it.
try {
await expandPane(page);
pass('(2) clicking the toggle expands the pane');
} catch {
const cls = await page.getAttribute('#mapSidePane', 'class').catch(() => '(missing)');
fail(`(2) the pane did not gain .expanded (class="${cls}")`);
}
// (3) Real API candidates must resolve to both seeded routes. An HTTP 200
// with an empty result used to count as coverage for this round trip.
try {
const response = await page.request.post(BASE + '/api/paths/inspect', {
data: { prefixes: PREFIXES.split(',') },
});
assert.equal(response.status(), 200, 'fixture inspector API must be ready');
const data = await response.json();
assert.equal(data.candidates.length, 2, 'fixture must return two distinct candidates; apply path-inspector.sql before server startup');
assert.deepEqual(data.candidates.map(candidate => candidate.path.join(',')).sort(), [
[key('f20601'), key('f20602', '1'), key('f20603')].join(','),
[key('f20601'), key('f20602', '2'), key('f20603')].join(','),
].sort(), 'both complete fixture routes must be returned, regardless of score order');
for (const candidate of data.candidates) {
assert.equal(candidate.path.length, 3);
assert.equal(candidate.speculative, false, 'seeded graph must support both normal routes');
candidate.path.forEach(pk => assert.ok(POSITIONS[pk], 'candidate must use a seeded GPS repeater'));
assert.equal(candidate.evidence.perHop.length, 3);
assert.ok(candidate.evidence.perHop.every(hop => hop.trusted), 'three-byte hops must satisfy the unchanged trust policy');
}
await openPaneAndSubmit(page);
assert.equal(await page.locator('#mapPiResults button[data-idx]').count(), 2);
pass('(3) submitting prefixes renders both real, trusted fixture candidates');
// (4) Check actual Leaflet polylines, their coordinates and rendered SVG.
// No forced clicks: overlays blocking either candidate must fail the test.
for (let index = 0; index < 2; index++) {
const path = data.candidates[index].path.map(pk => POSITIONS[pk]);
const expected = [path.slice(0, 2), path.slice(1, 3)];
if (index === 1) {
const receivesClick = await page.locator('#mapPiResults button[data-idx="1"]').evaluate(button => {
const rect = button.getBoundingClientRect();
return button.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
});
assert.ok(receivesClick, '#2081: the rendered map must not intercept the second candidate button');
}
await page.locator(`#mapPiResults button[data-idx="${index}"]`).click();
await page.waitForFunction(expected => {
const group = window.__mc_routeLayer;
if (!group) return false;
const lines = group.getLayers().filter(layer => layer instanceof L.Polyline && !(layer instanceof L.Polygon));
const mapRect = window.__mc_map.getContainer().getBoundingClientRect();
const visible = line => {
const element = line.getElement();
if (!element || !element.getAttribute('d')) return false;
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.display !== 'none' && style.visibility === 'visible' && Number(style.opacity) > 0 &&
rect.width > 0 && rect.height > 0 && rect.right > mapRect.left && rect.left < mapRect.right &&
rect.bottom > mapRect.top && rect.top < mapRect.bottom;
};
return lines.length === 2 && expected.every(points => lines.some(line =>
JSON.stringify(line.getLatLngs().map(p => [p.lat, p.lng])) === JSON.stringify(points) &&
window.__mc_map.hasLayer(line) && visible(line)
));
}, expected, { timeout: 10000 });
if (index === 0) {
await page.evaluate(() => { window.__previousInspectorLayers = window.__mc_routeLayer.getLayers(); });
pass('(4) Show on Map draws both segments at the first candidate coordinates');
} else {
const previousRemoved = await page.evaluate(() => window.__previousInspectorLayers.every(layer =>
!window.__mc_routeLayer.hasLayer(layer) && !window.__mc_map.hasLayer(layer)
));
assert.ok(previousRemoved, 'switching candidates must remove every prior route layer from the map');
pass('(5) switching candidates draws the other route and removes all prior route objects');
}
}
await assertDesktopLayout(page);
// The desktop layout repair must preserve the mobile fixed bottom sheet.
await page.setViewportSize({ width: 375, height: 812 });
const mobile = await page.locator('.mc-rt-sidebar').evaluate(sidebar => ({
position: getComputedStyle(sidebar).position,
rect: sidebar.getBoundingClientRect().toJSON(),
map: document.querySelector('#leaflet-map').getBoundingClientRect().toJSON(),
}));
assert.equal(mobile.position, 'fixed');
assert.equal(mobile.rect.left, 0);
assert.equal(mobile.rect.width, 375);
assert.equal(mobile.map.left, 0);
assert.equal(mobile.map.width, 375);
assert.ok(mobile.rect.bottom <= 812 && mobile.rect.top > 600, 'mobile route details remain a bottom sheet');
await page.locator('.mc-rt-mobile-handle').click();
assert.equal(await page.locator('.mc-rt-mobile-handle').getAttribute('aria-expanded'), 'true');
await page.waitForFunction(() => document.querySelector('.mc-rt-sidebar').getBoundingClientRect().height >= innerHeight * 0.75 - 1);
const expanded = await page.locator('.mc-rt-sidebar').evaluate(sidebar => ({
rect: sidebar.getBoundingClientRect().toJSON(),
map: document.querySelector('#leaflet-map').getBoundingClientRect().toJSON(),
}));
assert.equal(expanded.rect.left, 0);
assert.equal(expanded.rect.width, 375);
assert.ok(Math.abs(expanded.rect.bottom - mobile.rect.bottom) < 1, 'expanded sheet keeps its bottom anchor');
assert.ok(expanded.map.height > 0 && expanded.map.height < mobile.map.height, 'expanded sheet leaves a smaller usable map');
assert.ok(expanded.map.bottom <= expanded.rect.top + 1, 'map ends above the expanded sheet');
await page.locator('.mc-rt-mobile-handle').click();
assert.equal(await page.locator('.mc-rt-mobile-handle').getAttribute('aria-expanded'), 'false');
await page.waitForFunction(initial => {
const sidebar = document.querySelector('.mc-rt-sidebar').getBoundingClientRect();
const map = document.querySelector('#leaflet-map').getBoundingClientRect();
return Math.abs(sidebar.height - initial.rect.height) < 1 && Math.abs(sidebar.top - initial.rect.top) < 1 &&
Math.abs(map.height - initial.map.height) < 1 && Math.abs(map.bottom - initial.map.bottom) < 1;
}, mobile);
pass('(6) mobile sheet expansion reserves map space and collapse restores both geometries');
await page.setViewportSize({ width: 1280, height: 900 });
await page.locator('.mc-rt-collapse-btn').click();
await assertDesktopLayout(page);
assert.equal(await page.locator('.mc-rt-sidebar').evaluate(el => el.getBoundingClientRect().width), 36);
await page.locator('.mc-rt-collapse-btn').click();
await assertDesktopLayout(page);
pass('(7) collapsing and restoring the route sidebar keeps both map and inspector accessible');
await resizeSidebar(page, 400);
assert.equal(await page.locator('.mc-rt-sidebar').evaluate(el => el.getBoundingClientRect().width), 400);
await assertDesktopLayout(page);
pass('(8) dragging the route sidebar to 400px preserves space for the inspector');
await resizeSidebar(page, 700);
assert.equal(await page.evaluate(() => localStorage.getItem('mc-rt-sidebar-width')), '700');
for (const width of [900, 768]) {
await page.setViewportSize({ width, height: 900 });
await assertDesktopLayout(page);
await page.locator('.mc-rt-collapse-btn').click();
await assertDesktopLayout(page);
assert.equal(await page.locator('.mc-rt-sidebar').evaluate(el => el.getBoundingClientRect().width), 36);
await page.locator('.mc-rt-collapse-btn').click();
await assertDesktopLayout(page);
}
// A saved desktop preference must be constrained on a new narrow page too.
// The previous route's sidebar survives the hash-only goto to #/map, and
// the new one replaces it only after /api/resolve-hops answers. Waiting
// for any .mc-rt-sidebar matched the old one, so the checks below could
// measure it, or measure it just after its removal: a detached element
// has a 0px rect, which is how this failed intermittently in CI.
await openPaneAndSubmit(page);
await page.evaluate(() => { window.__previousRouteSidebar = document.querySelector('.mc-rt-sidebar'); });
await page.locator('#mapPiResults button[data-idx="0"]').click();
await page.waitForFunction(() => {
const sidebar = document.querySelector('.mc-rt-sidebar');
return !!sidebar && sidebar !== window.__previousRouteSidebar;
}, null, { timeout: 10000 });
assert.equal(await page.locator('.mc-rt-sidebar').evaluate(el => el.style.width), '700px');
await assertDesktopLayout(page);
await page.setViewportSize({ width: 1280, height: 900 });
assert.equal(await page.locator('.mc-rt-sidebar').evaluate(el => el.getBoundingClientRect().width), 700);
await assertDesktopLayout(page);
pass('(9) dragged and saved 700px widths adapt at 900/768px and restore on a wider viewport');
} catch (err) {
fail('(3-9) fixture route coverage: ' + err.message);
}
// (10) The legacy trace URL still redirects.
await page.goto(`${BASE}/#/traces/abc123`, { waitUntil: 'domcontentloaded' });
try {
await page.waitForFunction(() => location.hash.indexOf('#/tools/trace/abc123') === 0, null, { timeout: 5000 });
pass('(10) /#/traces/<hash> redirects to /#/tools/trace/<hash>');
} catch {
fail(`(10) no redirect; the URL is ${JSON.stringify(page.url())}`);
}
// (11) The tools landing lists both tools.
await page.goto(`${BASE}/#/tools`, { waitUntil: 'domcontentloaded' });
try {
await page.waitForSelector('.tools-landing', { timeout: 8000 });
const links = await page.evaluate(() => ({
pi: !!document.querySelector('a[href="#/tools/path-inspector"]'),
trace: !!document.querySelector('a[href*="#/tools/trace"]'),
}));
if (links.pi && links.trace) pass('(11) the tools landing links to both tools');
else fail(`(11) the tools landing is missing a link (path-inspector: ${links.pi}, trace: ${links.trace})`);
} catch {
fail('(11) .tools-landing never rendered within 8s');
}
await browser.close();
console.log(`\ntest-path-inspector-e2e: ${passes} passed, ${failures} failed`);
process.exit(failures ? 1 : 0);
}
main().catch((e) => { console.error(e); process.exit(1); });