diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5ce86045..74162291 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -232,6 +232,7 @@ jobs: CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-bottom-nav-1061-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1062-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gestures-1185-scroll-discriminator-e2e.js 2>&1 | tee -a e2e-output.txt + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-gesture-hints-1065-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-channel-fluid-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-table-fluid-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-charts-fluid-1058-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/test-gesture-hints-1065-e2e.js b/test-gesture-hints-1065-e2e.js new file mode 100644 index 00000000..ed2abe33 --- /dev/null +++ b/test-gesture-hints-1065-e2e.js @@ -0,0 +1,250 @@ +#!/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 1024x800, edge-swipe hint visible on first visit ── + const ctx2 = await browser.newContext({ viewport: { width: 1024, height: 800 } }); + 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 1024x800'); + } else { + fail(`(e) edge-drawer hint NOT visible at 1024x800 — 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(); + + 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); });