Files
meshcore-analyzer/tests/e2e/test-issue-1648-m4-icons-e2e.js
T
n30nex ded27a8312 fix(packets): stabilize fixture tests and preserve mobile empty states (#2080)
Fixes #2054 and #2079.

Packet-row tests could outlive the fixture's default 15-minute window.
An audit of 115 wired suites identified four needing explicit fixture
windows: 180 minutes on mobile, 1440 on desktop. Mobile rejects larger
saved windows, so a desktop-only preference was insufficient.
Packet-icon checks now require a real path-bearing packet and both
detail controls.

Default-window coverage exposed #2079: responsive column hiding also hid
the spanning empty-message cell. Skip structural single-cell spanning
rows when hiding individual columns, preserving status messages and
virtual-scroll spacer height. Ordinary and partial-span data cells
retain their behavior; application defaults stay unchanged.

- Red evidence: `bae5e78` fails the aged-fixture row assertion;
`9b75cf6` fails spanning-row visibility. Guards/setup make them pass.
- Validation: 183 standalone suites; 82 targeted browser cases on a
fixture aged over 30 minutes (one pre-existing node-gesture skip).
Broader browser run: 131 passed, 3 fixture-dependent skips.
- Browser verified: fixture-backed Go server and Chromium;
`coverage/2079-mobile-empty.png`. Real mobile scrolling retains spacer
height and has no horizontal overflow.
- E2E assertions: `test-observer-iata-1188-e2e.js` default-window cases
and `test-table-fluid-e2e.js:58` shared-helper regression, both under
`tests/e2e/`.
- Three independent reviews completed; packet-icon coverage feedback
addressed in `760057d`.
- Populated mobile axe coverage remains part of #1995.
- One constant-time guard per already-visited row; no new requests or
collections.

## Preflight overrides

- External `run-all.sh` unavailable; repository checks run directly.
Local runs use UTF-8, the release harness's `GITHUB_REF_NAME`, and a 60s
navigation budget; repository timeouts are unchanged.
2026-09-30 11:40:36 +02:00

227 lines
11 KiB
JavaScript

#!/usr/bin/env node
/* Issue #1648 — M4: emoji → Phosphor sprite migration (E2E behavioral).
*
* Asserts (in a real Chromium against a running server):
* (a) /map — Path Inspector pane toggle renders a Phosphor sprite (no
* Misc-Symbols caret), and overall map page is icon-free.
* (b) /map?packet=<hash>&obs=<id> (multi-path CHAN fixture) — route
* overlay sidebar collapse button renders Phosphor caret.
* (c) /packets — replay button renders Phosphor play sprite (no ▶ char).
* (d) /analytics — distance map-jump buttons render ph-map-trifold and
* the Network Overview chevron renders a sprite.
* (e) /area-map standalone — clear/undo buttons render sprites.
* (f) NO .notdef anywhere — every <use> resolves to a defined symbol id.
*
* CI gating: CHROMIUM_REQUIRE=1 makes Chromium-launch failure a HARD FAIL.
*/
'use strict';
const { chromium } = require('playwright');
const assert = require('assert');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
// Forbidden Misc-Symbols icon chars in rendered M4 surfaces:
const M4_FORBIDDEN_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}◆●■▲★☆○✓✗⚠✉✕▶◀▾⚑]/u;
let passes = 0, failures = 0;
function pass(msg) { console.log(` ✓ ${msg}`); passes++; }
function fail(msg) { console.error(` ✗ ${msg}`); failures++; }
async function gotoSpa(page, route) {
await page.goto(`${BASE}/#${route}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => !!document.querySelector('#app'), null, { timeout: 8000 }).catch(() => {});
await page.waitForTimeout(700);
}
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-issue-1648-m4-icons-e2e.js: HARD FAIL — Chromium unavailable: ${err.message}`);
process.exit(1);
}
console.warn(`SKIP — Chromium unavailable: ${err.message}`);
process.exit(0);
}
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
// #2054: packet-detail icons need fixture rows even after the CI job passes 15 minutes.
await page.addInitScript(() => localStorage.setItem('meshcore-time-window', '1440'));
// (a) /map — pane toggle + general sprite presence
await gotoSpa(page, '/map');
const mapState = await page.evaluate(() => {
const toggle = document.getElementById('mapPaneToggle');
return {
paneToggleText: toggle ? toggle.textContent : null,
paneToggleSprites: toggle ? toggle.querySelectorAll('svg.ph-icon use').length : 0,
allSprites: document.querySelectorAll('svg.ph-icon use').length,
bodyText: (document.getElementById('app') || document.body).textContent || '',
};
});
if (mapState.paneToggleSprites === 0) fail('(a) /map: pane toggle has no Phosphor sprite');
else pass(`(a) /map: pane toggle has ${mapState.paneToggleSprites} sprite(s)`);
if (mapState.paneToggleText && /[▶◀]/.test(mapState.paneToggleText)) {
fail(`(a) /map: pane toggle still contains ▶/◀ text (got ${JSON.stringify(mapState.paneToggleText)})`);
} else {
pass('(a) /map: pane toggle text has no Misc-Symbols carets');
}
if (mapState.allSprites < 3) fail(`(a) /map: only ${mapState.allSprites} sprite refs total (expected ≥3)`);
else pass(`(a) /map: ${mapState.allSprites} sprite refs on page`);
// (b) /map?packet=<hash>&obs=<id> — multi-path CHAN fixture renders
// route overlay sidebar with a collapse button. We don't require the
// fixture to exist in staging — only assert the sprite scheme if the
// sidebar shows up.
await gotoSpa(page, '/map?packet=305b678c9394b964&obs=10591318');
await page.waitForTimeout(1200);
const route = await page.evaluate(() => {
const sidebar = document.querySelector('.mc-rt-sidebar, [class*="mc-rt-"]');
const collapseBtn = document.querySelector('.mc-rt-collapse-btn');
return {
sidebarPresent: !!sidebar,
collapseSprites: collapseBtn ? collapseBtn.querySelectorAll('svg.ph-icon use').length : 0,
collapseText: collapseBtn ? collapseBtn.textContent : '',
mapSprites: document.querySelectorAll('svg.ph-icon use').length,
};
});
if (route.sidebarPresent && route.collapseSprites === 0) {
fail('(b) /map?packet=…: route collapse button has no sprite');
} else if (route.sidebarPresent) {
pass(`(b) /map?packet=…: route collapse button has ${route.collapseSprites} sprite(s)`);
} else {
console.warn(' ⚠ (b) /map?packet=…: route sidebar not present (fixture may be missing in this env)');
}
if (route.collapseText && /[▶◀]/.test(route.collapseText)) {
fail('(b) /map?packet=…: collapse btn still has ▶/◀ char');
}
// (c) /packets — replay button renders Phosphor play sprite
await gotoSpa(page, '/packets');
await page.waitForSelector('#pktLeft[data-loaded="true"]', { state: 'attached' });
assert(await page.locator('#pktBody tr[data-hash]').count() > 0,
'(c) packet detail icons require a real packet row, not the empty-state row');
// View route is conditional on nonempty hops. Choose a single-observation
// row so the path shown in the table is also the path opened in detail.
const pathRow = page.locator('#pktBody tr[data-action="select-hash"]:has(.path-hops .hop)').first();
assert(await pathRow.count() > 0, '(c) fixture requires a packet with path hops');
await pathRow.locator('td.col-time').click();
await page.locator('#pktRight .replay-live-btn').waitFor();
await page.locator('#pktRight #viewRouteBtn').waitFor();
const packets = await page.evaluate(() => {
const replay = document.querySelector('.replay-live-btn');
const viewRoute = document.querySelector('#viewRouteBtn');
return {
replaySprite: replay ? replay.querySelectorAll('svg.ph-icon use').length : null,
replayText: replay ? replay.textContent : null,
viewRouteSprites: viewRoute ? viewRoute.querySelectorAll('svg.ph-icon use').length : null,
pageSprites: document.querySelectorAll('svg.ph-icon use').length,
};
});
if (!(packets.replaySprite > 0)) {
fail('(c) /packets: replay button missing or has no sprite');
} else {
pass(`(c) /packets: replay button has ${packets.replaySprite} sprite(s)`);
}
if (packets.replayText && /[▶◀⏸]/.test(packets.replayText)) {
fail(`(c) /packets: replay button still has play/pause char (${JSON.stringify(packets.replayText)})`);
}
if (!(packets.viewRouteSprites > 0)) fail('(c) /packets: View-route button missing or has no sprite');
else pass('(c) /packets: View-route button has sprite');
if (packets.pageSprites < 5) fail(`(c) /packets: only ${packets.pageSprites} sprite refs (expected ≥5)`);
else pass(`(c) /packets: ${packets.pageSprites} sprite refs on page`);
// (d) /analytics — Network Overview chevron + distance map-jump buttons
await gotoSpa(page, '/analytics?tab=prefix-tool');
await page.waitForTimeout(1500);
const analytics = await page.evaluate(() => {
const chev = document.getElementById('ptOverviewChevron');
return {
chevText: chev ? chev.textContent : null,
chevSprites: chev ? chev.querySelectorAll('svg.ph-icon use').length : 0,
pageSprites: document.querySelectorAll('svg.ph-icon use').length,
};
});
if (analytics.chevSprites === 0) {
fail('(d) /analytics: Network Overview chevron has no sprite');
} else {
pass(`(d) /analytics: Network Overview chevron has ${analytics.chevSprites} sprite(s)`);
}
if (analytics.chevText && /[▶◀]/.test(analytics.chevText)) {
fail('(d) /analytics: chevron still contains ▶ text');
}
if (analytics.pageSprites < 10) fail(`(d) /analytics: only ${analytics.pageSprites} sprite refs`);
else pass(`(d) /analytics: ${analytics.pageSprites} sprite refs on page`);
// Also check distance tab if reachable
await gotoSpa(page, '/analytics?tab=distance');
await page.waitForTimeout(2000);
const dist = await page.evaluate(() => {
const btn = document.querySelector('.dist-map-hop, .dist-map-path');
return {
mapBtnSprites: btn ? btn.querySelectorAll('svg.ph-icon use').length : null,
};
});
if (dist.mapBtnSprites === 0) fail('(d) /analytics distance: map-jump button missing sprite');
else if (dist.mapBtnSprites > 0) pass('(d) /analytics distance: map-jump button has sprite');
else console.warn(' ⚠ (d) /analytics distance: no map-jump button rendered (empty dataset)');
// (e) /area-map (standalone HTML)
await page.goto(`${BASE}/area-map.html`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
const area = await page.evaluate(() => {
const clear = document.getElementById('btn-clear-draw');
const undo = document.getElementById('btn-undo');
return {
clearSprites: clear ? clear.querySelectorAll('svg.ph-icon use').length : 0,
clearText: clear ? clear.textContent : '',
undoSprites: undo ? undo.querySelectorAll('svg.ph-icon use').length : 0,
};
});
if (area.clearSprites === 0) fail('(e) /area-map: clear button has no sprite');
else pass(`(e) /area-map: clear button has ${area.clearSprites} sprite(s)`);
if (area.undoSprites === 0) fail('(e) /area-map: undo button has no sprite');
else pass(`(e) /area-map: undo button has ${area.undoSprites} sprite(s)`);
if (/[✕↩]/.test(area.clearText)) fail('(e) /area-map: clear button still has ✕ char');
// (f) No unresolved sprite refs anywhere we've visited
await gotoSpa(page, '/map');
const undef = await page.evaluate(async () => {
const resp = await fetch('/icons/phosphor-sprite.svg').catch(() => null);
if (!resp || !resp.ok) return { error: 'sprite fetch failed' };
const text = await resp.text();
const ids = new Set();
for (const m of text.matchAll(/id="(ph-[a-z-]+)"/g)) ids.add(m[1]);
const uses = Array.from(document.querySelectorAll('svg.ph-icon use'));
const missing = [];
for (const u of uses) {
const href = u.getAttribute('href') || u.getAttribute('xlink:href') || '';
const m = href.match(/#(ph-[a-z-]+)/);
if (!m) { missing.push(href); continue; }
if (!ids.has(m[1])) missing.push(m[1]);
}
return { count: uses.length, ids: ids.size, missing };
});
if (undef.error) fail(`(f) sprite fetch: ${undef.error}`);
else if (undef.missing && undef.missing.length) fail(`(f) ${undef.missing.length} sprite ref(s) unresolved: ${undef.missing.slice(0,5).join(', ')}`);
else pass(`(f) all ${undef.count} sprite refs resolve to one of ${undef.ids} defined symbols`);
await browser.close();
console.log(`\ntest-issue-1648-m4-icons-e2e.js: ${passes} passed, ${failures} failed`);
assert.strictEqual(failures, 0, `${failures} M4 icon-render assertions failed`);
process.exit(0);
}
main().catch((err) => {
console.error('test-issue-1648-m4-icons-e2e.js: FAIL —', err);
process.exit(1);
});