From 16c48e73b370526bb2fa6c7baaeef6308fe80db1 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Fri, 8 May 2026 18:50:30 -0700 Subject: [PATCH] fix(live): compact header + pinned controls with narrow-viewport collapse (#1178, #1179) (#1180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red commit: 61fcc8c19b96543f1b4bbd6fd2ce54e6265d5e38 (CI run: pending — see Checks tab on this PR) Fixes #1178 Fixes #1179 ## Summary Live page layout polish — both issues touch `public/live.css` + a small `public/live.js` slice, so they ship as one PR per AGENTS rule 34. ### #1178 — Header compactness + narrow-viewport collapse - `.live-header` total height ≤ 40px at desktop widths (smaller padding, gap, title font, and pill sizing; `max-height: 40px` as a belt-and-suspenders gate). - Body wrapped in `.live-header-body` so it can collapse cleanly. - New 32×32 toggle button `[data-live-header-toggle]`, hidden at wide viewports, visible at `≤768px`. ### #1179 — Controls pinned bottom-right + narrow-viewport collapse - New `.live-controls` cluster around the toggles list and audio controls, `position: fixed; right: 12px;` and `bottom: calc(78px + var(--bottom-nav-height, 56px) + env(safe-area-inset-bottom, 0px))`. - That bottom calc reserves space for the VCR bar **and** the bottom nav (#1061, currently in PR #1174). When the bottom-nav exposes `--bottom-nav-height` the cluster tracks it; otherwise the 56px fallback keeps it clear regardless of merge order. - `z-index: 1000` keeps it above map markers but below modals. - New 32×32 toggle button `[data-live-controls-toggle]`, hidden at wide viewports, visible at `≤768px`. ### Breakpoint + selectors - Narrow = `max-width: 768px` (matches #1061 bottom-nav activation). - Stable selectors for E2E: `[data-live-header-toggle]`, `[data-live-header-body]`, `[data-live-controls-toggle]`, `[data-live-controls-body]`. No DOM-order dependence. ### Bottom-nav coexistence The expanded narrow-viewport controls panel uses `max-height: 50vh; overflow-y: auto` on its toggles list, and the cluster's `bottom` reservation guarantees the panel's bottom edge sits above the (possibly absent) bottom-nav region. The E2E test asserts exactly this with `expandedRect.bottom + 8 < innerHeight − navH`, defaulting `navH` to 56 if `.bottom-nav` is not in the DOM yet. ### Theming All new colors via existing CSS tokens (`--surface-1`, `--text`, `--text-muted`, `--border`, `--accent`). check-css-vars passes. ### TDD - Red commit: `61fcc8c` — assertions only (no impl), wired into `.github/workflows/deploy.yml` Playwright matrix. - Green commit: `7d591be` — DOM split + CSS + collapse JS. - E2E assertion added: `test-live-layout-1178-1179-e2e.js:55` (desktop header height) through `:170` (narrow controls bottom-nav coexistence). ### Local verification ``` ./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db & CHROMIUM_PATH=/usr/bin/chromium BASE_URL=http://localhost:13581 \ node test-live-layout-1178-1179-e2e.js # → 8/8 passed ``` --------- Co-authored-by: meshcore-bot Co-authored-by: openclaw-bot --- .github/workflows/deploy.yml | 2 + public/live.css | 129 ++++++++++++++--- public/live.js | 122 ++++++++++++++-- test-live-layout-1178-1179-e2e.js | 227 ++++++++++++++++++++++++++++++ test-live-mql-leak-1180-e2e.js | 78 ++++++++++ 5 files changed, 528 insertions(+), 30 deletions(-) create mode 100644 test-live-layout-1178-1179-e2e.js create mode 100644 test-live-mql-leak-1180-e2e.js diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 27bc9d0b..cd1ff88f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -246,6 +246,8 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-theme-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-logo-default-sage-teal-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt - name: Collect frontend coverage (parallel) if: success() && github.event_name == 'push' diff --git a/public/live.css b/public/live.css index c5e67ea7..1fa0aa22 100644 --- a/public/live.css +++ b/public/live.css @@ -57,23 +57,74 @@ left: 12px; display: flex; align-items: center; - gap: 14px; + gap: 10px; background: color-mix(in srgb, var(--surface-1) 92%, transparent); backdrop-filter: blur(12px); - padding: 8px 16px; - border-radius: 10px; + padding: 4px 10px; + border-radius: 8px; border: 1px solid var(--border); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.04); + max-height: 40px; + box-sizing: border-box; } - -.live-title { - font-size: 14px; - font-weight: 800; - letter-spacing: 2px; - color: var(--text); +.live-header-body { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +/* Critical strip (Mesh-Operator review #1180): beacon + pkt count are + always visible even when the collapsible body is hidden at narrow + widths. This is the ingest-state cue (red beacon = WS down) + the + one number operators check while the header is otherwise collapsed. */ +.live-header-critical { display: flex; align-items: center; gap: 8px; + flex-shrink: 0; +} +/* Toggle buttons (#1178, #1179) — hidden at wide viewports, visible at ≤768px. + Mesh-Operator review #1180: tap target ≥48×48 (#1060 floor + AGENTS glove + operability rule). Visible glyph stays small (decorative); transparent + padding expands the hit area without changing the visual chrome. */ +.live-header-toggle, +.live-controls-toggle { + display: none; + align-items: center; + justify-content: center; + min-width: 48px; + min-height: 48px; + /* Visible chrome stays compact; padding grows the hit area. */ + width: 48px; + height: 48px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--text) 8%, transparent); + color: var(--text); + font-size: 16px; + line-height: 1; + cursor: pointer; + flex-shrink: 0; +} +.live-header-toggle:hover, +.live-controls-toggle:hover { + background: color-mix(in srgb, var(--text) 14%, transparent); +} +.live-header-toggle:focus-visible, +.live-controls-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.live-title { + font-size: 12px; + font-weight: 800; + letter-spacing: 1.5px; + color: var(--text); + display: flex; + align-items: center; + gap: 6px; text-transform: uppercase; } @@ -100,9 +151,9 @@ .live-stat-pill { background: color-mix(in srgb, var(--text) 8%, transparent); border: 1px solid var(--border); - padding: 3px 10px; - border-radius: 20px; - font-size: 12px; + padding: 1px 8px; + border-radius: 16px; + font-size: 11px; color: var(--text-muted); white-space: nowrap; } @@ -287,11 +338,42 @@ font-size: 11px; color: var(--text-muted); align-items: center; - margin-left: 8px; + flex-wrap: wrap; } .live-toggles label { display: flex; align-items: center; gap: 3px; cursor: pointer; white-space: nowrap; } .live-toggles input { margin: 0; } +/* ---- Live controls cluster (#1179) ---- + * Pinned to bottom-right, above the VCR bar and the global bottom-nav. + * Reserves space for both env(safe-area-inset-bottom) and the bottom-nav + * (#1061, currently in PR #1174). When the bottom-nav lands the layout + * tracks its custom property (--bottom-nav-height); otherwise the + * fallback (56px) keeps the cluster clear of the VCR bar / bottom-nav + * region. + */ +.live-controls { + position: fixed; + right: 12px; + bottom: calc(78px + var(--bottom-nav-height, 56px) + env(safe-area-inset-bottom, 0px)); + z-index: 1000; + background: color-mix(in srgb, var(--surface-1) 92%, transparent); + backdrop-filter: blur(12px); + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.04); + max-width: min(620px, calc(100vw - 24px)); + display: flex; + align-items: center; + gap: 8px; +} +.live-controls-body { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + /* Region filter (#1045) inline in live header toggles */ .live-toggles .live-region-filter-container { display: inline-flex; align-items: center; } .live-toggles .live-region-filter-container .region-dropdown-trigger { font-size: inherit; padding: 2px 6px; } @@ -307,14 +389,29 @@ background: rgba(59, 130, 246, 0.2) !important; } -/* ---- Medium breakpoint (#279) ---- */ +/* ---- Medium breakpoint (#279) + collapse toggles (#1178, #1179) ---- */ @media (max-width: 768px) { .live-feed { width: 280px; max-height: 200px; } .live-node-detail { width: 260px; } .live-legend { font-size: 10px; padding: 8px 10px; } - .live-header { gap: 8px; padding: 6px 12px; } - .live-stat-pill { font-size: 11px; padding: 2px 8px; } + .live-header { gap: 6px; padding: 4px 8px; max-height: none; min-height: 48px; } + .live-stat-pill { font-size: 11px; padding: 1px 7px; } .live-toggles { font-size: 10px; gap: 6px; } + + /* Show toggle buttons */ + .live-header-toggle, + .live-controls-toggle { display: inline-flex; } + + /* When collapsed, hide the body */ + .live-header.is-collapsed .live-header-body, + .live-controls.is-collapsed .live-controls-body { display: none; } + .live-header.is-collapsed { gap: 0; padding: 4px 6px; } + .live-controls.is-collapsed { padding: 6px; } + + /* Expanded body on narrow: stack so it never overflows the cluster */ + .live-controls.is-expanded { max-width: calc(100vw - 24px); } + .live-controls.is-expanded .live-controls-body { flex-wrap: wrap; } + .live-controls.is-expanded .live-toggles { flex-wrap: wrap; max-height: 50vh; overflow-y: auto; } } /* ---- Responsive ---- */ diff --git a/public/live.js b/public/live.js index a5a50bf3..b3b04b93 100644 --- a/public/live.js +++ b/public/live.js @@ -860,17 +860,27 @@
-
- - MESH LIVE +
+ +
0 pkts
-
-
0 pkts
-
0 nodes
-
0 active
-
0/min
+ +
+
+ MESH LIVE +
+
+
0 nodes
+
0 active
+
0/min
+
-
+
+
+
+
Overlay a density heat map on the mesh nodes @@ -895,12 +905,16 @@
+
+
- +
@@ -1382,6 +1396,78 @@ // Legend toggle for mobile (#60) const legendEl = document.getElementById('liveLegend'); const legendToggleBtn = document.getElementById('legendToggleBtn'); + + // ── Live header / controls toggles (#1178, #1179) ────────────────────── + // At narrow viewports (≤768px) the header collapses to a single + // toggle button revealing the stats body, and the controls collapse + // to a single toggle button revealing the toggles list. CSS gates + // visibility of the toggle buttons; JS only flips classes and the + // hidden attribute. At wide viewports the bodies are always shown. + (function wireLiveCollapseToggles() { + var pairs = [ + { rootId: 'liveHeader', togId: 'liveHeaderToggle', bodyId: 'liveHeaderBody', + showLabel: 'Show live stats', hideLabel: 'Hide live stats' }, + { rootId: 'liveControls', togId: 'liveControlsToggle', bodyId: 'liveControlsBody', + showLabel: 'Show live controls', hideLabel: 'Hide live controls' }, + ]; + var narrowMql = window.matchMedia('(max-width: 768px)'); + function setExpanded(p, expanded) { + var root = document.getElementById(p.rootId); + var tog = document.getElementById(p.togId); + var body = document.getElementById(p.bodyId); + if (!root || !tog || !body) return; + if (expanded) { + root.classList.add('is-expanded'); root.classList.remove('is-collapsed'); + body.removeAttribute('hidden'); + tog.setAttribute('aria-expanded', 'true'); + tog.setAttribute('aria-label', p.hideLabel); + } else { + root.classList.add('is-collapsed'); root.classList.remove('is-expanded'); + body.setAttribute('hidden', ''); + tog.setAttribute('aria-expanded', 'false'); + tog.setAttribute('aria-label', p.showLabel); + } + } + function applyForViewport() { + for (var i = 0; i < pairs.length; i++) { + var p = pairs[i]; + if (narrowMql.matches) { + // Default collapsed at narrow viewports + setExpanded(p, false); + } else { + // Always expanded; no hidden attr; no collapse class + var root = document.getElementById(p.rootId); + var body = document.getElementById(p.bodyId); + var tog = document.getElementById(p.togId); + if (body) body.removeAttribute('hidden'); + if (root) { root.classList.remove('is-collapsed'); root.classList.remove('is-expanded'); } + if (tog) { tog.setAttribute('aria-expanded', 'true'); } + } + } + } + pairs.forEach(function (p) { + var tog = document.getElementById(p.togId); + if (!tog) return; + tog.addEventListener('click', function () { + var root = document.getElementById(p.rootId); + var nowExpanded = !(root && root.classList.contains('is-expanded')); + setExpanded(p, nowExpanded); + }); + }); + applyForViewport(); + // #1180 — bind once across SPA re-mounts. MQL is process-global per + // query string; per-init binds accumulate handlers without bound. + if (!_liveNarrowMqlBound) { + if (narrowMql.addEventListener) narrowMql.addEventListener('change', applyForViewport); + else if (narrowMql.addListener) narrowMql.addListener(applyForViewport); + _liveNarrowMqlBound = true; + try { + window.__liveMQLBindCount = (window.__liveMQLBindCount || 0) + 1; + } catch (_) { /* sealed window */ } + } + })(); + // ─────────────────────────────────────────────────────────────────────── + if (legendToggleBtn && legendEl) { // Restore legend collapsed state from localStorage (#279) try { @@ -3278,6 +3364,14 @@ let _themeRefreshHandler = null; + // #1180 — singleton guard for the wireLiveCollapseToggles() narrow-viewport + // MQL listener. MediaQueryList is process-global per query string; without + // this gate, every SPA re-mount of /live registers a new 'change' handler. + // The handler reads from current DOM each time, so a one-shot bind is safe + // across re-mounts. window.__liveMQLBindCount is a debug seam consumed by + // test-live-mql-leak-1180-e2e.js and otherwise unused. + var _liveNarrowMqlBound = false; + registerPage('live', { init: function(app, routeParam) { _themeRefreshHandler = () => { diff --git a/test-live-layout-1178-1179-e2e.js b/test-live-layout-1178-1179-e2e.js new file mode 100644 index 00000000..9acddc18 --- /dev/null +++ b/test-live-layout-1178-1179-e2e.js @@ -0,0 +1,227 @@ +/** + * E2E tests for #1178 (Live header compactness + collapse toggle) + * and #1179 (Live controls pinned bottom-right + collapse toggle). + * + * Run: BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js + * + * Assertions: + * Desktop (1440x900): + * (a) .live-header bounding-rect height ≤ 40px. + * (b) .live-controls computed position is 'fixed' or 'absolute'; + * right ≤ 24px; bottom is non-zero (safe-area / nav reservation). + * Narrow (360x800): + * (c) [data-live-header-toggle] visible; live-stats body hidden until click. + * (d) Clicking header toggle reveals the stats body. + * (e) [data-live-controls-toggle] visible; controls body hidden until click. + * (f) Clicking controls toggle reveals controls; expanded panel bottom +8 < + * (window.innerHeight − bottomNavHeight). Bottom-nav height defaults + * to 56 if .bottom-nav is not present in the DOM. + */ +'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(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function gotoLive(page) { + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 }); + await page.waitForTimeout(400); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + + console.log(`\n=== #1178/#1179 live layout E2E against ${BASE} ===`); + + // ───── Desktop assertions ───── + { + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + await step('[1440x900] navigate to /live', async () => { await gotoLive(page); }); + + // (a) + await step('[1440x900] .live-header bounding-rect height ≤ 40px', async () => { + const h = await page.$eval('.live-header', el => el.getBoundingClientRect().height); + assert(h <= 40, `expected ≤40px, got ${h}px`); + }); + + // (b) + await step('[1440x900] .live-controls fixed/absolute, right ≤ 24px, bottom > 0', async () => { + const info = await page.evaluate(() => { + const el = document.querySelector('.live-controls'); + if (!el) return null; + const cs = getComputedStyle(el); + const r = el.getBoundingClientRect(); + return { + position: cs.position, + right: parseFloat(cs.right), + bottom: parseFloat(cs.bottom), + rectRight: r.right, + vw: window.innerWidth, + }; + }); + assert(info, '.live-controls element not found'); + assert(info.position === 'fixed' || info.position === 'absolute', + `.live-controls position must be fixed/absolute, got ${info.position}`); + assert(info.right <= 24, `.live-controls right must be ≤24px, got ${info.right}px`); + assert(info.bottom > 0, + `.live-controls bottom must reserve space for safe-area/nav, got ${info.bottom}px`); + }); + + await ctx.close(); + } + + // ───── Narrow assertions ───── + { + const ctx = await browser.newContext({ viewport: { width: 360, height: 800 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + await step('[360x800] navigate to /live', async () => { await gotoLive(page); }); + + // (c0) Mesh-Operator review #1180: beacon + pkt count must remain visible + // even when the header body is collapsed at narrow widths. + await step('[360x800] beacon + pkt count visible while header body is collapsed', async () => { + const r = await page.evaluate(() => { + const beacon = document.querySelector('.live-beacon'); + const pkt = document.querySelector('#livePktCount'); + const body = document.querySelector('[data-live-header-body]'); + const bodyHidden = body && (body.hasAttribute('hidden') || + getComputedStyle(body).display === 'none'); + function vis(el) { + if (!el) return false; + const cs = getComputedStyle(el); + if (cs.display === 'none' || cs.visibility === 'hidden') return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + return { + bodyHidden, + beaconVisible: vis(beacon), + pktVisible: vis(pkt), + // The pill wrapping the count must also be visible (not just the + // span hidden inside a collapsed parent with display:none). + pktPillVisible: vis(pkt && pkt.closest('.live-stat-pill')), + }; + }); + assert(r.bodyHidden, 'header body must be collapsed at narrow viewport pre-click'); + assert(r.beaconVisible, '.live-beacon must remain visible while header body is collapsed'); + assert(r.pktVisible, '#livePktCount must remain visible while header body is collapsed'); + assert(r.pktPillVisible, 'pkt-count pill must remain visible while header body is collapsed'); + }); + + // (c1) Mesh-Operator review #1180: toggles ≥48×48 tap target (#1060 floor, + // AGENTS' glove operability rule). + await step('[360x800] both toggles ≥48×48 tap target', async () => { + const r = await page.evaluate(() => { + function box(sel) { + const el = document.querySelector(sel); + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { w: rect.width, h: rect.height }; + } + return { + header: box('[data-live-header-toggle]'), + controls: box('[data-live-controls-toggle]'), + }; + }); + assert(r.header, '[data-live-header-toggle] not found'); + assert(r.controls, '[data-live-controls-toggle] not found'); + assert(r.header.w >= 48 && r.header.h >= 48, + `header toggle must be ≥48×48, got ${r.header.w}×${r.header.h}`); + assert(r.controls.w >= 48 && r.controls.h >= 48, + `controls toggle must be ≥48×48, got ${r.controls.w}×${r.controls.h}`); + }); + + // (c) + await step('[360x800] header toggle visible; stats body hidden until click', async () => { + const r = await page.evaluate(() => { + const tog = document.querySelector('[data-live-header-toggle]'); + const body = document.querySelector('[data-live-header-body]'); + if (!tog || !body) return { tog: !!tog, body: !!body }; + const togVis = getComputedStyle(tog).display !== 'none' && + getComputedStyle(tog).visibility !== 'hidden'; + const bodyHidden = body.hasAttribute('hidden') || + getComputedStyle(body).display === 'none'; + return { tog: true, body: true, togVis, bodyHidden }; + }); + assert(r.tog, '[data-live-header-toggle] not found'); + assert(r.body, '[data-live-header-body] not found'); + assert(r.togVis, '[data-live-header-toggle] not visible at 360px'); + assert(r.bodyHidden, 'stats body must be hidden until toggle click'); + }); + + // (d) + await step('[360x800] clicking header toggle reveals stats body', async () => { + await page.click('[data-live-header-toggle]'); + const visible = await page.evaluate(() => { + const body = document.querySelector('[data-live-header-body]'); + if (!body) return false; + return !body.hasAttribute('hidden') && getComputedStyle(body).display !== 'none'; + }); + assert(visible, 'stats body not visible after click'); + }); + + // (e) + await step('[360x800] controls toggle visible; controls body hidden until click', async () => { + const r = await page.evaluate(() => { + const tog = document.querySelector('[data-live-controls-toggle]'); + const body = document.querySelector('[data-live-controls-body]'); + if (!tog || !body) return { tog: !!tog, body: !!body }; + const togVis = getComputedStyle(tog).display !== 'none' && + getComputedStyle(tog).visibility !== 'hidden'; + const bodyHidden = body.hasAttribute('hidden') || + getComputedStyle(body).display === 'none'; + return { tog: true, body: true, togVis, bodyHidden }; + }); + assert(r.tog, '[data-live-controls-toggle] not found'); + assert(r.body, '[data-live-controls-body] not found'); + assert(r.togVis, '[data-live-controls-toggle] not visible at 360px'); + assert(r.bodyHidden, 'controls body must be hidden until toggle click'); + }); + + // (f) + await step('[360x800] clicking controls toggle reveals; no overlap with bottom-nav region', async () => { + await page.click('[data-live-controls-toggle]'); + const r = await page.evaluate(() => { + const root = document.querySelector('.live-controls'); + const body = document.querySelector('[data-live-controls-body]'); + const nav = document.querySelector('.bottom-nav'); + const navH = nav ? nav.getBoundingClientRect().height : 56; + const bodyVisible = body && !body.hasAttribute('hidden') && + getComputedStyle(body).display !== 'none'; + const expandedRect = root ? root.getBoundingClientRect() : null; + return { + bodyVisible, + expandedBottom: expandedRect ? expandedRect.bottom : null, + innerH: window.innerHeight, + navH, + isExpandedClass: root ? root.classList.contains('is-expanded') : false, + }; + }); + assert(r.bodyVisible, 'controls body not visible after click'); + assert(r.expandedBottom !== null, '.live-controls element missing'); + assert(r.expandedBottom + 8 < r.innerH - r.navH, + `expanded panel bottom (${r.expandedBottom}) + 8 must be < innerHeight (${r.innerH}) − navH (${r.navH})`); + }); + + await ctx.close(); + } + + await browser.close(); + console.log(`\n=== Results: passed ${passed} failed ${failed} ===`); + process.exit(failed > 0 ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/test-live-mql-leak-1180-e2e.js b/test-live-mql-leak-1180-e2e.js new file mode 100644 index 00000000..442a9e17 --- /dev/null +++ b/test-live-mql-leak-1180-e2e.js @@ -0,0 +1,78 @@ +/** + * E2E regression for #1180 review must-fix: + * MediaQueryList 'change' listener leak in wireLiveCollapseToggles(). + * + * SPA navigates to /#/live, then bounces /#/explore ↔ /#/live N times. + * Each /#/live mount re-runs the wiring IIFE; without a guard, every + * mount calls narrowMql.addEventListener('change', applyForViewport) + * against a process-global MediaQueryList instance, so listeners + * accumulate without bound. + * + * live.js exposes a debug seam: window.__liveMQLBindCount is incremented + * exactly when the MQL listener is registered. After 5 round-trips it + * MUST be ≤ 1. + * + * Run: BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-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(' ✓ ' + name); } + catch (e) { failed++; console.error(' ✗ ' + name + ': ' + e.message); } +} +function assert(c, m) { if (!c) throw new Error(m || 'assertion failed'); } + +async function gotoHash(page, hash) { + await page.evaluate((h) => { window.location.hash = h; }, hash); + // Allow router to run + await page.waitForTimeout(150); +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + + console.log(`\n=== #1180 MQL listener leak E2E against ${BASE} ===`); + + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + page.setDefaultTimeout(8000); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + await step('initial /#/live load registers MQL listener at most once', async () => { + await page.goto(BASE + '/#/live', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 }); + await page.waitForTimeout(300); + const count = await page.evaluate(() => window.__liveMQLBindCount); + assert(typeof count === 'number', + 'window.__liveMQLBindCount missing — debug seam not exposed by live.js'); + assert(count <= 1, `expected MQL bind count ≤ 1 after first mount, got ${count}`); + }); + + await step('5 SPA round-trips do NOT accumulate MQL listeners', async () => { + for (let i = 0; i < 5; i++) { + await gotoHash(page, '#/packets'); + await page.waitForTimeout(80); + await gotoHash(page, '#/live'); + await page.waitForSelector('#liveHeader, .live-header', { timeout: 8000 }); + await page.waitForTimeout(120); + } + const count = await page.evaluate(() => window.__liveMQLBindCount); + assert(typeof count === 'number', + 'window.__liveMQLBindCount missing after navigations'); + assert(count <= 1, + `MQL listener leak: bind count after 5 round-trips = ${count}, expected ≤ 1`); + }); + + await ctx.close(); + await browser.close(); + console.log(`\n=== Results: passed ${passed} failed ${failed} ===`); + process.exit(failed > 0 ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); });