diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 833573c2b..d707b258b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -186,6 +186,7 @@ jobs: node test-issue-1509-nav-active-bg.js node test-issue-1509-detect-preset.js node test-live.js + node test-issue-125-live-toggles-wiring.js node test-coverage-gate.js node test-node-reach-coverage.js node test-reach-rank.js @@ -502,6 +503,7 @@ jobs: 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 BASE_URL=http://localhost:13581 node test-live-multibyte-only-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-125-live-toggles-early-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 node test-issue-1705-subpath-contrast-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/public/live.js b/public/live.js index 3f0806702..b7d38aa89 100644 --- a/public/live.js +++ b/public/live.js @@ -48,6 +48,9 @@ let _lastAnimFrame = 0; let nodeActivity = {}; let recentPaths = []; + // Heat is a mirror like the toggles below (#125): wireLiveControls() + // restores from it and its change handler writes it back. + let heatEnabled = localStorage.getItem('meshcore-live-heatmap') !== 'false'; let showGhostHops = localStorage.getItem('live-ghost-hops') !== 'false'; let realisticPropagation = localStorage.getItem('live-realistic-propagation') === 'true'; let showOnlyFavorites = localStorage.getItem('live-favorites-only') === 'true'; @@ -1123,6 +1126,102 @@ startReplay(); } + /** + * Restore and wire the persisted Live view toggles (#125). + * + * MUST run synchronously right after init() writes the markup, before init + * awaits anything: until it runs the checkboxes are painted and clickable + * but show their default state, and a click is neither saved nor kept. + * Restoring state and attaching listeners never sits behind an await; the + * visible EFFECT of a setting that needs the map, markers or heat layer is + * applied again by applyLiveControlEffects() once init has built them. + * + * Idempotent per element (data-live-wired). init() writes new markup on + * every mount, so each mounted checkbox has exactly one listener. + * + * Not covered, deliberately: #liveAudioToggle (MeshAudio.restore() and its + * slider panel; audio needs a user gesture anyway) and the geo-filter and + * region controls, which stay hidden or inert until their own fetches. + */ + function wireLiveControls() { + const TOGGLES = [ + { id: 'liveHeatToggle', restore: () => heatEnabled, onChange: (v) => { + heatEnabled = v; + localStorage.setItem('meshcore-live-heatmap', heatEnabled); + if (v) showHeatMap(); else hideHeatMap(); + } }, + { id: 'liveGhostToggle', restore: () => showGhostHops, onChange: (v) => { + showGhostHops = v; + localStorage.setItem('live-ghost-hops', showGhostHops); + } }, + { id: 'liveRealisticToggle', restore: () => realisticPropagation, onChange: (v) => { + realisticPropagation = v; + localStorage.setItem('live-realistic-propagation', realisticPropagation); + } }, + { id: 'liveColorHashToggle', restore: () => colorByHash, onChange: (v) => { + colorByHash = v; + localStorage.setItem('meshcore-color-packets-by-hash', colorByHash); + window.dispatchEvent(new Event('storage')); + } }, + { id: 'liveFavoritesToggle', restore: () => showOnlyFavorites, onChange: (v) => { + showOnlyFavorites = v; + localStorage.setItem('live-favorites-only', showOnlyFavorites); + applyFavoritesFilter(); + } }, + { id: 'liveForeignToggle', restore: () => highlightForeign, onChange: (v) => { + highlightForeign = v; + localStorage.setItem('live-highlight-foreign', highlightForeign); + } }, + { id: 'liveMultibyteToggle', restore: () => multibyteOnly, onChange: (v) => { + multibyteOnly = v; + localStorage.setItem('live-multibyte-only', multibyteOnly); + rebuildFeedList(); + } }, + { id: 'liveMatrixToggle', restore: () => matrixMode, onChange: (v) => { + matrixMode = v; + localStorage.setItem('live-matrix-mode', matrixMode); + applyMatrixTheme(matrixMode); + syncHeatToggleToMatrix(matrixMode); + } }, + { id: 'liveMatrixRainToggle', restore: () => matrixRain, onChange: (v) => { + matrixRain = v; + localStorage.setItem('live-matrix-rain', matrixRain); + if (matrixRain) startMatrixRain(); else stopMatrixRain(); + } }, + ]; + for (const t of TOGGLES) { + const el = document.getElementById(t.id); + if (!el) { console.warn('[live] control not found: ' + t.id); continue; } + if (el.dataset.liveWired === '1') continue; + el.dataset.liveWired = '1'; + el.checked = t.restore(); + el.addEventListener('change', (e) => t.onChange(e.target.checked)); + } + // The Heat/Matrix interlock holds from the first paint. + syncHeatToggleToMatrix(matrixMode); + } + + // Matrix mode owns the heat map: while it is on, the heat layer is hidden + // and its toggle unchecked and disabled. Safe before the map exists. + function syncHeatToggleToMatrix(on) { + const ht = document.getElementById('liveHeatToggle'); + if (on) { + hideHeatMap(); + if (ht) { ht.checked = false; ht.disabled = true; } + } else if (ht) { + ht.disabled = false; // recover from stale state + } + } + + // Effects of the restored toggles that need what init() builds behind its + // awaits: the map, the node markers and the heat layer. + function applyLiveControlEffects() { + if (heatEnabled && !matrixMode) showHeatMap(); else hideHeatMap(); + applyMatrixTheme(matrixMode); + syncHeatToggleToMatrix(matrixMode); + if (matrixRain) startMatrixRain(); + } + async function init(app) { app.innerHTML = `
@@ -1276,18 +1375,9 @@
`; - // "Multibyte only" is restored and wired before the first await. The - // controls are visible and clickable as soon as they are rendered, and the - // feed already filters on the saved multibyteOnly. Wired after the awaits - // below, the box showed OFF while init ran even when saved ON, and a click - // made then was not saved and was reset when init caught up. - const multibyteToggle = document.getElementById('liveMultibyteToggle'); - multibyteToggle.checked = multibyteOnly; - multibyteToggle.addEventListener('change', (e) => { - multibyteOnly = e.target.checked; - localStorage.setItem('live-multibyte-only', multibyteOnly); - rebuildFeedList(); - }); + // The persisted view toggles are restored and wired before the first + // await (#88, #125): see wireLiveControls(). + wireLiveControls(); // Fetch configurable map defaults (#115) let mapCenter = [37.45, -122.0]; @@ -1581,7 +1671,7 @@ AreaFilter.init(document.getElementById('liveAreaFilter')); AreaFilter.onChange(function () { loadNodes(); }); await loadNodes(); - showHeatMap(); + applyLiveControlEffects(); connectWS(); initResizeHandler(); initVCRHeightTracker(); @@ -1610,52 +1700,6 @@ map.on('zoomend', rescaleMarkers); - // Heat map toggle — persist in localStorage - const liveHeatEl = document.getElementById('liveHeatToggle'); - if (localStorage.getItem('meshcore-live-heatmap') === 'false') { liveHeatEl.checked = false; hideHeatMap(); } - else if (localStorage.getItem('meshcore-live-heatmap') === 'true') { liveHeatEl.checked = true; } - liveHeatEl.addEventListener('change', (e) => { - localStorage.setItem('meshcore-live-heatmap', e.target.checked); - if (e.target.checked) showHeatMap(); else hideHeatMap(); - }); - - const ghostToggle = document.getElementById('liveGhostToggle'); - ghostToggle.checked = showGhostHops; - ghostToggle.addEventListener('change', (e) => { - showGhostHops = e.target.checked; - localStorage.setItem('live-ghost-hops', showGhostHops); - }); - - const realisticToggle = document.getElementById('liveRealisticToggle'); - realisticToggle.checked = realisticPropagation; - realisticToggle.addEventListener('change', (e) => { - realisticPropagation = e.target.checked; - localStorage.setItem('live-realistic-propagation', realisticPropagation); - }); - - const colorHashToggle = document.getElementById('liveColorHashToggle'); - colorHashToggle.checked = colorByHash; - colorHashToggle.addEventListener('change', (e) => { - colorByHash = e.target.checked; - localStorage.setItem('meshcore-color-packets-by-hash', colorByHash); - window.dispatchEvent(new Event('storage')); - }); - - const favoritesToggle = document.getElementById('liveFavoritesToggle'); - favoritesToggle.checked = showOnlyFavorites; - favoritesToggle.addEventListener('change', (e) => { - showOnlyFavorites = e.target.checked; - localStorage.setItem('live-favorites-only', showOnlyFavorites); - applyFavoritesFilter(); - }); - - const foreignToggle = document.getElementById('liveForeignToggle'); - foreignToggle.checked = highlightForeign; - foreignToggle.addEventListener('change', (e) => { - highlightForeign = e.target.checked; - localStorage.setItem('live-highlight-foreign', highlightForeign); - }); - // Region filter (#1045): dropdown of observer IATA regions (function initLiveRegionFilter() { var rfEl = document.getElementById('liveRegionFilter'); @@ -1962,41 +2006,6 @@ refresh(); })(); - const matrixToggle = document.getElementById('liveMatrixToggle'); - matrixToggle.checked = matrixMode; - matrixToggle.addEventListener('change', (e) => { - matrixMode = e.target.checked; - localStorage.setItem('live-matrix-mode', matrixMode); - applyMatrixTheme(matrixMode); - if (matrixMode) { - hideHeatMap(); - const ht = document.getElementById('liveHeatToggle'); - if (ht) { ht.checked = false; ht.disabled = true; } - } else { - const ht = document.getElementById('liveHeatToggle'); - if (ht) { ht.disabled = false; } - } - }); - applyMatrixTheme(matrixMode); - if (matrixMode) { - hideHeatMap(); - const ht = document.getElementById('liveHeatToggle'); - if (ht) { ht.checked = false; ht.disabled = true; } - } else { - // Ensure heat toggle is enabled if matrix mode is off (recover from stale state) - const ht = document.getElementById('liveHeatToggle'); - if (ht) { ht.disabled = false; } - } - - const rainToggle = document.getElementById('liveMatrixRainToggle'); - rainToggle.checked = matrixRain; - rainToggle.addEventListener('change', (e) => { - matrixRain = e.target.checked; - localStorage.setItem('live-matrix-rain', matrixRain); - if (matrixRain) startMatrixRain(); else stopMatrixRain(); - }); - if (matrixRain) startMatrixRain(); - // Audio toggle const audioToggle = document.getElementById('liveAudioToggle'); const audioControls = document.getElementById('audioControls'); @@ -4394,6 +4403,7 @@ } function showHeatMap() { + if (!map) return; // a Heat click during init: applyLiveControlEffects() builds it if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; } const points = []; Object.values(nodeData).forEach(n => { @@ -4421,7 +4431,8 @@ } function hideHeatMap() { - if (heatLayer) { map.removeLayer(heatLayer); heatLayer = null; } + if (heatLayer && map) map.removeLayer(heatLayer); + heatLayer = null; } /** Extract channel row style from a packet (shared by feed item builders). */ diff --git a/test-all.sh b/test-all.sh index 884db1880..a60c0e9f9 100755 --- a/test-all.sh +++ b/test-all.sh @@ -59,6 +59,7 @@ node test-issue-1883-redirect-history.js node test-issue-1890-og-url.js node test-traces.js node test-live-multibyte-filter.js +node test-issue-125-live-toggles-wiring.js node test-issue-2052-touch-target-css.js node test-channel-proposals.js node test-packet-detail-channel-xss.js diff --git a/test-issue-125-live-toggles-early-e2e.js b/test-issue-125-live-toggles-early-e2e.js new file mode 100644 index 000000000..6e59d6236 --- /dev/null +++ b/test-issue-125-live-toggles-early-e2e.js @@ -0,0 +1,268 @@ +/** + * E2E (#125): every persisted Live view toggle is restored and wired before + * Live init awaits anything. + * + * live.js renders the controls panel, then awaits /api/config/map and + * loadNodes(). #88 moved only "Multibyte only" ahead of those awaits; the + * other persisted toggles (Heat, Inferred/ghost hops, Realistic, Color by + * hash, Favorites, Foreign, Matrix, Rain) were restored and wired after + * them, so during init they showed their default state and a click made then + * was not saved and was reverted when init caught up. + * + * Same hold technique as test-live-multibyte-only-e2e.js: /api/config/map is + * intercepted, which freezes init at its first await right after the panel is + * rendered; window._liveWSHandler() marks a finished init. + * + * Checks, per toggle: the saved (non-default) value is shown while init is + * held; a click then saves at once, exactly once, and survives init. Then + * the effects that need the map: a Heat click during init decides whether + * the heat layer exists after init; Matrix saved or clicked ON disables Heat + * from the first paint and applies the theme after init; Rain clicked ON + * shows its canvas. Finally three SPA round trips leave one listener per + * toggle (one click writes once). + * + * Usage: BASE_URL=http://localhost:13581 node test-issue-125-live-toggles-early-e2e.js + */ +'use strict'; +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const ORIGIN = new URL(BASE).origin; +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'); } + +// id, localStorage key, value shown when nothing is saved +const TOGGLES = [ + { id: 'liveHeatToggle', key: 'meshcore-live-heatmap', dflt: true }, + { id: 'liveGhostToggle', key: 'live-ghost-hops', dflt: true }, + { id: 'liveRealisticToggle', key: 'live-realistic-propagation', dflt: false }, + { id: 'liveColorHashToggle', key: 'meshcore-color-packets-by-hash', dflt: true }, + { id: 'liveFavoritesToggle', key: 'live-favorites-only', dflt: false }, + { id: 'liveForeignToggle', key: 'live-highlight-foreign', dflt: true }, + { id: 'liveMatrixToggle', key: 'live-matrix-mode', dflt: false }, + { id: 'liveMatrixRainToggle', key: 'live-matrix-rain', dflt: false }, +]; +const KEYS = TOGGLES.map((t) => t.key); + +// A context whose first page load starts from `seed` ({key: 'true'|'false'}; +// other toggle keys removed), counting writes per key and recording errors. +async function newLivePage(browser, errors, seed) { + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(15000); + page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); + page.on('console', (m) => { + if (m.type() !== 'error') return; + const url = (m.location() && m.location().url) || ''; + if (url && !url.startsWith(ORIGIN)) return; // external tile/CDN noise + errors.push('console.error: ' + m.text()); + }); + await page.addInitScript(([keys, seedObj]) => { + if (!sessionStorage.getItem('t125-seeded')) { + sessionStorage.setItem('t125-seeded', '1'); + for (const k of keys) { + if (Object.prototype.hasOwnProperty.call(seedObj, k)) localStorage.setItem(k, seedObj[k]); + else localStorage.removeItem(k); + } + } + window.__t125Writes = {}; + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (k, v) { + if (this === window.localStorage && keys.includes(k)) window.__t125Writes[k] = (window.__t125Writes[k] || 0) + 1; + return setItem.call(this, k, v); + }; + window.addEventListener('unhandledrejection', (e) => { + console.error('unhandledrejection: ' + (e.reason && e.reason.message || e.reason)); + }); + }, [KEYS, seed || {}]); + return { ctx, page }; +} + +async function holdLiveInit(page) { + let release, hit; + const released = new Promise((r) => { release = r; }); + const intercepted = new Promise((r) => { hit = r; }); + await page.route('**/api/config/map', async (route) => { + hit(); + await released; + await route.continue().catch((e) => { if (!page.isClosed()) throw e; }); + }, { times: 1 }); + return { + release, + async reached() { + let timer; + const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error( + 'Live init never requested /api/config/map within 15s; the hold point this test relies on has moved')), 15000); }); + try { await Promise.race([intercepted, timeout]); } finally { clearTimeout(timer); } + }, + }; +} + +async function readState(page) { + return page.evaluate((toggles) => { + const out = { liveReady: !!window._liveWSHandler(), writes: Object.assign({}, window.__t125Writes), t: {} }; + for (const t of toggles) { + const cb = document.getElementById(t.id); + out.t[t.id] = { exists: !!cb, checked: cb ? cb.checked : null, disabled: cb ? cb.disabled : null, stored: localStorage.getItem(t.key) }; + } + const mapEl = document.getElementById('liveMap'); + out.matrixTheme = !!(mapEl && mapEl.classList.contains('matrix-theme')); + out.heatLayer = !!document.querySelector('#liveMap .leaflet-heatmap-layer'); + out.rainCanvas = !!document.getElementById('matrixRainCanvas'); + return out; + }, TOGGLES); +} + +// load() with init held, inspect(page, state) while held, then release and +// wait for init to finish (even when a check failed). +async function withInitHeld(page, load, inspect) { + const hold = await holdLiveInit(page); + let failure = null; + try { + await load(); + await hold.reached(); + await page.locator('#liveHeatToggle').waitFor({ state: 'attached' }); + const st = await readState(page); + assert(!st.liveReady, 'Live finished init while it should be held; the readiness signal moved'); + await inspect(page, st); + } catch (e) { + failure = e; + } finally { + hold.release(); + } + const ready = await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 }).then(() => true, () => false); + if (failure) throw failure; + if (!ready) throw new Error('Live init did not finish after it was released'); +} + +// A user click while init is held: the controls body may be collapsed, so +// click the input programmatically the same way a label click would. +async function clickDuringInit(page, id) { + await page.evaluate((i) => document.getElementById(i).click(), id); +} + +const loadLive = (page) => () => page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + const errors = []; + console.log('\n=== #125 live toggles wired before init awaits, against ' + BASE + ' ==='); + + await step('with nothing saved, every toggle shows its default while init is held', async () => { + const { ctx, page } = await newLivePage(browser, errors, {}); + try { + await withInitHeld(page, loadLive(page), async (p, st) => { + for (const t of TOGGLES) { + assert(st.t[t.id].exists, t.id + ' missing'); + assert(st.t[t.id].checked === t.dflt, t.id + ' shows ' + st.t[t.id].checked + ' during init, default is ' + t.dflt); + } + }); + const st = await readState(page); + for (const t of TOGGLES) assert(!st.writes[t.key], 'init wrote ' + t.key + ' on its own'); + } finally { await ctx.close(); } + }); + + for (const t of TOGGLES) { + await step(t.id + ': the saved value is shown during init, and a click then is saved once and kept', async () => { + const saved = String(!t.dflt); + const { ctx, page } = await newLivePage(browser, errors, { [t.key]: saved }); + const want = t.dflt; // the click flips the saved (non-default) value back + try { + await withInitHeld(page, loadLive(page), async (p, st) => { + assert(st.t[t.id].checked === !t.dflt, t.id + ' shows ' + st.t[t.id].checked + ' during init, saved ' + saved); + await clickDuringInit(p, t.id); + const after = await readState(p); + assert(after.t[t.id].checked === want, 'the click did not flip ' + t.id); + assert(after.t[t.id].stored === String(want), 'a click during init must save at once (stored=' + after.t[t.id].stored + ')'); + assert(after.writes[t.key] === 1, 'one click must write ' + t.key + ' once (writes=' + after.writes[t.key] + ')'); + }); + const st = await readState(page); + assert(st.t[t.id].checked === want, 'init reverted the click on ' + t.id); + assert(st.t[t.id].stored === String(want), 'setting changed after init (stored=' + st.t[t.id].stored + ')'); + } finally { await ctx.close(); } + }); + } + + await step('Heat clicked OFF during init: no heat layer after init; clicked back ON: layer shown', async () => { + const { ctx, page } = await newLivePage(browser, errors, { 'meshcore-live-heatmap': 'true' }); + try { + await withInitHeld(page, loadLive(page), async (p) => { await clickDuringInit(p, 'liveHeatToggle'); }); + let st = await readState(page); + assert(!st.heatLayer, 'heat layer present after init although Heat was switched off during init'); + await page.evaluate(() => document.getElementById('liveHeatToggle').click()); + st = await readState(page); + assert(st.heatLayer, 'heat layer missing after switching Heat on after init'); + } finally { await ctx.close(); } + }); + + await step('Matrix saved ON: Heat unchecked and disabled from the first paint; theme applied after init', async () => { + const { ctx, page } = await newLivePage(browser, errors, { 'live-matrix-mode': 'true', 'meshcore-live-heatmap': 'true' }); + try { + await withInitHeld(page, loadLive(page), async (p, st) => { + assert(st.t.liveHeatToggle.checked === false && st.t.liveHeatToggle.disabled === true, + 'with Matrix saved ON, Heat must be unchecked and disabled during init (checked=' + st.t.liveHeatToggle.checked + ', disabled=' + st.t.liveHeatToggle.disabled + ')'); + }); + const st = await readState(page); + assert(st.matrixTheme, 'matrix theme not applied after init'); + assert(!st.heatLayer, 'heat layer shown while Matrix is on'); + assert(st.t.liveHeatToggle.disabled, 'Heat enabled while Matrix is on'); + } finally { await ctx.close(); } + }); + + await step('Matrix and Rain clicked ON during init: interlock at once, theme and rain after init; Matrix OFF re-enables Heat', async () => { + const { ctx, page } = await newLivePage(browser, errors, {}); + try { + await withInitHeld(page, loadLive(page), async (p) => { + await clickDuringInit(p, 'liveMatrixToggle'); + await clickDuringInit(p, 'liveMatrixRainToggle'); + const st = await readState(p); + assert(st.t.liveHeatToggle.disabled && !st.t.liveHeatToggle.checked, 'Matrix clicked ON did not disable Heat during init'); + }); + let st = await readState(page); + assert(st.matrixTheme, 'matrix theme not applied after init'); + assert(st.rainCanvas, 'rain canvas missing after init'); + assert(!st.heatLayer, 'heat layer shown while Matrix is on'); + await page.evaluate(() => document.getElementById('liveMatrixToggle').click()); + st = await readState(page); + assert(!st.matrixTheme, 'matrix theme still applied after switching Matrix off'); + assert(!st.t.liveHeatToggle.disabled, 'Heat still disabled after switching Matrix off'); + } finally { await ctx.close(); } + }); + + await step('three SPA round trips keep one listener per toggle', async () => { + const { ctx, page } = await newLivePage(browser, errors, {}); + try { + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 }); + for (let i = 0; i < 3; i++) { + await page.evaluate(() => { location.hash = '#/packets'; }); + await page.waitForSelector('#pktTable', { state: 'attached' }); + await page.evaluate(() => { location.hash = '#/live'; }); + await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 }); + } + for (const t of TOGGLES) { + if (t.id === 'liveHeatToggle') continue; // disabled while Matrix is on; covered above + const before = (await readState(page)).writes[t.key] || 0; + await page.evaluate((id) => document.getElementById(id).click(), t.id); + const after = (await readState(page)).writes[t.key] || 0; + assert(after - before === 1, 'one click on ' + t.id + ' after 3 round trips wrote ' + (after - before) + ' times'); + } + } finally { await ctx.close(); } + }); + + await step('no page errors, console errors or unhandled rejections', async () => { + assert(errors.length === 0, errors.slice(0, 5).join(' | ')); + }); + + await browser.close(); + console.log('\n--- ' + passed + ' passed, ' + failed + ' failed ---'); + process.exit(failed ? 1 : 0); +})(); diff --git a/test-issue-125-live-toggles-wiring.js b/test-issue-125-live-toggles-wiring.js new file mode 100644 index 000000000..fdb03900f --- /dev/null +++ b/test-issue-125-live-toggles-wiring.js @@ -0,0 +1,72 @@ +/* test-issue-125-live-toggles-wiring.js — structural guard for #125. + * + * The persisted Live view toggles must be restored and wired before init() + * awaits anything (see test-issue-125-live-toggles-early-e2e.js for the + * behaviour). This fails if the wiring call moves behind an await, if a + * toggle is dropped from the wiring table, or if a second change listener + * for one of these toggles is added elsewhere in live.js. + */ +'use strict'; +const fs = require('fs'); +const assert = require('assert'); + +console.log('--- test-issue-125-live-toggles-wiring.js ---'); +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(' ✅ ' + name); } + catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); } +} + +const SRC = fs.readFileSync(__dirname + '/public/live.js', 'utf8'); +const IDS = ['liveHeatToggle', 'liveGhostToggle', 'liveRealisticToggle', 'liveColorHashToggle', + 'liveFavoritesToggle', 'liveForeignToggle', 'liveMultibyteToggle', 'liveMatrixToggle', 'liveMatrixRainToggle']; + +function blockAt(src, start) { + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(start, i + 1); } + } + throw new Error('unbalanced block'); +} +function fn(name) { + const i = SRC.search(new RegExp('function ' + name + '\\s*\\(')); + assert(i !== -1, name + '() not found in live.js'); + return blockAt(SRC, i); +} + +test('init() calls wireLiveControls() before its first await', () => { + // full-line comments may mention "await"; only code counts + const init = fn('init').split('\n').filter((l) => !/^\s*\/\//.test(l)).join('\n'); + const wire = init.indexOf('wireLiveControls();'); + const firstAwait = init.search(/\bawait\b/); + assert(wire !== -1, 'init() does not call wireLiveControls()'); + assert(firstAwait !== -1, 'init() has no await any more; update this test'); + assert(wire < firstAwait, 'wireLiveControls() runs after the first await in init()'); + assert(init.indexOf('app.innerHTML') < wire, 'wireLiveControls() must run after the markup exists'); +}); + +test('wireLiveControls() restores and wires every persisted view toggle', () => { + const wire = fn('wireLiveControls'); + for (const id of IDS) assert(wire.includes("'" + id + "'"), id + ' is not in wireLiveControls()'); +}); + +test('no toggle gets a change listener outside wireLiveControls()', () => { + const wire = fn('wireLiveControls'); + const rest = SRC.replace(wire, ''); + for (const id of IDS) { + // Outside the wiring the id may only be looked up without a listener + // (e.g. the Heat/Matrix interlock). + const re = new RegExp("getElementById\\('" + id + "'\\)\\s*\\.addEventListener|const (\\w+) = document\\.getElementById\\('" + id + "'\\);[\\s\\S]{0,400}?\\1\\.addEventListener\\('change'", 'g'); + assert(!re.test(rest), id + ' gets a change listener outside wireLiveControls()'); + } +}); + +test('the heat layer is built after init only when Heat is enabled', () => { + const init = fn('init'); + assert(!/await loadNodes\(\);\s*showHeatMap\(\);/.test(init), 'init() builds the heat layer unconditionally after loadNodes()'); +}); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1);