Files
meshcore-analyzer/test-issue-1136-live-region-e2e.js
T
Kpa-clawbot eddca7acde fix(live): region filter wipes feed — parse {observers:[...]} response (#1136) (#1140)
## Summary
Fixes #1136. The live page region filter wiped all packets, polylines,
and feed entries the moment any region was selected. Root cause:
`public/live.js` parsed `/api/observers` as a top-level array, but the
endpoint returns `{observers:[...], server_time:"..."}` — so
`observerIataMap` stayed empty and `packetMatchesRegion` rejected every
packet.

This was a regression introduced in #1080 (live region filter) after the
typed-struct refactor wrapped the observer list in
`ObserverListResponse` (cmd/server/types.go).

## Fix
- Extracted the parse into `buildObserverIataMap(data)` — a pure helper
that accepts both the real `{observers:[...]}` shape and a bare array
(defensive). Skips observers with no IATA so the result is a direct
lookup map.
- `initLiveRegionFilter` now uses the helper, so the map is populated on
first paint.
- Exposed `_liveBuildObserverIataMap` and `_liveGetObserverIataMap` on
`window` for tests (read-only — no behavior change).

Backend untouched — the API shape is correct.

## Tests (red → green)
**Red commit** (`test(live): failing tests for #1136 region filter wipes
feed`):
- `test-issue-1136-observer-iata-map.js` — failed at "helper must be
exposed" assertion (parser was inlined, not extracted).
- `test-issue-1136-live-region-e2e.js` — Playwright. Loads `/#/live`,
queries `/api/observers` to discover an SJC observer, asserts the live
module's `observerIataMap` is populated, selects SJC via
`RegionFilter.setSelected`, pushes a fixture packet through
`_liveBufferPacket`, and asserts a `.live-feed-item[data-hash=...]`
renders. Failed at both the "map populated" and "feed renders"
assertions — exactly the user-reported symptom.
- Both wired into `.github/workflows/deploy.yml` (unit step + Playwright
step).

**Green commit** (`fix(live): parse {observers:[...]} ...`): all five
unit assertions + all five E2E assertions pass. Existing
`test-live-region-filter.js` from #1080 still passes (no behavior change
to `packetMatchesRegion`).

## Verification (local)
```
node test-issue-1136-observer-iata-map.js   # 5/5 pass
node test-live-region-filter.js              # 9/9 pass (regression guard)
BASE_URL=http://localhost:13581 \
  CHROMIUM_PATH=/usr/bin/chromium \
  node test-issue-1136-live-region-e2e.js    # 5/5 pass against fixture DB
```

## Scope
- One frontend file changed (`public/live.js`).
- Two new tests + 2 lines of CI wiring.
- No backend changes.
- No refactor of unrelated `live.js` code.
- Out of scope: #1108 (the related "hide nodes not seen by region"
feature request) is intentionally not addressed here.

Fixes #1136

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-06 21:24:32 -07:00

135 lines
6.3 KiB
JavaScript

/**
* E2E (#1136): Live page region filter must NOT wipe all packets and lines.
*
* Regression introduced in #1080 — `public/live.js` parsed `/api/observers`
* as if it were a top-level array, but the endpoint returns
* `{observers: [...], server_time: ...}`. Result: `observerIataMap` stayed
* empty and `packetMatchesRegion` returned false for EVERY packet whenever
* any region was selected — so no markers, no polylines, no feed entries.
*
* This test:
* 1. Loads /#/live against the fixture DB.
* 2. Waits for the observer roster to load and verifies the live module
* has a populated observer_id → IATA map (proves the parse path works).
* 3. Programmatically selects a region (SJC) that we know maps to fixture
* observers (test-fixtures/e2e-fixture.db has multiple observers in
* SJC, OAK, MRY, SFO).
* 4. Synthesizes a packet whose observer_id IS in the SJC region and
* pushes it through the same path live websocket packets take.
* 5. Asserts at least one `.live-feed-item` is rendered for that hash.
*
* Before the fix this test FAILS at assertion 2 (map empty) AND at
* assertion 5 (feed never renders the packet). After the fix both pass.
*
* Usage: BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
let passed = 0, failed = 0;
async function step(name, fn) {
try { await fn(); passed++; console.log(' \u2713 ' + name); }
catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
}
function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); }
(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(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
console.log('\n=== #1136 live region filter E2E against ' + BASE + ' ===');
// Discover an observer_id in SJC from the API (drives test from real data).
let sjcObserverId = null;
let allObservers = [];
await step('GET /api/observers returns {observers:[...]} shape with SJC entries', async () => {
const res = await page.request.get(BASE + '/api/observers');
assert(res.ok(), 'API returned non-OK: ' + res.status());
const body = await res.json();
assert(body && Array.isArray(body.observers), 'response must have .observers array (the bug-1136 root cause)');
allObservers = body.observers;
const sjc = body.observers.filter(function (o) { return o && o.iata === 'SJC' && o.id; });
assert(sjc.length > 0, 'fixture must contain at least one SJC observer (got ' + sjc.length + ')');
sjcObserverId = sjc[0].id;
});
await step('navigate to /#/live and wait for live module to register', async () => {
// Pre-clear region selection so it starts unrestricted.
await page.addInitScript(() => {
try { localStorage.removeItem('meshcore-region-filter'); } catch (e) {}
});
await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!(window._liveBufferPacket && window.RegionFilter), { timeout: 15000 });
});
await step('observer iata map is POPULATED after init fetch (regression #1136)', async () => {
const exposed = await page.evaluate(() => typeof window._liveGetObserverIataMap);
assert(exposed === 'function', '_liveGetObserverIataMap must be exposed as a function (regression: not wired up)');
// Wait for fetch + setObserverIataMap to land.
await page.waitForFunction(() => {
const m = window._liveGetObserverIataMap && window._liveGetObserverIataMap();
return m && Object.keys(m).length > 0;
}, { timeout: 8000 }).catch(() => {});
const sample = await page.evaluate((oid) => {
const m = window._liveGetObserverIataMap();
return { size: Object.keys(m).length, iataForOid: m[oid] || null };
}, sjcObserverId);
assert(sample.size > 0, 'observerIataMap should be populated from /api/observers (was empty — #1136 bug)');
assert(sample.iataForOid === 'SJC', 'observerIataMap[' + sjcObserverId + '] should be "SJC", got ' + sample.iataForOid);
});
await step('select SJC region in RegionFilter, verify selection took effect', async () => {
await page.evaluate(() => {
window.RegionFilter.setSelected(['SJC']);
});
const sel = await page.evaluate(() => window.RegionFilter.getSelected());
assert(Array.isArray(sel) && sel.indexOf('SJC') !== -1, 'RegionFilter selected should include SJC, got ' + JSON.stringify(sel));
});
await step('packet with SJC observer renders to live feed when SJC region selected', async () => {
const targetHash = 'fixture-1136-' + Date.now().toString(16);
await page.evaluate(function (args) {
const pkt = {
id: 9999991136,
hash: args.hash,
raw_hex: '00',
path_json: '[]',
observer_id: args.oid,
observer_name: 'fixture-observer',
timestamp: new Date().toISOString(),
snr: 5, rssi: -90,
decoded: {
header: { payloadTypeName: 'GRP_TXT' },
payload: { text: 'region-1136-probe' },
path: { hops: [] },
},
};
// Push through the same buffer entry point the WS handler uses.
window._liveBufferPacket(pkt);
}, { hash: targetHash, oid: sjcObserverId });
// Allow the (non-realistic-propagation) immediate renderPacketTree to land.
await page.waitForFunction((h) => {
return !!document.querySelector('.live-feed-item[data-hash="' + h + '"]');
}, targetHash, { timeout: 5000 }).catch(() => {});
const found = await page.evaluate((h) => !!document.querySelector('.live-feed-item[data-hash="' + h + '"]'), targetHash);
assert(found, 'expected .live-feed-item[data-hash=' + targetHash + '] to render with SJC selected (#1136: filter wiped feed)');
});
await page.evaluate(() => { try { window.RegionFilter.setSelected([]); } catch(e) {} });
await browser.close();
console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---\n');
process.exit(failed > 0 ? 1 : 0);
})().catch((e) => { console.error(e); process.exit(1); });