mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-13 00:24:46 +00:00
test(#1064): RED — E2E for edge-swipe nav drawer
Adds test-nav-drawer-1064-e2e.js covering: (a) edge-swipe at x=10→200 opens drawer flush at left:0 (b) drawer items mirror PR #1174 long-tail routes (c) tap drawer item navigates + closes (d) Esc closes (e) backdrop click closes (f) narrow viewport (≤768px) does NOT open drawer (Option A) (g) singleton: 5 SPA round-trips keep pointermove bind count ≤ 1 (h) focus trap: Tab from last wraps to first Wires test into deploy.yml Playwright matrix. CI must FAIL on assertion: nav-drawer.js / CSS not implemented yet.
This commit is contained in:
@@ -250,6 +250,7 @@ jobs:
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1109-hamburger-dropdown-visible-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-layout-1178-1179-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-live-mql-leak-1180-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-nav-drawer-1064-e2e.js 2>&1 | tee -a e2e-output.txt
|
||||
|
||||
- name: Collect frontend coverage (parallel)
|
||||
if: success() && github.event_name == 'push'
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Issue #1064 — Edge-swipe nav drawer (parent epic #1052).
|
||||
*
|
||||
* Asserts:
|
||||
* (a) at 1024x800: pointer-down at x=10, drag to x=200 → drawer opens,
|
||||
* drawer.getBoundingClientRect().left === 0
|
||||
* (b) drawer items present (long-tail routes from PR #1174)
|
||||
* (c) tap a drawer item → URL hash changes, drawer closes
|
||||
* (d) Esc closes drawer
|
||||
* (e) backdrop click closes drawer
|
||||
* (f) at 360x800: edge-swipe does NOT open drawer (Option A —
|
||||
* drawer disabled at narrow widths because bottom-nav has More tab)
|
||||
* (g) singleton: navigate away+back 5 times, pointermove bind count ≤ 1
|
||||
* (h) focus trap: open drawer, Tab from last focusable wraps to first
|
||||
*
|
||||
* Stable selectors (consumed by the test):
|
||||
* - <aside data-nav-drawer> ............. drawer panel
|
||||
* - [data-nav-drawer-backdrop] .......... backdrop
|
||||
* - [data-nav-drawer-item="<route>"] .... drawer route links
|
||||
* - window.__navDrawer .................. { open(), close(), isOpen() }
|
||||
* - window.__navDrawerPointerBindCount .. integer, MUST be ≤ 1 across SPA navs
|
||||
*
|
||||
* CI gating: CHROMIUM_REQUIRE=1 ⇒ missing/broken Chromium is a HARD FAIL.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:13581';
|
||||
const EXPECTED_LONG_TAIL = ['nodes', 'tools', 'observers', 'analytics', 'perf', 'audio-lab'];
|
||||
|
||||
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'); }
|
||||
|
||||
// Synthesize a pointer drag at the document level using PointerEvent.
|
||||
// Headless Chromium supports PointerEvent natively; dispatching them
|
||||
// directly on document avoids touch-emulation flakiness.
|
||||
async function edgeSwipe(page, x0, y0, x1, y1, steps) {
|
||||
await page.evaluate(({ x0, y0, x1, y1, steps }) => {
|
||||
const target = document.elementFromPoint(x0, y0) || document.body;
|
||||
function pe(type, x, y) {
|
||||
return new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
isPrimary: true,
|
||||
clientX: x, clientY: y,
|
||||
screenX: x, screenY: y,
|
||||
button: 0, buttons: type === 'pointerup' ? 0 : 1,
|
||||
});
|
||||
}
|
||||
target.dispatchEvent(pe('pointerdown', x0, y0));
|
||||
const N = Math.max(2, steps | 0);
|
||||
for (let i = 1; i <= N; i++) {
|
||||
const t = i / N;
|
||||
const x = Math.round(x0 + (x1 - x0) * t);
|
||||
const y = Math.round(y0 + (y1 - y0) * t);
|
||||
document.dispatchEvent(pe('pointermove', x, y));
|
||||
}
|
||||
document.dispatchEvent(pe('pointerup', x1, y1));
|
||||
}, { x0, y0, x1, y1, steps });
|
||||
}
|
||||
|
||||
(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-nav-drawer-1064-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`test-nav-drawer-1064-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\n=== #1064 edge-swipe nav drawer E2E against ${BASE} ===`);
|
||||
|
||||
// ── Wide viewport: drawer enabled ──
|
||||
const wideCtx = await browser.newContext({ viewport: { width: 1024, height: 800 } });
|
||||
const wide = await wideCtx.newPage();
|
||||
wide.setDefaultTimeout(10000);
|
||||
wide.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
await wide.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await wide.waitForSelector('main#app', { timeout: 8000 });
|
||||
await wide.waitForTimeout(300);
|
||||
|
||||
await step('(a) edge-swipe at x=10→200 opens drawer flush at left:0', async () => {
|
||||
await edgeSwipe(wide, 10, 400, 220, 400, 12);
|
||||
await wide.waitForTimeout(250);
|
||||
const rect = await wide.evaluate(() => {
|
||||
const d = document.querySelector('[data-nav-drawer]');
|
||||
if (!d) return null;
|
||||
const r = d.getBoundingClientRect();
|
||||
return { left: r.left, width: r.width, isOpen: !!(window.__navDrawer && window.__navDrawer.isOpen && window.__navDrawer.isOpen()) };
|
||||
});
|
||||
assert(rect, '[data-nav-drawer] not in DOM');
|
||||
assert(rect.isOpen, 'drawer.isOpen() returned false after edge-swipe');
|
||||
assert(rect.left === 0, `drawer.getBoundingClientRect().left expected 0, got ${rect.left}`);
|
||||
assert(rect.width > 0, 'drawer width is 0');
|
||||
});
|
||||
|
||||
await step('(b) drawer contains long-tail routes from PR #1174', async () => {
|
||||
const items = await wide.evaluate((expected) => {
|
||||
return expected.map((r) => ({
|
||||
route: r,
|
||||
present: !!document.querySelector(`[data-nav-drawer-item="${r}"]`),
|
||||
}));
|
||||
}, EXPECTED_LONG_TAIL);
|
||||
const missing = items.filter((it) => !it.present).map((it) => it.route);
|
||||
assert(missing.length === 0, `missing drawer items: ${missing.join(', ')}`);
|
||||
});
|
||||
|
||||
await step('(c) tapping a drawer item navigates and closes drawer', async () => {
|
||||
// Open drawer (in case prior step left it closed) then click an item.
|
||||
await wide.evaluate(() => window.__navDrawer && window.__navDrawer.open && window.__navDrawer.open());
|
||||
await wide.waitForTimeout(150);
|
||||
await wide.click('[data-nav-drawer-item="tools"]');
|
||||
await wide.waitForTimeout(200);
|
||||
const result = await wide.evaluate(() => ({
|
||||
hash: location.hash,
|
||||
isOpen: !!(window.__navDrawer && window.__navDrawer.isOpen && window.__navDrawer.isOpen()),
|
||||
}));
|
||||
assert(result.hash.indexOf('#/tools') === 0, `expected hash #/tools, got ${result.hash}`);
|
||||
assert(!result.isOpen, 'drawer should have closed after item tap');
|
||||
});
|
||||
|
||||
await step('(d) Esc closes drawer', async () => {
|
||||
await wide.evaluate(() => window.__navDrawer && window.__navDrawer.open && window.__navDrawer.open());
|
||||
await wide.waitForTimeout(150);
|
||||
await wide.keyboard.press('Escape');
|
||||
await wide.waitForTimeout(150);
|
||||
const open = await wide.evaluate(() => !!(window.__navDrawer && window.__navDrawer.isOpen && window.__navDrawer.isOpen()));
|
||||
assert(!open, 'drawer still open after Esc');
|
||||
});
|
||||
|
||||
await step('(e) backdrop click closes drawer', async () => {
|
||||
await wide.evaluate(() => window.__navDrawer && window.__navDrawer.open && window.__navDrawer.open());
|
||||
await wide.waitForTimeout(150);
|
||||
const has = await wide.$('[data-nav-drawer-backdrop]');
|
||||
assert(has, '[data-nav-drawer-backdrop] missing');
|
||||
await wide.click('[data-nav-drawer-backdrop]', { position: { x: 5, y: 5 } });
|
||||
await wide.waitForTimeout(150);
|
||||
const open = await wide.evaluate(() => !!(window.__navDrawer && window.__navDrawer.isOpen && window.__navDrawer.isOpen()));
|
||||
assert(!open, 'drawer still open after backdrop click');
|
||||
});
|
||||
|
||||
await step('(g) singleton: 5 SPA round-trips keep pointermove bind count ≤ 1', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await wide.evaluate(() => { location.hash = '#/packets'; });
|
||||
await wide.waitForTimeout(80);
|
||||
await wide.evaluate(() => { location.hash = '#/map'; });
|
||||
await wide.waitForTimeout(80);
|
||||
}
|
||||
const count = await wide.evaluate(() => window.__navDrawerPointerBindCount);
|
||||
assert(typeof count === 'number',
|
||||
'window.__navDrawerPointerBindCount missing — debug seam not exposed by nav-drawer.js');
|
||||
assert(count <= 1,
|
||||
`nav-drawer pointermove handler bind count = ${count}, expected ≤ 1 (singleton)`);
|
||||
});
|
||||
|
||||
await step('(h) focus trap: Tab from last focusable wraps to first', async () => {
|
||||
await wide.evaluate(() => window.__navDrawer && window.__navDrawer.open && window.__navDrawer.open());
|
||||
await wide.waitForTimeout(150);
|
||||
// Focus the LAST focusable inside the drawer, then press Tab.
|
||||
const wrapped = await wide.evaluate(async () => {
|
||||
const drawer = document.querySelector('[data-nav-drawer]');
|
||||
if (!drawer) return { error: 'no drawer' };
|
||||
const focusables = drawer.querySelectorAll(
|
||||
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
|
||||
);
|
||||
if (focusables.length < 2) return { error: 'fewer than 2 focusables in drawer' };
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
last.focus();
|
||||
return { firstId: first.getAttribute('data-nav-drawer-item') || first.id || first.textContent.trim(),
|
||||
lastFocused: document.activeElement === last };
|
||||
});
|
||||
assert(!wrapped.error, wrapped.error);
|
||||
assert(wrapped.lastFocused, 'failed to focus last drawer focusable');
|
||||
await wide.keyboard.press('Tab');
|
||||
const wrappedToFirst = await wide.evaluate(() => {
|
||||
const drawer = document.querySelector('[data-nav-drawer]');
|
||||
const focusables = drawer.querySelectorAll(
|
||||
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input, select, textarea'
|
||||
);
|
||||
return document.activeElement === focusables[0];
|
||||
});
|
||||
assert(wrappedToFirst, 'Tab from last focusable did NOT wrap to first (focus trap broken)');
|
||||
});
|
||||
|
||||
await wideCtx.close();
|
||||
|
||||
// ── Narrow viewport (Option A): drawer disabled ──
|
||||
const narrowCtx = await browser.newContext({ viewport: { width: 360, height: 800 } });
|
||||
const narrow = await narrowCtx.newPage();
|
||||
narrow.setDefaultTimeout(10000);
|
||||
narrow.on('pageerror', (e) => console.error('[pageerror]', e.message));
|
||||
|
||||
await narrow.goto(BASE + '/#/packets', { waitUntil: 'domcontentloaded' });
|
||||
await narrow.waitForSelector('main#app', { timeout: 8000 });
|
||||
await narrow.waitForTimeout(300);
|
||||
|
||||
await step('(f) narrow viewport: edge-swipe does NOT open drawer (Option A)', async () => {
|
||||
await edgeSwipe(narrow, 10, 400, 220, 400, 12);
|
||||
await narrow.waitForTimeout(250);
|
||||
const open = await narrow.evaluate(() => {
|
||||
if (!window.__navDrawer || !window.__navDrawer.isOpen) return false;
|
||||
return window.__navDrawer.isOpen();
|
||||
});
|
||||
assert(!open, 'drawer opened at narrow viewport (≤768px) — Option A says it must stay closed');
|
||||
});
|
||||
|
||||
await narrowCtx.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); });
|
||||
Reference in New Issue
Block a user