Files
meshcore-analyzer/tests/e2e/test-nav-priority-1102-e2e.js
T
Alex B 893773338e chore(tests): move root test-*.js into tests/unit and tests/e2e (#2036)
Moves 290 root test-*.js into tests/unit (177, listed in test-all.sh) and tests/e2e (113, classified in scripts/non-unit-tests.json), per #1981 and PR-D of #1385. Root goes from 348 entries to 48. test-all.sh and test-fixtures/ stay put. The inventory guard now fails if a test reappears in the root or sits in the wrong folder.

Verified independently of the diff: the invoked sets are unchanged (test-all.sh 177 before and after, deploy.yml 96 before and after, both identical as sets), and a full local run of test-all.sh on master and on the branch produced 4702 output lines each whose only differences are absolute paths, stack-trace line numbers shifted by the REPO_ROOT line, the inventory wording and two perf ratios. The guard was mutation-checked: a test back in the root, a unit suite in tests/e2e, and a suite dropped from test-all.sh each make it exit 1. CI run 35246304316 ran 97 suites from tests/e2e and is green.

Follow-up 9335c51d finished the instruction files: no bare root test command is left in AGENTS.md, the squad charters, .github or docs, and every tests/ path they name resolves.

Merged by the interim maintainer without a second human reviewer: CI and the local runs above are the independent checks.

Known and deliberately out of scope: 18 of the 113 files in tests/e2e are invoked by no runner at all, and one of them cannot run anywhere because it requires jsdom, which is not a declared dependency. Tracked separately.
2026-09-17 19:06:07 +02:00

222 lines
10 KiB
JavaScript

#!/usr/bin/env node
/* Issue #1102 — Nav Priority+ at very wide widths and "More" menu correctness.
*
* Regression from PR #1097 polish: at all widths >=768px the CSS rule
* .nav-links a:not([data-priority="high"]) { display: none; }
* unconditionally hides 6 of 11 links, even at 2560px where there is
* plenty of room for everything. The "More" menu is built once on load
* from the same selector, so it correctly shows the hidden links — but
* the bug here is the SET being hidden is wrong (way too aggressive).
*
* Acceptance:
* - At 2560px: ALL 11 links visible inline AND "More ▾" hidden.
* - At 1920px: at least 9 links visible (room for most).
* - At 1080px: 5 high-priority links visible AND More menu contains
* every link not currently visible inline.
* - At 768px (just above hamburger threshold): 5 high-priority links
* visible AND More menu non-empty.
*
* #1105 MINOR 7: at 1080/800px we now assert the visible set is *exactly*
* the 5 high-priority links (Home/Packets/Map/Live/Nodes). A buggy queue
* that hid Home and showed Lab would still pass the cardinality check.
*
* #1105 MINOR 9: also asserts that navigating to a route whose link
* lives in the More menu lights up #navMoreBtn with .active.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// [width, expected behavior]
// requireExactHighPri: when true, asserts the visible set matches HIGH_PRIORITY_HREFS exactly
const HIGH_PRIORITY_HREFS = ['#/home', '#/packets', '#/map', '#/live', '#/nodes'];
const CASES = [
// viewport, minVisible, moreVisible, requireExactHighPri, label
{ w: 2560, minVisible: 11, moreVisible: false, requireExactHighPri: false, label: '2560px — all visible' },
{ w: 1920, minVisible: 9, moreVisible: null, requireExactHighPri: false, label: '1920px — most visible' },
{ w: 1080, minVisible: 5, moreVisible: true, requireExactHighPri: true, label: '1080px — collapsed' },
{ w: 800, minVisible: 5, moreVisible: true, requireExactHighPri: true, label: '800px — collapsed' },
];
const HEIGHT = 900;
async function main() {
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 (process.env.CHROMIUM_REQUIRE === '1') {
console.error(`test-nav-priority-1102-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-nav-priority-1102-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0;
let passes = 0;
const ctx = await browser.newContext();
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
for (const c of CASES) {
await page.setViewportSize({ width: c.w, height: HEIGHT });
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.top-nav .nav-links');
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
// Settle layout (two consecutive frames identical for nav-right).
await page.waitForFunction(() => {
const el = document.querySelector('.top-nav .nav-right');
if (!el) return false;
const r1 = el.getBoundingClientRect();
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
const r2 = el.getBoundingClientRect();
resolve(r1.right === r2.right && r1.left === r2.left);
}));
});
}, null, { timeout: 5000 });
const data = await page.evaluate(() => {
const links = Array.from(document.querySelectorAll('.nav-links .nav-link'));
const visible = links.filter(a => getComputedStyle(a).display !== 'none');
const visibleHrefs = visible.map(a => a.getAttribute('href'));
const allHrefs = links.map(a => a.getAttribute('href'));
const hiddenInline = allHrefs.filter(h => !visibleHrefs.includes(h));
const moreWrap = document.querySelector('.nav-more-wrap');
const moreVisible = moreWrap ? getComputedStyle(moreWrap).display !== 'none' : false;
const moreMenuLinks = Array.from(document.querySelectorAll('#navMoreMenu .nav-link'))
.map(a => a.getAttribute('href'));
return { totalLinks: links.length, visibleCount: visible.length,
visibleHrefs, hiddenInline, moreVisible, moreMenuLinks };
});
const reasons = [];
if (data.visibleCount < c.minVisible) {
reasons.push(`only ${data.visibleCount}/${data.totalLinks} links visible (need >=${c.minVisible})`);
}
if (c.moreVisible === true && !data.moreVisible) {
reasons.push(`"More" button should be visible but is hidden`);
}
if (c.moreVisible === false && data.moreVisible) {
reasons.push(`"More" button should be HIDDEN at ${c.w}px (all links fit) but is visible`);
}
// More menu MUST contain every link not currently visible inline.
if (data.moreVisible) {
const missing = data.hiddenInline.filter(h => !data.moreMenuLinks.includes(h));
if (missing.length) {
reasons.push(`More menu missing hidden links: ${missing.join(', ')} ` +
`(menu has ${data.moreMenuLinks.length}, expected ${data.hiddenInline.length})`);
}
}
// #1105 MINOR 7: identity, not just cardinality. The 5 visible links
// at the collapsed widths must be EXACTLY the high-priority set
// (Home/Packets/Map/Live/Nodes). A buggy queue that hid Home and
// showed Lab would still pass `visibleCount >= 5`.
if (c.requireExactHighPri) {
const missingHighPri = HIGH_PRIORITY_HREFS.filter(h => !data.visibleHrefs.includes(h));
if (missingHighPri.length) {
reasons.push(`high-priority link(s) NOT visible inline: ${missingHighPri.join(', ')} ` +
`(visible=[${data.visibleHrefs.join(', ')}])`);
}
const extra = data.visibleHrefs.filter(h => !HIGH_PRIORITY_HREFS.includes(h));
if (extra.length) {
reasons.push(`unexpected non-high-priority link(s) visible: ${extra.join(', ')} ` +
`(expected exactly [${HIGH_PRIORITY_HREFS.join(', ')}])`);
}
}
const tag = c.label;
if (reasons.length === 0) {
passes++;
console.log(` ✅ ${tag}: visible=${data.visibleCount}/${data.totalLinks} more=${data.moreVisible} menu=${data.moreMenuLinks.length}`);
} else {
failures++;
console.log(` ❌ ${tag}: ${reasons.join(' | ')} ` +
`(visible=${data.visibleCount}/${data.totalLinks} more=${data.moreVisible} menu=${data.moreMenuLinks.length})`);
}
}
// #1105 MINOR 9 (updated by #1391): the active-route pill is now
// PINNED inline at any viewport ≥768px — even if it is not a
// data-priority="high" link. So when we navigate to /#/observers
// (non-high) at 1080px, the observers link MUST stay inline and the
// More menu MUST NOT contain it. The navMoreBtn .active mirror only
// fires when the active route is actually in the dropdown — under
// #1391 that can no longer happen at any width ≥768px, so this test
// verifies the inverse contract.
await page.setViewportSize({ width: 1080, height: HEIGHT });
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('.top-nav .nav-links');
await page.evaluate(() => document.fonts && document.fonts.ready ? document.fonts.ready : null);
// Wait for layout to settle.
await page.waitForFunction(() => {
const el = document.querySelector('.top-nav .nav-right');
if (!el) return false;
const r1 = el.getBoundingClientRect();
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => {
const r2 = el.getBoundingClientRect();
resolve(r1.right === r2.right && r1.left === r2.left);
}));
});
}, null, { timeout: 5000 });
// Give the hashchange-triggered applyNavPriority a frame to run.
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
const activeMirror = await page.evaluate(() => {
const observersInline = document.querySelector('.nav-links .nav-link[href="#/observers"]');
const inlineHidden = observersInline && observersInline.classList.contains('is-overflow');
const inlineActive = observersInline && observersInline.classList.contains('active');
const inlineWidth = observersInline ? observersInline.getBoundingClientRect().width : 0;
const moreBtn = document.getElementById('navMoreBtn');
const moreBtnActive = moreBtn ? moreBtn.classList.contains('active') : false;
const moreMenuHrefs = Array.from(document.querySelectorAll('#navMoreMenu .nav-link'))
.map(a => a.getAttribute('href'));
return { inlineHidden, inlineActive, inlineWidth, moreBtnActive, moreMenuHrefs };
});
const mirrorReasons = [];
// #1391: active link MUST stay inline (not overflowed).
if (activeMirror.inlineHidden) {
mirrorReasons.push('#1391 contract: #/observers is active route — MUST stay inline at 1080px, not in More');
}
if (!activeMirror.inlineActive) {
mirrorReasons.push('inline #/observers link missing .active class');
}
if (activeMirror.inlineWidth === 0) {
mirrorReasons.push('inline #/observers has zero width (clipped)');
}
// #1391: navMoreBtn should NOT have .active because the active link
// is inline, not in the dropdown.
if (activeMirror.moreBtnActive) {
mirrorReasons.push('navMoreBtn has .active but active route #/observers is inline (mirror should be off)');
}
// #1391: More menu must NOT contain the active link.
if (activeMirror.moreMenuHrefs.includes('#/observers')) {
mirrorReasons.push(`More menu contains active route #/observers (must be inline only): menu=[${activeMirror.moreMenuHrefs.join(', ')}]`);
}
if (mirrorReasons.length === 0) {
passes++;
console.log(` ✅ active-pinned @1080 #/observers: inline + .active set, More mirror off, menu excludes active`);
} else {
failures++;
console.log(` ❌ active-pinned @1080 #/observers: ${mirrorReasons.join(' | ')}`);
}
await browser.close();
console.log(`\ntest-nav-priority-1102-e2e.js: ${failures === 0 ? 'OK' : 'FAIL'} — ${passes}/${CASES.length + 1} passed`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((err) => {
console.error('test-nav-priority-1102-e2e.js: fatal', err);
process.exit(1);
});