Files
EventSnap/e2e
fabi 5f702f2b40 fix(audit-5): export disk preflight, media reclaim, low-disk warning, restore docs
Fifth round, all storage. The keepsake could not fit on the documented hardware
and failed halfway through a multi-GB write, leaving the deliverable stuck; the
quota had stopped bounding the disk; and the backup had no restore procedure.

Squashed from 6 commits, original messages preserved below.

──────── fix(export): refuse an export that cannot fit, and stop peaking at two generations

Nothing in export.rs ever asked whether the keepsake would fit. Both archives write
their media `Compression::Stored`, so each is essentially a byte-for-byte second copy
of the originals -- Gallery.zip always, and Memories.zip for every video and every
image at or under 5 MB. On the documented CX33 (80 GB, all three volumes on one
filesystem) the upload quota's fixed point leaves ~40 GB free, and a release spawns
BOTH halves concurrently against it.

The failure is not "the export failed", it is "the deliverable is stuck":

  1. ENOSPC lands partway through a multi-GB write.
  2. The epoch has already moved, so the job row is `failed` at the CURRENT
     generation and readiness (epoch = event.export_epoch AND status = 'done') is
     false -- GET /export/zip 404s.
  3. The last good archive sits on disk, unreferenced and unreachable.
  4. POST /host/export/rebuild, the only escape, re-arms the same doomed write.

Three changes.

Reclaim before building. `prune_stale_export_files` ran only after the new archive
was written, renamed and finalised. That reads as durability but buys nothing: the
moment `invalidate_and_arm` bumps the epoch the old archive is ALREADY unreachable,
so keeping it reserves gigabytes for a download nobody can perform -- and for a
takedown it is content someone explicitly asked to have removed. Peak usage is now
one generation. Narrower than the post-finalize prune on purpose: final archives
only, never a `.tmp` or a `viewer_tmp_` dir, since a superseded worker can still be
streaming into those and at build START is far more likely to be alive.

Preflight the space. SUM(original_size_bytes) over exactly `query_uploads`'
visibility filter, +10% for ZIP overhead, multiplied by the number of armed jobs --
without that multiplier each of the two concurrent halves independently sees "it
fits" and together they don't. Runs AFTER claim_job, not before as reported: bailing
before the claim leaves the row `pending` with no worker and no error, the
spinner-forever state `mark_failed`'s status guard exists to prevent. Fails open when
the mount can't be read, exactly as the upload quota does.

Show the host the reason. /export/status returned {status, progress_pct} and nothing
else, so the host dashboard could only render "fehlgeschlagen" next to the retry
button. The message was written to the row and surfaced solely in the ADMIN job list
-- a different screen, possibly a different person. It now travels with the status,
and only on a failure, so a message left on a since-succeeded row can't appear beside
a green "ist bereit".

Tests: 10 unit (the u128 clamp caught a real bug in the first draft -- saturating_mul
then /100 turns an overflow into a number ~100x too small, the one direction that
authorises the write being guarded against; the carried-forward archive must survive
its own older epoch in the filename), 4 DB-backed (the estimate is asserted against
the row set the archive actually contains, not against a restatement of the WHERE
clause, so the two queries cannot drift), 3 e2e over the four-hop plumbing.

──────── fix(maintenance): reclaim the media of deliberately deleted uploads

The quota stopped bounding the disk. `soft_delete_in_event` stamps `deleted_at` and
refunds `total_upload_bytes`, but nothing ever removed the bytes, and the hourly
sweep reached only `compression_status = 'failed'`. Upload 500 MB, delete, quota back
to zero, upload another 500 MB. Not an attack -- a guest curating their camera roll,
which is what people do. The host then sees guests hitting "Du hast dein Upload-Limit
erreicht" while the admin widget shows a disk full of files no upload row points at,
and the quota message is actively misleading because the space really is gone, just
not to anyone the accounting can name.

Two retention windows, because the two deletes mean different things. A compression
failure keeps its 14 days: the guest didn't ask for it and may not be able to retake
the photo. A deliberate removal gets 24 hours -- 14 days outlives the whole event, so
a deliberate delete would never reclaim anything while it mattered, and a day still
covers a mis-tap.

Wider than reported: ALL FOUR paths are reclaimed, not just the original. Preview,
display and thumbnail are each a separate file, none counted in
`original_size_bytes`, and nothing ever removed them either. That was invisible while
the sweep only saw failed compressions (which produce no derivatives) and becomes
three leaked files per upload the moment it reaches a successful one. A row is
re-selected until every path is cleared, and the columns are cleared only once every
file for that upload is gone -- clearing after a partial success would strand the
survivors in exactly the unowned state this drains.

`backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT
NULL`, which is close enough to the post-sweep state to be worth pinning: it is
guarded on `deleted_at IS NULL`, so it cannot re-decode an original that is no longer
on disk. Covered.

Residual, deliberately: within the 24h window the bytes are still spent and still
unaccounted, so delete-and-re-upload through an eight-hour event can outrun the
sweep. Bounding that means holding the quota until the file is reclaimed rather than
refunding at `deleted_at`. The low-disk warning is the net under it.

Tests: 6 DB-backed, replacing 3. The one asserting an owner-deleted upload IS
reclaimed is the exact inverse of what this file used to assert.

──────── feat(host): warn about low disk before it becomes unrecoverable

Storage visibility existed in exactly one place: a passive Speicherauslastung widget
on the ADMIN dashboard. A host who isn't the admin had no view of it, and nothing
warned anyone. README carried "Low-disk alert (< 10 GB free)" under Planned since v1.

Two things make this a safety net rather than a nice-to-have. postgres_data,
media_data and exports_data are all Docker named volumes on ONE filesystem, so
running out doesn't degrade a subsystem -- Postgres stops being able to write and the
whole event goes down. And the keepsake needs room for two gallery-sized archives,
which the export preflight can only ever refuse AFTER the release, when the event is
over and every remedy is harder.

So the threshold is not a fixed number alone. It fires on the 10 GB floor the README
always named, OR on "you could not build the keepsake right now" -- the trigger a
host can still act on, computed with the same arithmetic the preflight uses. Unknown
free space is NOT low: it fails open like the upload quota and the preflight do,
because a banner that cries wolf on an unreadable mount is a banner nobody reads.

Carried on GET /host/event, which the dashboard already fetches on load and on every
reload -- no new endpoint, no new poll. Rendered above everything else including the
PIN-reset queue, and it names the consequence (the event, not just the download)
rather than only the number.

Also fixes the host page's formatBytes, which topped out at MB: 30 GB free would have
rendered as "30720.0 MB", and a guest with 2 GB of uploads was already being shown
that way in the user list.

Tests: 5 unit on the threshold (including that plenty of free space is still low when
the keepsake wouldn't fit -- the case a fixed threshold misses entirely), 3 e2e.
The e2e drives it through `original_size_bytes` rather than a genuinely full disk:
the estimate is pure SQL over that column, so overstating one row moves the
accounting without touching a byte on disk.

──────── docs: add a restore procedure, fix the backup cadence, and correct quota_tolerance

Four things, all found by the same question: what does an operator standing at the
venue actually need?

A RESTORE PROCEDURE. There was none anywhere, and a backup you have never restored
isn't a backup. Two hazards worth writing down: media must be extracted preserving
ownership (the app runs as uid 100 / gid 101, and a root-owned restore makes every
upload fail with EACCES surfacing as a generic 500), and the app must be STOPPED
first, because migrations run on boot and a live pool will fight the restore.

Both the backup and the restore commands were run against the real stack before being
written down, which caught two that would have failed:

  - The plain `pg_dump` did not restore: `psql` aborted on `ERROR: schema
    "_sqlx_test" already exists`. pg_dump emits no DROPs without --clean --if-exists,
    so the documented dump could only ever be restored into an empty database. Fixed
    at the source (the dump is now self-cleaning) and verified end to end: 16 tables
    back, exit 0.
  - `--same-owner` does not exist in BusyBox tar, which is what `alpine` ships, so
    the extract aborted before unpacking anything. `--numeric-owner` plus the
    explicit chown, verified to land 100:101.

BACKUP CADENCE. "Weekly offsite" is the wrong shape when every irreplaceable byte is
created in one eight-hour window and nobody can retake a wedding. The backup that
matters runs that night, and again after the release so the keepsake is captured.
Also: take the DB dump and the media tarball back to back, or you get rows pointing
at files the dump doesn't know about.

quota_tolerance WAS DOCUMENTED AS SOMETHING IT ISN'T. .env.example called it "fraction
of disk that triggers the low-storage warning". It is the multiplier in
`floor(free_disk * tolerance / active_uploaders)` -- so an operator who wants "warn me
later" and sets 0.95 is actually authorising guests to fill 95% of the disk, moving
the fixed point from 43% to ~49% and eating the export headroom. The admin UI labelled
it "Toleranz (0-1)" with no explanation at all, which invites exactly that reading;
it is now "Speicher-Anteil für Gäste" with the formula in the hint. Wrong docs on a
tuning knob are worse than no docs.

SIZING. New section with the arithmetic: three volumes on one filesystem, the quota
fixed point at tolerance/(1+tolerance), and the fact the 80 GB baseline does not cover
the keepsake -- both archives are built concurrently and each is roughly a second copy
of every original. Provision ~3x expected media, or give exports its own volume.

Also ticks the low-disk alert off the roadmap, since it now exists.

──────── chore: raise the db memory limit and rate-limit social writes

Two smaller operational items.

POSTGRES 512M -> 1G. DATABASE_MAX_CONNECTIONS is 30 for a ~100-guest event (feed
polling + SSE + uploads at once), and 30 backends plus Postgres 16's default
shared_buffers leaves very little headroom at 512M. An OOM here doesn't degrade one
feature -- every request path touches the database, so it takes the event down.
Memory is the cheaper knob than shrinking the pool back and reintroducing the
queueing it was raised to fix. .env.example now names the pairing explicitly, the way
it already does for COMPRESSION_WORKER_CONCURRENCY.

SOCIAL WRITES WERE UNTHROTTLED. toggle_like, add_comment and delete_comment were the
only mutating endpoints in the app with no limit at all -- upload, join, recover,
export and admin login all carry one. Asymmetric coverage rather than a deliberate
decision.

Low severity, and honestly so: a like fans an SSE broadcast to every client, but the
export regeneration a comment deletion triggers is contained (REGEN_DEBOUNCE 20s,
workers born with their epoch, superseded ones inert). So the ceiling is 120/min --
far above anything a real guest produces. This bounds a script, not an enthusiastic
double-tapper.

ONE bucket across all three actions: separate buckets would let a caller triple the
aggregate write rate by alternating between them. Keyed per USER, matching the feed
and upload limits -- at a venue every guest is behind one NAT, and an IP key is what
made the /join and /feed limits turn guests away in the first place.

Migration 020 seeds both keys, and both are wired into the admin allowlist, the
config UI and the e2e reseed -- the step two earlier per-area toggles missed, which
left switches that existed in code and could never be flipped.

Tests: 4 e2e, including that the shared bucket really is shared (the part most likely
to be lost in a refactor) and that one guest hitting the ceiling doesn't block
another behind the same IP.

──────── fix(e2e): stop the video poster assertion racing the ffmpeg thumbnail

Pre-existing, and it fired for real during the full-suite run on a cold stack.

The lightbox binds `poster={upload.thumbnail_url ?? undefined}`, so the attribute is
absent until compression produces the thumbnail. This test asserted on it immediately
after seeding, never waiting for the worker -- unlike the Range test further down the
same file, which does poll. Against a warm stack the worker usually wins; against a
freshly rebuilt one (`stack:down -v`, cold ffmpeg) it doesn't.

That is the worst possible time for a false failure: the first run after a rebuild is
exactly when you are trying to establish whether a change broke something. Poll for
`compression_status = 'done'` before the poster assertion. The `src` assertion needs
no wait and keeps none.

Verified with --repeat-each=3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:10:54 +02:00
..

EventSnap E2E Suite

Playwright-driven end-to-end tests for the EventSnap stack. The suite spins up an isolated docker-compose stack on ports :3101 (Caddy → frontend + backend) and :55432 (Postgres), and exercises the SvelteKit frontend against a real Rust backend with rate limits and quotas disabled.

Phases 1, 2, and 3-mobile-gestures are landed:

  • Phase 1 — happy-path coverage of every documented user journey, plus a smoke matrix across nine browser/UA profiles to catch engine-level divergences.
  • Phase 2 — adversarial inputs (XSS, SQL-injection, JWT forgery, MIME spoofing, oversize, brute-force) and browser chaos (storage purge, offline/slow-3G, multi-tab, clock skew, no-JS, quota exhaustion).
  • Phase 3 (gestures only) — touch-target audit, safe-area structural check, long-press → ContextSheet, double-tap → like, viewport reflow, plus test.fixme stubs for planned gestures (lightbox swipe, swipe-down dismiss, pull-to-refresh).

Phase 3 real-device compat (Android emulator + Samsung Internet via connectOverCDP, BrowserStack), visual regression, and a11y audits are sketched in the Roadmap at the bottom.

Quickstart

cd e2e
npm install
npm run install:browsers      # one-time: ~500 MB across chromium/firefox/webkit

# 1. Boot the test stack (rebuilds backend + frontend Docker images)
npm run stack:up

# 2. Wait ~20s for migrations + warmup, then run tests
npm run test:e2e              # full Phase 1 suite on chromium-desktop
npm run test:e2e:smoke        # cross-UA smoke matrix (~9 projects × 1 test)
npm run test:e2e:ui           # interactive Playwright UI mode

# 3. After: tear the stack down (deletes volumes)
npm run stack:down

The CI workflow at .github/workflows/e2e.yml runs both jobs on every PR.

What's tested

Every spec covers a journey from docs/USER_JOURNEYS.md or a security/chaos scenario. One folder per area:

Folder Phase Journeys / Topic Tests Notes
specs/01-auth/ 1 §1, §2, §3, §11, §15 13 Join, recover, PIN lockout, admin login, leave event.
specs/02-upload/ 1 §5, §6, §18 5 Gallery picker, multi-file, rate-limit, admin toggle.
specs/03-feed/ 1 §7, §8, §17 5 Like/comment SSE, filter chips, SSE reconnect.
specs/04-host/ 1 §9 5 Event lock, ban/unban, role change.
specs/05-admin/ 1 §11, §16 11 Config validation, foundational auth guards, stats.
specs/06-export/ 1 §12 3 Status, release, download stub.
specs/__smoke/ 1 (matrix) 1 × 9 UAs @smoke-tagged happy-path on every UA project.
specs/07-adversarial/ 2 Input attacks, file upload boundaries, JWT forgery, brute-force, deep authorization, small DDoS ~40 See breakdown below.
specs/08-browser-chaos/ 2 Storage purge, IndexedDB, offline/slow-3G, multi-tab, no-JS, clock skew, quota ~20 See breakdown below.
specs/09-mobile/ 3 Touch-target audit, safe-area, long-press, double-tap, viewport reflow, fixme stubs 23 Runs only on chromium-mobile (Pixel 7 viewport). See below.

Phase 2 — adversarial (specs/07-adversarial/)

  • xss-injection.spec.ts — 13 tests. Six XSS payloads × display-name path
    • four SQLi patterns + length/encoding edge cases (NUL byte, RTL override, caption overflow). Asserts window.__xssFired never gets set and no dialog event fires.
  • ui-rendering.spec.ts — 2 tests. Belt-and-braces: even when a script- payload sits in localStorage as the user's display name, rendering through /account keeps it as text.
  • file-upload-attacks.spec.ts — 9 tests. ELF body claimed as JPEG, oversize image vs max_image_size_mb, zero-byte, missing file field, path-traversal filename, NUL filename, application/* declared category bypass, SVG-with-script.
  • auth-tampering.spec.ts — 8 tests. alg:none forging admin role, signature tamper, payload tamper with original signature, logged-out session reuse, header without Bearer , missing Authorization, PIN brute-force lockout, admin password brute-force (documented finding — no lockout today, bcrypt cost is the only defense).
  • authorization-deep.spec.ts — 6 tests. Cross-user comment delete, banned user across like/comment/feed-read, host→admin escalation attempts.
  • ddos.spec.ts — 4 small-scale abuse tests. 20 parallel /join, 10 MB comment body, 10 concurrent SSE streams, malformed JSON.

Phase 2 — browser chaos (specs/08-browser-chaos/)

  • storage-purge.spec.ts — 5 tests. localStorage.clear() mid-session, cookies cleared (JWT in localStorage still works), sessionStorage cleared, admin force-relogin, PIN intentionally survives clearAuth.
  • indexeddb.spec.ts — 2 tests. Drop all IDB databases mid-session; stub IDB to undefined before navigation.
  • offline-network.spec.ts — 4 tests. setOffline(true) → reconnect, slow-3G via page.route delay, intermittent 503s, 429 from server (no infinite retry storm).
  • multi-tab.spec.ts — 3 tests. Same user two tabs, two users two contexts (storage isolated), logout in tab A doesn't sync to tab B (documented gap).
  • environment.spec.ts — 5 tests. JS disabled, localStorage quota exhausted, hostile CSS hiding nav, clock skew ±1h / -2d.

Pending tests covering features that need a Node-side multipart upload helper are marked test.fixme and will activate when that helper lands.

Browser & UA matrix

Project Engine UA / Device Why
chromium-desktop Chromium Desktop Chrome Baseline. Full suite runs here.
chromium-pixel7 Chromium Pixel 7 device descriptor Chrome Android.
chromium-galaxy-s22 Chromium Galaxy viewport + Samsung phone UA Chrome on Samsung hardware.
samsung-internet Chromium Galaxy viewport + SamsungBrowser UA Tier-A Samsung Internet baseline.
edge-android Chromium Pixel viewport + EdgA UA Edge Mobile (Blink-based).
chrome-ios Chromium iPhone viewport + CriOS UA Chrome iOS (actually WebKit, but UA differs).
webkit-iphone WebKit iPhone 14 Pro Real iOS Safari engine.
firefox-android Firefox Pixel viewport + Firefox Android UA Gecko engine.
firefox-desktop Firefox Desktop Firefox FF-specific quirks.

Only the @smoke happy-path runs across all projects (controlled by grep in playwright.config.ts). The full Phase 1 suite is chromium-desktop-only by default to keep CI under 15 min.

Samsung Internet — three escalation tiers

Samsung Internet ships on every Galaxy phone (~5% of mobile traffic in DE). It's Blink-based, so Tier-A catches ~90% of regressions. Real Samsung divergences (Smart Switch save-data mode, dark-mode injection, custom autoplay, in-browser ad blocking) are only reproducible at Tier B+:

  • Tier A (this repo, free, in CI): Playwright Chromium with the Samsung Internet user-agent + Galaxy viewport. See the samsung-internet project in playwright.config.ts.
  • Tier B (free, manual, future): Android Studio emulator on Linux → install Samsung Internet APK → enable --remote-debugging-port=9222chromium.connectOverCDP('http://localhost:9222'). Setup docs live in docs/samsung-emulator.md (to be written).
  • Tier C (paid, optional): BrowserStack or LambdaTest cloud devices. Real Galaxy S22/S23 hardware via Playwright's cloud integration.

Test isolation

Every test runs against a freshly truncated database:

  1. global-setup.ts waits for /health, logs in admin, and disables every rate-limit and quota toggle via PATCH /admin/config.
  2. The auto-fixture truncate in fixtures/test.ts calls POST /api/v1/admin/__truncate before every test.
  3. The truncate endpoint is only registered when the backend is started with EVENTSNAP_TEST_MODE=1 (see backend/src/main.rs and backend/src/handlers/test_admin.rs). Production builds return 404.

Single-worker by design (workers: 1 in the config). Per-worker isolated DBs are a Phase-2+ change.

Architecture

e2e/
├── docker-compose.test.yml   # Isolated test stack: db :55432, caddy :3101
├── Caddyfile.test            # Proxies /api/* /media/* /health to backend
├── playwright.config.ts      # UA matrix + smoke grep
├── global-setup.ts           # admin login, rate-limit disable
├── global-teardown.ts        # (no-op; use `npm run stack:down`)
├── fixtures/
│   ├── api-client.ts         # Typed wrapper over /api/v1/*
│   ├── db.ts                 # Direct Postgres escape hatch (locked-PIN, etc.)
│   ├── test.ts               # Central test.extend (guest, host, signIn fixtures)
│   └── media/                # sample.jpg, sample.mp4, not-an-image.jpg
├── helpers/
│   ├── sse-listener.ts       # Async SSE iterator with waitForEvent()
│   ├── storage-helpers.ts    # localStorage/sessionStorage helpers
│   └── fake-media.ts         # Camera permissions (Chromium only)
├── page-objects/
│   ├── join-page.ts          # /join
│   ├── recover-page.ts       # /recover
│   ├── admin-login-page.ts   # /admin/login
│   ├── feed-page.ts          # /feed + bottom nav
│   ├── upload-sheet.ts       # UploadSheet.svelte + /upload
│   ├── lightbox.ts           # LightboxModal.svelte
│   ├── account-page.ts       # /account
│   ├── host-dashboard.ts     # /host
│   ├── admin-dashboard.ts    # /admin
│   └── export-page.ts        # /export
└── specs/
    ├── __smoke/              # @smoke cross-UA matrix (1 spec)
    ├── 01-auth/
    ├── 02-upload/
    ├── 03-feed/
    ├── 04-host/
    ├── 05-admin/
    └── 06-export/

Debugging a failure

  • npm run test:e2e:ui — interactive UI with time-travel and selector probe.
  • npm run test:e2e:headed — watch the browser run live.
  • npm run test:e2e:debug — Playwright inspector with breakpoints.
  • npm run stack:logs — tail backend + Postgres logs during a failure.
  • playwright-report/index.html — opens the HTML report (auto-generated on every run).
  • Trace files (test-results/**/trace.zip) drag-and-drop into https://trace.playwright.dev.

Conventions

  • One assertion per expect. Bundling multiple expects in one statement loses the line-level failure context.
  • Wait on data, not time. Use expect.poll for DB checks; never waitForTimeout in production specs.
  • @smoke tag on each suite's happiest path so the matrix run stays under 2 min.
  • test.fixme for features that need infrastructure not yet built (Node-side multipart upload helper, real video fixtures, etc.). Fixme tests don't fail the suite but show up in the report.
  • Page objects own selectors. Specs never use raw locators.
  • German text in assertions is fine — it's not going to change frequently. When it does, the page object is the only file to update.

Roadmap

Phase 2 — Adversarial & browser chaos landed

See the What's tested table above and the per-file breakdown. Known findings surfaced (documented in tests, not silent failures):

  1. /admin/login has no rate-limit or lockout — bcrypt cost is the only defense.
  2. localStorage 'storage' event is not listened for, so logout in tab A doesn't synchronously sign out tab B (the next 401 from any API call clears it).
  3. SVG uploads currently pass the magic-byte check (depends on infer's detection coverage) — consider adding X-Content-Type-Options: nosniff
    • CSP on /media/* if SVGs are ever expected as user content.

Phase 3 — Mobile gestures (specs/09-mobile/) landed

Runs only on the chromium-mobile project (Pixel 7 device descriptor with hasTouch and isMobile). The chromium-desktop project explicitly ignores this folder via testIgnore in playwright.config.ts.

  • touch-targets.spec.ts — 4 tests. Audits ≥ 44×44 px on bottom nav, FAB, join submit, admin-login submit, PIN-modal buttons. Uses expect.soft so a single failure surfaces the actual bounding-box dimensions instead of stopping the suite.
  • safe-area.spec.ts — 4 tests. Asserts env(safe-area-inset-bottom) is present in the inline style of every bottom-anchored UI element (bottom nav, UploadSheet, ContextSheet), and that the nav stays flush with the viewport bottom on a no-notch emulated device.
  • gestures-longpress.spec.ts — 3 tests. A 600 ms hold on a FeedListCard opens the ContextSheet; a 200 ms tap does not; the click-suppression logic prevents the lightbox from also opening at pointer-up. Driven via page.mouse.down/up because the longpress action listens for pointer events (mouse/touch/pen unified).
  • gestures-doubletap.spec.ts — 2 tests. Double-tap on a feed card image button records a like; double-tap inside the lightbox triggers the heart-burst animation and records a like. Assertions read the like count back via /api/v1/feed so they don't couple to specific badge markup.
  • viewport-reflow.spec.ts — 5 tests. Portrait, landscape, narrow (320×568), phablet (480×1024) — each asserts the bottom nav is visible, the FAB stays roughly centered, and there's no horizontal overflow on <html>. Plus a rotation test that confirms auth survives a viewport resize.
  • planned-gestures.spec.ts — 5 test.fixme stubs documenting the contracts for gestures from journey §17 that aren't shipped yet (lightbox swipe L/R, swipe-down to dismiss UploadSheet, pull-to-refresh, long-press on a comment). Flip test.fixme to test when wiring each gesture.

Driving gestures: the helpers/touch.ts module

  • longPress(page, locator, durationMs) — holds the pointer down for the duration. Default 600 ms beats the action's 500 ms threshold.
  • doubleTap(page, locator) — two mouse.down/up pairs within the doubletap action's 300 ms window.
  • swipe(page, from, to, steps) — gradual mouse-driven move (used by the fixme stubs once swipe gestures land).
  • inlineStyle(locator) / computedStyle(locator, prop) — read raw style attributes (where env(...) strings live) and computed values.

Phase 3 — Real-device compat & visual / a11y (not landed)

  • Long-press own/other post, swipe lightbox L/R, swipe-down dismiss, pull-to-refresh, double-tap like.
  • Safe-area inset visual diff on iPhone notch.
  • Touch-target ≥ 44 px audit.
  • Tier B Samsung Internet via connectOverCDP on Android Studio emulator.
  • Tier C BrowserStack integration (paid, optional).
  • @axe-core/playwright accessibility audits.
  • Visual regression with screenshot diffs.

Out of scope (handed to other tools)

  • Load testing → k6 / Vegeta.
  • API contract testing → backend cargo test integration tests.
  • Static asset auditing → Lighthouse CI.