mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-14 08:38:51 +00:00
116efe4bd7
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>
329 lines
15 KiB
JavaScript
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); });
|