diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 85664485..370eeddc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -271,6 +271,7 @@ jobs: 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 + CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-issue-1402-gesture-hints-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 BASE_URL=http://localhost:13581 node test-touch-gestures-coverage-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 diff --git a/test-issue-1402-gesture-hints-e2e.js b/test-issue-1402-gesture-hints-e2e.js new file mode 100644 index 00000000..52aae04f --- /dev/null +++ b/test-issue-1402-gesture-hints-e2e.js @@ -0,0 +1,242 @@ +#!/usr/bin/env node +/* Issue #1402 — Gesture-hint regressions on iPhone-class mobile. + * + * Per issue body, vw=393, /#/home, console probe at deploy: + * bottomNav: true, navDrawer: true, pullEl: false, storedKeys: [] + * + * Asserts (gates the 4 fixes): + * (1) vw=393 /#/home → tab-swipe hint renders within 1500ms (Bug 1) + * (2) vw=393 /#/home → edge-drawer hint renders within 1500ms (Bug 2 — currently + * inverted: code says innerWidth > 768) + * (3) vw=393 /#/home → pull-refresh hint renders within 1500ms (Bug 3 — currently + * requires .pull-to-reconnect in DOM, which only exists on WS-disconnect) + * (4) vw=393 /#/channels and /#/observers → row-swipe hint renders (Bug 4 — currently + * scoped to /packets|/nodes only) + * (5) vw=1024 /#/home → edge-drawer hint does NOT render (mobile-only per fix) + * (6) auto-fade does NOT mark seen for tab-swipe; explicit dismiss DOES + * (regression guard on the dismissal flow under the new render conditions) + * (7) FIRST-LOAD path: vw=393 /#/home, fresh page (no hashchange fired), hints render. + * Bug confirmed via operator console trace: hints_in_dom=0 on initial load + * but hints_appended_in_2s=[row-swipe,tab-swipe] after a hashchange. + * Asserts the schedule path runs without needing a hashchange. + * (8) HASHCHANGE path: after first load, navigate to a different route — hints + * relevant for the new route render. Validates _routeChangeBound still works. + */ +'use strict'; + +const { chromium } = require('playwright'); + +const BASE = process.env.BASE_URL || 'http://localhost:13581'; +const HINT_SETTLE_MS = 1700; // SHOW_DELAY_MS (800) + margin + +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, + }; + }, hintId); +} + +async function freshContext(browser, viewport, hasTouch) { + return browser.newContext({ viewport, hasTouch: !!hasTouch }); +} + +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-issue-1402-gesture-hints-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`); + process.exit(1); + } + console.log(`test-issue-1402-gesture-hints-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); }; + + // ── Mobile (vw=393, hasTouch) — operator's actual device class ── + const mobileCtx = await freshContext(browser, { width: 393, height: 852 }, true); + const mPage = await mobileCtx.newPage(); + mPage.setDefaultTimeout(15000); + mPage.on('pageerror', (e) => console.error('[pageerror]', e.message)); + + // First-visit /#/home setup. + await mPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' }); + await clearAllHintFlags(mPage); + await mPage.reload({ waitUntil: 'domcontentloaded' }); + await mPage.waitForTimeout(HINT_SETTLE_MS); + + // Sanity probe — mirrors the operator's console probe. + const probe = await mPage.evaluate(() => ({ + vw: window.innerWidth, + bottomNav: !!document.querySelector('[data-bottom-nav]'), + navDrawer: !!document.querySelector('.nav-drawer, [data-nav-drawer]'), + pullEl: !!document.querySelector('.pull-to-reconnect'), + pointerCoarse: window.matchMedia && window.matchMedia('(pointer: coarse)').matches, + })); + console.log(' PROBE (mobile /#/home): ' + JSON.stringify(probe)); + + // ── (1) Bug 1: tab-swipe at /#/home, vw=393 ── + const tabSwipe = await hintVisible(mPage, 'tab-swipe'); + if (tabSwipe.present && tabSwipe.visible) { + pass('(1) tab-swipe hint visible at vw=393 /#/home within 1500ms (Bug 1)'); + } else { + fail(`(1) tab-swipe hint NOT visible at vw=393 /#/home — state=${JSON.stringify(tabSwipe)} probe=${JSON.stringify(probe)}`); + } + + // ── (2) Bug 2: edge-drawer at /#/home, vw=393 ── + const edgeMobile = await hintVisible(mPage, 'edge-drawer'); + if (edgeMobile.present && edgeMobile.visible) { + pass('(2) edge-drawer hint visible at vw=393 /#/home (Bug 2 — was inverted to desktop-only)'); + } else { + fail(`(2) edge-drawer hint NOT visible at vw=393 /#/home — state=${JSON.stringify(edgeMobile)}`); + } + + // ── (3) Bug 3: pull-refresh at /#/home, vw=393 (touch viewport) ── + const pullRefresh = await hintVisible(mPage, 'pull-refresh'); + if (pullRefresh.present && pullRefresh.visible) { + pass('(3) pull-refresh hint visible at vw=393 /#/home (Bug 3 — was gated on WS-disconnect element)'); + } else { + fail(`(3) pull-refresh hint NOT visible at vw=393 /#/home — state=${JSON.stringify(pullRefresh)}`); + } + + await mobileCtx.close(); + + // ── (4) Bug 4: row-swipe on /#/channels and /#/observers ── + for (const route of ['/#/channels', '/#/observers']) { + const ctx = await freshContext(browser, { width: 393, height: 852 }, true); + const p = await ctx.newPage(); + p.on('pageerror', (e) => console.error('[pageerror]', e.message)); + await p.goto(`${BASE}${route}`, { waitUntil: 'domcontentloaded' }); + await clearAllHintFlags(p); + await p.reload({ waitUntil: 'domcontentloaded' }); + await p.waitForTimeout(HINT_SETTLE_MS); + const rs = await hintVisible(p, 'row-swipe'); + if (rs.present && rs.visible) { + pass(`(4) row-swipe hint visible at vw=393 ${route} (Bug 4 — route scope widened)`); + } else { + fail(`(4) row-swipe hint NOT visible at vw=393 ${route} — state=${JSON.stringify(rs)}`); + } + await ctx.close(); + } + + // ── (5) Desktop: edge-drawer hint must NOT render at vw=1024 (mobile-only) ── + const dCtx = await freshContext(browser, { width: 1024, height: 800 }, false); + const dPage = await dCtx.newPage(); + await dPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' }); + await clearAllHintFlags(dPage); + await dPage.reload({ waitUntil: 'domcontentloaded' }); + await dPage.waitForTimeout(HINT_SETTLE_MS); + const edgeDesktop = await hintVisible(dPage, 'edge-drawer'); + if (!edgeDesktop.present || !edgeDesktop.visible) { + pass('(5) edge-drawer hint NOT visible at vw=1024 /#/home (mobile-only per Bug 2 fix)'); + } else { + fail(`(5) edge-drawer hint SHOULD NOT render at vw=1024 but did — state=${JSON.stringify(edgeDesktop)}`); + } + await dCtx.close(); + + // ── (6) tab-swipe explicit-dismiss sets seen flag ── + const dismissCtx = await freshContext(browser, { width: 393, height: 852 }, true); + const dpPage = await dismissCtx.newPage(); + await dpPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' }); + await clearAllHintFlags(dpPage); + await dpPage.reload({ waitUntil: 'domcontentloaded' }); + await dpPage.waitForTimeout(HINT_SETTLE_MS); + const clicked = await dpPage.evaluate(() => { + const el = document.querySelector('[data-gesture-hint="tab-swipe"]'); + if (!el) return false; + const btn = el.querySelector('[data-gesture-hint-dismiss]'); + if (!btn) return false; + btn.click(); + return true; + }); + await dpPage.waitForTimeout(300); + const flagAfter = await dpPage.evaluate((k) => localStorage.getItem(k), KEYS.tabSwipe); + if (clicked && flagAfter === 'seen') { + pass('(6) tab-swipe explicit dismiss sets localStorage seen flag'); + } else { + fail(`(6) tab-swipe dismiss did not record seen — clicked=${clicked} flag=${flagAfter}`); + } + await dismissCtx.close(); + + // ── (7) FIRST-LOAD path: fresh page, no hashchange — hints must render ── + // Operator console trace showed hints_in_dom=0 on initial paint and only + // hashchange triggered the schedule path. Asserts schedule fires without nav. + const flCtx = await freshContext(browser, { width: 393, height: 852 }, true); + const flPage = await flCtx.newPage(); + flPage.on('pageerror', (e) => console.error('[pageerror]', e.message)); + // Pre-clear flags via prelude script BEFORE any navigation so the very-first + // page-load is clean. (Reloading would still be a "first load" technically, + // but this exercises the genuinely-cold path with no prior hashchange.) + await flPage.addInitScript((keys) => { + try { Object.values(keys).forEach((k) => localStorage.removeItem(k)); } catch (_) {} + }, KEYS); + await flPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' }); + await flPage.waitForTimeout(HINT_SETTLE_MS); + const flHints = await flPage.evaluate(() => + Array.from(document.querySelectorAll('[data-gesture-hint]')).map((e) => e.getAttribute('data-gesture-hint')) + ); + if (flHints.includes('tab-swipe')) { + pass(`(7) FIRST-LOAD: tab-swipe hint rendered without prior hashchange (hints=${JSON.stringify(flHints)})`); + } else { + fail(`(7) FIRST-LOAD: no tab-swipe hint on initial paint (hints=${JSON.stringify(flHints)})`); + } + await flCtx.close(); + + // ── (8) HASHCHANGE path: after first load, navigating still triggers hints ── + const hcCtx = await freshContext(browser, { width: 393, height: 852 }, true); + const hcPage = await hcCtx.newPage(); + hcPage.on('pageerror', (e) => console.error('[pageerror]', e.message)); + await hcPage.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' }); + await clearAllHintFlags(hcPage); + // Mark home-relevant hints as seen so we can prove navigation to a NEW route + // surfaces NEW hints (row-swipe on packets) — proving the hashchange path is alive. + await hcPage.evaluate((keys) => { + localStorage.setItem(keys.tabSwipe, 'seen'); + localStorage.setItem(keys.edgeDrawer, 'seen'); + localStorage.setItem(keys.pullRefresh, 'seen'); + }, KEYS); + await hcPage.waitForTimeout(300); + await hcPage.evaluate(() => { location.hash = '#/packets'; }); + await hcPage.waitForTimeout(HINT_SETTLE_MS); + const rowAfterNav = await hintVisible(hcPage, 'row-swipe'); + if (rowAfterNav.present && rowAfterNav.visible) { + pass('(8) HASHCHANGE: row-swipe hint rendered after nav from /#/home to /#/packets'); + } else { + fail(`(8) HASHCHANGE: row-swipe not rendered after hashchange — state=${JSON.stringify(rowAfterNav)}`); + } + await hcCtx.close(); + + await browser.close(); + console.log(`\ntest-issue-1402-gesture-hints-e2e.js: ${passes} passed, ${failures} failed`); + process.exit(failures > 0 ? 1 : 0); +} + +main().catch((err) => { console.error('test-issue-1402-gesture-hints-e2e.js: FAIL —', err); process.exit(1); });