diff --git a/e2e/helpers/touch.ts b/e2e/helpers/touch.ts index d8a3772..9852ae3 100644 --- a/e2e/helpers/touch.ts +++ b/e2e/helpers/touch.ts @@ -34,6 +34,47 @@ export async function longPress(page: Page, locator: Locator, durationMs = 600) await page.mouse.up(); } +/** + * A sub-threshold press dispatched ENTIRELY in-browser: `pointerdown`, an in-browser + * `setTimeout(holdMs)`, then `pointerup` — all on the element carrying `use:longpress`. + * + * Why not `longPress` (page.mouse + waitForTimeout) for the "quick tap must NOT long-press" + * case: those are three independent CDP round-trips with a Node-side wait between the down + * and the up. Under cross-test load the browser can process the `pointerup` far later than + * `holdMs` of *browser* time, so the app's real 500 ms long-press timer fires and the + * ContextSheet opens on what the test intended as a quick tap. (Observed: a 200 ms-intended + * tap opening the sheet ~1 % of full-suite runs.) Dispatching both events from one + * `evaluate` puts the down→up gap and the app's 500 ms timer on the SAME clock: even if the + * event loop stalls, the earlier-scheduled `pointerup` (holdMs) still fires before the + * later 500 ms timer, so the relationship holds. Mirrors `doubleTap`. + * + * No trusted `click` follows a synthetic `pointerup`, so use this ONLY where the assertion is + * that the long-press did NOT fire — not where a real click must land (see `longPress`). + */ +export async function quickTap(locator: Locator, holdMs = 200) { + await locator.evaluate((el: HTMLElement, ms: number) => { + 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')); + return new Promise((resolve) => + setTimeout(() => { + el.dispatchEvent(mk('pointerup')); + resolve(); + }, ms) + ); + }, holdMs); +} + /** * Two rapid pointer-event pairs within the doubletap action's 300 ms window. * diff --git a/e2e/specs/09-mobile/gestures-longpress.spec.ts b/e2e/specs/09-mobile/gestures-longpress.spec.ts index a1c0925..eca5bcf 100644 --- a/e2e/specs/09-mobile/gestures-longpress.spec.ts +++ b/e2e/specs/09-mobile/gestures-longpress.spec.ts @@ -12,7 +12,7 @@ */ import { test, expect } from '../../fixtures/test'; import { uploadRaw } from '../../helpers/upload-client'; -import { longPress } from '../../helpers/touch'; +import { longPress, quickTap } from '../../helpers/touch'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -67,8 +67,12 @@ test.describe('Mobile — long-press gesture', () => { const card = page.locator('article').filter({ hasText: g.displayName }).first(); await expect(card).toBeVisible({ timeout: 10_000 }); - // Simulate a short press (200 ms — well under the 500 ms threshold). - await longPress(page, card, 200); + // A short press (200 ms — well under the 500 ms threshold), dispatched in-browser so + // its duration is measured on the browser's own clock. `longPress` (page.mouse + + // Node-side waitForTimeout) can't guarantee that under load: the pointerup can reach + // the browser >500 ms after the pointerdown even when Node waited only 200 ms, firing + // the long-press and opening the sheet on a "quick tap". See `quickTap`. + await quickTap(card, 200); // Within 1 s, the ContextSheet must not be open (aria-modal is set only when // open). A quick tap opens the lightbox instead, which is a different element. diff --git a/e2e/specs/09-mobile/sheet-escape.spec.ts b/e2e/specs/09-mobile/sheet-escape.spec.ts index c3c1e59..efe02c8 100644 --- a/e2e/specs/09-mobile/sheet-escape.spec.ts +++ b/e2e/specs/09-mobile/sheet-escape.spec.ts @@ -11,6 +11,13 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => { await signIn(page, g); await page.goto('/account'); + // /account is server-rendered, so its controls exist and look clickable BEFORE Svelte + // hydrates their handlers. The data-mode options are custom `div[role=radio]` (no native + // input fallback), so a pre-hydration click does nothing and the sheet never opens (a + // reproduced ~1% flake). The profile name/expiry are filled in onMount (post-hydration); + // waiting for the real display name is a reliable "the page is interactive now" barrier. + await expect(page.getByText(g.displayName)).toBeVisible(); + // Click the "Original" radio in the Datennutzung section to open the warning sheet. // The radio's accessible name is its title + description ("Original Lädt die // Originaldateien…"), so anchor on the start, not the end. @@ -33,6 +40,10 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => { await signIn(page, g); await page.goto('/account'); + // Wait for /account to hydrate before tapping the (server-rendered) leave button, so the + // click reaches a live handler. See the test above. + await expect(page.getByText(g.displayName)).toBeVisible(); + await page.getByRole('button', { name: /Event verlassen/i }).click(); const sheet = page.getByTestId('confirm-sheet'); await expect(sheet).toBeVisible(); diff --git a/e2e/specs/09-mobile/upload-cancel-confirm.spec.ts b/e2e/specs/09-mobile/upload-cancel-confirm.spec.ts index be18fff..9c66375 100644 --- a/e2e/specs/09-mobile/upload-cancel-confirm.spec.ts +++ b/e2e/specs/09-mobile/upload-cancel-confirm.spec.ts @@ -19,7 +19,13 @@ test.describe('Mobile — upload composer cancel confirmation', () => { await page.goto('/upload'); const caption = page.getByTestId('upload-caption'); - await expect(caption).toBeVisible(); + // The composer is server-rendered, so the textarea is visible (and fillable) BEFORE Svelte + // hydrates its `bind:value`. A fill that lands pre-hydration sets the DOM value but never + // reaches the reactive `caption` state — so `cancel()` sees an empty caption and navigates + // straight to /feed instead of opening the discard sheet (a reproduced ~1% flake). The + // composer auto-focuses the textarea in onMount (which only runs post-hydration), so waiting + // for focus is a reliable "the bindings are live now" barrier before we type. + await expect(caption).toBeFocused(); await caption.fill('a meaningful caption that I do not want to lose'); // Tap the close (X) button in the composer header. @@ -44,6 +50,9 @@ test.describe('Mobile — upload composer cancel confirmation', () => { await signIn(page, g); await page.goto('/upload'); + // Wait for the composer to hydrate (onMount auto-focuses the textarea) before tapping + // Abbrechen, so the click reaches a hydrated button. See the test above. + await expect(page.getByTestId('upload-caption')).toBeFocused(); await page.getByRole('button', { name: 'Abbrechen' }).click(); await page.waitForURL('**/feed', { timeout: 3_000 }); });