mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-24 14:15:20 +00:00
aba20b3eda
## 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>
304 lines
15 KiB
JavaScript
304 lines
15 KiB
JavaScript
/**
|
||
* 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); });
|