Files
meshcore-analyzer/tests/e2e/test-issue-1128-multi-viewport-e2e.js
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

305 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* E2E (#1128 final): Multi-viewport layout collision + z-scale enforcement.
*
* Sister of test-issue-1128-packets-layout-e2e.js. That file asserts
* individual component properties; this one closes the original
* acceptance criterion: SCREENSHOTS at multiple viewports + bounding-rect
* collision detection on visible interactive elements.
*
* What this test asserts (on top of the sibling):
*
* A. At each of three viewports (1280×900, 1080×800, 768×1024), no
* two `.filter-group` siblings vertically overlap. (Bug 3
* regression guard — row-gap must be large enough for 34px
* controls when the bar wraps.)
*
* B. (Removed during self-review — was conceptually flawed; see the
* block comment inside the per-viewport loop. Z-scale band check
* C and the check-css-vars lint together gate the actual Bug 4
* regression — dropdowns rendering transparent or behind rows.)
*
* C. Z-scale enforcement (audit Section 2): every dropdown selector
* (`.col-toggle-menu`, `.multi-select-menu`,
* `.region-dropdown-menu`, `.fux-saved-menu`,
* `.fux-ac-dropdown`) must compute a z-index inside the
* `--z-dropdown` band [100,199] — NOT 50, 90, or 9200.
*
* D. Path chip line-height lock: `.path-hops .hop, .path-hops
* .hop-named, .path-hops .arrow` must all compute line-height
* ≤ 18px so chips never spill the 22px host (Bug 1 belt-
* and-suspenders, audit Section 3).
*
* E. `.col-path` must use `height: 28px`, not `max-height` (table
* cells ignore max-height per audit).
*
* F. Screenshots saved under e2e-screenshots/ for the PR record.
*
* Usage: BASE_URL=http://localhost:13581 node \
* test-issue-1128-multi-viewport-e2e.js
*/
'use strict';
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const SHOT_DIR = 'e2e-screenshots';
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'); }
const VIEWPORTS = [
{ w: 1280, h: 900, name: 'desktop-1280' },
{ w: 1080, h: 800, name: 'laptop-1080' },
{ w: 768, h: 1024, name: 'tablet-768' },
];
function vOverlap(a, b) {
// Reject sub-pixel rounding noise.
if (a.bottom <= b.top + 1) return false;
if (b.bottom <= a.top + 1) return false;
return true;
}
async function gotoPackets(page) {
await page.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#packetFilterInput', { timeout: 8000 });
await page.waitForFunction(() => !!document.querySelector('#filterUxBar'), { timeout: 8000 });
await page.evaluate(() => {
const sel = document.getElementById('fTimeWindow');
if (sel) { sel.value = '0'; sel.dispatchEvent(new Event('change', { bubbles: true })); }
});
await page.waitForFunction(
() => Array.from(document.querySelectorAll('#pktBody tr'))
.filter(r => r.id !== 'vscroll-top' && r.id !== 'vscroll-bottom').length > 0,
{ timeout: 8000 });
await page.waitForTimeout(400); // hop-resolver
}
(async () => {
if (!fs.existsSync(SHOT_DIR)) fs.mkdirSync(SHOT_DIR, { recursive: true });
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
});
console.log(`\n=== #1128 multi-viewport E2E against ${BASE} ===`);
for (const vp of VIEWPORTS) {
const ctx = await browser.newContext({ viewport: { width: vp.w, height: vp.h } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
await step(`[${vp.name}] navigate to /packets`, async () => {
await gotoPackets(page);
});
await step(`[${vp.name}] screenshot toolbar`, async () => {
await page.screenshot({
path: path.join(SHOT_DIR, `issue-1128-${vp.name}.png`),
fullPage: false,
});
});
await step(`[${vp.name}] no two .filter-group siblings vertically overlap`, async () => {
const result = await page.evaluate(() => {
const groups = Array.from(document.querySelectorAll('.filter-bar > .filter-group'))
.filter(el => {
const cs = getComputedStyle(el);
return cs.display !== 'none' && cs.visibility !== 'hidden';
});
const rects = groups.map(g => {
const r = g.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right, w: r.width, h: r.height };
});
// Pairs that share x-overlap (same row attempt) but vertical overlap = bug.
const offenders = [];
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const a = rects[i], b = rects[j];
const xOverlap = !(a.right <= b.left + 1 || b.right <= a.left + 1);
const yOverlap = !(a.bottom <= b.top + 1 || b.bottom <= a.top + 1);
if (xOverlap && yOverlap) offenders.push({ i, j, a, b });
}
}
return { count: groups.length, offenders };
});
assert(result.count > 0, 'no .filter-group elements found');
assert(result.offenders.length === 0,
`${result.offenders.length} .filter-group sibling pairs overlap: ` +
JSON.stringify(result.offenders[0]));
});
// #1128 Bug 5 — toolbar reorder gate: toggles MUST appear before dropdowns
// in document order. packets.js was reordered so the most-frequently-used
// toggles (Group/My Nodes/time) sit next to the search input. A revert
// would reintroduce the original eye-trail problem; this assertion fails
// if the order is swapped back.
await step(`[${vp.name}] Bug 5: filter-group-toggles precedes filter-group-dropdowns`, async () => {
const order = await page.evaluate(() => {
const groups = Array.from(document.querySelectorAll('.filter-bar .filter-group'));
const togIdx = groups.findIndex(g => g.classList.contains('filter-group-toggles'));
const dropIdx = groups.findIndex(g => g.classList.contains('filter-group-dropdowns'));
return { togIdx, dropIdx, total: groups.length };
});
assert(order.togIdx >= 0, 'no .filter-group-toggles found in toolbar');
assert(order.dropIdx >= 0, 'no .filter-group-dropdowns found in toolbar');
assert(order.togIdx < order.dropIdx,
`toggles (idx ${order.togIdx}) must precede dropdowns (idx ${order.dropIdx})`);
});
// NOTE on removed sub-tests (#1128 self-review): earlier drafts had
// "[vp] Saved menu does not overlap toolbar groups below it" and
// "[vp] Types multi-select dropdown does not overlap toolbar groups
// below it". Those sub-tests had a fundamentally wrong premise — a
// position:absolute dropdown opened from a wrapped toolbar row will
// ALWAYS overlap toolbar rows below it; that's by design. What
// matters for #1128 Bug 4 is that the dropdown (a) paints on top
// (z-index) and (b) is opaque (no transparent --surface). Both are
// already gated independently:
// - z-index band: "Z-scale: dropdown selectors compute z-index in
// [100,199] band" (below)
// - opacity / undefined vars: scripts/check-css-vars.js (CI lint,
// wired in deploy.yml)
// Reintroducing a "no rect overlap" assertion would require pinning
// the toolbar to a single non-wrapping row, which contradicts the
// responsive design the rest of this file exercises.
void vp;
await ctx.close();
}
// Single z-scale + line-height + col-path checks are viewport-agnostic.
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
page.setDefaultTimeout(8000);
await gotoPackets(page);
await step('Z-scale: dropdown selectors compute z-index in [100,199] band', async () => {
const checks = await page.evaluate(() => {
// We can't always force every menu to render, so we test the
// computed z-index by inserting throwaway DOM nodes that match the
// selector and reading getComputedStyle. The browser applies the
// CSS rule by selector, regardless of whether the menu is the
// "real" one — what matters is the rule's declared z-index.
const selectors = [
'.col-toggle-menu',
'.multi-select-menu',
'.region-dropdown-menu',
'.fux-saved-menu',
'.fux-ac-dropdown',
];
const results = [];
for (const sel of selectors) {
const el = document.createElement('div');
// Strip leading dot, attach class.
el.className = sel.replace(/^\./, '');
// Force visible so getComputedStyle returns the matched rule.
el.style.position = 'absolute';
el.style.top = '-9999px';
el.style.display = 'block';
document.body.appendChild(el);
const z = parseInt(getComputedStyle(el).zIndex, 10);
results.push({ sel, z: isNaN(z) ? null : z });
el.remove();
}
return results;
});
const offenders = checks.filter(c => c.z === null || c.z < 100 || c.z > 199);
assert(offenders.length === 0,
'dropdown z-index outside [100,199] band: ' + JSON.stringify(offenders));
});
await step('Bug 1 polish: .path-hops chip children compute line-height ≤ 18px', async () => {
const offenders = await page.evaluate(() => {
const probe = (cls) => {
const host = document.createElement('div');
host.className = 'path-hops';
const child = document.createElement('span');
child.className = cls;
child.textContent = 'x';
host.appendChild(child);
document.body.appendChild(host);
const lh = parseFloat(getComputedStyle(child).lineHeight);
host.remove();
return { cls, lh };
};
const probed = ['hop', 'hop-named', 'arrow'].map(probe);
return probed.filter(p => !(p.lh <= 18.5));
});
assert(offenders.length === 0,
'.path-hops chip line-height > 18px: ' + JSON.stringify(offenders));
});
await step('Bug 1 polish: .col-path uses fixed height (not max-height) ≤ 28px', async () => {
const result = await page.evaluate(() => {
// Build a fake table cell to read the computed rule.
const tbl = document.createElement('table');
tbl.className = 'data-table';
const tr = document.createElement('tr');
const td = document.createElement('td');
td.className = 'col-path';
tr.appendChild(td);
const tbody = document.createElement('tbody');
tbody.appendChild(tr);
tbl.appendChild(tbody);
tbl.style.position = 'absolute';
tbl.style.top = '-9999px';
document.body.appendChild(tbl);
const cs = getComputedStyle(td);
const height = parseFloat(cs.height);
const maxHeight = cs.maxHeight;
tbl.remove();
// Walk the loaded stylesheets to find the rule(s) that target
// .data-table td.col-path and report which physical property
// (height vs max-height) is declared. The audit fix REQUIRES
// `height: 28px`; `max-height: 28px` is the original Bug 1
// regression and must fail this test.
const declared = { height: null, maxHeight: null, ruleSrc: null };
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch (e) { continue; }
if (!rules) continue;
for (const rule of rules) {
if (!rule.selectorText) continue;
// Match selectors that include both .data-table and .col-path
// (allow combinator variations like ".data-table td.col-path").
if (/\.data-table[\s\S]*\.col-path/.test(rule.selectorText)) {
const h = rule.style && rule.style.getPropertyValue('height');
const mh = rule.style && rule.style.getPropertyValue('max-height');
if (h) declared.height = h.trim();
if (mh) declared.maxHeight = mh.trim();
if (h || mh) declared.ruleSrc = rule.selectorText;
}
}
}
return { height, maxHeight, declared };
});
// Computed height must be exactly 28px.
assert(Math.abs(result.height - 28) < 0.5,
'.col-path computed height not 28px: ' + JSON.stringify(result));
// Audit fix gate: rule must declare `height: 28px`. If the regression
// is reverted to `max-height: 28px`, declared.height is null and this
// assertion fails.
assert(result.declared.height && /^28px$/.test(result.declared.height),
'.col-path rule must declare `height: 28px` (audit fix), got: ' +
JSON.stringify(result.declared));
assert(!result.declared.maxHeight,
'.col-path rule must NOT declare max-height (original Bug 1 regression): ' +
JSON.stringify(result.declared));
});
await ctx.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); });