Files
meshcore-analyzer/test-touch-gestures-coverage-e2e.js
T
Kpa-clawbot 92df28a569 fix(touch-gestures): stamp data-hash on Trace and Filter buttons (#1305) (#1332)
## Summary

Row-overlay Trace and Filter buttons silently did nothing on touch
swipes. `ensureRowOverlay` stamped `data-hash` only on the Copy button,
while `onClickAction` gates both `trace` and `filter` navigation on
`hash && ...` — so the click handler short-circuited before
`location.hash` was set. Users saw the buttons but tapping them was a
no-op.

## Fix

`public/touch-gestures.js` — in `ensureRowOverlay`, stamp `data-hash` on
all three buttons (Trace, Filter, Copy) from the same source the Copy
button already used (`row.getAttribute('data-hash') ||
row.getAttribute('data-id')`). One-line factoring of the attribute
fragment to avoid duplicating the escape logic.

Behavior after fix:
- Trace → `#/packets/<hash>`
- Filter → `#/packets?hash=<hash>`
- Copy → clipboard (unchanged)

All three match the existing branches in `onClickAction`.

## TDD

- **RED commit** (`dd90f72c`): removes the cov1/cov2 workaround in
`test-touch-gestures-coverage-e2e.js` that artificially stamped
`data-hash` on trace/filter buttons from the test harness. With this
commit alone, cov1/cov2 fail their `location.hash` assertions because
`onClickAction`'s guard short-circuits.
- **GREEN commit** (`a526c30f`): production fix in `ensureRowOverlay`.
cov1/cov2 now pass natively against the real production code path with
no harness-side stamping.

## Browser verified

Coverage E2E (`test-touch-gestures-coverage-e2e.js`) exercises the real
swipe → overlay → button-click → navigation path in headless Chromium
against the running server. cov1 asserts `location.hash ===
#/packets/<hash>`, cov2 asserts `location.hash ===
#/packets?hash=<hash>` — these assertions are the regression gate.

E2E assertion added: test-touch-gestures-coverage-e2e.js:227 (cov1
trace) and test-touch-gestures-coverage-e2e.js:259 (cov2 filter).

## Preflight

All hard gates and warnings pass.

Fixes #1305

---------

Co-authored-by: openclaw <bot@openclaw>
2026-05-23 20:54:03 -07:00

506 lines
24 KiB
JavaScript

#!/usr/bin/env node
/* B6 coverage push for public/touch-gestures.js (umbrella #1297).
*
* Sister suite to test-gestures-1062-e2e.js — that file proves correctness
* of the *primary* swipe paths (row-action, bottom-nav forward, slide-over
* dismiss). This file drives the branches that the primary suite does not:
*
* (cov1) onClickAction — trace button on a row overlay → URL updates to
* #/packets/<hash> and overlay dismisses.
* (cov2) onClickAction — filter button → URL updates to
* #/packets?hash=<hash> and overlay dismisses.
* (cov3) onClickAction — copy button populates navigator.clipboard
* (stubbed), then dismisses.
* (cov4) onClickAction — outside-overlay click dismisses overlay
* (the "click outside" branch).
* (cov5) Bottom-nav swipe LEFT-TO-RIGHT on #/live → navigates BACK to
* #/packets (the dx >= +TAB_SWIPE_PX branch — opposite direction
* to the existing test's "next tab" case).
* (cov6) Bottom-nav swipe boundary — on #/home (first tab), swipe RTL
* should go to #/packets (next), but swipe LTR must NOT navigate
* below index 0 (boundary guard branch).
* (cov7) Desktop viewport (>768px) — pointerdown on a row is a no-op:
* isNarrow() === false short-circuits onPointerDown, so no overlay
* ever appears even on a 200px left swipe.
* (cov8) onPointerCancel — start a swipe, fire pointercancel mid-gesture;
* row transform must be cleared and gestureContext reset (next
* gesture works normally).
* (cov9) lostpointercapture — same as cov8 but via lostpointercapture
* event (browser-stolen capture path).
* (cov10) findRow nodes-table coverage — swipe a #nodesTable row, overlay
* must appear (proves findRow's nodes-table branch executes).
*
* Pointer events are synthesized at the document level (same approach as
* test-gestures-1062-e2e.js) because headless Chromium's native
* page.touchscreen does not interact reliably with axis-locked custom
* handlers driven by Pointer Events.
*/
'use strict';
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:13581';
async function synthSwipe(page, fromX, fromY, toX, toY, opts) {
opts = opts || {};
const steps = opts.steps || 12;
await page.evaluate(({ fromX, fromY, toX, toY, steps }) => {
const target = document.elementFromPoint(fromX, fromY) || document.body;
function ev(type, x, y) {
return new PointerEvent(type, {
bubbles: true, cancelable: true, composed: true,
pointerId: 1, pointerType: 'touch', isPrimary: true,
clientX: x, clientY: y,
button: 0, buttons: type === 'pointerup' ? 0 : 1,
});
}
target.dispatchEvent(ev('pointerdown', fromX, fromY));
for (let i = 1; i <= steps; i++) {
const x = fromX + (toX - fromX) * (i / steps);
const y = fromY + (toY - fromY) * (i / steps);
const t = document.elementFromPoint(x, y) || target;
t.dispatchEvent(ev('pointermove', x, y));
}
const tup = document.elementFromPoint(toX, toY) || target;
tup.dispatchEvent(ev('pointerup', toX, toY));
}, { fromX, fromY, toX, toY, steps });
await page.waitForTimeout(80);
}
// Fire pointerdown + a few pointermoves, then dispatch the named cancel
// event ("pointercancel" or "lostpointercapture") — never a pointerup.
async function synthSwipeCancel(page, fromX, fromY, toX, toY, cancelEvent) {
const steps = 6;
await page.evaluate(({ fromX, fromY, toX, toY, steps, cancelEvent }) => {
const target = document.elementFromPoint(fromX, fromY) || document.body;
function ev(type, x, y) {
return new PointerEvent(type, {
bubbles: true, cancelable: true, composed: true,
pointerId: 1, pointerType: 'touch', isPrimary: true,
clientX: x, clientY: y, button: 0, buttons: 1,
});
}
target.dispatchEvent(ev('pointerdown', fromX, fromY));
for (let i = 1; i <= steps; i++) {
const x = fromX + (toX - fromX) * (i / steps);
const y = fromY + (toY - fromY) * (i / steps);
const t = document.elementFromPoint(x, y) || target;
t.dispatchEvent(ev('pointermove', x, y));
}
const last = document.elementFromPoint(toX, toY) || target;
last.dispatchEvent(ev(cancelEvent, toX, toY));
}, { fromX, fromY, toX, toY, steps, cancelEvent });
await page.waitForTimeout(80);
}
async function rowRect(page, sel) {
return page.evaluate((sel) => {
const r = document.querySelector(sel);
if (!r) return null;
const b = r.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height,
hash: r.getAttribute('data-hash') || r.getAttribute('data-id') || '' };
}, sel);
}
async function clearOverlays(page) {
await page.evaluate(() => {
if (window.TouchGestures && typeof window.TouchGestures.dismissRowAction === 'function') {
window.TouchGestures.dismissRowAction();
}
document.querySelectorAll('.row-action-overlay').forEach(o => o.remove());
});
}
// Re-open the row-action overlay by swiping a fresh row. Used by cov2/3/4
// after cov1's trace-click navigated to #/packets/<hash> and dismissed the
// overlay — subsequent covs need a clean re-open to assert on filter/copy/
// outside-click branches. Polls for overlay-open up to ~2s with one retry
// because the first swipe after a hash-route navigation occasionally races
// the SPA re-render in CI (faster than the swipe gesture lands).
async function openRowOverlay(page, rowSel) {
await page.waitForSelector(rowSel, { timeout: 10000 });
for (let attempt = 0; attempt < 3; attempt++) {
await clearOverlays(page);
await page.waitForTimeout(120);
const r = await rowRect(page, rowSel);
if (!r) { await page.waitForTimeout(200); continue; }
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
await synthSwipe(page, cx + 100, cy, cx - 100, cy);
// Poll for overlay up to ~800ms.
for (let i = 0; i < 8; i++) {
const ok = await page.evaluate(() =>
!!document.querySelector('.row-action-overlay.row-action-overlay-open'));
if (ok) return r;
await page.waitForTimeout(100);
}
}
return null;
}
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-touch-gestures-coverage-e2e.js: FAIL — Chromium required but unavailable: ${err.message}`);
process.exit(1);
}
console.log(`test-touch-gestures-coverage-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); };
// ────────────────────────────────────────────────────────────────
// Phone viewport context (most tests live here).
// ────────────────────────────────────────────────────────────────
const ctx = await browser.newContext({
viewport: { width: 375, height: 812 },
hasTouch: true,
isMobile: true,
});
const page = await ctx.newPage();
page.setDefaultTimeout(15000);
page.on('pageerror', (e) => console.error('[pageerror]', e.message));
// Stub clipboard so cov3 can observe writes without a real permission.
await page.addInitScript(() => {
window.__clipboardWrites = [];
if (!navigator.clipboard) {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: (s) => { window.__clipboardWrites.push(String(s)); return Promise.resolve(); } },
});
} else {
const orig = navigator.clipboard.writeText.bind(navigator.clipboard);
navigator.clipboard.writeText = (s) => { window.__clipboardWrites.push(String(s)); return orig(s); };
}
});
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(150);
const moduleReady = await page.evaluate(() => typeof window.__touchGestures1062InitCount === 'number');
if (!moduleReady) { fail('touch-gestures.js not loaded'); } else { pass('touch-gestures.js loaded'); }
const r = await rowRect(page, '#pktBody tr[data-hash]');
if (!r) {
fail('no packets row available — fixture/setup problem (cannot run row-action assertions)');
}
// ── (cov1) row-action: Trace button → #/packets/<hash> ──
if (r) {
await clearOverlays(page);
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
await synthSwipe(page, cx + 100, cy, cx - 100, cy);
const overlayPresent = await page.evaluate(() =>
!!document.querySelector('.row-action-overlay.row-action-overlay-open'));
if (!overlayPresent) {
fail('(cov1) precondition — overlay did not appear after left swipe');
} else {
await page.evaluate(() => {
// Production stamps data-hash on trace/filter/copy buttons natively
// (issue #1305). Just click — no test-side workaround needed.
const btn = document.querySelector('.row-action-overlay [data-row-action="trace"]');
if (btn) { btn.click(); }
});
await page.waitForTimeout(120);
const state = await page.evaluate(() => ({
hash: location.hash,
overlay: !!document.querySelector('.row-action-overlay.row-action-overlay-open'),
}));
const expected = `#/packets/${encodeURIComponent(r.hash)}`;
if (state.hash === expected && !state.overlay) {
pass(`(cov1) trace button navigated to ${state.hash} and dismissed overlay`);
} else {
fail(`(cov1) trace button: hash=${state.hash} expected=${expected}, overlay=${state.overlay}`);
}
}
}
// ── (cov2) row-action: Filter button → #/packets?hash=<hash> ──
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(150);
const r2 = await openRowOverlay(page, '#pktBody tr[data-hash]');
if (r2) {
const ok = await page.evaluate(() =>
!!document.querySelector('.row-action-overlay [data-row-action="filter"]'));
if (!ok) {
fail('(cov2) precondition — filter button not in overlay');
} else {
await page.evaluate(() => {
// Production stamps data-hash on filter button natively (#1305).
const btn = document.querySelector('.row-action-overlay [data-row-action="filter"]');
if (btn) { btn.click(); }
});
await page.waitForTimeout(120);
const state = await page.evaluate(() => ({
hash: location.hash,
overlay: !!document.querySelector('.row-action-overlay.row-action-overlay-open'),
}));
const expected = `#/packets?hash=${encodeURIComponent(r2.hash)}`;
if (state.hash === expected && !state.overlay) {
pass(`(cov2) filter button navigated to ${state.hash} and dismissed overlay`);
} else {
fail(`(cov2) filter: hash=${state.hash} expected=${expected}, overlay=${state.overlay}`);
}
}
}
// ── (cov3) row-action: Copy button writes to clipboard ──
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(150);
await page.evaluate(() => { window.__clipboardWrites = []; });
const r3 = await openRowOverlay(page, '#pktBody tr[data-hash]');
if (r3) {
const has = await page.evaluate(() =>
!!document.querySelector('.row-action-overlay [data-row-action="copy"]'));
if (!has) {
fail('(cov3) precondition — copy button not in overlay');
} else {
await page.evaluate(() => {
const btn = document.querySelector('.row-action-overlay [data-row-action="copy"]');
if (btn) btn.click();
});
await page.waitForTimeout(120);
const writes = await page.evaluate(() => window.__clipboardWrites || []);
const overlay = await page.evaluate(() =>
!!document.querySelector('.row-action-overlay.row-action-overlay-open'));
if (writes.length === 1 && writes[0] === r3.hash && !overlay) {
pass(`(cov3) copy button wrote ${writes[0]} to clipboard and dismissed`);
} else {
fail(`(cov3) copy: writes=${JSON.stringify(writes)} expected="${r3.hash}", overlay=${overlay}`);
}
}
}
// ── (cov4) outside-click dismisses overlay ──
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(150);
const r4 = await openRowOverlay(page, '#pktBody tr[data-hash]');
if (r4) {
const before = await page.evaluate(() =>
!!document.querySelector('.row-action-overlay.row-action-overlay-open'));
if (!before) {
fail('(cov4) precondition — overlay not present before outside click');
} else {
// Click somewhere clearly outside the overlay — top-left corner.
await page.evaluate(() => {
const el = document.elementFromPoint(5, 5) || document.body;
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }));
});
await page.waitForTimeout(120);
const after = await page.evaluate(() =>
!!document.querySelector('.row-action-overlay.row-action-overlay-open'));
if (!after) pass('(cov4) outside click dismissed overlay');
else fail('(cov4) outside click did not dismiss overlay');
}
}
// ── (cov5) bottom-nav swipe LTR on #/live → back to #/packets ──
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-bottom-nav]', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(250);
const nav5 = await page.evaluate(() => {
const n = document.querySelector('[data-bottom-nav]');
if (!n) return null;
const b = n.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height };
});
if (!nav5) {
fail('(cov5) [data-bottom-nav] missing at 375x812 on #/live');
} else {
const cx = nav5.x + nav5.w / 2, cy = nav5.y + nav5.h / 2;
// Swipe LEFT-TO-RIGHT (positive dx) → previous tab (delta = -1).
await synthSwipe(page, cx - 80, cy, cx + 80, cy);
await page.waitForTimeout(250);
const hash = await page.evaluate(() => location.hash);
if (hash === '#/packets') pass('(cov5) LTR bottom-nav swipe on #/live navigated back to #/packets');
else fail(`(cov5) expected #/packets, got ${hash}`);
}
// ── (cov6) bottom-nav boundary — LTR swipe on first tab (#/home) no-op ──
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-bottom-nav]', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(250);
const nav6 = await page.evaluate(() => {
const n = document.querySelector('[data-bottom-nav]');
if (!n) return null;
const b = n.getBoundingClientRect();
return { x: b.left, y: b.top, w: b.width, h: b.height };
});
if (!nav6) {
fail('(cov6) [data-bottom-nav] missing on #/home');
} else {
const cx = nav6.x + nav6.w / 2, cy = nav6.y + nav6.h / 2;
const before = await page.evaluate(() => location.hash);
// Try to go BEFORE index 0 — must be a no-op.
await synthSwipe(page, cx - 80, cy, cx + 80, cy);
await page.waitForTimeout(250);
const after = await page.evaluate(() => location.hash);
if (after === before) pass(`(cov6) LTR swipe on first tab (#/home) was no-op (hash=${after})`);
else fail(`(cov6) LTR swipe on first tab unexpectedly navigated: ${before}${after}`);
}
await ctx.close();
// ────────────────────────────────────────────────────────────────
// Desktop viewport context for (cov7).
// ────────────────────────────────────────────────────────────────
{
const ctxD = await browser.newContext({ viewport: { width: 1200, height: 900 }, hasTouch: true });
const pD = await ctxD.newPage();
pD.setDefaultTimeout(15000);
pD.on('pageerror', (e) => console.error('[pageerror-desktop]', e.message));
await pD.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await pD.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
await pD.waitForTimeout(200);
const rD = await rowRect(pD, '#pktBody tr[data-hash]');
if (rD) {
const cx = rD.x + rD.w / 2, cy = rD.y + rD.h / 2;
await synthSwipe(pD, cx + 100, cy, cx - 100, cy);
const overlayState = await pD.evaluate(() => {
const o = document.querySelector('.row-action-overlay');
if (!o) return { present: false };
const cs = getComputedStyle(o);
return { present: true, display: cs.display, visibility: cs.visibility };
});
if (!overlayState.present || overlayState.display === 'none' || overlayState.visibility === 'hidden') {
pass('(cov7) desktop viewport (>768px) — left swipe did NOT create overlay (isNarrow guard works)');
} else {
fail(`(cov7) overlay appeared at 1200px viewport — isNarrow guard broken (state=${JSON.stringify(overlayState)})`);
}
} else {
fail('(cov7) no row at desktop viewport — cannot test isNarrow guard');
}
await ctxD.close();
}
// ────────────────────────────────────────────────────────────────
// Phone viewport again for (cov8/9/10).
// ────────────────────────────────────────────────────────────────
{
const ctxP = await browser.newContext({
viewport: { width: 375, height: 812 }, hasTouch: true, isMobile: true,
});
const pP = await ctxP.newPage();
pP.setDefaultTimeout(15000);
pP.on('pageerror', (e) => console.error('[pageerror-phone2]', e.message));
await pP.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded' });
await pP.waitForSelector('#pktBody tr[data-hash]', { timeout: 10000 }).catch(() => {});
await pP.waitForTimeout(200);
// ── (cov8) pointercancel mid-gesture clears state ──
const rC = await rowRect(pP, '#pktBody tr[data-hash]');
if (rC) {
const cx = rC.x + rC.w / 2, cy = rC.y + rC.h / 2;
await synthSwipeCancel(pP, cx + 100, cy, cx - 100, cy, 'pointercancel');
const transformAfter = await pP.evaluate(() => {
const r = document.querySelector('#pktBody tr[data-hash]');
return r ? (r.style.transform || '') : '<no-row>';
});
if (!/translateX/i.test(transformAfter)) {
pass(`(cov8) pointercancel cleared row transform (was "${transformAfter}")`);
} else {
fail(`(cov8) pointercancel left transform="${transformAfter}"`);
}
// Verify subsequent gesture still works (state was reset).
await pP.evaluate(() => document.querySelectorAll('.row-action-overlay').forEach(o => o.remove()));
await synthSwipe(pP, cx + 100, cy, cx - 100, cy);
const overlay = await pP.evaluate(() =>
!!document.querySelector('.row-action-overlay.row-action-overlay-open'));
if (overlay) pass('(cov8) gesture works after pointercancel (state reset cleanly)');
else fail('(cov8) subsequent gesture failed after pointercancel — state leaked');
await clearOverlays(pP);
} else {
fail('(cov8) no row for pointercancel test');
}
// ── (cov9) lostpointercapture mid-gesture clears state ──
const rL = await rowRect(pP, '#pktBody tr[data-hash]');
if (rL) {
const cx = rL.x + rL.w / 2, cy = rL.y + rL.h / 2;
await synthSwipeCancel(pP, cx + 100, cy, cx - 100, cy, 'lostpointercapture');
const transformAfter = await pP.evaluate(() => {
const r = document.querySelector('#pktBody tr[data-hash]');
return r ? (r.style.transform || '') : '<no-row>';
});
if (!/translateX/i.test(transformAfter)) {
pass(`(cov9) lostpointercapture cleared row transform (was "${transformAfter}")`);
} else {
fail(`(cov9) lostpointercapture left transform="${transformAfter}"`);
}
await pP.evaluate(() => document.querySelectorAll('.row-action-overlay').forEach(o => o.remove()));
// Verify next gesture still works.
await synthSwipe(pP, cx + 100, cy, cx - 100, cy);
const overlay = await pP.evaluate(() =>
!!document.querySelector('.row-action-overlay.row-action-overlay-open'));
if (overlay) pass('(cov9) gesture works after lostpointercapture');
else fail('(cov9) subsequent gesture failed after lostpointercapture');
await clearOverlays(pP);
} else {
fail('(cov9) no row for lostpointercapture test');
}
// ── (cov10) findRow nodes-table branch ──
// Navigate to #/nodes and verify the nodes-table swipe path executes.
await pP.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded' });
// Either id="nodesTable" or class="nodes-table" — try both.
await pP.waitForSelector('#nodesTable tr[data-id], .nodes-table tr[data-id], #nodesTable tr[data-hash], .nodes-table tr[data-hash]', { timeout: 8000 }).catch(() => {});
await pP.waitForTimeout(200);
const nRow = await pP.evaluate(() => {
const sels = [
'#nodesTable tbody tr[data-id]', '.nodes-table tbody tr[data-id]',
'#nodesTable tbody tr[data-hash]', '.nodes-table tbody tr[data-hash]',
];
for (const s of sels) {
const r = document.querySelector(s);
if (!r) continue;
const b = r.getBoundingClientRect();
if (b.width === 0 || b.height === 0) continue;
return { sel: s, x: b.left, y: b.top, w: b.width, h: b.height };
}
return null;
});
if (!nRow) {
// Don't fail — fixture may not populate /#/nodes the same way. The
// important branch (findRow nodes-table) is still walked at handler
// registration; record as a soft skip so the suite stays informative.
console.log(' SKIP: (cov10) no rows in #/nodes table at this viewport — branch executes at module load, no assertion possible without rows');
} else {
await clearOverlays(pP);
const cx = nRow.x + nRow.w / 2, cy = nRow.y + nRow.h / 2;
await synthSwipe(pP, cx + 100, cy, cx - 100, cy);
const overlay = await pP.evaluate(() =>
!!document.querySelector('.row-action-overlay.row-action-overlay-open'));
if (overlay) pass(`(cov10) findRow accepted nodes-table row (sel=${nRow.sel}) — overlay shown`);
else fail(`(cov10) findRow did not produce overlay on nodes-table row (sel=${nRow.sel})`);
await clearOverlays(pP);
}
await ctxP.close();
}
await browser.close();
console.log(`\ntest-touch-gestures-coverage-e2e.js: ${passes} passed, ${failures} failed`);
process.exit(failures > 0 ? 1 : 0);
}
main().catch((err) => {
console.error('test-touch-gestures-coverage-e2e.js: FAIL —', err);
process.exit(1);
});