mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-08-29 09:38:28 +00:00
Red commit: `6c28227884a1e79e277653465028365dc0863171` — CI: https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1207 Fixes #1207 ## Diagnosis The Live Map page renders `#liveFeed` (bottom-left panel) with two header buttons — `◫` (panel-corner-btn) and `✕` (feed-hide-btn) — but its `.panel-content` body has zero children on first paint, before any packets have been ingested via WebSocket. The user-reported "X + book icons, no content" is exactly these two header buttons sitting on an empty body. **Verdict:** intended panel, missing content due to a data race — the chrome mounts in HTML before the WS pushes its first packet. Not orphaned, not a leftover from #1186. ## Fix - Always render a persistent `.live-feed-empty` placeholder ("Waiting for packets…") inside `#liveFeed .panel-content`. - CSS hides it via `.live-feed .panel-content:has(.live-feed-item) .live-feed-empty { display: none; }` when real feed items exist. - `rebuildFeedList` re-adds the placeholder defensively after a wipe; eviction loop counts `.live-feed-item` only so the placeholder is never trimmed out. All colors via CSS variables (`var(--text-muted)`). ## Test (RED → GREEN) - **RED** `6c28227884a1e79e277653465028365dc0863171` — `test-e2e-playwright.js` adds a new test ("#1207 Live Feed panel never renders as empty chrome") that wipes `.live-feed-item` children to simulate the empty state and asserts the panel body has visible text or children. Fails on master. - **GREEN** `a5af80960ac42759ec83fd5ca5a72e81856228d4` — adds the placeholder; test now passes. ## Acceptance criteria - [x] No empty panel chrome visible on Live Map page - [x] Panel renders "Waiting for packets…" while feed is empty - [x] CSS auto-hides placeholder when packets arrive - [x] E2E assertion in `test-e2e-playwright.js` enforces non-empty `.panel-content` on `#liveFeed` ## Files - `public/live.js` — HTML markup + `rebuildFeedList` re-add + eviction-loop guard - `public/live.css` — `.live-feed-empty` style + `:has()` hide rule - `test-e2e-playwright.js` — regression test --------- Co-authored-by: clawbot <clawbot@kpabap.local> Co-authored-by: openclaw-bot <bot@openclaw.local>
This commit is contained in:
co-authored by
clawbot
openclaw-bot
parent
dbb013a6bf
commit
4925770aa4
@@ -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;
|
||||
|
||||
+23
-2
@@ -921,7 +921,9 @@
|
||||
<button class="panel-corner-btn" data-panel="liveFeed" title="Move panel to next corner" aria-label="Move panel to next corner">◫</button>
|
||||
<button class="feed-hide-btn" id="feedHideBtn" title="Hide feed">✕</button>
|
||||
</div>
|
||||
<div class="panel-content" aria-live="polite" aria-relevant="additions" role="log"></div>
|
||||
<div class="panel-content" aria-live="polite" aria-relevant="additions" role="log">
|
||||
<div class="live-feed-empty" aria-hidden="true">Waiting for packets…</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="feed-show-btn hidden" id="feedShowBtn" title="Show feed">📋</button>
|
||||
<div id="nodeDetailBackdrop" class="node-detail-backdrop"></div>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user