Files
meshcore-analyzer/test-gesture-hints-1065-e2e.js
T
Kpa-clawbot 116efe4bd7 fix(#1402): gesture hints — edge-drawer mobile-only + row-swipe widening (re-fix) (#1586)
Partial fix for #1402

## Summary
Re-fix two of the four #1402 regressions on mobile after `#1452`
silently reverted the prior fix (`6ec08acb`). Two predicate flips in
`public/gesture-hints.js` + extended E2E coverage to prevent another
silent revert.

This PR is intentionally **scoped to Bug 2 and Bug 4 only**. Bug 1 and
Bug 3 were also dropped by `#1452` and are NOT restored here — `#1402`
remains open for the rest.

## Changes
- `public/gesture-hints.js` (edge-drawer): `window.innerWidth > 768` →
`window.innerWidth <= 768`. The edge-swipe drawer is the MOBILE layout's
nav per #1064/#1184; `nav-drawer.js` `NARROW_MAX=768` (inclusive —
narrow when width <= NARROW_MAX). Above 768 the sidebar is persistent,
no edge-swipe is needed.
- `public/gesture-hints.js` (row-swipe): widen route filter from
`/^#\/(packets|nodes)/` to `/^#\/(packets|nodes|channels|observers)/`.
Channels and observers also render swipable row tables.
- `public/gesture-hints.js`: expose read-only
`window.__gestureHintsDefs` test hook (frozen) for direct predicate
probes (avoids race with render path).
- `test-gesture-hints-1065-e2e.js`: add assertions (i)+(j) at vw=393 —
edge-drawer relevant on `/#/home`, row-swipe relevant on `/#/channels`;
(k) negative-direction gate at vw=1024 asserts `edge-drawer.relevant()
=== false` on desktop. Retarget (e) from 1024x800 → 393x800 to match the
corrected mobile-only gate.

## TDD
- Red commit: `1e7545d1` — test additions fail against current
production code (edge-drawer relevant returns false at vw=393, row-swipe
filter rejects /channels).
- Green commit: `6f844d5b` — predicate flips + route widening make both
assertions pass.
- Polish commit (round-1 fixes): boundary <= 768, doc-header refresh,
freeze the test hook, negative-direction gate (k), precondition
assertion on (i).

## Acceptance criteria from #1402
- [ ] Bug 1 (`window 'load'` rescheduler + `pointer: coarse` gate) —
dropped by #1452, NOT restored in this PR. Tracked in #1402.
- [x] Bug 2 (edge-drawer mobile-only) — fixed here.
- [ ] Bug 3 (pull-refresh touch-gate decoupling) — dropped by #1452, NOT
restored in this PR. Tracked in #1402.
- [x] Bug 4 (row-swipe widening → /channels + /observers) — fixed here.
- [x] E2E mutation gate: assertions (i)+(j)+(k) provably fail if either
predicate is reverted or re-broadened.

## Notes
- Silently reverted by #1452 — re-fix here, with regression gates so the
next reviewer of the next refactor will see the assertions fail rather
than the production behavior change unnoticed.

## Preflight
All gates pass (PII, branch scope, red commit, CSS vars, XSS sinks,
etc.).

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: fix-1166-bot <bot@corescope.local>
2026-06-04 16:41:32 -07:00

329 lines
15 KiB
JavaScript

#!/usr/bin/env node
/* Issue #1065 — Gesture discoverability hints (first-visit).
*
* Asserts (per parent brief):
* (a) on first visit at 360x800 + /#/packets, hint balloon visible after page settle,
* with role=status / aria-live=polite region containing swipe-row hint text
* (b) tap "Got it" → balloon disappears, localStorage `meshcore-gesture-hints-row-swipe`=`seen`
* (c) reload → hint NOT shown (flag persists)
* (d) clear flag via Settings UI ("Reset gesture hints") → reload → hint shown again
* (e) at 1024x800, edge-swipe hint visible
* (f) prefers-reduced-motion: reduce — animation-name 'none' (just opacity fade)
* (g) hint does NOT steal focus (document.activeElement === document.body after settle)
* (h) singleton: 5 SPA round-trips don't re-show dismissed hints
*
* Hint timing: brief expects 800ms post-page-settle delay; we wait 1500ms after navigate.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
const HINT_SETTLE_MS = 1500;
const KEYS = {
rowSwipe: 'meshcore-gesture-hints-row-swipe',
tabSwipe: 'meshcore-gesture-hints-tab-swipe',
edgeDrawer: 'meshcore-gesture-hints-edge-drawer',
pullRefresh: 'meshcore-gesture-hints-pull-refresh',
};
async function clearAllHintFlags(page) {
await page.evaluate((keys) => {
Object.values(keys).forEach((k) => localStorage.removeItem(k));
}, KEYS);
}
async function hintVisible(page, hintId) {
return page.evaluate((id) => {
const el = document.querySelector('[data-gesture-hint="' + id + '"]');
if (!el) return { present: false };
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return {
present: true,
visible: cs.display !== 'none' && cs.visibility !== 'hidden' && parseFloat(cs.opacity || '1') > 0.01 && r.width > 0 && r.height > 0,
role: el.getAttribute('role'),
ariaLive: el.getAttribute('aria-live'),
text: el.textContent || '',
animationName: cs.animationName,
pointerEvents: cs.pointerEvents,
};
}, hintId);
}
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-gesture-hints-1065-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-gesture-hints-1065-e2e.js: SKIP (Chromium unavailable: ${err.message.split('\n')[0]})`);
process.exit(0);
}
let failures = 0, passes = 0;
const fail = (m) => { failures++; console.error(' FAIL: ' + m); };
const pass = (m) => { passes++; console.log(' PASS: ' + m); };
// ── (a) first visit on /#/packets at 360x800 → row-swipe hint visible ──
const ctx = await browser.newContext({ viewport: { width: 360, height: 800 }, hasTouch: true });
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
// Clear localStorage before first navigate.
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await clearAllHintFlags(page);
// Reload to simulate first-visit cleanly.
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(HINT_SETTLE_MS);
const moduleReady = await page.evaluate(() => typeof window.__gestureHints1065Init === 'number');
if (moduleReady) pass('gesture-hints.js loaded (window.__gestureHints1065Init present)');
else fail('gesture-hints.js NOT loaded (window.__gestureHints1065Init missing)');
const rowHint = await hintVisible(page, 'row-swipe');
if (rowHint.present && rowHint.visible) {
pass('(a) row-swipe hint visible on first visit at /#/packets 360x800');
} else {
fail(`(a) row-swipe hint NOT visible — state=${JSON.stringify(rowHint)}`);
}
if (rowHint.role === 'status' && rowHint.ariaLive === 'polite') {
pass('(a) hint has role=status and aria-live=polite');
} else {
fail(`(a) hint missing aria — role=${rowHint.role} aria-live=${rowHint.ariaLive}`);
}
if (rowHint.pointerEvents === 'none') {
pass('(a) hint pointer-events: none — does not capture pointer');
} else {
fail(`(a) hint pointer-events=${rowHint.pointerEvents}, expected none`);
}
// ── (g) does not steal focus ──
const activeTag = await page.evaluate(() => document.activeElement && document.activeElement.tagName);
if (activeTag === 'BODY' || activeTag === null || activeTag === 'HTML') {
pass(`(g) focus not stolen (activeElement=${activeTag})`);
} else {
// Allow if active element is not inside the hint.
const inHint = await page.evaluate(() => {
const a = document.activeElement;
if (!a) return false;
return !!a.closest('[data-gesture-hint]');
});
if (!inHint) pass(`(g) focus not in hint (activeElement=${activeTag})`);
else fail(`(g) hint stole focus to element inside hint (${activeTag})`);
}
// ── (b) tap "Got it" → balloon gone, localStorage flag set ──
const dismissed = await page.evaluate(() => {
const el = document.querySelector('[data-gesture-hint="row-swipe"]');
if (!el) return { ok: false, reason: 'no hint' };
const btn = el.querySelector('[data-gesture-hint-dismiss]');
if (!btn) return { ok: false, reason: 'no button' };
btn.click();
return { ok: true };
});
if (!dismissed.ok) fail('(b) cannot dismiss: ' + dismissed.reason);
await page.waitForTimeout(400);
const afterDismiss = await page.evaluate((k) => ({
stillThere: !!document.querySelector('[data-gesture-hint="row-swipe"]'),
flag: localStorage.getItem(k),
}), KEYS.rowSwipe);
if (!afterDismiss.stillThere && afterDismiss.flag === 'seen') {
pass('(b) "Got it" removed hint and set localStorage flag = "seen"');
} else {
fail(`(b) dismiss failed — stillThere=${afterDismiss.stillThere} flag=${afterDismiss.flag}`);
}
// ── (c) reload → hint NOT shown ──
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(HINT_SETTLE_MS);
const afterReload = await hintVisible(page, 'row-swipe');
if (!afterReload.present || !afterReload.visible) {
pass('(c) hint NOT shown after reload (flag persisted)');
} else {
fail('(c) hint reappeared after reload — flag did not persist');
}
// ── (d) clear flag via Settings UI → reload → hint visible again ──
// Brief asks for a "Reset gesture hints" button. Click it programmatically
// via the UI element if present; otherwise fall back to direct localStorage clear
// and FAIL the assertion (the brief requires a UI surface).
const resetWorked = await page.evaluate(() => {
// Open customize panel.
var btn = document.getElementById('customizeToggle');
if (btn) btn.click();
// The reset button may live anywhere in the panel; look for it by data-attr.
var resetBtn = document.querySelector('[data-cv2-reset-hints], [data-reset-gesture-hints]');
if (!resetBtn) return { ok: false, reason: 'reset button not found' };
resetBtn.click();
return { ok: true };
});
if (!resetWorked.ok) {
fail('(d) Settings UI "Reset gesture hints" button not found — ' + resetWorked.reason);
// Force-clear so subsequent assertions can run.
await clearAllHintFlags(page);
} else {
pass('(d.1) "Reset gesture hints" button clicked');
}
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(HINT_SETTLE_MS);
const afterReset = await hintVisible(page, 'row-swipe');
if (afterReset.present && afterReset.visible) {
pass('(d.2) hint shown again after settings reset');
} else {
fail(`(d.2) hint NOT shown after reset — state=${JSON.stringify(afterReset)}`);
}
// ── (h) singleton: 5 SPA round-trips don't re-show dismissed hints ──
// Dismiss again first.
await page.evaluate(() => {
const el = document.querySelector('[data-gesture-hint="row-swipe"]');
if (el) {
const b = el.querySelector('[data-gesture-hint-dismiss]');
if (b) b.click();
}
});
await page.waitForTimeout(300);
let reShowCount = 0;
for (let i = 0; i < 5; i++) {
await page.evaluate(() => { location.hash = '#/nodes'; });
await page.waitForTimeout(300);
await page.evaluate(() => { location.hash = '#/packets'; });
await page.waitForTimeout(800);
const v = await hintVisible(page, 'row-swipe');
if (v.present && v.visible) reShowCount++;
}
if (reShowCount === 0) pass('(h) 5 SPA round-trips: hint did NOT re-show after dismiss');
else fail(`(h) hint re-showed ${reShowCount}/5 SPA round-trips after dismiss`);
await ctx.close();
// ── (e) at vw=393 with touch, edge-swipe hint visible on first visit ──
// #1065 follow-up: edge-swipe is a touch gesture. #1402 fix: edge-drawer
// is the MOBILE layout's nav UI (per #1064/#1184, nav-drawer.js NARROW_MAX=768);
// hint must appear at narrow viewports, not wide ones.
const ctx2 = await browser.newContext({ viewport: { width: 393, height: 800 }, hasTouch: true });
const page2 = await ctx2.newPage();
await page2.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page2.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
await page2.reload({ waitUntil: 'domcontentloaded' });
await page2.waitForTimeout(HINT_SETTLE_MS);
const edgeHint = await hintVisible(page2, 'edge-drawer');
if (edgeHint.present && edgeHint.visible) {
pass('(e) edge-drawer hint visible at 393x800 (mobile)');
} else {
fail(`(e) edge-drawer hint NOT visible at 393x800 — state=${JSON.stringify(edgeHint)}`);
}
await ctx2.close();
// ── (f) prefers-reduced-motion: animation-name = 'none' ──
const ctx3 = await browser.newContext({ viewport: { width: 360, height: 800 }, hasTouch: true, reducedMotion: 'reduce' });
const page3 = await ctx3.newPage();
await page3.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page3.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
await page3.reload({ waitUntil: 'domcontentloaded' });
await page3.waitForTimeout(HINT_SETTLE_MS);
const reducedHint = await hintVisible(page3, 'row-swipe');
if (reducedHint.present && reducedHint.visible) {
if (reducedHint.animationName === 'none' || reducedHint.animationName === '' || /none/i.test(String(reducedHint.animationName))) {
pass(`(f) prefers-reduced-motion: animation-name=${reducedHint.animationName} (no slide animation)`);
} else {
fail(`(f) reduced-motion: animation-name=${reducedHint.animationName}, expected 'none'`);
}
} else {
fail(`(f) hint not visible under reduced-motion — state=${JSON.stringify(reducedHint)}`);
}
await ctx3.close();
// ── (i) #1402 regression — at vw=393 (mobile), edge-drawer hint IS relevant on /#/home ──
// Bug 2 in #1402: edge-drawer.relevant had window.innerWidth > 768 (inverted).
// nav-drawer.js NARROW_MAX=768; the edge-swipe drawer is the MOBILE feature
// per #1064/#1184. At vw=393 with a .nav-drawer in the DOM, the hint MUST
// be classified as relevant. Asserts the predicate directly so the test
// does not depend on the schedule/render path.
const ctx4 = await browser.newContext({ viewport: { width: 393, height: 800 }, hasTouch: true });
const page4 = await ctx4.newPage();
await page4.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await page4.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
await page4.reload({ waitUntil: 'domcontentloaded' });
await page4.waitForTimeout(HINT_SETTLE_MS);
const probe1402 = await page4.evaluate(() => {
const hints = window.__gestureHintsDefs || null;
return {
hintsExposed: !!hints,
vw: window.innerWidth,
navDrawerInDom: !!document.querySelector('.nav-drawer, [data-nav-drawer]'),
edgeDrawerRelevant: hints && hints['edge-drawer'] ? !!hints['edge-drawer'].relevant() : null,
};
});
if (probe1402.hintsExposed && probe1402.navDrawerInDom && probe1402.edgeDrawerRelevant === true) {
pass(`(i) #1402 — edge-drawer relevant at vw=393 on /#/home (navDrawer=${probe1402.navDrawerInDom})`);
} else if (!probe1402.navDrawerInDom) {
fail(`(i) #1402 — precondition failed: .nav-drawer NOT in DOM at vw=393 — state=${JSON.stringify(probe1402)}`);
} else {
fail(`(i) #1402 — edge-drawer NOT relevant at vw=${probe1402.vw} — state=${JSON.stringify(probe1402)}`);
}
// ── (j) #1402 regression — at vw=393, row-swipe hint IS relevant on /#/channels ──
// Bug 4 in #1402: row-swipe filter was /^#\/(packets|nodes)/ — must widen to
// include channels and observers (both render swipable row tables).
await page4.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded' });
await page4.waitForTimeout(HINT_SETTLE_MS);
const probe1402b = await page4.evaluate(() => {
const hints = window.__gestureHintsDefs || null;
return {
hintsExposed: !!hints,
hash: location.hash,
rowSwipeRelevant: hints && hints['row-swipe'] ? !!hints['row-swipe'].relevant() : null,
};
});
if (probe1402b.hintsExposed && probe1402b.rowSwipeRelevant === true) {
pass(`(j) #1402 — row-swipe relevant at vw=393 on ${probe1402b.hash}`);
} else {
fail(`(j) #1402 — row-swipe NOT relevant on /#/channels — state=${JSON.stringify(probe1402b)}`);
}
await ctx4.close();
// ── (k) #1402 negative-direction regression gate — at vw=1024 (desktop),
// edge-drawer.relevant() MUST return false. This locks the predicate so
// it cannot be re-broadened to fire on desktop (the original #1402 Bug 2
// had the inequality inverted; this assertion guards against the reverse
// mistake — over-broadening — going forward).
const ctx5 = await browser.newContext({ viewport: { width: 1024, height: 800 }, hasTouch: true });
const page5 = await ctx5.newPage();
await page5.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await page5.evaluate((keys) => Object.values(keys).forEach((k) => localStorage.removeItem(k)), KEYS);
await page5.reload({ waitUntil: 'domcontentloaded' });
await page5.waitForTimeout(HINT_SETTLE_MS);
const probe1402c = await page5.evaluate(() => {
const hints = window.__gestureHintsDefs || null;
return {
hintsExposed: !!hints,
vw: window.innerWidth,
edgeDrawerRelevant: hints && hints['edge-drawer'] ? !!hints['edge-drawer'].relevant() : null,
};
});
if (probe1402c.hintsExposed && probe1402c.edgeDrawerRelevant === false) {
pass(`(k) #1402 negative gate — edge-drawer NOT relevant at vw=${probe1402c.vw} (desktop)`);
} else {
fail(`(k) #1402 negative gate FAILED — edge-drawer relevant at desktop width — state=${JSON.stringify(probe1402c)}`);
}
await ctx5.close();
await browser.close();
console.log(`\ntest-gesture-hints-1065-e2e.js: ${passes} passed, ${failures} failed`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((err) => { console.error('test-gesture-hints-1065-e2e.js: FAIL —', err); process.exit(1); });