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);