diff --git a/cmd/ingestor/config.go b/cmd/ingestor/config.go index 3c4082416..493905dd6 100644 --- a/cmd/ingestor/config.go +++ b/cmd/ingestor/config.go @@ -73,7 +73,8 @@ type Config struct { // IATAWarnIntervalSec is how often a region dropped by // ObserverIATAWhitelist is re-logged while it keeps arriving (#110). - // 0 or less means the default, 6 hours. See iata_drop_warn.go. + // 0 or less means the default, 6 hours; larger than 86400 (24 hours) + // is capped at 86400. See iata_drop_warn.go. IATAWarnIntervalSec int `json:"iataWarnIntervalSec,omitempty"` // iataDropWarn is the bounded per-region throttle for that warning. diff --git a/cmd/ingestor/iata_drop_warn.go b/cmd/ingestor/iata_drop_warn.go index 6451da0d7..22cf41af7 100644 --- a/cmd/ingestor/iata_drop_warn.go +++ b/cmd/ingestor/iata_drop_warn.go @@ -33,6 +33,10 @@ const ( iataWarnMaxKeyLen = 32 // defaultIATAWarnIntervalSec is the default re-log interval (6h). defaultIATAWarnIntervalSec = 6 * 60 * 60 + // maxIATAWarnIntervalSec caps the configured interval (24h). A larger + // value buys nothing for a warning meant to stay visible, and a huge one + // overflows time.Duration into a negative interval that logs every drop. + maxIATAWarnIntervalSec = 24 * 60 * 60 ) // iataDropThrottle is the per-Config throttle state. The zero value is ready. @@ -47,11 +51,15 @@ type iataDropThrottle struct { sweeps int // full-table sweeps, for tests } -// IATAWarnInterval returns how often a dropped region is re-logged. +// IATAWarnInterval returns how often a dropped region is re-logged: +// iataWarnIntervalSec capped at 24 hours, or the default for 0 or less. func (c *Config) IATAWarnInterval() time.Duration { if c == nil || c.IATAWarnIntervalSec <= 0 { return defaultIATAWarnIntervalSec * time.Second } + if c.IATAWarnIntervalSec > maxIATAWarnIntervalSec { + return maxIATAWarnIntervalSec * time.Second + } return time.Duration(c.IATAWarnIntervalSec) * time.Second } diff --git a/cmd/ingestor/iata_drop_warn_test.go b/cmd/ingestor/iata_drop_warn_test.go index 9ea129312..8d4b1617c 100644 --- a/cmd/ingestor/iata_drop_warn_test.go +++ b/cmd/ingestor/iata_drop_warn_test.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "math" "os" "path/filepath" "strings" @@ -32,6 +33,43 @@ func TestIATAWarnIntervalDefaultAndConfigured(t *testing.T) { } } +// A huge configured interval must not overflow time.Duration (int64 +// nanoseconds) into a negative interval, which would log every drop: the +// flood the throttle exists to prevent (#150). It is capped at 24 hours. +func TestIATAWarnIntervalClampsHugeValues(t *testing.T) { + var huge int64 = 1e10 // seconds; 1e19 ns overflows int64 + for _, sec := range []int{int(huge), math.MaxInt, 24*60*60 + 1, 365 * 24 * 60 * 60} { + if got := (&Config{IATAWarnIntervalSec: sec}).IATAWarnInterval(); got != 24*time.Hour { + t.Errorf("iataWarnIntervalSec=%d: interval %v, want the 24h cap", sec, got) + } + } + if got := (&Config{IATAWarnIntervalSec: 24 * 60 * 60}).IATAWarnInterval(); got != 24*time.Hour { + t.Errorf("exactly 24h: interval %v", got) + } + if got := (&Config{IATAWarnIntervalSec: 24*60*60 - 1}).IATAWarnInterval(); got != 24*time.Hour-time.Second { + t.Errorf("just under the cap: interval %v", got) + } + + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"observerIATAWhitelist":["ARN"],"iataWarnIntervalSec":10000000000}`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + if got := cfg.IATAWarnInterval(); got != 24*time.Hour { + t.Fatalf("iataWarnIntervalSec=10000000000 from JSON: interval %v, want 24h", got) + } + // and the throttle built on it still suppresses a repeat + if w, _ := cfg.iataDropWarn.shouldWarn("GOT", t0IATA, cfg.IATAWarnInterval()); !w { + t.Fatal("first drop not logged") + } + if w, _ := cfg.iataDropWarn.shouldWarn("GOT", t0IATA.Add(time.Minute), cfg.IATAWarnInterval()); w { + t.Error("a repeat a minute later was logged again") + } +} + func TestIATAWarnIntervalFromJSON(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if err := os.WriteFile(path, []byte(`{"observerIATAWhitelist":["ARN"],"iataWarnIntervalSec":600}`), 0o600); err != nil { @@ -159,6 +197,85 @@ func TestIATADropThrottleSweepsAreAmortized(t *testing.T) { } } +// fillIATA tracks n new codes with the given prefix at ts; each must get its +// own slot. +func fillIATA(t *testing.T, th *iataDropThrottle, prefix string, n int, ts time.Time, iv time.Duration) { + t.Helper() + for i := 0; i < n; i++ { + if w, o := th.shouldWarn(fmt.Sprintf("%s%04d", prefix, i), ts, iv); !w || o { + t.Fatalf("%s%04d at %v: warn=%v overflow=%v, want an own slot", prefix, i, ts.Sub(t0IATA), w, o) + } + } +} + +// oldest is a lower bound on the oldest tracked time (#150). With entries +// of different ages it must follow the oldest one still tracked: too high +// and an expired slot is not reclaimed (a new region falls into the shared +// overflow throttle), too low and a full table of fresh entries is swept +// on every drop. +func TestIATADropThrottleOldestTracksStaggeredEntries(t *testing.T) { + var th iataDropThrottle + iv := time.Hour + half := iataWarnMaxTracked / 2 + fillIATA(t, &th, "A", half, t0IATA, iv) + fillIATA(t, &th, "B", iataWarnMaxTracked-half, t0IATA.Add(30*time.Minute), iv) + + // full of fresh entries: overflow, and no sweep (nothing can be expired) + if w, o := th.shouldWarn("EARLY", t0IATA.Add(40*time.Minute), iv); !w || !o { + t.Fatalf("full table at +40m: warn=%v overflow=%v, want the overflow warning", w, o) + } + if th.sweeps != 0 { + t.Fatalf("%d sweeps before any entry could expire", th.sweeps) + } + + // +1h: the A half has expired; a new region reclaims a slot + if w, o := th.shouldWarn("N1", t0IATA.Add(time.Hour), iv); !w || o { + t.Fatalf("+1h: warn=%v overflow=%v, want an own slot (A entries expired)", w, o) + } + if len(th.last) != iataWarnMaxTracked-half+1 || th.sweeps != 1 { + t.Fatalf("+1h: %d entries after %d sweeps, want %d after 1", len(th.last), th.sweeps, iataWarnMaxTracked-half+1) + } + // refill to the cap with entries at +1h + fillIATA(t, &th, "C", half-1, t0IATA.Add(time.Hour), iv) + + // +1h30m: the B half (+30m) has expired now and must be reclaimed, + // although every entry swept at +1h is gone and the newest are fresh + if w, o := th.shouldWarn("N2", t0IATA.Add(90*time.Minute), iv); !w || o { + t.Fatalf("+1h30m: warn=%v overflow=%v, want an own slot (B entries expired)", w, o) + } + if _, ok := th.last["B0000"]; ok { + t.Error("expired B entries were not reclaimed at +1h30m") + } + if len(th.last) != half+1 || th.sweeps != 2 { + t.Fatalf("+1h30m: %d entries after %d sweeps, want %d after 2", len(th.last), th.sweeps, half+1) + } +} + +// A drop can carry an earlier time than the current oldest entry (the +// caller takes the time before the lock). oldest must drop to it, or that +// entry outlives its interval in a full table. +func TestIATADropThrottleOldestFollowsAnEarlierDrop(t *testing.T) { + var th iataDropThrottle + iv := time.Hour + fillIATA(t, &th, "A", iataWarnMaxTracked, t0IATA, iv) + // +1h: everything expired; sweep, keep only LATE (taken at +1h) + if w, o := th.shouldWarn("LATE", t0IATA.Add(time.Hour), iv); !w || o { + t.Fatalf("+1h: warn=%v overflow=%v", w, o) + } + // a drop stamped +50m arrives after it + if w, o := th.shouldWarn("EARLIER", t0IATA.Add(50*time.Minute), iv); !w || o { + t.Fatalf("out-of-order drop: warn=%v overflow=%v", w, o) + } + fillIATA(t, &th, "F", iataWarnMaxTracked-2, t0IATA.Add(time.Hour), iv) + // +1h50m: EARLIER has expired and must free its slot + if w, o := th.shouldWarn("NEW", t0IATA.Add(110*time.Minute), iv); !w || o { + t.Fatalf("+1h50m: warn=%v overflow=%v, want EARLIER's expired slot", w, o) + } + if _, ok := th.last["EARLIER"]; ok { + t.Error("EARLIER outlived its interval in a full table") + } +} + func TestIATADropThrottleConcurrent(t *testing.T) { var th iataDropThrottle iv := time.Hour diff --git a/config.example.json b/config.example.json index ce4bd1f92..a5446416c 100644 --- a/config.example.json +++ b/config.example.json @@ -8,7 +8,7 @@ "observerIATAWhitelist": [], "_comment_observerIATAWhitelist": "Global IATA region whitelist. When non-empty, only observers whose IATA code (from MQTT topic) matches are processed. Case-insensitive. Empty = allow all. Unlike per-source iataFilter, this applies across all MQTT sources. A dropped region is logged by the ingestor as one '[region-filter] dropping region \"XYZ\"' line, repeated at most every iataWarnIntervalSec while it keeps arriving.", "iataWarnIntervalSec": 21600, - "_comment_iataWarnIntervalSec": "Optional (ingestor). Seconds between repeated '[region-filter]' warnings for a region dropped by observerIATAWhitelist. Default 21600 (6h); 0 or omitted = default. At most 512 regions are tracked individually (region codes come from the MQTT topic, so this is bounded); beyond that, drops share one overflow warning with the same interval.", + "_comment_iataWarnIntervalSec": "Optional (ingestor). Seconds between repeated '[region-filter]' warnings for a region dropped by observerIATAWhitelist. Default 21600 (6h); 0 or omitted = default; maximum 86400 (24h), larger values are capped. At most 512 regions are tracked individually (region codes come from the MQTT topic, so this is bounded); beyond that, drops share one overflow warning with the same interval.", "retention": { "nodeDays": 7, "observerDays": 14, diff --git a/public/live.js b/public/live.js index b7d38aa89..382638050 100644 --- a/public/live.js +++ b/public/live.js @@ -1182,6 +1182,9 @@ localStorage.setItem('live-matrix-mode', matrixMode); applyMatrixTheme(matrixMode); syncHeatToggleToMatrix(matrixMode); + // Matrix ON hid the heat layer; OFF brings it back as Heat is set. + // During init applyLiveControlEffects() (re)builds it after loadNodes(). + if (!matrixMode && heatEnabled) showHeatMap(); } }, { id: 'liveMatrixRainToggle', restore: () => matrixRain, onChange: (v) => { matrixRain = v; @@ -1202,7 +1205,8 @@ } // 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. + // and its toggle unchecked and disabled. When it is off, the toggle shows + // the Heat setting again (#150). Safe before the map exists. function syncHeatToggleToMatrix(on) { const ht = document.getElementById('liveHeatToggle'); if (on) { @@ -1210,6 +1214,7 @@ if (ht) { ht.checked = false; ht.disabled = true; } } else if (ht) { ht.disabled = false; // recover from stale state + ht.checked = heatEnabled; } } diff --git a/public/rx-coverage.js b/public/rx-coverage.js index 8db95f527..da1ba9df2 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -11,6 +11,11 @@ // coverage and leaderboard responses, the settle timer, move handlers) // captures it and does nothing once a newer mount or a destroy happened. var generation = 0; + // Request sequence per data stream (#150): a coverage or leaderboard + // response renders only if no newer request of its kind has started since, + // so a slow response for an earlier days (or rx, or viewport) cannot + // overwrite newer data. + var coverageSeq = 0, boardSeq = 0; // Initial viewport (#124), first valid one wins: explicit URL lat/lon/zoom, // this page's own saved view, /api/config/map, then this offline fallback @@ -22,6 +27,8 @@ var MIN_ZOOM = 1, MAX_ZOOM = 19; function isLive(gen) { return !destroyed && gen === generation; } + function isLatestCoverage(gen, seq) { return isLive(gen) && seq === coverageSeq; } + function isLatestBoard(gen, seq) { return isLive(gen) && seq === boardSeq; } // validView returns {lat, lon, zoom} when all three are present, numeric and // in range; anything invalid, partial or out of range gives null. @@ -124,12 +131,12 @@ function drawCoverage() { if (!map || destroyed) return; - var gen = generation; + var gen = generation, seq = ++coverageSeq; var b = map.getBounds(); var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(','); var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : ''); fetch(url).then(function (r) { return r.json(); }).then(function (fc) { - if (!isLive(gen) || !covLayer) return; + if (!isLatestCoverage(gen, seq) || !covLayer) return; covLayer.clearLayers(); (fc.features || []).forEach(function (f) { var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; }); @@ -251,11 +258,11 @@ } function loadBoard() { - var gen = generation; + var gen = generation, seq = ++boardSeq; fetch('/api/rx-leaderboard?days=' + days + '&limit=25').then(function (r) { return r.json(); }) - .then(function (d) { if (!isLive(gen)) return; boardCache = d.observers || []; renderBoard(); }) + .then(function (d) { if (!isLatestBoard(gen, seq)) return; boardCache = d.observers || []; renderBoard(); }) .catch(function (e) { - if (!isLive(gen)) return; + if (!isLatestBoard(gen, seq)) return; console.warn('rx-coverage: leaderboard fetch failed', e); var el = document.getElementById('rxBoard'); if (el) el.innerHTML = '
Could not load mobile observers.
'; diff --git a/public/style.css b/public/style.css index 60b3a97d5..5b02ae965 100644 --- a/public/style.css +++ b/public/style.css @@ -5101,7 +5101,9 @@ td[data-filter-field] { cursor: context-menu; } justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--border); - background: var(--surface-2, var(--surface)); + /* nav-drawer.css paints the title and close button with --nav-text(-muted), + so the header sits on the nav colours in every theme (#150). */ + background: var(--nav-bg2); } .nav-drawer-title { font-weight: 700; diff --git a/test-issue-124-rx-coverage-viewport.js b/test-issue-124-rx-coverage-viewport.js index bf077c58f..147b26d73 100644 --- a/test-issue-124-rx-coverage-viewport.js +++ b/test-issue-124-rx-coverage-viewport.js @@ -281,6 +281,60 @@ function assertView(env, want, tag) { assert.strictEqual(newLayer.cleared + newLayer.added, 0, 'the old coverage response was drawn on the new layer (cleared ' + newLayer.cleared + ', added ' + newLayer.added + ')'); }); + await test('13. #150: switching days quickly, the older days response cannot overwrite the newer data', async () => { + const env = makeEnv({ storage: { 'rx-coverage-view': JSON.stringify({ lat: 56.1, lng: 9.9, zoom: 10 }) } }); + const layers = []; + env.sandbox.L.layerGroup = () => { + const l = { cleared: 0, polys: [], addTo() { return l; }, clearLayers() { l.cleared++; l.polys = []; } }; + layers.push(l); + return l; + }; + env.sandbox.L.polygon = (ring) => { + const pg = { addTo(l) { l.polys.push(ring); return pg; }, bindTooltip() { return pg; } }; + return pg; + }; + // the days bar: record its click handler + const bar = env.sandbox.document.getElementById('rxDays'); + bar.addEventListener = (ev, fn) => { if (ev === 'click') bar.onclick = fn; }; + const pickDays = (d) => bar.onclick({ target: { closest: () => ({ dataset: { days: String(d) } }) } }); + const feature = (lat) => ({ features: [{ properties: {}, geometry: { coordinates: [[[10, lat], [11, lat], [10, lat + 1]]] } }] }); + const observers = (name) => ({ observers: [{ pubkey: name.toLowerCase(), name, score: 1, cells: 1, nodes: 1, receptions: 1 }] }); + + await mount(env, null); + env.runTimers(); // first coverage request (days=7) + assert(env.pending.some((p) => /rx-leaderboard\?days=7&/.test(p.url)), 'no days=7 leaderboard request'); + assert(env.pending.some((p) => /rx-coverage\?bbox=.*&days=7$/.test(p.url)), 'no days=7 coverage request'); + + pickDays(30); + assert(env.pending.some((p) => /rx-leaderboard\?days=30&/.test(p.url)), 'no days=30 leaderboard request'); + assert(env.pending.some((p) => /rx-coverage\?bbox=.*&days=30$/.test(p.url)), 'no days=30 coverage request'); + + // the newer (days=30) responses arrive first + const board = env.sandbox.document.getElementById('rxBoard'); + assert(env.respond(/rx-leaderboard\?days=30&/, observers('NEWER'))); + assert(env.respond(/rx-coverage\?bbox=.*&days=30$/, feature(60))); + await flush(); + const layer = layers[0]; + assert(/NEWER/.test(board.innerHTML), 'the days=30 leaderboard did not render'); + assert(layer.polys.length === 1 && layer.polys[0][0][0] === 60, 'the days=30 coverage did not render: ' + JSON.stringify(layer.polys)); + + // then the slow days=7 responses are released + assert(env.respond(/rx-leaderboard\?days=7&/, observers('OLDER'))); + assert(env.respond(/rx-coverage\?bbox=.*&days=7$/, feature(50))); + await flush(); + assert(/NEWER/.test(board.innerHTML) && !/OLDER/.test(board.innerHTML), 'the older days=7 leaderboard replaced the days=30 one'); + assert(layer.polys.length === 1 && layer.polys[0][0][0] === 60, 'the older days=7 coverage replaced the days=30 one: ' + JSON.stringify(layer.polys)); + + // a late failure of an older leaderboard request does not replace it either + pickDays(14); + pickDays(1); + assert(env.respond(/rx-leaderboard\?days=1&/, observers('NEWEST'))); + await flush(); + assert(env.failFetch(/rx-leaderboard\?days=14&/)); + await flush(); + assert(/NEWEST/.test(board.innerHTML), 'a failed older leaderboard request replaced the newest one: ' + board.innerHTML); + }); + await test('11. coverage filtering and leaderboard requests are unchanged', async () => { const env = makeEnv({ hash: '#/rx-coverage?days=14' }); await mount(env, { center: [55.68, 12.57], zoom: 9 }); diff --git a/test-issue-125-live-toggles-early-e2e.js b/test-issue-125-live-toggles-early-e2e.js index 6e59d6236..efa164bf7 100644 --- a/test-issue-125-live-toggles-early-e2e.js +++ b/test-issue-125-live-toggles-early-e2e.js @@ -21,6 +21,14 @@ * shows its canvas. Finally three SPA round trips leave one listener per * toggle (one click writes once). * + * #150 follow-ups: with Matrix and Heat saved ON, Matrix switched OFF during + * init leaves the Heat checkbox, the heat layer and the saved setting in + * agreement (also for Matrix OFF after init, and for Heat saved OFF). And + * leaving Live while init is still awaiting loadNodes(), then coming back, + * leaves one listener per toggle on the new mount: before #135 the old + * init's continuation wired the new mount's toggles a second time (2 + * listeners on 8 of 9 toggles, one click wrote localStorage twice). + * * Usage: BASE_URL=http://localhost:13581 node test-issue-125-live-toggles-early-e2e.js */ 'use strict'; @@ -46,7 +54,9 @@ const TOGGLES = [ { id: 'liveMatrixToggle', key: 'live-matrix-mode', dflt: false }, { id: 'liveMatrixRainToggle', key: 'live-matrix-rain', dflt: false }, ]; -const KEYS = TOGGLES.map((t) => t.key); +// All nine wired toggles (#150): Multibyte (#88) has its own E2E above. +const ALL_TOGGLES = TOGGLES.concat([{ id: 'liveMultibyteToggle', key: 'live-multibyte-only', dflt: false }]); +const KEYS = ALL_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. @@ -237,6 +247,100 @@ const loadLive = (page) => () => page.goto(BASE + '/#/live', { waitUntil: 'domco } finally { await ctx.close(); } }); + await step('#150: Matrix and Heat saved ON, Matrix clicked OFF during init: Heat checked, enabled and its layer shown', async () => { + const { ctx, page } = await newLivePage(browser, errors, { 'live-matrix-mode': 'true', 'meshcore-live-heatmap': 'true' }); + try { + await withInitHeld(page, loadLive(page), async (p) => { + await clickDuringInit(p, 'liveMatrixToggle'); + const st = await readState(p); + assert(st.t.liveHeatToggle.checked === true && st.t.liveHeatToggle.disabled === false, + 'Matrix OFF during init: Heat must be checked and enabled (checked=' + st.t.liveHeatToggle.checked + ', disabled=' + st.t.liveHeatToggle.disabled + ')'); + }); + let st = await readState(page); + assert(st.heatLayer, 'heat layer missing after init although Heat is saved ON and Matrix is OFF'); + assert(st.t.liveHeatToggle.checked === true, 'Heat checkbox unchecked while the heat layer is shown'); + assert(st.t.liveHeatToggle.stored === 'true', 'Heat setting changed (stored=' + st.t.liveHeatToggle.stored + ')'); + // after init: Matrix ON hides the layer; Matrix OFF brings Heat back as saved + await page.evaluate(() => document.getElementById('liveMatrixToggle').click()); + st = await readState(page); + assert(!st.heatLayer && !st.t.liveHeatToggle.checked && st.t.liveHeatToggle.disabled, 'Matrix ON after init: Heat must be hidden, unchecked and disabled'); + await page.evaluate(() => document.getElementById('liveMatrixToggle').click()); + st = await readState(page); + assert(st.t.liveHeatToggle.checked === true && !st.t.liveHeatToggle.disabled, 'Matrix OFF after init: Heat must be checked and enabled again (checked=' + st.t.liveHeatToggle.checked + ')'); + assert(st.heatLayer, 'Matrix OFF after init: Heat is checked but its layer is not shown'); + assert(st.t.liveHeatToggle.stored === 'true', 'Heat setting changed by Matrix (stored=' + st.t.liveHeatToggle.stored + ')'); + } finally { await ctx.close(); } + }); + + await step('#150: Matrix ON and Heat OFF saved, Matrix clicked OFF during init: Heat stays unchecked with no layer', async () => { + const { ctx, page } = await newLivePage(browser, errors, { 'live-matrix-mode': 'true', 'meshcore-live-heatmap': 'false' }); + try { + await withInitHeld(page, loadLive(page), async (p) => { + await clickDuringInit(p, 'liveMatrixToggle'); + const st = await readState(p); + assert(st.t.liveHeatToggle.checked === false && st.t.liveHeatToggle.disabled === false, + 'Matrix OFF during init with Heat saved OFF: Heat must be unchecked and enabled (checked=' + st.t.liveHeatToggle.checked + ', disabled=' + st.t.liveHeatToggle.disabled + ')'); + }); + const st = await readState(page); + assert(!st.heatLayer, 'heat layer shown although Heat is saved OFF'); + assert(st.t.liveHeatToggle.checked === false, 'Heat checkbox checked although Heat is saved OFF'); + } finally { await ctx.close(); } + }); + + await step('#150 (#135 regression): leaving Live while init awaits loadNodes() and coming back keeps one listener per toggle', async () => { + const { ctx, page } = await newLivePage(browser, errors, {}); + // this scenario's own errors: the abandoned init's continuation is not + // what this step checks (listed, not asserted) + const ownErrors = []; + page.on('pageerror', (e) => ownErrors.push(e.message)); + try { + await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#pktTable', { state: 'attached' }); + await page.waitForLoadState('networkidle'); + // hold the first Live loadNodes() request: init is then past its first + // await and stuck in its second one + let armed = true, release, hit; + const released = new Promise((r) => { release = r; }); + const intercepted = new Promise((r) => { hit = r; }); + await page.route(/\/api\/nodes\?limit=/, async (route) => { + if (!armed) return route.continue(); + armed = false; + hit(); + await released; + await route.continue().catch((e) => { if (!page.isClosed()) throw e; }); + }); + try { + await page.evaluate(() => { location.hash = '#/live'; }); + let timer; + await Promise.race([intercepted, new Promise((_, rej) => { timer = setTimeout(() => rej(new Error( + 'Live init never requested /api/nodes within 15s; the hold point this test relies on has moved')), 15000); })]); + clearTimeout(timer); + assert(!(await page.evaluate(() => !!window._liveWSHandler())), 'Live finished init while held'); + await page.evaluate(() => { location.hash = '#/packets'; }); + await page.waitForSelector('#pktTable', { state: 'attached' }); + await page.evaluate(() => { location.hash = '#/live'; }); + await page.locator('#liveHeatToggle').waitFor({ state: 'attached' }); + } finally { + // api() shares the in-flight request, so the old and the new init + // both continue when it is answered (the old one first) + release(); + } + await page.waitForFunction(() => !!window._liveWSHandler(), null, { timeout: 15000 }); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + const doubled = []; + for (const t of ALL_TOGGLES) { + const before = (await readState(page)).writes[t.key] || 0; + await page.evaluate((id) => document.getElementById(id).click(), t.id); + const after = (await page.evaluate(() => Object.assign({}, window.__t125Writes)))[t.key] || 0; + if (after - before !== 1) doubled.push(t.id + '=' + (after - before)); + } + assert(doubled.length === 0, 'one click wrote localStorage more or less than once on ' + doubled.length + ' of ' + + ALL_TOGGLES.length + ' toggles: ' + doubled.join(', ') + (ownErrors.length ? ' (page errors: ' + ownErrors.slice(0, 3).join(' | ') + ')' : '')); + if (ownErrors.length) console.log(' note: page errors from the abandoned init: ' + ownErrors.slice(0, 3).join(' | ')); + } finally { await ctx.close(); } + }); + await step('three SPA round trips keep one listener per toggle', async () => { const { ctx, page } = await newLivePage(browser, errors, {}); try { diff --git a/test-nav-drawer-1064-e2e.js b/test-nav-drawer-1064-e2e.js index 9369d3b13..414985b9e 100644 --- a/test-nav-drawer-1064-e2e.js +++ b/test-nav-drawer-1064-e2e.js @@ -273,6 +273,70 @@ async function edgeSwipe(page, x0, y0, x1, y1, steps) { await wideCtx.close(); + // (k) #150: the header title (and close button) must be readable on the + // header background in both themes. The light theme used to paint the + // header with --surface-2 (white) under the white --nav-text title. + for (const theme of ['light', 'dark']) { + await step(`(k) ${theme} theme: header title ≥ 4.5:1 and close button ≥ 3:1 on the header background`, async () => { + const ctx = await browser.newContext({ viewport: { width: 1024, height: 800 }, colorScheme: theme }); + const p = await ctx.newPage(); + p.setDefaultTimeout(10000); + await p.addInitScript((t) => { try { localStorage.setItem('meshcore-theme', t); } catch (_) {} }, theme); + try { + await p.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await p.waitForFunction(() => !!(window.__navDrawer && window.__navDrawer.open)); + await p.evaluate(() => window.__navDrawer.open()); + await p.waitForSelector('[data-nav-drawer] .nav-drawer-title', { state: 'visible' }); + const r = await p.evaluate(() => { + function rgba(s) { + const m = /rgba?\(([^)]+)\)/.exec(s); + if (!m) throw new Error('unparsed colour ' + s); + const v = m[1].split(/[\s,/]+/).filter(Boolean).map(Number); + return { r: v[0], g: v[1], b: v[2], a: v.length > 3 ? v[3] : 1 }; + } + // opaque colour under el: its own background composited over its + // ancestors' (stopping at the first opaque one) + function backdrop(el) { + const layers = []; + for (let n = el; n; n = n.parentElement) { + const c = rgba(getComputedStyle(n).backgroundColor); + if (c.a > 0) layers.push(c); + if (c.a >= 1) break; + } + let out = { r: 255, g: 255, b: 255 }; + for (let i = layers.length - 1; i >= 0; i--) { + const c = layers[i]; + out = { r: c.r * c.a + out.r * (1 - c.a), g: c.g * c.a + out.g * (1 - c.a), b: c.b * c.a + out.b * (1 - c.a) }; + } + return out; + } + function lum(c) { + const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); + } + function ratio(fg, bg) { + const a = lum(fg), b = lum(bg); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + const header = document.querySelector('[data-nav-drawer] .nav-drawer-header'); + const title = header.querySelector('.nav-drawer-title'); + const close = header.querySelector('.nav-drawer-close'); + const bg = backdrop(header); + return { + theme: document.documentElement.getAttribute('data-theme'), + bg: getComputedStyle(header).backgroundColor, + title: getComputedStyle(title).color, + titleRatio: ratio(rgba(getComputedStyle(title).color), bg), + closeRatio: ratio(rgba(getComputedStyle(close).color), backdrop(close)), + }; + }); + assert(r.theme === theme, 'data-theme is ' + r.theme + ', want ' + theme); + assert(r.titleRatio >= 4.5, `title ${r.title} on header ${r.bg}: ${r.titleRatio.toFixed(2)}:1 < 4.5:1`); + assert(r.closeRatio >= 3, `close button on header ${r.bg}: ${r.closeRatio.toFixed(2)}:1 < 3:1`); + } finally { await ctx.close(); } + }); + } + // ── Narrow viewport (Option A): drawer disabled ── const narrowCtx = await browser.newContext({ viewport: { width: 360, height: 800 } }); const narrow = await narrowCtx.newPage();