mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-22 21:26:01 +00:00
9d1f5d2395
Red commit: a200704d5e (CI URL added once
Actions resolves the run)
Fixes #1061
## What
Bottom navigation at ≤768px with 5 tabs in spec order: Home, Packets,
Live, Map, Channels. Top-nav suppressed at the same breakpoint — no
duplicate nav UX.
## Files
- NEW `public/bottom-nav.js` — renders 5 tabs, syncs `.active` on
`hashchange`, reuses the existing in-app hash router (`<a
href="#/...">`). Stable selector `[data-bottom-nav-tab="<route>"]`.
Container `[data-bottom-nav]`.
- NEW `public/bottom-nav.css` — styles. Tokens reused: `--nav-bg`,
`--nav-text`, `--nav-text-muted`, `--nav-active-bg`, `--accent`,
`--border` (all global → resolve in BOTH light and dark themes).
- `public/index.html` — one `<link>` for the CSS, one `<script>` after
`app.js`. The `<nav>` is appended by JS as a sibling of `<main
id="app">` at DOMContentLoaded.
- `test-bottom-nav-1061-e2e.js` + `.github/workflows/deploy.yml` —
Playwright wiring.
## Decisions
- **Breakpoint:** `@media (max-width: 768px)`. No `@container` rules
exist anywhere in `style.css` today — media query is consistent.
- **Top-nav suppression:** `display:none` at ≤768px. Simpler than a
hamburger collapse; long-tail routes (Tools/Lab/Perf) remain reachable
by URL; "More"-tab/hamburger fallback deferred per issue body.
- **Active indicator:** `var(--nav-active-bg)` + 2px accent top-border.
No moving pill.
- **Safe-area:** `padding-bottom: env(safe-area-inset-bottom)` on nav +
reciprocal `body` reservation. `viewport-fit=cover` already in place.
- **Reduced motion:** `prefers-reduced-motion: reduce` disables the
transition.
## TDD
- Red: `a200704` — assertions fail (no bottom-nav).
- Green: `53851a1` — component + styles.
E2E assertion added: `test-bottom-nav-1061-e2e.js:71` (case (a) —
bottom-nav visible at 360x800).
---------
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
Co-authored-by: openclaw-bot <bot@openclaw>
450 lines
21 KiB
JavaScript
450 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
/* Issue #1061 — Bottom navigation for narrow viewports.
|
|
*
|
|
* Asserts:
|
|
* (a) at 360x800, the bottom-nav container is visible AND the top-nav
|
|
* (.top-nav) is hidden (display:none / visibility:hidden / size 0).
|
|
* (b) at 1440x900, the bottom-nav is NOT visible AND the top-nav IS visible.
|
|
* (c) at 360x800, all 5 bottom-nav tabs (Home, Packets, Live, Map, Channels)
|
|
* have a tap target height >= 48px.
|
|
* (d) at 360x800, tapping the "Packets" tab navigates to #/packets via the
|
|
* in-app router — i.e. URL hash changes WITHOUT a full reload (a
|
|
* window.__bottomNav1061BootstrapId sentinel set on DOMContentLoaded
|
|
* MUST persist across the navigation).
|
|
* (e) at 360x800, the active-tab indicator class is applied to the Packets
|
|
* tab when on #/packets and is NOT applied when on #/.
|
|
* (f) the bottom-nav element has a non-empty padding-bottom resolved style
|
|
* (proxy for safe-area-inset-bottom; can't directly test the inset in
|
|
* headless Chromium).
|
|
*
|
|
* Stable selectors: bottom-nav tabs MUST be selectable via
|
|
* `[data-bottom-nav-tab="<route>"]` to avoid the virtual-scroll-spacer trap
|
|
* (DOM-order ambiguous matches).
|
|
*
|
|
* CI gating: when CHROMIUM_REQUIRE=1 a missing/broken Chromium is a HARD FAIL.
|
|
*/
|
|
'use strict';
|
|
|
|
const { chromium } = require('playwright');
|
|
|
|
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
|
const EXPECTED_TABS = ['home', 'packets', 'live', 'map', 'channels', 'more'];
|
|
// #1174: long-tail routes surfaced in the More sheet (the routes NOT in
|
|
// the 5 primary bottom-nav slots). Mirror data-route values from the
|
|
// existing top-nav.
|
|
const EXPECTED_MORE_ROUTES = ['nodes', 'tools', 'observers', 'analytics', 'perf', 'audio-lab'];
|
|
|
|
function isVisible(rect) {
|
|
return rect && rect.width > 0 && rect.height > 0;
|
|
}
|
|
|
|
async function main() {
|
|
const requireChromium = process.env.CHROMIUM_REQUIRE === '1';
|
|
let browser;
|
|
try {
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath: process.env.CHROMIUM_PATH || undefined,
|
|
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
|
});
|
|
} catch (err) {
|
|
if (requireChromium) {
|
|
console.error(`test-bottom-nav-1061-e2e.js: FAIL — Chromium required (CHROMIUM_REQUIRE=1) but unavailable: ${err.message}`);
|
|
process.exit(1);
|
|
}
|
|
console.log(`test-bottom-nav-1061-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
|
process.exit(0);
|
|
}
|
|
|
|
let failures = 0;
|
|
let passes = 0;
|
|
const fail = (msg) => { failures += 1; console.error(` FAIL: ${msg}`); };
|
|
const pass = (msg) => { passes += 1; console.log(` PASS: ${msg}`); };
|
|
|
|
const ctx = await browser.newContext();
|
|
const page = await ctx.newPage();
|
|
page.setDefaultTimeout(15000);
|
|
|
|
// Inject a bootstrap sentinel BEFORE the page scripts run so we can
|
|
// detect a full reload. The same value must survive an in-app
|
|
// navigation; if the page reloads the sentinel is reset to a new id.
|
|
await page.addInitScript(() => {
|
|
window.__bottomNav1061BootstrapId = 'boot-' + Math.random().toString(36).slice(2);
|
|
});
|
|
|
|
// ── (a) 360x800: bottom-nav visible, top-nav hidden ──
|
|
await page.setViewportSize({ width: 360, height: 800 });
|
|
await page.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => document.body && document.body.classList.contains('app-ready') || document.querySelector('main#app'));
|
|
|
|
const sentinelA = await page.evaluate(() => window.__bottomNav1061BootstrapId);
|
|
|
|
const stateNarrow = await page.evaluate(() => {
|
|
const bn = document.querySelector('[data-bottom-nav]');
|
|
const navLinks = document.querySelector('.top-nav .nav-links');
|
|
const navRight = document.querySelector('.top-nav .nav-right');
|
|
const navBrand = document.querySelector('.top-nav .nav-brand');
|
|
const bnRect = bn ? bn.getBoundingClientRect() : null;
|
|
const bnCs = bn ? getComputedStyle(bn) : null;
|
|
const isHiddenByCss = (el) => {
|
|
if (!el) return true;
|
|
const cs = getComputedStyle(el);
|
|
const r = el.getBoundingClientRect();
|
|
return cs.display === 'none' || cs.visibility === 'hidden' || (r.width === 0 && r.height === 0);
|
|
};
|
|
return {
|
|
bnPresent: !!bn,
|
|
bnRect,
|
|
bnDisplay: bnCs ? bnCs.display : null,
|
|
bnVisibility: bnCs ? bnCs.visibility : null,
|
|
bnPaddingBottom: bnCs ? bnCs.paddingBottom : null,
|
|
// #1174 fix: top-nav LINKS hidden (no duplicate nav UX), but
|
|
// .nav-brand stays visible (logo identity, not navigation).
|
|
navLinksHidden: isHiddenByCss(navLinks),
|
|
navRightHidden: isHiddenByCss(navRight),
|
|
navBrandPresent: !!navBrand,
|
|
navBrandHidden: isHiddenByCss(navBrand),
|
|
};
|
|
});
|
|
|
|
if (!stateNarrow.bnPresent) {
|
|
fail('(a) [data-bottom-nav] container missing in DOM at 360x800');
|
|
} else if (stateNarrow.bnDisplay === 'none' || stateNarrow.bnVisibility === 'hidden' || !isVisible(stateNarrow.bnRect)) {
|
|
fail(`(a) bottom-nav not visible at 360x800 (display=${stateNarrow.bnDisplay}, rect=${JSON.stringify(stateNarrow.bnRect)})`);
|
|
} else {
|
|
pass('(a) bottom-nav visible at 360x800');
|
|
}
|
|
if (stateNarrow.navLinksHidden && stateNarrow.navRightHidden) {
|
|
pass('(a) top-nav LINKS hidden at 360x800 (no duplicate nav UX)');
|
|
} else {
|
|
fail(`(a) top-nav links/right still visible at 360x800 (links=${!stateNarrow.navLinksHidden}, right=${!stateNarrow.navRightHidden}) — duplicate nav UX`);
|
|
}
|
|
if (stateNarrow.navBrandPresent && !stateNarrow.navBrandHidden) {
|
|
pass('(a) .nav-brand (logo identity) remains visible at 360x800');
|
|
} else {
|
|
fail(`(a) .nav-brand hidden at 360x800 (present=${stateNarrow.navBrandPresent}, hidden=${stateNarrow.navBrandHidden}) — should remain visible per #1137`);
|
|
}
|
|
|
|
// ── (c) 5 tabs each ≥48px tap target ──
|
|
const tabSizes = await page.evaluate((expected) => {
|
|
return expected.map((r) => {
|
|
const el = document.querySelector(`[data-bottom-nav-tab="${r}"]`);
|
|
if (!el) return { route: r, present: false };
|
|
const rect = el.getBoundingClientRect();
|
|
return { route: r, present: true, height: rect.height, width: rect.width };
|
|
});
|
|
}, EXPECTED_TABS);
|
|
for (const t of tabSizes) {
|
|
if (!t.present) { fail(`(c) tab missing: [data-bottom-nav-tab="${t.route}"]`); continue; }
|
|
if (t.height < 48) fail(`(c) tab ${t.route} height ${t.height.toFixed(1)} < 48px`);
|
|
else pass(`(c) tab ${t.route} height ${t.height.toFixed(1)}px ≥ 48`);
|
|
}
|
|
|
|
// ── (f) padding-bottom rule exists (safe-area proxy) ──
|
|
if (stateNarrow.bnPaddingBottom && stateNarrow.bnPaddingBottom !== '' && stateNarrow.bnPaddingBottom !== '0px') {
|
|
pass(`(f) bottom-nav padding-bottom = ${stateNarrow.bnPaddingBottom}`);
|
|
} else if (stateNarrow.bnPaddingBottom === '0px') {
|
|
// 0px is acceptable as long as the rule resolved (safe-area-inset is 0 in headless)
|
|
pass(`(f) bottom-nav padding-bottom resolved (0px in headless; rule exists)`);
|
|
} else {
|
|
fail(`(f) bottom-nav padding-bottom not resolved: ${stateNarrow.bnPaddingBottom}`);
|
|
}
|
|
|
|
// ── (e) on #/, Packets tab is NOT active ──
|
|
const activeOnHome = await page.evaluate(() => {
|
|
const el = document.querySelector('[data-bottom-nav-tab="packets"]');
|
|
return el ? el.classList.contains('active') : null;
|
|
});
|
|
if (activeOnHome === false) pass('(e) Packets tab not active on #/');
|
|
else fail(`(e) Packets tab incorrectly active on #/ (got ${activeOnHome})`);
|
|
|
|
// ── (d) tap "Packets" → #/packets without reload ──
|
|
await page.click('[data-bottom-nav-tab="packets"]');
|
|
await page.waitForFunction(() => location.hash === '#/packets', null, { timeout: 5000 }).catch(() => {});
|
|
const afterTap = await page.evaluate(() => ({
|
|
hash: location.hash,
|
|
sentinel: window.__bottomNav1061BootstrapId,
|
|
}));
|
|
if (afterTap.hash === '#/packets') pass('(d) tap navigated to #/packets');
|
|
else fail(`(d) tap did NOT navigate to #/packets (got ${afterTap.hash})`);
|
|
if (afterTap.sentinel === sentinelA) pass('(d) sentinel preserved — no full reload');
|
|
else fail(`(d) sentinel changed (${sentinelA} → ${afterTap.sentinel}) — page reloaded`);
|
|
|
|
// ── (e) on #/packets, Packets tab IS active ──
|
|
// Wait for the hashchange handler to update the active class. The
|
|
// location.hash === '#/packets' check above resolves the moment the
|
|
// browser sets the URL, but the hashchange event dispatch is still
|
|
// in-flight; reading classList immediately races the handler.
|
|
let activeOnPackets = null;
|
|
try {
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector('[data-bottom-nav-tab="packets"]');
|
|
return el && el.classList.contains('active');
|
|
}, null, { timeout: 2000 });
|
|
activeOnPackets = true;
|
|
} catch (_) {
|
|
activeOnPackets = await page.evaluate(() => {
|
|
const el = document.querySelector('[data-bottom-nav-tab="packets"]');
|
|
return el ? el.classList.contains('active') : null;
|
|
});
|
|
}
|
|
if (activeOnPackets === true) pass('(e) Packets tab active on #/packets');
|
|
else fail(`(e) Packets tab NOT active on #/packets (got ${activeOnPackets})`);
|
|
|
|
// ── (g) #1174: More tab visible at 360x800 ──
|
|
const moreTabState = await page.evaluate(() => {
|
|
const el = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
if (!el) return { present: false };
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
present: true,
|
|
visible: r.width > 0 && r.height > 0,
|
|
ariaExpanded: el.getAttribute('aria-expanded'),
|
|
ariaControls: el.getAttribute('aria-controls'),
|
|
};
|
|
});
|
|
if (!moreTabState.present) fail('(g) [data-bottom-nav-tab="more"] missing');
|
|
else if (!moreTabState.visible) fail('(g) More tab present but not visible');
|
|
else pass('(g) More tab visible at 360x800');
|
|
if (moreTabState.present && moreTabState.ariaExpanded === 'false') {
|
|
pass('(g) More tab aria-expanded="false" before tap');
|
|
} else if (moreTabState.present) {
|
|
fail(`(g) More tab aria-expanded should be 'false' before tap, got ${moreTabState.ariaExpanded}`);
|
|
}
|
|
|
|
// ── (h) #1174: tap More opens a sheet listing 6 long-tail routes ──
|
|
await page.click('[data-bottom-nav-tab="more"]').catch(() => {});
|
|
// Wait for sheet to render.
|
|
await page.waitForSelector('[data-bottom-nav-sheet]', { timeout: 3000 }).catch(() => {});
|
|
const sheetOpen = await page.evaluate((expected) => {
|
|
const sheet = document.querySelector('[data-bottom-nav-sheet]');
|
|
if (!sheet) return { present: false };
|
|
const cs = getComputedStyle(sheet);
|
|
const r = sheet.getBoundingClientRect();
|
|
const items = Array.from(sheet.querySelectorAll('[data-bottom-nav-more-route]'))
|
|
.map(el => el.getAttribute('data-bottom-nav-more-route'));
|
|
const moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
return {
|
|
present: true,
|
|
visible: cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0,
|
|
role: sheet.getAttribute('role'),
|
|
itemRoutes: items,
|
|
missing: expected.filter(r => !items.includes(r)),
|
|
moreTabExpanded: moreTab ? moreTab.getAttribute('aria-expanded') : null,
|
|
};
|
|
}, EXPECTED_MORE_ROUTES);
|
|
if (!sheetOpen.present) fail('(h) [data-bottom-nav-sheet] missing after More tap');
|
|
else if (!sheetOpen.visible) fail('(h) sheet rendered but not visible after More tap');
|
|
else pass('(h) sheet visible after More tap');
|
|
if (sheetOpen.present && sheetOpen.role === 'menu') pass('(h) sheet role="menu"');
|
|
else if (sheetOpen.present) fail(`(h) sheet role should be 'menu', got ${sheetOpen.role}`);
|
|
if (sheetOpen.present && sheetOpen.missing.length === 0) {
|
|
pass(`(h) sheet lists all 6 long-tail routes: ${sheetOpen.itemRoutes.join(',')}`);
|
|
} else if (sheetOpen.present) {
|
|
fail(`(h) sheet missing routes: ${sheetOpen.missing.join(',')} (got ${sheetOpen.itemRoutes.join(',')})`);
|
|
}
|
|
if (sheetOpen.moreTabExpanded === 'true') pass('(h) More tab aria-expanded="true" while open');
|
|
else fail(`(h) More tab aria-expanded should be 'true' while open, got ${sheetOpen.moreTabExpanded}`);
|
|
|
|
// ── (i) #1174: tap a route navigates and closes the sheet ──
|
|
await page.click('[data-bottom-nav-more-route="tools"]').catch(() => {});
|
|
await page.waitForFunction(() => location.hash === '#/tools', null, { timeout: 3000 }).catch(() => {});
|
|
const afterRouteTap = await page.evaluate(() => {
|
|
const sheet = document.querySelector('[data-bottom-nav-sheet]');
|
|
const cs = sheet ? getComputedStyle(sheet) : null;
|
|
const r = sheet ? sheet.getBoundingClientRect() : null;
|
|
const moreTab = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
return {
|
|
hash: location.hash,
|
|
sheetVisible: !!(sheet && cs && cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0),
|
|
moreTabExpanded: moreTab ? moreTab.getAttribute('aria-expanded') : null,
|
|
};
|
|
});
|
|
if (afterRouteTap.hash === '#/tools') pass('(i) tapping Tools navigated to #/tools');
|
|
else fail(`(i) hash did not change to #/tools (got ${afterRouteTap.hash})`);
|
|
if (!afterRouteTap.sheetVisible) pass('(i) sheet closed after route tap');
|
|
else fail('(i) sheet still visible after route tap');
|
|
if (afterRouteTap.moreTabExpanded === 'false') pass('(i) More tab aria-expanded="false" after close');
|
|
else fail(`(i) More tab aria-expanded should be 'false' after close, got ${afterRouteTap.moreTabExpanded}`);
|
|
|
|
// ── (j) #1174: tap outside closes the sheet ──
|
|
// Reopen.
|
|
await page.click('[data-bottom-nav-tab="more"]').catch(() => {});
|
|
await page.waitForFunction(() => {
|
|
const s = document.querySelector('[data-bottom-nav-sheet]');
|
|
if (!s) return false;
|
|
const cs = getComputedStyle(s);
|
|
return cs.display !== 'none' && cs.visibility !== 'hidden';
|
|
}, null, { timeout: 3000 }).catch(() => {});
|
|
// Click on body somewhere outside the sheet and outside the bottom-nav.
|
|
// Use a coordinate near the top of the viewport (the page main area).
|
|
await page.mouse.click(10, 200);
|
|
// Allow the close handler to run.
|
|
await page.waitForFunction(() => {
|
|
const s = document.querySelector('[data-bottom-nav-sheet]');
|
|
if (!s) return true;
|
|
const cs = getComputedStyle(s);
|
|
return cs.display === 'none' || cs.visibility === 'hidden';
|
|
}, null, { timeout: 3000 }).catch(() => {});
|
|
const afterOutside = await page.evaluate(() => {
|
|
const s = document.querySelector('[data-bottom-nav-sheet]');
|
|
if (!s) return { closed: true };
|
|
const cs = getComputedStyle(s);
|
|
const r = s.getBoundingClientRect();
|
|
return { closed: cs.display === 'none' || cs.visibility === 'hidden' || (r.width === 0 && r.height === 0) };
|
|
});
|
|
if (afterOutside.closed) pass('(j) sheet closes on outside click');
|
|
else fail('(j) sheet still visible after outside click');
|
|
|
|
// ── (k) #1174: at 360x800, #hamburger is hidden (More tab replaces it) ──
|
|
const hamburgerHidden = await page.evaluate(() => {
|
|
const h = document.getElementById('hamburger');
|
|
if (!h) return { present: false };
|
|
const cs = getComputedStyle(h);
|
|
const r = h.getBoundingClientRect();
|
|
return {
|
|
present: true,
|
|
hidden: cs.display === 'none' || cs.visibility === 'hidden' || (r.width === 0 && r.height === 0),
|
|
display: cs.display,
|
|
};
|
|
});
|
|
if (!hamburgerHidden.present) {
|
|
pass('(k) #hamburger removed from DOM (acceptable)');
|
|
} else if (hamburgerHidden.hidden) {
|
|
pass(`(k) #hamburger hidden at 360x800 (display=${hamburgerHidden.display})`);
|
|
} else {
|
|
fail(`(k) #hamburger still visible at 360x800 (display=${hamburgerHidden.display}) — More tab should replace it`);
|
|
}
|
|
|
|
// ── (b) 1440x900: bottom-nav hidden, top-nav visible ──
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.top-nav .nav-right');
|
|
const stateWide = await page.evaluate(() => {
|
|
const bn = document.querySelector('[data-bottom-nav]');
|
|
const tn = document.querySelector('.top-nav');
|
|
const bnRect = bn ? bn.getBoundingClientRect() : null;
|
|
const tnRect = tn ? tn.getBoundingClientRect() : null;
|
|
const bnCs = bn ? getComputedStyle(bn) : null;
|
|
const tnCs = tn ? getComputedStyle(tn) : null;
|
|
return {
|
|
bnDisplay: bnCs ? bnCs.display : null,
|
|
bnVisibility: bnCs ? bnCs.visibility : null,
|
|
bnRect,
|
|
tnDisplay: tnCs ? tnCs.display : null,
|
|
tnVisibility: tnCs ? tnCs.visibility : null,
|
|
tnRect,
|
|
};
|
|
});
|
|
if (stateWide.bnDisplay === 'none' || stateWide.bnVisibility === 'hidden' || !isVisible(stateWide.bnRect)) {
|
|
pass('(b) bottom-nav hidden at 1440x900');
|
|
} else {
|
|
fail(`(b) bottom-nav still visible at 1440x900 (display=${stateWide.bnDisplay}, rect=${JSON.stringify(stateWide.bnRect)})`);
|
|
}
|
|
if (stateWide.tnDisplay !== 'none' && stateWide.tnVisibility !== 'hidden' && isVisible(stateWide.tnRect)) {
|
|
pass('(b) top-nav visible at 1440x900');
|
|
} else {
|
|
fail(`(b) top-nav not visible at 1440x900 (display=${stateWide.tnDisplay})`);
|
|
}
|
|
|
|
// ── (l) #1174 mesh-op review: .live-page bottom must NOT be covered by bottom-nav at ≤768 ──
|
|
await page.setViewportSize({ width: 360, height: 800 });
|
|
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('.live-page', { timeout: 5000 }).catch(() => {});
|
|
// Allow layout to settle.
|
|
await page.waitForFunction(() => !!document.querySelector('.live-page'), null, { timeout: 3000 }).catch(() => {});
|
|
const liveLayout = await page.evaluate(() => {
|
|
const lp = document.querySelector('.live-page');
|
|
if (!lp) return { present: false };
|
|
const r = lp.getBoundingClientRect();
|
|
return {
|
|
present: true,
|
|
bottom: r.bottom,
|
|
innerHeight: window.innerHeight,
|
|
};
|
|
});
|
|
if (!liveLayout.present) {
|
|
fail('(l) .live-page missing on #/live');
|
|
} else if (liveLayout.bottom > liveLayout.innerHeight - 56 + 1) {
|
|
// +1 for sub-pixel rounding tolerance.
|
|
fail(`(l) .live-page bottom (${liveLayout.bottom.toFixed(1)}) > viewport - 56 (${(liveLayout.innerHeight - 56).toFixed(1)}) — bottom-nav covers content`);
|
|
} else {
|
|
pass(`(l) .live-page bottom ${liveLayout.bottom.toFixed(1)} ≤ viewport - 56 (${(liveLayout.innerHeight - 56).toFixed(1)})`);
|
|
}
|
|
|
|
// ── (m) #1174 mesh-op review: bottom-nav has a connectivity indicator that toggles on setConnected(false) ──
|
|
await page.goto(`${BASE}/#/`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForSelector('[data-bottom-nav]', { timeout: 5000 });
|
|
const indicator = await page.evaluate(() => {
|
|
if (!window.__corescopeLogo || typeof window.__corescopeLogo.setConnected !== 'function') {
|
|
return { logoApiPresent: false };
|
|
}
|
|
window.__corescopeLogo.setConnected(true);
|
|
const nav = document.querySelector('[data-bottom-nav]');
|
|
const connectedCls = nav.classList.contains('disconnected');
|
|
window.__corescopeLogo.setConnected(false);
|
|
const disconnectedCls = nav.classList.contains('disconnected');
|
|
// restore
|
|
window.__corescopeLogo.setConnected(true);
|
|
return {
|
|
logoApiPresent: true,
|
|
onConnected: connectedCls,
|
|
onDisconnected: disconnectedCls,
|
|
};
|
|
});
|
|
if (!indicator.logoApiPresent) {
|
|
fail('(m) window.__corescopeLogo.setConnected not exposed');
|
|
} else if (indicator.onConnected === false && indicator.onDisconnected === true) {
|
|
pass('(m) bottom-nav .disconnected class toggles with setConnected()');
|
|
} else {
|
|
fail(`(m) bottom-nav disconnected class wiring broken (onConnected=${indicator.onConnected}, onDisconnected=${indicator.onDisconnected})`);
|
|
}
|
|
|
|
// ── (n) #1174 mesh-op review: More tab gets .active when on long-tail routes ──
|
|
await page.goto(`${BASE}/#/tools`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => location.hash === '#/tools', null, { timeout: 3000 }).catch(() => {});
|
|
let moreActiveOnTools = null;
|
|
try {
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
return el && el.classList.contains('active');
|
|
}, null, { timeout: 2000 });
|
|
moreActiveOnTools = true;
|
|
} catch (_) {
|
|
moreActiveOnTools = await page.evaluate(() => {
|
|
const el = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
return el ? el.classList.contains('active') : null;
|
|
});
|
|
}
|
|
if (moreActiveOnTools === true) pass('(n) More tab .active on #/tools (long-tail route)');
|
|
else fail(`(n) More tab NOT .active on #/tools (got ${moreActiveOnTools})`);
|
|
|
|
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => location.hash === '#/packets', null, { timeout: 3000 }).catch(() => {});
|
|
let moreActiveOnPackets = null;
|
|
try {
|
|
await page.waitForFunction(() => {
|
|
const el = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
return el && !el.classList.contains('active');
|
|
}, null, { timeout: 2000 });
|
|
moreActiveOnPackets = false;
|
|
} catch (_) {
|
|
moreActiveOnPackets = await page.evaluate(() => {
|
|
const el = document.querySelector('[data-bottom-nav-tab="more"]');
|
|
return el ? el.classList.contains('active') : null;
|
|
});
|
|
}
|
|
if (moreActiveOnPackets === false) pass('(n) More tab loses .active on primary route #/packets');
|
|
else fail(`(n) More tab still .active on #/packets (got ${moreActiveOnPackets})`);
|
|
|
|
await browser.close();
|
|
|
|
console.log(`\ntest-bottom-nav-1061-e2e.js: ${passes} passed, ${failures} failed`);
|
|
process.exit(failures > 0 ? 1 : 0);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('test-bottom-nav-1061-e2e.js: FAIL —', err);
|
|
process.exit(1);
|
|
});
|