diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4ad7356b..d75b2587 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -92,6 +92,7 @@ jobs: node test-packet-filter-time.js node test-channel-decrypt-insecure-context.js node test-live-region-filter.js + node test-issue-1136-observer-iata-map.js node test-channel-qr.js node test-channel-qr-wiring.js node test-channel-modal-ux.js @@ -232,6 +233,7 @@ jobs: BASE_URL=http://localhost:13581 node test-issue-1122-packets-filter-ux-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1128-packets-layout-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1128-multi-viewport-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-1136-live-region-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-rebrand-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/live.js b/public/live.js index 52b5a04e..a5a50bf3 100644 --- a/public/live.js +++ b/public/live.js @@ -52,6 +52,28 @@ return false; } function setObserverIataMap(m) { observerIataMap = m || {}; } + + /** + * Build observer_id → IATA map from the /api/observers response. + * The endpoint returns `{ observers: [...], server_time: "..." }` + * (cmd/server/types.go ObserverListResponse). Defensive: also accepts + * a bare array in case the API shape ever changes back, and ignores + * observers without an IATA. Returns a plain object (used as a hash). + * Exported for tests via window._liveBuildObserverIataMap. + * Fixes #1136 (regression introduced in #1080 which assumed array shape). + */ + function buildObserverIataMap(data) { + var list = null; + if (Array.isArray(data)) list = data; + else if (data && Array.isArray(data.observers)) list = data.observers; + var m = {}; + if (!list) return m; + for (var i = 0; i < list.length; i++) { + var o = list[i]; + if (o && o.id != null && o.iata) m[o.id] = o.iata; + } + return m; + } let rainCanvas = null, rainCtx = null, rainDrops = [], rainRAF = null; const propagationBuffer = new Map(); // hash -> {timer, packets[]} let _onResize = null; @@ -1042,16 +1064,13 @@ (function initLiveRegionFilter() { var rfEl = document.getElementById('liveRegionFilter'); if (!rfEl || !window.RegionFilter) return; - // Fetch observer roster to build observer_id → IATA map - fetch('/api/observers').then(function(r) { return r.json(); }).then(function(list) { - var m = {}; - if (Array.isArray(list)) { - for (var i = 0; i < list.length; i++) { - var o = list[i]; - if (o && o.id != null && o.iata) m[o.id] = o.iata; - } - } - setObserverIataMap(m); + // Fetch observer roster to build observer_id → IATA map. + // /api/observers returns `{observers:[...], server_time:"..."}` + // (cmd/server/types.go ObserverListResponse) — NOT a top-level array. + // Bug #1136: previously parsed as array → map empty → region filter + // dropped every packet. + fetch('/api/observers').then(function(r) { return r.json(); }).then(function(data) { + setObserverIataMap(buildObserverIataMap(data)); }).catch(function() { /* leave map empty; filter will hide all when active */ }); RegionFilter.init(rfEl, { dropdown: true }); regionFilterChangeHandler = RegionFilter.onChange(function() { /* selection persisted by RegionFilter; future packets reflect it */ }); @@ -2127,6 +2146,8 @@ window._liveGetNodeFilterKeys = function() { return nodeFilterKeys; }; window._livePacketMatchesRegion = packetMatchesRegion; window._liveSetObserverIataMap = setObserverIataMap; + window._liveBuildObserverIataMap = buildObserverIataMap; + window._liveGetObserverIataMap = function() { return observerIataMap; }; window._liveSetNodeFilter = setNodeFilter; window._liveFormatLiveTimestampHtml = formatLiveTimestampHtml; window._liveResolveHopPositions = resolveHopPositions; diff --git a/test-issue-1136-live-region-e2e.js b/test-issue-1136-live-region-e2e.js new file mode 100644 index 00000000..ffc59b4e --- /dev/null +++ b/test-issue-1136-live-region-e2e.js @@ -0,0 +1,134 @@ +/** + * 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); }); diff --git a/test-issue-1136-observer-iata-map.js b/test-issue-1136-observer-iata-map.js new file mode 100644 index 00000000..e3f81a3c --- /dev/null +++ b/test-issue-1136-observer-iata-map.js @@ -0,0 +1,133 @@ +/* Unit test (#1136): live.js must parse /api/observers correctly. + * + * Regression: PR #1080 wrote `if (Array.isArray(list))` and treated the + * response as a top-level array. The actual /api/observers shape is + * `{ observers: [...], server_time: "..." }` (cmd/server/types.go + * ObserverListResponse). Result: observerIataMap stays empty and ANY + * region selection drops every packet. + * + * This test loads live.js into a vm sandbox and asserts that the exposed + * builder helper produces a populated map from the realistic API shape. + */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' \u2705 ' + name); } + catch (e) { failed++; console.log(' \u274C ' + name + ': ' + e.message); } +} + +function makeSandbox() { + const ctx = { + window: { addEventListener: () => {}, dispatchEvent: () => {}, devicePixelRatio: 1 }, + document: { + readyState: 'complete', + createElement: () => ({ style: {}, classList: { add(){}, remove(){}, contains(){return false;} }, setAttribute(){}, addEventListener(){}, getContext: () => ({clearRect(){},fillRect(){},beginPath(){},arc(){},fill(){},scale(){},fillText(){}}) }), + head: { appendChild: () => {} }, + getElementById: () => null, + addEventListener: () => {}, + querySelectorAll: () => [], querySelector: () => null, + createElementNS: () => ({ setAttribute(){} }), + documentElement: { getAttribute: () => null, setAttribute: () => {}, dataset: {} }, + body: { appendChild: () => {}, removeChild: () => {}, contains: () => false }, + hidden: false, + }, + console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp, + Error, TypeError, Map, Set, Promise, URLSearchParams, + parseInt, parseFloat, isNaN, isFinite, + encodeURIComponent, decodeURIComponent, + setTimeout: () => 0, clearTimeout: () => {}, + setInterval: () => 0, clearInterval: () => {}, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + performance: { now: () => Date.now() }, + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, + localStorage: (() => { const s = {}; return { getItem: k => s[k] !== undefined ? s[k] : null, setItem: (k,v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; } }; })(), + location: { hash: '', protocol: 'https:', host: 'localhost' }, + CustomEvent: class CustomEvent {}, + addEventListener: () => {}, dispatchEvent: () => {}, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + matchMedia: () => ({ matches: false, addEventListener: () => {} }), + navigator: {}, visualViewport: null, + MutationObserver: function() { this.observe=()=>{}; this.disconnect=()=>{}; }, + WebSocket: function() { this.close=()=>{}; }, + IATA_COORDS_GEO: {}, + L: { + circleMarker: () => ({addTo(){return this;},bindTooltip(){return this;},on(){return this;},setRadius(){},setStyle(){},setLatLng(){},getLatLng(){return{lat:0,lng:0};},remove(){}}), + polyline: () => ({addTo(){return this;},setStyle(){},remove(){}}), + polygon: () => ({addTo(){return this;},remove(){}}), + map: () => ({setView(){return this;},addLayer(){return this;},on(){return this;},getZoom(){return 11;},getCenter(){return{lat:0,lng:0};},getBounds(){return{contains:()=>true};},fitBounds(){return this;},invalidateSize(){},remove(){},hasLayer(){return false;},removeLayer(){}}), + layerGroup: () => ({addTo(){return this;},addLayer(){},removeLayer(){},clearLayers(){},hasLayer(){return true;},eachLayer(){}}), + tileLayer: () => ({addTo(){return this;}}), + control: { attribution: () => ({addTo(){}}) }, + DomUtil: { addClass(){}, removeClass(){} }, + }, + registerPage: () => {}, onWS: () => {}, offWS: () => {}, connectWS: () => {}, + api: () => Promise.resolve([]), invalidateApiCache: () => {}, + favStar: () => '', bindFavStars: () => {}, + getFavorites: () => [], isFavorite: () => false, + HopResolver: { init(){}, resolve: () => ({}), ready: () => false }, + MeshAudio: null, + RegionFilter: { init(){}, getSelected: () => null, onChange: () => {}, offChange: () => {}, regionQueryString: () => '', getRegionParam: () => '' }, + }; + vm.createContext(ctx); + return ctx; +} + +function load(ctx, file) { + vm.runInContext(fs.readFileSync(file, 'utf8'), ctx); + for (const k of Object.keys(ctx.window)) ctx[k] = ctx.window[k]; +} + +console.log('\n=== live.js: /api/observers parse (#1136) ==='); +const ctx = makeSandbox(); +load(ctx, 'public/roles.js'); +load(ctx, 'public/live.js'); + +const build = ctx.window._liveBuildObserverIataMap; +assert.ok(build, '_liveBuildObserverIataMap must be exposed (regression: missing parser helper)'); + +const realShape = { + observers: [ + { id: 'OBS1', iata: 'SJC', name: 'A' }, + { id: 'OBS2', iata: 'OAK', name: 'B' }, + { id: 'OBS3', iata: 'SFO', name: 'C' }, + { id: 'OBS4', iata: null, name: 'no-iata' }, + ], + server_time: '2026-05-07T00:00:00Z', +}; + +test('parses {observers:[...], server_time} response and populates map', () => { + const m = build(realShape); + assert.strictEqual(m.OBS1, 'SJC'); + assert.strictEqual(m.OBS2, 'OAK'); + assert.strictEqual(m.OBS3, 'SFO'); +}); + +test('skips observers without iata', () => { + const m = build(realShape); + assert.ok(!('OBS4' in m), 'observers with null iata should not be in map'); +}); + +test('returns empty map for null/undefined input', () => { + assert.strictEqual(Object.keys(build(null)).length, 0); + assert.strictEqual(Object.keys(build(undefined)).length, 0); +}); + +test('returns empty map when observers field is missing', () => { + assert.strictEqual(Object.keys(build({ server_time: 'x' })).length, 0); +}); + +test('back-compat: also accepts a top-level array (defensive)', () => { + // If the API shape ever changes back, don\'t silently break. + const m = build([{ id: 'X1', iata: 'LAX' }]); + assert.strictEqual(m.X1, 'LAX'); +}); + +console.log('\n' + '='.repeat(40)); +console.log(' observer iata map tests: ' + passed + ' passed, ' + failed + ' failed'); +console.log('='.repeat(40) + '\n'); +if (failed > 0) process.exit(1);