Files
meshcore-analyzer/test-bottom-nav-1061-e2e.js
Kpa-clawbot 9d1f5d2395 fix(#1061): bottom navigation for narrow viewports (#1174)
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>
2026-05-09 11:00:46 -07:00

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);
});