Files
meshcore-analyzer/test-issue-1204-live-panel-structure-e2e.js
T
Kpa-clawbot 3255395bd0 fix(#1204): MESH LIVE panel — header inherited column flex from .live-overlay (#1215)
Red commit: c159a1153d (CI run: pending —
first CI is on this PR)

Fixes #1204.

## Root cause

`.live-overlay` (the base class for all overlay panels: feed, legend,
node-detail, header) declares `flex-direction: column`. Feed/legend/
node-detail need that for their `.panel-header` + scrollable
`.panel-content` stacking — but the header doesn't, it's a horizontal
bar.

PR #1180 (#16c48e73) split the header from a flat layout into three
children: `.live-header-critical` (beacon + `0 pkts`) + collapsible
toggle button + `.live-header-body` (title + stats row). Without an
explicit `flex-direction` override, those three pieces inherited the
column default and stacked vertically — pushing `0 pkts` above the
`MESH LIVE` title and clipping the stats row out of the 40px max-height
container. Exactly the "detached counter, hollow shell" the issue
reports.

## Fix

Add `flex-direction: row` to `.live-header` (one line + comment).
Single-property CSS change, no JS, no DOM, no behavior outside layout.

## TDD

Red commit `c159a115` — E2E
`test-issue-1204-live-panel-structure-e2e.js`
asserts:
1. `.live-header-critical` and `.live-title` vertically overlap (same
row).
2. `#livePktCount` pill and title mid-Y differ by < 8px.
3. `.live-stats-row` is visible (nonzero size).
4. `.live-feed .panel-content` accepts an injected row (column
container).

Verified failing on master at red commit (3 of 5 fail with the exact
"stacked above title" signature). Green commit `b7f57072` flips all to
pass.

E2E assertion added: `test-issue-1204-live-panel-structure-e2e.js:55`

## Verified

- Local `cmd/server` + fresh fixture, viewport 1440×900, headless
Chromium: 5/5 pass.
- Preflight (`run-all.sh origin/master`): clean.

## Files

- `public/live.css` — `flex-direction: row` on `.live-header` (+
rationale comment)
- `test-issue-1204-live-panel-structure-e2e.js` — new E2E (added to
`deploy.yml`)

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-15 22:34:22 -07:00

329 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* E2E for #1204 — MESH LIVE panel renders detached/empty on Live Map page.
*
* Root symptom: `.live-header` inherits `flex-direction: column` from
* `.live-overlay` and PR #1180 added a sibling `.live-header-critical`
* strip + collapsible `.live-header-body`. With column direction the
* critical strip ("0 pkts" counter) renders ABOVE the title row, the
* panel collapses to one cropped column, and the stats row disappears.
*
* Wide-viewport assertions (cohesive single-row header at desktop):
* (a) `.live-header-critical` and `.live-title` overlap vertically
* (same row, not stacked).
* (b) `#livePktCount` pill is on the same baseline as `.live-title`
* (mid-Y delta < 8px).
* (c) `.live-stats-row` is visible (height > 0, display ≠ none).
*
* Narrow-viewport coverage (PR #1215 r1 review #2): the fix sets
* `.live-header { flex-direction: row }` unconditionally. The header
* has two narrow-width regimes — `@media (max-width:640px)` adds
* `flex-wrap: wrap`, and `@media (max-width:768px)` enables
* `is-collapsed` mode hiding `.live-header-body`. Both must continue
* to work with `flex-direction: row` as the base:
* (e) 640px viewport: header wraps without horizontal overflow,
* title + pkt-count pill are both visible.
* (f) 768px viewport: default-collapsed state hides
* `.live-header-body` while `.live-header-critical` (beacon +
* pkt count) stays visible; clicking the toggle reveals the
* body; clicking again re-hides it.
*
* NOTE: assertion (d) from r0 (.live-feed .panel-content injection
* test) was dropped in r1 — it passed on master unchanged, so it
* didn't gate the #1204 regression. Feed mount contract belongs in
* its own test file if needed.
*
* Red-on-master matrix (verified against origin/master public/live.css):
* (a) wide overlap → RED on master (gates fix)
* (b) wide pill alignment → RED on master (gates fix)
* (c) wide stats visible → green on master (sanity)
* (e) 640px collapsed → RED on master (gates fix)
* (e) 640px expanded → RED on master (gates fix)
* (f) 768px collapsed/toggle → green on master (regression sentinel:
* at ≤768px the body is hidden by `is-collapsed`, so a column
* header still happens to lay out; sentinel guards future regressions
* that would re-introduce body-stacking on the toggle path).
*
* Run: BASE_URL=http://localhost:13581 node test-issue-1204-live-panel-structure-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 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=== #1204 MESH LIVE panel cohesion E2E against ${BASE} ===`);
// ── Wide viewport (1440×900) ────────────────────────────────────────────
const ctxWide = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const pageWide = await ctxWide.newPage();
pageWide.setDefaultTimeout(8000);
pageWide.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('[1440x900] navigate to /live', async () => { await gotoLive(pageWide); });
// (a) critical strip and title vertically overlap (same row, not stacked)
await step('[1440x900] .live-header-critical and .live-title share the same row', async () => {
const r = await pageWide.evaluate(() => {
const crit = document.querySelector('.live-header-critical');
const title = document.querySelector('.live-title');
if (!crit || !title) return { found: false, crit: !!crit, title: !!title };
const a = crit.getBoundingClientRect();
const b = title.getBoundingClientRect();
return { found: true, a, b };
});
assert(r.found, `missing element (critical=${r.crit}, title=${r.title})`);
const overlap = Math.min(r.a.bottom, r.b.bottom) - Math.max(r.a.top, r.b.top);
assert(overlap > 0,
`critical strip and title must overlap vertically (same row); ` +
`critical Y=[${r.a.top},${r.a.bottom}], title Y=[${r.b.top},${r.b.bottom}]`);
});
// (b) pkt count pill is on the same baseline as the title (mid-Y delta < 8px)
await step('[1440x900] #livePktCount pill aligns horizontally with .live-title', async () => {
const r = await pageWide.evaluate(() => {
const pkt = document.querySelector('#livePktCount');
const pill = pkt && pkt.closest('.live-stat-pill');
const title = document.querySelector('.live-title');
if (!pill || !title) return { found: false, pill: !!pill, title: !!title };
const a = pill.getBoundingClientRect();
const b = title.getBoundingClientRect();
return {
found: true,
midDelta: Math.abs((a.top + a.bottom) / 2 - (b.top + b.bottom) / 2),
pillBottom: a.bottom,
titleTop: b.top,
};
});
assert(r.found, `missing element (pill=${r.pill}, title=${r.title})`);
assert(r.midDelta < 8,
`pkt-count pill and title mid-Y must differ by < 8px (got ${r.midDelta.toFixed(1)}px); ` +
`bug repros as pill stacked above title`);
});
// (c) stats row is visible — height > 0 and display ≠ none
await step('[1440x900] .live-stats-row visible inside header', async () => {
const r = await pageWide.evaluate(() => {
const row = document.querySelector('.live-stats-row');
if (!row) return { found: false };
const cs = getComputedStyle(row);
const rect = row.getBoundingClientRect();
return { found: true, display: cs.display, h: rect.height, w: rect.width };
});
assert(r.found, '.live-stats-row missing');
assert(r.display !== 'none', `.live-stats-row display must not be none (got ${r.display})`);
assert(r.h > 0 && r.w > 0,
`.live-stats-row must have nonzero size (got ${r.w}×${r.h}); ` +
`bug repros as stats clipped by max-height with column flex`);
});
await ctxWide.close();
// ── Narrow viewport — 640px (flex-wrap regime) ──────────────────────────
// CSS contract under test (live.css @media max-width:640px):
// .live-header { flex-wrap: wrap; ... max-width: calc(100vw - 16px) }
// With base flex-direction: row from r0 fix, wrap must produce children
// that fit within the header's width (no horizontal overflow) and both
// the critical strip and stats row must remain visible.
const ctx640 = await browser.newContext({ viewport: { width: 640, height: 900 } });
const page640 = await ctx640.newPage();
page640.setDefaultTimeout(8000);
page640.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('[640x900] navigate to /live', async () => { await gotoLive(page640); });
// Collapsed default (≤768px also covers 640px): critical strip + toggle
// are visible inline; .live-title sits inside .live-header-body, so verify
// it once we expand. Wrap behavior matters in both states because the
// base rule is flex-direction: row.
await step('[640x900] collapsed state: critical + toggle inline, no horizontal overflow', async () => {
const r = await page640.evaluate(() => {
const hdr = document.querySelector('.live-header');
const crit = document.querySelector('.live-header-critical');
const tog = document.querySelector('#liveHeaderToggle');
const pkt = document.querySelector('#livePktCount');
if (!hdr || !crit || !tog || !pkt) {
return { found: false, hdr: !!hdr, crit: !!crit, tog: !!tog, pkt: !!pkt };
}
const cs = getComputedStyle(hdr);
const cRect = crit.getBoundingClientRect();
const pRect = pkt.getBoundingClientRect();
return {
found: true,
flexWrap: cs.flexWrap,
flexDirection: cs.flexDirection,
overflowX: hdr.scrollWidth - hdr.clientWidth,
critVisible: cRect.width > 0 && cRect.height > 0,
pktVisible: pRect.width > 0 && pRect.height > 0,
};
});
assert(r.found, `missing element (hdr=${r.hdr}, crit=${r.crit}, tog=${r.tog}, pkt=${r.pkt})`);
assert(r.flexWrap === 'wrap',
`.live-header at 640px must have flex-wrap: wrap (got ${r.flexWrap}); ` +
`@media (max-width:640px) rule failed to apply`);
assert(r.flexDirection === 'row',
`.live-header at 640px must keep flex-direction: row from base rule (got ${r.flexDirection})`);
// Allow 1px sub-pixel slop. Real horizontal overflow = bug.
assert(r.overflowX <= 1,
`.live-header must not overflow horizontally at 640px ` +
`(scrollWidth - clientWidth = ${r.overflowX}px); wrap should keep children inside`);
assert(r.critVisible, '.live-header-critical (beacon + pkt count) must remain visible at 640px');
assert(r.pktVisible, '#livePktCount must remain visible at 640px (counter cohesion)');
});
// Expanded state at 640px — the actual wrap scenario worth gating:
// body becomes visible alongside the critical strip, and the row must
// wrap to fit width. Title now lives in the rendered tree.
await step('[640x900] expanded state: header wraps, critical + title both visible, no overflow', async () => {
await page640.click('#liveHeaderToggle');
await page640.waitForTimeout(120);
const r = await page640.evaluate(() => {
const hdr = document.querySelector('.live-header');
const crit = document.querySelector('.live-header-critical');
const title = document.querySelector('.live-title');
const pkt = document.querySelector('#livePktCount');
if (!hdr || !crit || !title || !pkt) {
return { found: false, hdr: !!hdr, crit: !!crit, title: !!title, pkt: !!pkt };
}
const cs = getComputedStyle(hdr);
const cRect = crit.getBoundingClientRect();
const tRect = title.getBoundingClientRect();
const pRect = pkt.getBoundingClientRect();
return {
found: true,
flexWrap: cs.flexWrap,
flexDirection: cs.flexDirection,
overflowX: hdr.scrollWidth - hdr.clientWidth,
critVisible: cRect.width > 0 && cRect.height > 0,
titleVisible: tRect.width > 0 && tRect.height > 0,
pktVisible: pRect.width > 0 && pRect.height > 0,
};
});
assert(r.found, `missing element (hdr=${r.hdr}, crit=${r.crit}, title=${r.title}, pkt=${r.pkt})`);
assert(r.flexWrap === 'wrap', `.live-header expanded at 640px must wrap (got ${r.flexWrap})`);
assert(r.flexDirection === 'row',
`.live-header expanded at 640px must keep flex-direction: row (got ${r.flexDirection})`);
assert(r.overflowX <= 1,
`.live-header expanded must not overflow horizontally at 640px ` +
`(scrollWidth - clientWidth = ${r.overflowX}px)`);
assert(r.critVisible, '.live-header-critical must remain visible when expanded at 640px');
assert(r.titleVisible, '.live-title must be visible when header body is expanded at 640px');
assert(r.pktVisible, '#livePktCount must remain visible (counter + title cohesion)');
});
await ctx640.close();
// ── Narrow viewport — 768px (is-collapsed regime) ───────────────────────
// CSS contract under test (live.css @media max-width:768px):
// .live-header-toggle { display: inline-flex }
// .live-header.is-collapsed .live-header-body { display: none }
// JS contract (live.js wireLiveCollapseToggles): at narrow viewports the
// header initializes collapsed; clicking the toggle expands; clicking
// again collapses. With base flex-direction: row the toggle must
// remain reachable on the same row as the critical strip.
const ctx768 = await browser.newContext({ viewport: { width: 768, height: 900 } });
const page768 = await ctx768.newPage();
page768.setDefaultTimeout(8000);
page768.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step('[768x900] navigate to /live', async () => { await gotoLive(page768); });
await step('[768x900] header default-collapsed: body hidden, critical strip visible, toggle reachable', async () => {
const r = await page768.evaluate(() => {
const hdr = document.querySelector('#liveHeader');
const body = document.querySelector('#liveHeaderBody');
const tog = document.querySelector('#liveHeaderToggle');
const crit = document.querySelector('.live-header-critical');
if (!hdr || !body || !tog || !crit) {
return { found: false, hdr: !!hdr, body: !!body, tog: !!tog, crit: !!crit };
}
const togCS = getComputedStyle(tog);
const bodyCS = getComputedStyle(body);
const critRect = crit.getBoundingClientRect();
const togRect = tog.getBoundingClientRect();
return {
found: true,
isCollapsed: hdr.classList.contains('is-collapsed'),
bodyHiddenAttr: body.hasAttribute('hidden'),
bodyDisplay: bodyCS.display,
togDisplay: togCS.display,
togW: togRect.width,
togH: togRect.height,
critVisible: critRect.width > 0 && critRect.height > 0,
};
});
assert(r.found, `missing element (hdr=${r.hdr}, body=${r.body}, tog=${r.tog}, crit=${r.crit})`);
assert(r.isCollapsed,
`.live-header must default to is-collapsed at 768px viewport (got class state without is-collapsed)`);
assert(r.bodyHiddenAttr, `.live-header-body must have hidden attribute when collapsed`);
assert(r.bodyDisplay === 'none',
`.live-header-body must compute display:none when collapsed (got ${r.bodyDisplay})`);
assert(r.togDisplay !== 'none',
`.live-header-toggle must be visible at ≤768px (got display:${r.togDisplay})`);
assert(r.togW >= 48 && r.togH >= 48,
`.live-header-toggle must satisfy 48×48 tap-target floor (#1060) — got ${r.togW}×${r.togH}`);
assert(r.critVisible,
`.live-header-critical (beacon + pkt count) must remain visible while body is collapsed — ` +
`that's the always-on ingest cue`);
});
await step('[768x900] clicking toggle expands then re-collapses the header body', async () => {
await page768.click('#liveHeaderToggle');
await page768.waitForTimeout(120);
let expanded = await page768.evaluate(() => {
const hdr = document.querySelector('#liveHeader');
const body = document.querySelector('#liveHeaderBody');
const cs = getComputedStyle(body);
return {
isExpanded: hdr.classList.contains('is-expanded'),
bodyHidden: body.hasAttribute('hidden'),
bodyDisplay: cs.display,
};
});
assert(expanded.isExpanded,
`after toggle click .live-header must gain is-expanded class (got isExpanded=${expanded.isExpanded})`);
assert(!expanded.bodyHidden, `.live-header-body must lose hidden attribute when expanded`);
assert(expanded.bodyDisplay !== 'none',
`.live-header-body must render (display ≠ none) when expanded (got ${expanded.bodyDisplay})`);
await page768.click('#liveHeaderToggle');
await page768.waitForTimeout(120);
let collapsed = await page768.evaluate(() => {
const hdr = document.querySelector('#liveHeader');
const body = document.querySelector('#liveHeaderBody');
const cs = getComputedStyle(body);
return {
isCollapsed: hdr.classList.contains('is-collapsed'),
bodyHidden: body.hasAttribute('hidden'),
bodyDisplay: cs.display,
};
});
assert(collapsed.isCollapsed,
`second toggle click must re-collapse (got isCollapsed=${collapsed.isCollapsed})`);
assert(collapsed.bodyHidden, `.live-header-body must regain hidden attribute when re-collapsed`);
assert(collapsed.bodyDisplay === 'none',
`.live-header-body must compute display:none when re-collapsed (got ${collapsed.bodyDisplay})`);
});
await ctx768.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); });