/** * Touch-gesture primitives for Phase 3 mobile specs. * * The app uses two custom Svelte actions: * * 1. `longpress` — listens for `pointerdown`, starts a 500 ms timer, * cancels on pointerup / pointermove > 10 px / pointercancel. * [frontend/src/lib/actions/longpress.ts] * * 2. `doubletap` — listens for `pointerup` and times consecutive * releases within 300 ms on roughly the same coordinate. * [frontend/src/lib/actions/doubletap.ts] * * Both listen to pointer events, which fire from mouse, touch, and pen * input. We drive them with `page.mouse` because it works identically * across all Playwright engines and respects the project's mobile * viewport — the action doesn't care whether the underlying device was * touch or mouse. */ import type { Locator, Page } from '@playwright/test'; async function centerOf(locator: Locator): Promise<{ x: number; y: number }> { const box = await locator.boundingBox(); if (!box) throw new Error(`Cannot get bounding box for locator (not visible?)`); return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; } /** Hold the pointer down on `locator` for `durationMs`. Default 600 ms beats the 500 ms long-press threshold. */ export async function longPress(page: Page, locator: Locator, durationMs = 600) { const { x, y } = await centerOf(locator); await page.mouse.move(x, y); await page.mouse.down(); await page.waitForTimeout(durationMs); await page.mouse.up(); } /** * Two rapid pointer-event pairs within the doubletap action's 300 ms window. * * Dispatches synthetic `pointerdown`/`pointerup` directly on the element via * `page.evaluate` (not via `page.mouse`). Why: with real mouse events, the * first tap fires the element's `onclick` synchronously — which on a * FeedListCard image button means the lightbox opens *before* the second tap * lands. That makes mouse-driven double-tap impossible to test in headless * Chromium. Dispatching pointer events bypasses the synthetic click pipeline * entirely, so we can exercise the `doubletap` action's contract directly. */ export async function doubleTap(page: Page, locator: Locator) { await locator.evaluate((el: HTMLElement) => { const rect = el.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const mk = (type: string) => new PointerEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, pointerType: 'touch', isPrimary: true, }); el.dispatchEvent(mk('pointerdown')); el.dispatchEvent(mk('pointerup')); // Second tap < 300 ms later. return new Promise((resolve) => setTimeout(() => { el.dispatchEvent(mk('pointerdown')); el.dispatchEvent(mk('pointerup')); resolve(); }, 50) ); }); } /** Touch swipe from start to end coordinates via the touchscreen API (mobile viewports). */ export async function swipe( page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 10 ) { // page.touchscreen.tap exists but doesn't expose move; fall back to dispatching // raw touch events via page.evaluate when needed. For now, mouse-based pointer // events are equivalent because the app's planned swipe handlers (when they // land) will use pointer events too. await page.mouse.move(from.x, from.y); await page.mouse.down(); for (let i = 1; i <= steps; i++) { const t = i / steps; await page.mouse.move(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t); } await page.mouse.up(); } /** Read computed style on a locator. Useful for asserting `padding-bottom` includes env(safe-area-inset-bottom). */ export async function computedStyle(locator: Locator, prop: string): Promise { return locator.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), prop); } /** Read the raw inline `style` attribute (env() vars expand only inside computed style, not here). */ export async function inlineStyle(locator: Locator): Promise { return locator.evaluate((el) => (el as HTMLElement).getAttribute('style') ?? ''); }