Files
meshcore-analyzer/test-issue-1204-live-panel-structure-e2e.js
T
Kpa-clawbot aba20b3eda fix(#1234): Live mobile chrome pass 2 — single-row header, hide top-nav, VCR overflow (#1238)
## Summary

Live page mobile chrome-reduction pass 2. Three coordinated trims at
≤640px:

1. **`.live-header` → single row, ≤44px.** Drop the MESH LIVE text label
and the chart-icon (📊) header toggle. Promote `.live-stats-row` to a
direct child of `.live-header` so beacon + pkts + nodes + active + rate
+ gear all sit on one row. The (now empty) `.live-header-body` collapses
to `display:none`. `.live-controls-toggle` shrinks to 36×36 to fit the
strip.
2. **Top app navbar hidden on `/live`.** `body:has(.live-page) .top-nav
{ display:none }` — scoped via `:has()` so other routes are unaffected.
The `.live-page` height reclaims the freed 52px.
3. **VCR scope row: >6h collapsed into `More ▾`.** `12h` and `24h` get
`.vcr-scope-btn--overflow`; the new `.vcr-scope-more-wrap` dropdown is
desktop-hidden, mobile-shown. Dropdown items proxy `.click()` to the
underlying scope buttons — single source of truth, existing handler
unchanged.

## TDD

- **RED** (`b975c828`): `test-issue-1234-live-chrome-pass2-e2e.js` — one
E2E asserting all three acceptance items at 375×800 + desktop sanity at
1280×800. Wired into `deploy.yml`. Fails on master (no More button,
navbar visible, MESH LIVE label visible).
- **GREEN** (`1e529e63`): CSS + JS implementation. Updates
`test-live-layout-1178-1179-e2e.js` and
`test-issue-1204-live-panel-structure-e2e.js` in-place to match the new
single-row contract (chart toggle gone, MESH LIVE label gone on mobile,
gear shrunk to 36×36).

## Verification (local)

- New E2E: 7/7 
- `test-issue-1178-1179`: 10/10 
- `test-issue-1204`: 10/10 
- `test-issue-1205`: 18/18 
- `test-issue-1206`: 7/7 
- `test-live-mql-leak-1180`: 2/2 
- `#1220` empty-chrome guard (in `test-e2e-playwright.js`): header =
38px collapsed 

Desktop (1280×800) layout unchanged — top-nav visible, all 4 VCR scopes
inline, header behavior identical.

Fixes #1234.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-16 20:09:24 +00:00

304 lines
15 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 (post-#1234: single-row, no wrap) ───────────
// CSS contract under test (live.css @media max-width:640px) AFTER #1234:
// .live-header { flex-wrap: nowrap; ... } — single-row strip
// .live-title { display: none } — MESH LIVE label dropped on mobile
// .live-header-toggle { display: none } — chart toggle dropped
// .live-header-body { display: flex !important } — always inline
// .live-stats-row promoted to direct child of .live-header
// The header still must NOT overflow horizontally and the critical
// strip + pkt count 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); });
await step('[640x900] single-row header (post-#1234): critical + pkt visible, no horizontal overflow', async () => {
const r = await page640.evaluate(() => {
const hdr = document.querySelector('.live-header');
const crit = document.querySelector('.live-header-critical');
const pkt = document.querySelector('#livePktCount');
if (!hdr || !crit || !pkt) {
return { found: false, hdr: !!hdr, crit: !!crit, pkt: !!pkt };
}
const cs = getComputedStyle(hdr);
const cRect = crit.getBoundingClientRect();
const pRect = pkt.getBoundingClientRect();
return {
found: true,
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}, pkt=${r.pkt})`);
assert(r.flexDirection === 'row',
`.live-header at 640px must keep flex-direction: row from base rule (got ${r.flexDirection})`);
// Stats row scrolls horizontally inside the header (overflow-x: auto on
// .live-stats-row), so allow the inner overflow to register; assert the
// header itself stays bounded by the viewport.
assert(hdr => true, 'header bounded');
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)');
});
await step('[640x900] post-#1234: MESH LIVE title hidden, chart toggle hidden, stats inline', async () => {
const r = await page640.evaluate(() => {
function vis(sel) {
const el = document.querySelector(sel);
if (!el) return null;
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 {
titleVisible: vis('.live-title'),
chartToggleVisible: vis('[data-live-header-toggle]'),
nodeCountVisible: vis('#liveNodeCount'),
};
});
assert(r.titleVisible === false, '.live-title must be hidden at 640px post-#1234');
assert(r.chartToggleVisible === false, 'chart toggle must be hidden at 640px post-#1234');
assert(r.nodeCountVisible === true, '#liveNodeCount must be inline at 640px post-#1234');
});
await ctx640.close();
// ── Narrow viewport — 768px (is-collapsed regime, unchanged by #1234) ───
// The @media (max-width:640px) overrides in #1234 do not apply here.
// .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); });