diff --git a/public/live.css b/public/live.css index 5cc11af7..46cd2a01 100644 --- a/public/live.css +++ b/public/live.css @@ -49,6 +49,21 @@ gap: 1px; } +/* #1207: empty-state placeholder fills the feed body so the panel never + renders as orphan chrome (✕ + ◫ buttons with no content). The placeholder + is always in the DOM; CSS hides it when the feed has live-feed-item + siblings. This way the panel body is never empty, even between packets, + on first paint, or after a feed clear. */ +.live-feed-empty { + color: var(--text-muted); + font-size: 12px; + padding: 12px 8px; + text-align: center; +} +.live-feed .panel-content:has(.live-feed-item) .live-feed-empty { + display: none; +} + .live-legend .panel-content { display: flex; flex-direction: column; diff --git a/public/live.js b/public/live.js index b3b04b93..ca01ec81 100644 --- a/public/live.js +++ b/public/live.js @@ -921,7 +921,9 @@ -
+
+ +
@@ -2029,6 +2031,15 @@ if (!feedContent) return; feedContent.querySelectorAll('.live-feed-item').forEach(el => el.remove()); feedDedup.clear(); + // #1207: ensure empty-state placeholder is present (re-add if a prior + // rebuild wiped everything). CSS hides it when items exist. + if (!feedContent.querySelector('.live-feed-empty')) { + var _ph = document.createElement('div'); + _ph.className = 'live-feed-empty'; + _ph.setAttribute('aria-hidden', 'true'); + _ph.textContent = 'Waiting for packets…'; + feedContent.appendChild(_ph); + } // Aggregate VCR buffer by hash, then create one feed item per unique hash const byHash = new Map(); @@ -2241,6 +2252,12 @@ window._liveVcrPause = vcrPause; window._liveVcrResumeLive = vcrResumeLive; window._liveVcrSetMode = vcrSetMode; + // #1207 test seams: expose production feed mutators so E2E can exercise + // the real eviction guard / placeholder re-add path (not a test-local copy). + window._liveAddFeedItem = function(icon, typeName, payload, hops, color, pkt) { + return addFeedItem(icon, typeName, payload, hops, color, pkt); + }; + window._liveRebuildFeedList = function() { return rebuildFeedList(); }; async function replayRecent() { try { @@ -3250,7 +3267,11 @@ item.addEventListener('click', () => showFeedCard(item, pkt, color)); feed.prepend(item); requestAnimationFrame(() => requestAnimationFrame(() => item.classList.remove('live-feed-enter'))); - while (feed.children.length > 25) feed.removeChild(feed.lastChild); + // #1207: trim to 25 items, but never evict the empty-state placeholder. + while (feed.querySelectorAll('.live-feed-item').length > 25) { + var _items = feed.querySelectorAll('.live-feed-item'); + feed.removeChild(_items[_items.length - 1]); + } // Register if (hash) { diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js index 6bbd87e5..df179ca3 100644 --- a/test-e2e-playwright.js +++ b/test-e2e-playwright.js @@ -951,6 +951,215 @@ async function run() { }); + // Issue #1207: Live Feed panel (bottom-left) must NOT render as orphan + // chrome (only header buttons ✕ + ◫) when there are no feed items yet. + // Either content (live-feed-item) renders, or an explicit empty-state + // placeholder with the "Waiting for packets…" copy fills the panel-content + // body. The bug is the panel showing its header chrome with an empty body + // on first paint / cold load. + await test('#1207 Live Feed panel never renders as empty chrome', async () => { + await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#liveFeed', { timeout: 10000 }); + // Force the empty state: clear any feed items that may have arrived, + // then verify the placeholder copy is rendered in the panel body. + const state = await page.evaluate(() => { + const feed = document.getElementById('liveFeed'); + if (!feed) return { found: false }; + const content = feed.querySelector('.panel-content'); + if (!content) return { found: true, hasContent: false }; + // Wipe item children to simulate first-paint / no-traffic state. + content.querySelectorAll('.live-feed-item').forEach((el) => el.remove()); + const visibleText = (content.innerText || '').trim(); + return { + found: true, + hasContent: true, + feedHidden: feed.classList.contains('hidden'), + visibleText, + }; + }); + assert(state.found, '#liveFeed should be present on Live page'); + assert(state.hasContent, '#liveFeed must have a .panel-content body'); + if (state.feedHidden) return; // user hid the feed → header chrome gone too + // Pin to the actual placeholder copy — a non-empty div with no recognisable + // text would still be orphan chrome. + assert( + /Waiting for packets/i.test(state.visibleText), + `#1207: Live Feed body must contain placeholder copy "Waiting for packets…". ` + + `Got: ${JSON.stringify(state.visibleText)}`, + ); + }); + + // Issue #1207 (kent #2): the eviction loop in addFeedItem must use + // querySelectorAll('.live-feed-item').length, NOT feed.children.length — + // otherwise the placeholder div counts as a "child" and gets trimmed + // (or items are under-evicted) once the feed is full. Fill the feed with + // synthetic items, run the eviction guard, then strip items back to zero + // and assert the placeholder survives. + await test('#1207 eviction loop never trims the empty-state placeholder', async () => { + await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#liveFeed .panel-content', { timeout: 10000 }); + // Drive the REAL production addFeedItem (exposed as window._liveAddFeedItem) + // 30 times so the production eviction guard inside addFeedItem fires past + // the cap of 25. If production reverts to feed.children.length, the + // placeholder counts as a child and either under-evicts (26 items) or + // gets trimmed itself — both observable failures below. + const result = await page.evaluate(() => { + if (typeof window._liveAddFeedItem !== 'function') { + return { ok: false, reason: 'window._liveAddFeedItem not exposed (test seam missing)' }; + } + const feed = document.querySelector('#liveFeed .panel-content'); + if (!feed) return { ok: false, reason: 'no .panel-content' }; + // Clean slate: remove any pre-existing items the page may have rendered. + feed.querySelectorAll('.live-feed-item').forEach((el) => el.remove()); + // Re-add placeholder if a prior packet wiped it. + if (!feed.querySelector('.live-feed-empty')) { + const ph = document.createElement('div'); + ph.className = 'live-feed-empty'; + ph.setAttribute('aria-hidden', 'true'); + ph.textContent = 'Waiting for packets…'; + feed.appendChild(ph); + } + const placeholderBefore = !!feed.querySelector('.live-feed-empty'); + + // Fire 30 distinct packets through PRODUCTION addFeedItem. + // Unique hashes prevent dedup; minimal pkt shape mirrors what + // renderPacketTree passes through. + for (let i = 0; i < 30; i++) { + const hash = 'evict-test-' + i.toString(16).padStart(8, '0'); + const pkt = { + hash, + _ts: Date.now() + i, + route_type: 0, + observation_count: 1, + decoded: { header: { payloadTypeName: 'TXT_MSG' }, payload: { text: 'evict-' + i }, path: { hops: [] } }, + }; + window._liveAddFeedItem('💬', 'TXT_MSG', { text: 'evict-' + i }, [], '#888', pkt); + } + const itemsAfterEvict = feed.querySelectorAll('.live-feed-item').length; + const placeholderAfterEvict = !!feed.querySelector('.live-feed-empty'); + const childrenAfterEvict = feed.children.length; + return { + ok: true, + placeholderBefore, + itemsAfterEvict, + placeholderAfterEvict, + childrenAfterEvict, + }; + }); + assert(result.ok, `setup failed: ${result.reason || ''}`); + assert(result.placeholderBefore, 'placeholder must be present before firing packets'); + // The production guard `while (feed.querySelectorAll('.live-feed-item').length > 25)` + // MUST cap items at exactly 25. A buggy `feed.children.length` form caps at 25 + // total children (24 items + 1 placeholder) → itemsAfterEvict === 24 → fails here. + assert( + result.itemsAfterEvict === 25, + `eviction guard must cap at 25 .live-feed-item (got ${result.itemsAfterEvict}). ` + + `If 24: production is using feed.children.length (placeholder counted as child).`, + ); + // And the placeholder must SURVIVE the eviction loop. A buggy form that + // does removeChild on the last child would yank the placeholder once it's + // appended last. + assert( + result.placeholderAfterEvict, + 'eviction must NOT remove .live-feed-empty placeholder', + ); + // Total children == 25 items + 1 placeholder. + assert( + result.childrenAfterEvict === 26, + `feed.children.length should be 26 (25 items + 1 placeholder), got ${result.childrenAfterEvict}`, + ); + }); + + // Issue #1207 (kent #3): CSS rule + // .live-feed .panel-content:has(.live-feed-item) .live-feed-empty { display: none } + // must hide the placeholder when at least one item is present. Inject an + // item and assert getComputedStyle(empty).display === 'none'. + await test('#1207 :has() rule hides placeholder when items present', async () => { + await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#liveFeed .panel-content', { timeout: 10000 }); + const result = await page.evaluate(() => { + const feed = document.querySelector('#liveFeed .panel-content'); + if (!feed) return { ok: false, reason: 'no .panel-content' }; + // Strip any pre-existing items to isolate the test. + feed.querySelectorAll('.live-feed-item').forEach((el) => el.remove()); + // Ensure placeholder exists. + if (!feed.querySelector('.live-feed-empty')) { + const ph = document.createElement('div'); + ph.className = 'live-feed-empty'; + ph.textContent = 'Waiting for packets…'; + feed.appendChild(ph); + } + const ph = feed.querySelector('.live-feed-empty'); + const displayBefore = getComputedStyle(ph).display; + // Inject a real .live-feed-item child of .panel-content (sibling of ph). + const item = document.createElement('div'); + item.className = 'live-feed-item'; + item.textContent = 'injected'; + feed.prepend(item); + const displayAfter = getComputedStyle(ph).display; + return { ok: true, displayBefore, displayAfter }; + }); + assert(result.ok, `setup failed: ${result.reason || ''}`); + assert( + result.displayBefore !== 'none', + `placeholder must be visible with no items (got display=${result.displayBefore})`, + ); + assert( + result.displayAfter === 'none', + `:has(.live-feed-item) rule must hide placeholder (got display=${result.displayAfter})`, + ); + }); + + // Issue #1207 (kent #4): rebuildFeedList() must re-add the empty-state + // placeholder when it was previously removed. Without this, a sequence of + // (live packets render → user navigates / state rebuilds → placeholder gone) + // leaves the panel as orphan chrome. Drive the REAL production + // rebuildFeedList (via window._liveRebuildFeedList) AFTER explicitly + // removing the placeholder; assert it comes back. + await test('#1207 rebuildFeedList re-adds placeholder when missing', async () => { + await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#liveFeed .panel-content', { timeout: 10000 }); + const result = await page.evaluate(() => { + if (typeof window._liveRebuildFeedList !== 'function') { + return { ok: false, reason: 'window._liveRebuildFeedList not exposed (test seam missing)' }; + } + const feed = document.querySelector('#liveFeed .panel-content'); + if (!feed) return { ok: false, reason: 'no .panel-content' }; + // Simulate: ingest wiped the placeholder out (e.g. innerHTML replace, + // or a future code path that forgot to preserve it). + feed.querySelectorAll('.live-feed-empty').forEach((el) => el.remove()); + feed.querySelectorAll('.live-feed-item').forEach((el) => el.remove()); + const placeholderBefore = !!feed.querySelector('.live-feed-empty'); + // Call production rebuild. + window._liveRebuildFeedList(); + const placeholderAfter = feed.querySelector('.live-feed-empty'); + return { + ok: true, + placeholderBefore, + placeholderPresent: !!placeholderAfter, + placeholderText: placeholderAfter ? (placeholderAfter.textContent || '').trim() : '', + ariaHidden: placeholderAfter ? placeholderAfter.getAttribute('aria-hidden') : null, + }; + }); + assert(result.ok, `setup failed: ${result.reason || ''}`); + assert( + result.placeholderBefore === false, + 'precondition: placeholder must be removed before rebuildFeedList()', + ); + assert( + result.placeholderPresent, + 'rebuildFeedList MUST re-add .live-feed-empty placeholder when missing', + ); + assert( + /Waiting for packets/i.test(result.placeholderText), + `re-added placeholder copy must match. Got: ${JSON.stringify(result.placeholderText)}`, + ); + assert( + result.ariaHidden === 'true', + `re-added placeholder must have aria-hidden="true" (got ${JSON.stringify(result.ariaHidden)})`, + ); + }); + // Test 11: Live page heat checkbox disabled by matrix/ghosts mode await test('Live heat disabled when ghosts mode active', async () => { await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });