Files
meshcore-analyzer/tests/e2e/test-map-deeplink-path-params-e2e.js
CoderNemesis 65990873ad feat(map): deep-link the route path picker by observer or All view (#2376)
## Summary

The map route deep link (`#/map?packet=<hash>[&obs=<id>]`) always opened
in the canonical-path view. Two optional query params now choose the
path picker's starting view, so a shared link shows exactly what the
sender meant.

| Param | Effect |
|---|---|
| `paths=all` | Opens the picker's **All** view (every unique path), the
same as clicking **All** |
| `observer=<pubkey>` | Full public key or a hex prefix of 8+
characters, case-insensitive. Picks that observer's **earliest**
observation (by timestamp, then id), makes it the canonical route and
isolates its path row, the same as clicking the row. "Back to packet"
then links to that observation. |

Examples:

```
#/map?packet=974ad4ecefbe3761&paths=all
#/map?packet=974ad4ecefbe3761&observer=a1b2c3d4
```

When params are combined, `obs=` wins over `observer=`, and `observer=`
wins over `paths=all`. An unknown or malformed observer falls back to
the current default view and logs a console warning.

## Changes

- `public/route-view-utils.js`: two new pure helpers.
- `resolveDeepLinkObservation(observations, params)` picks the
observation and the initial view.
- `pathGroupKey(rawHops, filterHops)` is the path picker's grouping key.
It was written out twice in `route-view.js` and now lives in one place,
which keeps the deep link and the picker rows in sync.
- `public/map.js`: `loadRouteFromDeepLink` uses the helper and passes
`initialPathView` through `drawPacketRouteMulti` to the renderer.
- `public/route-view.js`: the picker count and rows use `pathGroupKey`.
`sidebar._applyPathView` applies the view through the existing All and
row click handlers, in the frame after the route's initial fit.

## Performance

This doesn't touch a hot path. The work is one pass over a single
packet's observations (tens at most) plus a scan of the picker rows, and
it runs once per deep-link load. It adds no API calls.

## Config / customizer

No new configurable values.

## Tests

- New unit suite `tests/unit/test-map-deeplink-path-params.js` (29
checks). It covers prefix vs full key, case, short/non-hex keys,
earliest-observation choice with tie-break, the precedence rules,
`pathGroupKey`, and the source wiring.
- `tests/unit/test-issue-1633-hide-1byte-hops.js` replayed a copy of the
key logic. It now tests the real `pathGroupKey`.
- New Playwright suite `tests/e2e/test-map-deeplink-path-params-e2e.js`
(5 checks) on the seeded #1486 packet. It's registered in
`scripts/non-unit-tests.json` and CI shard 3. With the `route-view.js`
change removed, 3 of its 5 checks fail.
- Local runs against the CI-prepared fixture: `test-all.sh` passes.
Route-map a11y E2E 20/20, map mobile E2E 3/3, `test-e2e-playwright.js`
149/152 (3 skipped by the fixture, 0 failed).

## Browser validation

Checked in a browser against a local server, using a scratch copy of the
fixture that gives the seeded packet multi-hop paths through real GPS
nodes. One observer had two observations, and the API listed the later
one first.
- `observer=<prefix>` picked that observer's earliest observation and
drew only its path, with numbered markers and the map fitted to it.
- `paths=all` drew the same picture as clicking **All** by hand.
2026-11-08 17:13:22 +01:00

107 lines
4.9 KiB
JavaScript

/**
* E2E: map route deep-link path params.
*
* #/map?packet=<hash>&observer=<pubkey or 8+ char prefix>
* → that observer's observation is the canonical route and its path is
* isolated in the path picker (row active), as if the row was clicked.
* #/map?packet=<hash>&paths=all
* → the picker opens in its "All" union view, as if All was clicked.
*
* Uses the CI-seeded #1486 transmission (SEED_HASH): three observations,
* observers idx 1/2/3 on paths ["AA"], ["BB"], ["CC"]. When running
* locally, seed the same row (see .github/workflows/deploy.yml for the SQL).
*
* Usage: BASE_URL=http://localhost:13581 node test-map-deeplink-path-params-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const SEED_HASH = 'fae0c9e6d357a814';
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'); }
// Cold-load a route deep link and wait until the path picker exists and the
// deep-linked view (if any) has had its frame to apply.
async function openRoute(page, query) {
await page.goto(BASE + '/#/map?packet=' + SEED_HASH + query, { waitUntil: 'domcontentloaded' });
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.mc-rt-path-row', { timeout: 15000 });
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
return page.evaluate(() => {
const sb = document.querySelector('.mc-rt-sidebar');
return {
rows: [...document.querySelectorAll('.mc-rt-path-row')].map(r => ({
key: r.dataset.pathKey, active: r.classList.contains('mc-rt-path-active')
})),
back: sb && sb.querySelector('.mc-rt-back-link').getAttribute('href'),
union: !!sb && Array.isArray(sb._unionOverlay),
};
});
}
const active = s => s.rows.filter(r => r.active).map(r => r.key);
(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: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(10000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log(`\n=== map deep-link path params E2E against ${BASE} ===`);
const resp = await ctx.request.get(BASE + '/api/packets/' + SEED_HASH);
const observations = (await resp.json()).observations || [];
const target = observations.find(o => JSON.parse(o.path_json || '[]').join() === 'BB');
assert(target, 'seeded observation with path ["BB"] not found');
const pubkey = String(target.observer_id);
await step('default view: no path isolated, no union view', async () => {
const s = await openRoute(page, '');
assert(s.rows.length === 3, 'expected 3 unique paths, got ' + s.rows.length);
assert(active(s).length === 0, 'no row should be active, got ' + active(s));
assert(!s.union, 'union view should not be open');
});
await step('observer=<8-char prefix> isolates that observer\'s path', async () => {
const s = await openRoute(page, '&observer=' + pubkey.slice(0, 8).toLowerCase());
assert(active(s).join() === 'BB', 'expected BB active, got ' + active(s));
assert(s.back === '#/packets/' + SEED_HASH + '?obs=' + target.id, 'back link should name the observation, got ' + s.back);
});
await step('observer=<full public key> isolates the same path', async () => {
const s = await openRoute(page, '&observer=' + pubkey);
assert(active(s).join() === 'BB', 'expected BB active, got ' + active(s));
});
await step('unmatched obs falls through to observer; back link names the selected observation', async () => {
const s = await openRoute(page, '&obs=999999999&observer=' + pubkey.slice(0, 8));
assert(active(s).join() === 'BB', 'expected BB active, got ' + active(s));
assert(s.back === '#/packets/' + SEED_HASH + '?obs=' + target.id, 'back link must not keep the unmatched obs, got ' + s.back);
});
await step('paths=all opens the All (union) view', async () => {
const s = await openRoute(page, '&paths=all');
assert(s.union, 'union overlay should be drawn');
assert(active(s).length === 0, 'no row should be active, got ' + active(s));
});
await step('unknown observer falls back to the default view', async () => {
const s = await openRoute(page, '&observer=00000000');
assert(active(s).length === 0 && !s.union, 'expected the default view');
});
await browser.close();
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed === 0 ? 0 : 1);
})().catch(e => { console.error(e); process.exit(1); });