diff --git a/public/bottom-nav.js b/public/bottom-nav.js index 1aaf7fac..20f05514 100644 --- a/public/bottom-nav.js +++ b/public/bottom-nav.js @@ -81,6 +81,20 @@ { route: 'audio-lab', hash: '#/audio-lab', label: 'Audio Lab', ph: 'music-note' }, ]; + // The Coverage route is opt-in (config `clientRxCoverage`). roles.js injects + // its link into the desktop top-nav only, so it must be added here too or it + // is unreachable on phones. Evaluated at lazy build time (first sheet open), + // by which point MeshConfigReady has resolved window.MC_CLIENT_RX_COVERAGE. + var COVERAGE_ROUTE = { route: 'rx-coverage', hash: '#/rx-coverage', label: 'Coverage', ph: 'broadcast' }; + + function moreRoutes() { + if (!window.MC_CLIENT_RX_COVERAGE) return MORE_ROUTES; + var out = MORE_ROUTES.slice(); + var after = out.findIndex(function (r) { return r.route === 'analytics'; }) + 1; + out.splice(after, 0, COVERAGE_ROUTE); + return out; + } + var SHEET_ID = 'bottomNavMoreSheet'; function currentRoute() { @@ -200,7 +214,7 @@ sheet.setAttribute('aria-label', 'More navigation'); sheet.hidden = true; - MORE_ROUTES.forEach(function (r) { + moreRoutes().forEach(function (r) { var a = document.createElement('a'); a.className = 'bottom-nav-sheet-item'; a.setAttribute('href', r.hash); diff --git a/public/nav-drawer.js b/public/nav-drawer.js index 37820d21..cf8a4bc4 100644 --- a/public/nav-drawer.js +++ b/public/nav-drawer.js @@ -65,6 +65,20 @@ { route: 'audio-lab', hash: '#/audio-lab', label: 'Audio Lab', ph: 'music-note' }, ]; + // Coverage is opt-in (config `clientRxCoverage`). roles.js injects its link + // into the desktop top-nav only, so add it here too or it is unreachable via + // the drawer on touch tablets (>768px). Evaluated at lazy build time, by which + // point MeshConfigReady has resolved window.MC_CLIENT_RX_COVERAGE. + var COVERAGE_ROUTE = { route: 'rx-coverage', hash: '#/rx-coverage', label: 'Coverage', ph: 'broadcast' }; + + function routes() { + if (!window.MC_CLIENT_RX_COVERAGE) return ROUTES; + var out = ROUTES.slice(); + var after = out.findIndex(function (r) { return r.route === 'analytics'; }) + 1; + out.splice(after, 0, COVERAGE_ROUTE); + return out; + } + function phIconHTML(name) { return ''; @@ -126,7 +140,7 @@ var list = document.createElement('nav'); list.className = 'nav-drawer-list'; - ROUTES.forEach(function (r) { + routes().forEach(function (r) { var a = document.createElement('a'); a.className = 'nav-drawer-item'; a.setAttribute('href', r.hash); diff --git a/test-rx-coverage-mobile-nav-e2e.js b/test-rx-coverage-mobile-nav-e2e.js new file mode 100644 index 00000000..cdc40180 --- /dev/null +++ b/test-rx-coverage-mobile-nav-e2e.js @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * Coverage route reachability on mobile navigation. + * + * The "Coverage" route (#/rx-coverage) is opt-in: roles.js injects its link + * into the DESKTOP top-nav (.nav-links) only, when window.MC_CLIENT_RX_COVERAGE + * is true. The two mobile navigation surfaces build their long-tail route lists + * independently: + * - public/bottom-nav.js → the "More" sheet (phones, ≤768px) + * - public/nav-drawer.js → the edge-swipe drawer (tablets, >768px) + * Both used hardcoded arrays that omitted rx-coverage, so when Coverage was + * enabled it was present on desktop but UNREACHABLE on mobile. This test pins + * that, once enabled, Coverage appears in BOTH mobile surfaces, ordered right + * after Analytics (matching the desktop top-nav insertion point). + * + * Skips cleanly when: + * - Chromium is unavailable (unless CHROMIUM_REQUIRE=1 → hard fail), or + * - the deployment under test has clientRxCoverage disabled (default off). + * + * Stable selectors consumed here: + * [data-bottom-nav-tab="more"] — More tab (opens the sheet) + * [data-bottom-nav-more-route="rx-coverage"] — Coverage row in the sheet + * window.__navDrawer.open() — opens the edge-swipe drawer + * [data-nav-drawer-item="rx-coverage"] — Coverage row in the drawer + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +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 () => { + 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-rx-coverage-mobile-nav-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`); + process.exit(1); + } + console.log(`test-rx-coverage-mobile-nav-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`); + process.exit(0); + } + + console.log(`\n=== Coverage mobile-nav reachability E2E against ${BASE} ===`); + + // Coverage is opt-in (config flag, default off). Skip when the deployment + // under test has it disabled — mirrors test-node-reach-coverage-e2e.js. + const probeCtx = await browser.newContext(); + const probe = await probeCtx.newPage(); + let enabled = false; + try { + const cfg = await (await probe.request.get(BASE + '/api/config/client')).json(); + enabled = cfg.clientRxCoverage === true; + } catch (e) { + console.log(`test-rx-coverage-mobile-nav-e2e.js: SKIP (could not read /api/config/client: ${e.message})`); + await browser.close(); + process.exit(0); + } + await probeCtx.close(); + if (!enabled) { + console.log('test-rx-coverage-mobile-nav-e2e.js: SKIP (clientRxCoverage disabled on this deployment)'); + await browser.close(); + process.exit(0); + } + + // ── Phone viewport: bottom-nav "More" sheet ── + const phoneCtx = await browser.newContext({ viewport: { width: 360, height: 800 } }); + const phone = await phoneCtx.newPage(); + phone.setDefaultTimeout(10000); + phone.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + await phone.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await phone.waitForSelector('main#app', { timeout: 8000 }); + // Wait until roles.js has resolved the config flag (the lazy sheet build + // reads window.MC_CLIENT_RX_COVERAGE at open time). + await phone.waitForFunction(() => window.MC_CLIENT_RX_COVERAGE === true, null, { timeout: 8000 }); + + await step('(a) bottom-nav More sheet contains Coverage, ordered after Analytics', async () => { + await phone.click('[data-bottom-nav-tab="more"]'); + await phone.waitForTimeout(150); + const info = await phone.evaluate(() => { + const cov = document.querySelector('[data-bottom-nav-more-route="rx-coverage"]'); + if (!cov) return { present: false }; + const items = Array.prototype.map.call( + document.querySelectorAll('[data-bottom-nav-more-route]'), + (a) => a.getAttribute('data-bottom-nav-more-route') + ); + const label = (cov.textContent || '').trim(); + return { + present: true, + label, + afterAnalytics: items.indexOf('rx-coverage') === items.indexOf('analytics') + 1, + items, + }; + }); + assert(info.present, 'Coverage row missing from bottom-nav More sheet'); + assert(/Coverage/.test(info.label), `expected label "Coverage", got "${info.label}"`); + assert(info.afterAnalytics, `Coverage not directly after Analytics: ${info.items.join(', ')}`); + }); + + await step('(b) tapping Coverage in the sheet navigates to #/rx-coverage', async () => { + await phone.click('[data-bottom-nav-more-route="rx-coverage"]'); + await phone.waitForTimeout(200); + const hash = await phone.evaluate(() => location.hash); + assert(hash.indexOf('#/rx-coverage') === 0, `expected hash #/rx-coverage, got ${hash}`); + }); + + await phoneCtx.close(); + + // ── Tablet viewport: edge-swipe drawer (enabled > 768px) ── + const tabletCtx = await browser.newContext({ viewport: { width: 1024, height: 800 } }); + const tablet = await tabletCtx.newPage(); + tablet.setDefaultTimeout(10000); + tablet.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + await tablet.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' }); + await tablet.waitForSelector('main#app', { timeout: 8000 }); + await tablet.waitForFunction(() => window.MC_CLIENT_RX_COVERAGE === true, null, { timeout: 8000 }); + + await step('(c) edge-swipe drawer contains Coverage, ordered after Analytics', async () => { + await tablet.evaluate(() => window.__navDrawer && window.__navDrawer.open && window.__navDrawer.open()); + await tablet.waitForTimeout(150); + const info = await tablet.evaluate(() => { + const cov = document.querySelector('[data-nav-drawer-item="rx-coverage"]'); + if (!cov) return { present: false }; + const items = Array.prototype.map.call( + document.querySelectorAll('[data-nav-drawer-item]'), + (a) => a.getAttribute('data-nav-drawer-item') + ); + return { + present: true, + label: (cov.textContent || '').trim(), + afterAnalytics: items.indexOf('rx-coverage') === items.indexOf('analytics') + 1, + items, + }; + }); + assert(info.present, 'Coverage row missing from edge-swipe drawer'); + assert(/Coverage/.test(info.label), `expected label "Coverage", got "${info.label}"`); + assert(info.afterAnalytics, `Coverage not directly after Analytics: ${info.items.join(', ')}`); + }); + + await tabletCtx.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); });