mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-03 04:38:56 +00:00
fix(map): keep Path Inspector candidates accessible and cover route replacement (#2082)
## Fix Fixes #2060. Fixes #2081. Path Inspector route drawing and candidate replacement now have deterministic browser coverage. An idempotent SQL seed adds four synthetic repeaters and four edges after fixture migration, without changing packet ordering. The test uses the real API, normal clicks, visible Leaflet geometry, and removal of every previous route object. Restoring coverage exposed a desktop layout bug: drawing the first route moved the map over the next candidate button. The route sidebar now participates in the existing flex layout, including resizing and collapse, while preserving the mobile bottom sheet. The historical zero-candidate result did not reproduce on the current baseline. No search thresholds or API behavior changed. No new dependencies or customizer settings. ## Validation - Local assertion-red history: `042db2b` requires seeded candidates; `ca1d22a` exposes the blocked second click. Subsequent commits repair layout and narrow-window behavior. - All 183 standalone suites passed. Unchanged-base Go failures: #2083 readiness race (fixed in #2084) and Windows symlink privilege. - Real Chromium: 11 Path Inspector/layout checks, 33 related map checks, and core E2E (131 passed, 3 existing fixture skips). - CSS-variable checks, XSS diff check, inventory, whitespace and PII checks passed. Seed remains valid after periodic graph refresh. Local browser validation used Chromium with a 60-second navigation budget; the OpenClaw profile and external preflight script were unavailable. Repository checks ran directly. [CI run 36343343569](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36343343569) records the initial assertion-red commit. Final CI must pass before merge.
This commit is contained in:
@@ -452,6 +452,9 @@ jobs:
|
||||
# required shape before the server boots.
|
||||
run: ./corescope-migrate -db test-fixtures/e2e-fixture.db
|
||||
|
||||
- name: Seed deterministic Path Inspector routes (#2060)
|
||||
run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/path-inspector.sql
|
||||
|
||||
- name: Start Go server with fixture DB
|
||||
run: |
|
||||
fuser -k 13581/tcp 2>/dev/null || true
|
||||
|
||||
@@ -34,3 +34,27 @@ classification records that limitation rather than installing them implicitly.
|
||||
The existing E2E workflow retains its browser installation, local fixture
|
||||
server, coverage collection, and repeat checks. Only use local servers for
|
||||
browser tests; inventory membership alone does not mean a suite runs in CI.
|
||||
|
||||
## Path Inspector route fixture
|
||||
|
||||
`tests/e2e/test-path-inspector-e2e.js` requires the real fixture API. After
|
||||
freshening and migrating a disposable copy of `test-fixtures/e2e-fixture.db`,
|
||||
seed it **before starting the server**:
|
||||
|
||||
```sh
|
||||
sqlite3 fixture-copy.db < test-fixtures/path-inspector.sql
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node tests/e2e/test-path-inspector-e2e.js
|
||||
```
|
||||
|
||||
The SQL adds four synthetic GPS repeaters and four persisted neighbor edges.
|
||||
Prefixes `f20601,f20602,f20603` produce two paths through different middle
|
||||
repeaters. The suite requires trusted hop evidence, clicks both Show on Map
|
||||
buttons, checks the real Leaflet segment coordinates, and verifies all previous
|
||||
route objects leave the map when the candidate changes. Missing seeds fail;
|
||||
there is no candidate-dependent skip. Rerunning the seed is safe, and it does
|
||||
not add packets or alter their ordering. CI runs this setup after migration.
|
||||
|
||||
The historical claim that low scores remove all candidates is not how the
|
||||
current inspector works: scores are floored, and path trust marks evidence.
|
||||
An empty candidate list means at least one prefix has no relay candidate.
|
||||
Deterministic prefixes avoid relying on whichever graph neighbors sort first.
|
||||
|
||||
+14
-7
@@ -1,6 +1,19 @@
|
||||
/* route-view.css — minimal route view layout + styling. */
|
||||
|
||||
body.mc-route-active #leaflet-map { left: 320px !important; width: calc(100% - 320px) !important; }
|
||||
/* Reserve the timeline's real width in the map's flex row (#2081). Offsetting
|
||||
the map visually covers the Path Inspector instead of making room for it. */
|
||||
@media (min-width: 768px) {
|
||||
#map-wrap > .mc-rt-sidebar {
|
||||
position: relative;
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
/* A saved/dragged wide panel must yield space when the viewport narrows.
|
||||
Match the resize handle's 220px minimum, except for the 36px collapse. */
|
||||
#map-wrap > .mc-rt-sidebar:not(.mc-rt-collapsed) { min-width: 220px; }
|
||||
body.mc-route-active #leaflet-map { min-width: 160px; }
|
||||
}
|
||||
|
||||
/* Auto-collapse Map Controls panel when route view opens. The toggle button
|
||||
(.map-controls-toggle) stays visible — clicking it expands the panel.
|
||||
@@ -579,12 +592,6 @@ body.mc-route-active .leaflet-overlay-pane svg path:not(.mc-rt-edge) { display:
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
/* When sidebar is collapsed, expand map to fill */
|
||||
body.mc-route-active:has(.mc-rt-sidebar.mc-rt-collapsed) #leaflet-map {
|
||||
left: 36px !important;
|
||||
width: calc(100% - 36px) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Mobile: hide desktop collapse + resize affordances (mobile uses bottom-sheet) */
|
||||
body.mc-route-active .mc-rt-collapse-btn,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Synthetic Path Inspector topology (#2060). Apply AFTER fixture migration
|
||||
-- and timestamp freshening, BEFORE starting the read-only server.
|
||||
-- Three-byte prefixes meet path trust without changing production thresholds.
|
||||
-- The shared middle prefix offers two distinct GPS routes: Start-North-End
|
||||
-- and Start-South-End. No packets/observations are added or reordered.
|
||||
BEGIN;
|
||||
INSERT INTO nodes (public_key, name, role, lat, lon, first_seen, last_seen, advert_count) VALUES
|
||||
('f206010000000000000000000000000000000000000000000000000000000000', 'Fixture Path Start', 'repeater', 1.00, 1.00, strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 day'), strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 hour'), 1),
|
||||
('f206021111111111111111111111111111111111111111111111111111111111', 'Fixture Path North', 'repeater', 1.01, 1.01, strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 day'), strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 hour'), 1),
|
||||
('f206022222222222222222222222222222222222222222222222222222222222', 'Fixture Path South', 'repeater', 0.99, 1.01, strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 day'), strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 hour'), 1),
|
||||
('f206030000000000000000000000000000000000000000000000000000000000', 'Fixture Path End', 'repeater', 1.00, 1.02, strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 day'), strftime('%Y-%m-%dT%H:%M:%SZ','now','-1 hour'), 1)
|
||||
ON CONFLICT(public_key) DO UPDATE SET name=excluded.name, role=excluded.role,
|
||||
lat=excluded.lat, lon=excluded.lon, first_seen=excluded.first_seen,
|
||||
last_seen=excluded.last_seen, advert_count=excluded.advert_count;
|
||||
|
||||
-- The server loads this persisted graph at startup and refreshes it from the
|
||||
-- same table. Edge counts affect ranking, not whether candidates exist or
|
||||
-- whether a prefix is trusted. Saturated counts keep both routes comfortably
|
||||
-- above the speculative score cutoff without adding synthetic packet rows.
|
||||
INSERT INTO neighbor_edges (node_a, node_b, count, last_seen) VALUES
|
||||
('f206010000000000000000000000000000000000000000000000000000000000', 'f206021111111111111111111111111111111111111111111111111111111111', 100, strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
('f206010000000000000000000000000000000000000000000000000000000000', 'f206022222222222222222222222222222222222222222222222222222222222', 100, strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
('f206021111111111111111111111111111111111111111111111111111111111', 'f206030000000000000000000000000000000000000000000000000000000000', 100, strftime('%Y-%m-%dT%H:%M:%SZ','now')),
|
||||
('f206022222222222222222222222222222222222222222222222222222222222', 'f206030000000000000000000000000000000000000000000000000000000000', 100, strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
ON CONFLICT(node_a, node_b) DO UPDATE SET count=excluded.count, last_seen=excluded.last_seen;
|
||||
COMMIT;
|
||||
@@ -1,82 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
/* Path Inspector — the map side pane, the legacy trace redirect, and the
|
||||
* tools landing.
|
||||
*
|
||||
* Scope note: test-path-inspector-coverage-e2e.js (wired, runs every build)
|
||||
* already covers the standalone /#/tools/path-inspector page, including its
|
||||
* validation paths and the ?prefixes= deep-link auto-fill. It was written
|
||||
* precisely because this file could not run. So this file no longer repeats
|
||||
* the standalone page. What it keeps is what nothing else covers:
|
||||
*
|
||||
* - the side pane on /#/map: present, collapsed, expands, submits
|
||||
* - /#/traces/<hash> still redirects to /#/tools/trace/<hash>
|
||||
* - /#/tools lists both tools
|
||||
*
|
||||
* NOT covered here, deliberately: "Show on Map draws a route" and "switching
|
||||
* candidates replaces it rather than stacking". Both need
|
||||
* /api/paths/inspect to return a candidate, and its beam search finds none in
|
||||
* the CI fixture's neighbour graph. They were written, they worked against a
|
||||
* populated instance, and they skipped in CI — a green suite hiding two
|
||||
* untested assertions, which is the exact shape #2037 is about. Removed
|
||||
* rather than shipped as skips; the fixture work they need is #2060.
|
||||
*
|
||||
* Ported from the @playwright/test version, which was written against a
|
||||
* runner this project does not install and so had never run (#2037). The
|
||||
* original's "switching candidate clears prior polyline" case ended after
|
||||
* the click with a comment and no assertion, which is the same
|
||||
* no-coverage-but-green problem #2037 is about; it is a real assertion here.
|
||||
*
|
||||
* CHROMIUM_REQUIRE=1 makes Chromium-launch failure a HARD FAIL.
|
||||
/* 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';
|
||||
// Prefixes are taken from the dataset under test rather than hardcoded, so
|
||||
// the submit below exercises a real lookup instead of a constant that resolves
|
||||
// to nothing.
|
||||
let PREFIXES = null;
|
||||
|
||||
async function pickPrefixes(page) {
|
||||
// /api/paths/inspect beam-searches the NEIGHBOUR GRAPH, so prefixes only
|
||||
// yield candidates when the nodes behind them are actually connected in it.
|
||||
// Taking them from a packet's recorded path is not enough: those hops need
|
||||
// not form an edge chain the search can walk. So walk the graph itself,
|
||||
// through the same API the product uses.
|
||||
//
|
||||
// Against a populated instance this yields candidates: POST
|
||||
// /api/paths/inspect with the three prefixes picked here returned 10, where
|
||||
// prefixes taken from a packet's recorded path returned none. Against the CI
|
||||
// fixture (110 edges over 200 nodes) it still returns none, which is why the
|
||||
// two drawing assertions are not in this file — see the header and #2060.
|
||||
const get = async (path) => {
|
||||
const r = await page.request.get(BASE + path);
|
||||
return r.ok() ? r.json() : null;
|
||||
};
|
||||
// A neighbour entry can carry a null pubkey: the graph records the hop by
|
||||
// prefix, and the node behind it need not be known. Those are useless here,
|
||||
// since the chain has to be followed one hop further.
|
||||
const resolved = (r) => ((r && r.neighbors) || []).filter(x => typeof x.pubkey === 'string' && x.pubkey.length >= 2);
|
||||
|
||||
const seed = await get('/api/nodes?role=repeater&limit=25');
|
||||
const nodes = ((seed && seed.nodes) || []).filter(n => typeof n.public_key === 'string' && n.public_key.length >= 2);
|
||||
for (const n of nodes) {
|
||||
const pk = n.public_key;
|
||||
const aN = resolved(await get(`/api/nodes/${pk}/neighbors`));
|
||||
if (!aN.length) continue;
|
||||
// Prefer a neighbour that itself has a neighbour, so the chain is three
|
||||
// hops long and (5) below has two candidates to switch between.
|
||||
for (const hop of aN) {
|
||||
const bN = resolved(await get(`/api/nodes/${hop.pubkey}/neighbors`)).filter(x => x.pubkey !== pk);
|
||||
if (bN.length) {
|
||||
return [pk, hop.pubkey, bN[0].pubkey].map(k => k.slice(0, 2).toLowerCase()).join(',');
|
||||
}
|
||||
}
|
||||
return [pk, aN[0].pubkey].map(k => k.slice(0, 2).toLowerCase()).join(',');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// 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++; }
|
||||
@@ -112,19 +55,38 @@ async function openPaneAndSubmit(page) {
|
||||
await expandPane(page);
|
||||
await page.fill('#mapPiInput', PREFIXES);
|
||||
await page.click('#mapPiSubmit');
|
||||
// Either outcome means the round trip finished.
|
||||
await page.waitForFunction(() => {
|
||||
const r = document.getElementById('mapPiResults');
|
||||
const e = document.getElementById('mapPiError');
|
||||
return (r && r.textContent.trim().length > 0) || (e && e.textContent.trim().length > 0);
|
||||
}, null, { timeout: 10000 });
|
||||
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 });
|
||||
browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined });
|
||||
} catch (err) {
|
||||
if (requireChromium) {
|
||||
console.error(`HARD FAIL — Chromium unavailable: ${err.message}`);
|
||||
@@ -137,17 +99,6 @@ async function main() {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
PREFIXES = await pickPrefixes(page);
|
||||
if (!PREFIXES) {
|
||||
// A failure, not a skip. Every dataset this runs against, fixture
|
||||
// included, has a neighbour graph; none would mean the graph or the nodes
|
||||
// API changed shape.
|
||||
console.error(' ✗ (0) no connected pair found in the neighbour graph, so the inspector cannot be exercised');
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` · prefixes taken from the dataset: ${PREFIXES}`);
|
||||
|
||||
// (1) The side pane exists and starts collapsed.
|
||||
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded' });
|
||||
try {
|
||||
@@ -168,24 +119,157 @@ async function main() {
|
||||
fail(`(2) the pane did not gain .expanded (class="${cls}")`);
|
||||
}
|
||||
|
||||
// (3) Submitting prefixes completes a round trip.
|
||||
// (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);
|
||||
pass('(3) submitting prefixes renders results or an error');
|
||||
} catch {
|
||||
fail('(3) neither results nor an error appeared within 10s of submitting');
|
||||
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.
|
||||
await openPaneAndSubmit(page);
|
||||
await page.locator('#mapPiResults button[data-idx="0"]').click();
|
||||
await page.waitForSelector('.mc-rt-sidebar');
|
||||
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);
|
||||
}
|
||||
|
||||
// (4) The legacy trace URL still redirects.
|
||||
// (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('(4) /#/traces/<hash> redirects to /#/tools/trace/<hash>');
|
||||
pass('(10) /#/traces/<hash> redirects to /#/tools/trace/<hash>');
|
||||
} catch {
|
||||
fail(`(4) no redirect; the URL is ${JSON.stringify(page.url())}`);
|
||||
fail(`(10) no redirect; the URL is ${JSON.stringify(page.url())}`);
|
||||
}
|
||||
|
||||
// (5) The tools landing lists both tools.
|
||||
// (11) The tools landing lists both tools.
|
||||
await page.goto(`${BASE}/#/tools`, { waitUntil: 'domcontentloaded' });
|
||||
try {
|
||||
await page.waitForSelector('.tools-landing', { timeout: 8000 });
|
||||
@@ -193,10 +277,10 @@ async function main() {
|
||||
pi: !!document.querySelector('a[href="#/tools/path-inspector"]'),
|
||||
trace: !!document.querySelector('a[href*="#/tools/trace"]'),
|
||||
}));
|
||||
if (links.pi && links.trace) pass('(5) the tools landing links to both tools');
|
||||
else fail(`(5) the tools landing is missing a link (path-inspector: ${links.pi}, trace: ${links.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('(5) .tools-landing never rendered within 8s');
|
||||
fail('(11) .tools-landing never rendered within 8s');
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
Reference in New Issue
Block a user