test(fase-9): WebKit project + swipe-toggle gesture specs

Closes 9.4 (swipe-delete E2E in WebKit — repurposed to the current
swipe-to-check / swipe-to-uncheck gesture, since Fase 5.10 replaced
swipe-to-delete with a double-tap delete flow).

- playwright.config.ts gains a second project named "webkit"
  (devices['iPhone 13']) gated behind RUN_WEBKIT=1 so the suite stays
  green on hosts that do not have the GTK/WebKit system libs
  (libicu74, libxml2, libmanette-0.2-0, libwoff1) installed — those
  need `sudo pnpm exec playwright install-deps`. The chromium project
  ignores `*.webkit.test.ts` so the same file never runs in both.
- Justfile adds `just playwright-install` (chromium + webkit) and
  `just test-webkit` for the on-demand run.
- New tests/e2e/swipe-toggle.webkit.test.ts contains SW-01 + SW-02:
  swipe right on an unchecked row marks it checked; swipe left on a
  checked row marks it unchecked. The handlers in lists/[id]/+page.svelte
  use unified pointer events, so the helper dispatches
  PointerEvent('pointer{down,move,up}') with pointerType:'touch'
  directly — works regardless of the project's hasTouch setting.
- lists/[id]/+page.svelte exposes data-checked and data-name on each
  item-row to give the specs a robust locator without depending on
  text/heading hierarchy.

Note: cannot validate the WebKit runs end-to-end in this sandbox
(missing libicu74/libwoff1 require sudo apt install). The spec is
ready to run under any CI image that has Playwright's standard
linux deps installed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 01:37:42 +02:00
parent ed556ce245
commit cda7321d37
4 changed files with 156 additions and 3 deletions

View File

@@ -0,0 +1,110 @@
/**
* SW-series: swipe-to-toggle gestures in Mobile Safari (Fase 9.4).
*
* History: pre-Fase 5.10 there were two `.skip` swipe-to-delete tests in
* `mobile.test.ts` because Chromium's touch emulation misfires the
* touch* events the gesture handlers expect. That file was removed when
* swipe was repurposed from delete → check-toggle; the gap stayed open.
*
* Fase 9.4 closes the gap by adding a WebKit project to playwright.config
* and these two specs (the only ones gated on touch fidelity):
*
* SW-01: an unchecked item + swipe right → mark checked.
* SW-02: a checked item + swipe left → mark unchecked.
*
* The swipe handlers in `apps/web/src/routes/(app)/lists/[id]/+page.svelte`
* listen to `pointer*` events (unified pointer model), so we synthesise
* the gesture by dispatching PointerEvents with pointerType:'touch'
* directly. We chose this over `page.touchscreen.tap` so the same code
* works whether the project uses `hasTouch: true` (WebKit/iPhone 13) or
* not — it's the event payload the handler cares about.
*/
import { test, expect, type Page } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
const SEED_LIST_PATH = '/lists/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
async function swipeRow(page: Page, itemName: string, dx: number): Promise<void> {
await page.evaluate(
({ name, dxLocal }) => {
const row = document.querySelector(
`[data-testid="item-row"][data-name="${CSS.escape(name)}"]`
) as HTMLElement | null;
if (!row) throw new Error(`row ${name} not found`);
const r = row.getBoundingClientRect();
const sx = r.left + r.width / 2;
const sy = r.top + r.height / 2;
const makeEvent = (type: string, clientX: number) =>
new PointerEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
pointerType: 'touch',
pointerId: 1,
isPrimary: true,
clientX,
clientY: sy,
button: type === 'pointerup' || type === 'pointerdown' ? 0 : -1,
buttons: type === 'pointerdown' || type === 'pointermove' ? 1 : 0
});
row.dispatchEvent(makeEvent('pointerdown', sx));
for (let i = 1; i <= 8; i++) {
row.dispatchEvent(makeEvent('pointermove', sx + (dxLocal * i) / 8));
}
row.dispatchEvent(makeEvent('pointerup', sx + dxLocal));
},
{ name: itemName, dxLocal: dx }
);
}
test.describe('Swipe-to-toggle — Mobile Safari (Fase 9.4)', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.borja);
await page.goto(SEED_LIST_PATH);
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
});
test('SW-01: unchecked item + swipe right → marked checked', async ({ page }) => {
const itemName = `SW-01-${Date.now()}`;
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await input.fill(itemName);
await input.press('Enter');
const unchecked = page.locator(
`[data-testid="item-row"][data-name="${itemName}"][data-checked="false"]`
);
await expect(unchecked).toBeVisible({ timeout: 10_000 });
await swipeRow(page, itemName, 180);
const checked = page.locator(
`[data-testid="item-row"][data-name="${itemName}"][data-checked="true"]`
);
await expect(checked).toBeVisible({ timeout: 10_000 });
});
test('SW-02: checked item + swipe left → marked unchecked', async ({ page }) => {
const itemName = `SW-02-${Date.now()}`;
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await input.fill(itemName);
await input.press('Enter');
await expect(
page.locator(`[data-testid="item-row"][data-name="${itemName}"][data-checked="false"]`)
).toBeVisible({ timeout: 10_000 });
// Check it first via a swipe right.
await swipeRow(page, itemName, 180);
await expect(
page.locator(`[data-testid="item-row"][data-name="${itemName}"][data-checked="true"]`)
).toBeVisible({ timeout: 10_000 });
// Now swipe left to uncheck.
await swipeRow(page, itemName, -180);
await expect(
page.locator(`[data-testid="item-row"][data-name="${itemName}"][data-checked="false"]`)
).toBeVisible({ timeout: 10_000 });
});
});