chore: archive v1.1 phase directories to milestones/v1.1-phases/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-18 22:21:38 -04:00
co-authored by Claude Opus 4.8
parent a2890d1542
commit c7955a46b9
243 changed files with 0 additions and 0 deletions
@@ -0,0 +1,182 @@
---
phase: 07-mobile-test-harness
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/pwa/package.json
- apps/pwa/playwright.config.ts
- apps/pwa/vitest.config.ts
- apps/pwa/tsconfig.json
- package.json
autonomous: true
requirements: [TEST-01, TEST-02]
user_setup: []
must_haves:
truths:
- 'playwright test --list reports exactly two projects: iphone and pixel'
- 'Vitest does NOT pick up e2e/*.spec.ts files (no glob collision)'
- 'Both WebKit and Chromium browser engines are installed for @playwright/test'
- 'tsc --noEmit passes in apps/pwa with the new playwright.config.ts and e2e/ files in scope'
artifacts:
- path: 'apps/pwa/playwright.config.ts'
provides: 'Two-project device matrix (iPhone/WebKit, Pixel/Chromium), serviceWorkers block, env baseURL, globalSetup ref, vite-only webServer, trace/artifact config'
contains: "devices['iPhone 14']"
- path: 'apps/pwa/vitest.config.ts'
provides: "exclude e2e/** so Vitest's default *.spec.ts glob does not collide with Playwright specs"
contains: 'exclude'
- path: 'apps/pwa/package.json'
provides: '@playwright/test devDependency + test:e2e scripts'
contains: 'test:e2e'
key_links:
- from: 'apps/pwa/playwright.config.ts'
to: 'apps/pwa/e2e/global-setup.ts'
via: 'globalSetup config option'
pattern: 'globalSetup.*global-setup'
- from: 'apps/pwa/playwright.config.ts'
to: 'PLAYWRIGHT_BASE_URL env'
via: 'use.baseURL env-driven'
pattern: 'PLAYWRIGHT_BASE_URL'
---
<objective>
Stand up the Playwright test-harness foundation in `apps/pwa`: add `@playwright/test` as a dev dependency, install the WebKit + Chromium browser engines, author `playwright.config.ts` with the two-profile device matrix (iPhone/WebKit + Pixel/Chromium), and isolate the new `e2e/*.spec.ts` glob from the existing Vitest `*.spec.ts` default glob. This is the blocking dependency for the seed plan and all spec plans.
Purpose: Every downstream plan (global-setup, layout/calendar/lists specs) imports from `@playwright/test` and runs under this config. Nothing else in the phase can land until the config matrix, browser engines, and glob isolation exist.
Output: `apps/pwa/playwright.config.ts`, the `@playwright/test` dev dep + installed browsers, `vitest.config.ts` exclude, package.json scripts, and `e2e/` brought into the `tsc --noEmit` gate.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-mobile-test-harness/07-CONTEXT.md
@.planning/phases/07-mobile-test-harness/07-RESEARCH.md
@.planning/phases/07-mobile-test-harness/07-PATTERNS.md
@.planning/phases/07-mobile-test-harness/07-VALIDATION.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Install @playwright/test + browser engines, wire package.json scripts</name>
<files>apps/pwa/package.json, package.json</files>
<read_first>
- apps/pwa/package.json — current scripts block (`dev`, `build`, `preview`, `typecheck`, `test`) and devDependencies; mirror naming
- package.json (root) — workspace script convention: `pnpm --filter @familysync/<app> <script>`, `verb:modifier` naming (e.g. `dev:pwa`, `typecheck`)
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Installation" + § "Package Legitimacy Audit" — pinned versions and the SUS-but-approved mysql2 note
- .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/package.json (modify)" — exact script + devDependency additions
</read_first>
<action>
Install via the pnpm workspace filter (NOT npx, NOT root): `pnpm --filter @familysync/pwa add -D @playwright/test` — pin to 1.60.0 (verified current in RESEARCH.md; do not float to `latest`). Then install both engines with system deps: `pnpm --filter @familysync/pwa exec playwright install --with-deps webkit chromium` (the `--with-deps` flag is mandatory — WebKit on Linux needs system libraries; this is the iphone profile's engine per D-04). Do NOT rely on the global `playwright-cli` Chromium — the harness brings its own browser store. Add three scripts to `apps/pwa/package.json` scripts block: `test:e2e` = `playwright test`, `test:e2e:ui` = `playwright test --ui`, `test:e2e:headed` = `playwright test --headed`. Add a root `package.json` workspace script `test:e2e` = `pnpm --filter @familysync/pwa test:e2e`. Confirm `@playwright/test` lands in `devDependencies` (not `dependencies`). mysql2 is already a project dep (used by apps/api) — do NOT add it here; global-setup (Plan 02) imports the existing one. No checkpoint is needed for the mysql2 SUS verdict per RESEARCH.md (already installed, official package).
</action>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa exec playwright --version` prints a 1.60.x version
- `apps/pwa/package.json` lists `@playwright/test` under `devDependencies` and has a `test:e2e` script equal to `playwright test`
- root `package.json` has a `test:e2e` script delegating to `pnpm --filter @familysync/pwa test:e2e`
- WebKit and Chromium binaries are resolvable: `pnpm --filter @familysync/pwa exec playwright install --dry-run webkit chromium` reports both already installed (or installs cleanly)
</acceptance_criteria>
<verify>
<automated>pnpm --filter @familysync/pwa exec playwright --version</automated>
</verify>
<done>@playwright/test@1.60.x is a devDependency in apps/pwa, WebKit+Chromium engines installed, and `test:e2e` scripts exist in both apps/pwa and root package.json.</done>
</task>
<task type="auto">
<name>Task 2: Author playwright.config.ts (two-profile matrix, SW block, env baseURL, vite webServer)</name>
<files>apps/pwa/playwright.config.ts</files>
<read_first>
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Code Examples — playwright.config.ts (complete)" + § "Pattern 1" — canonical config shape
- .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/playwright.config.ts" — config-file shape, analog `defineConfig` convention from vitest.config.ts
- .planning/phases/07-mobile-test-harness/07-UI-SPEC.md § "Device / Viewport Matrix" + § "Rule 7" + § "Rule 8" — descriptor strings, SW-block precondition, env-driven baseURL contract
- apps/pwa/vitest.config.ts — `defineConfig` wrapper convention to mirror
- apps/pwa/vite.config.ts — confirms dev server is :5173 and proxies /api, /health, /callback to :3000 (baseURL points at the vite origin; readiness hits proxied /health)
</read_first>
<action>
Create `apps/pwa/playwright.config.ts` importing `defineConfig, devices` from `@playwright/test`. Set `testDir: './e2e'`, `testMatch: '**/*.spec.ts'`, `fullyParallel: true`, `retries: process.env.CI ? 2 : 0`, `workers: process.env.CI ? 1 : undefined`, `reporter: process.env.CI ? 'github' : 'list'`, and `globalSetup: './e2e/global-setup.ts'` (Plan 02 creates that file — the reference is forward-declared and resolves at run time). In top-level `use`: `baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'` (env-driven per D-08/Rule 8 — never a hardcoded host), `trace: 'on-first-retry'`, `video: 'on-first-retry'`, `screenshot: 'only-on-failure'`. Define exactly two `projects`: `{ name: 'iphone', use: { ...devices['iPhone 14'], serviceWorkers: 'block' } }` and `{ name: 'pixel', use: { ...devices['Pixel 7'], serviceWorkers: 'block' } }` — exact descriptor strings `'iPhone 14'` (WebKit) and `'Pixel 7'` (Chromium) per D-03/D-04; `serviceWorkers: 'block'` on BOTH per D-02/Pitfall 15. Add a `webServer` block managing vite ONLY (D-10): `command: 'pnpm --filter @familysync/pwa dev'`, `url:` same as baseURL, `reuseExistingServer: !process.env.CI`, `timeout: 120_000`. Do NOT add `storageState` anywhere (D-01/Pitfall 14 — auth comes from DEV_AUTH_BYPASS on the API, not a checked-in state file). Do NOT add `toHaveScreenshot` expectations or snapshot config (UI-SPEC Rule 6 — structural assertions only; Schedule-X drift). The webServer manages vite only — API + MariaDB + Redis stay caller-managed (D-09); do not try to start the API from webServer.
</action>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa exec playwright test --list` lists exactly two projects named `iphone` and `pixel` (it will report 0 tests until specs land — that is expected; the project count is what matters here)
- the config file contains `serviceWorkers: 'block'` in both project `use` blocks and contains no `storageState` key
- the config references `PLAYWRIGHT_BASE_URL` for `baseURL` and `globalSetup: './e2e/global-setup.ts'`
- the config contains `reuseExistingServer: !process.env.CI` and `command: 'pnpm --filter @familysync/pwa dev'` in webServer
- grep finds no `toHaveScreenshot` and no `storageState` in the file
</acceptance_criteria>
<verify>
<automated>cd apps/pwa && npx playwright test --list 2>&1 | grep -E '\[iphone\]|\[pixel\]|projects' | head; grep -c "serviceWorkers: 'block'" playwright.config.ts</automated>
</verify>
<done>`playwright.config.ts` exists with two projects (iphone/WebKit, pixel/Chromium), `serviceWorkers: 'block'` on both, env-driven baseURL, globalSetup ref, vite-only webServer, no storageState, no screenshot assertions.</done>
</task>
<task type="auto">
<name>Task 3: Isolate Vitest glob + bring e2e/ into the typecheck gate</name>
<files>apps/pwa/vitest.config.ts, apps/pwa/tsconfig.json</files>
<read_first>
- apps/pwa/vitest.config.ts — current `test:` block (environment jsdom, globals, setupFiles, env TZ); no explicit include/exclude today
- .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/vitest.config.ts (modify)" — the one-line `exclude` diff
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Pitfall 1" + § "Anti-Patterns" (Vitest picks up *.spec.ts) — why exclude is mandatory
- apps/pwa/tsconfig.json — current `include`; confirm whether `e2e/**` is already covered or must be added so playwright.config.ts + e2e specs pass `tsc --noEmit`
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Project Constraints" — `tsc --noEmit` gate must cover e2e/
</read_first>
<action>
In `apps/pwa/vitest.config.ts`, add `exclude: ['e2e/**', 'node_modules/**']` inside the existing `test:` block. This stops Vitest's default `**/*.{test,spec}.{js,ts,tsx}` glob from picking up `e2e/*.spec.ts` (which import `@playwright/test` and would throw `devices is not defined`/import errors under jsdom — Pitfall 1). Do NOT remove or narrow the existing `environment`, `globals`, `setupFiles`, or `env` keys. Then ensure `apps/pwa/tsconfig.json` brings `playwright.config.ts` and `e2e/**/*.ts` into the typecheck program so they pass the project-wide `tsc --noEmit` gate: if the current `include` is `["src"]` or similar and excludes the new files, add `"playwright.config.ts"` and `"e2e"` to `include` (or widen the glob). Verify `tsc --noEmit` is green after the change — but note e2e/global-setup.ts and the spec files do not exist yet (Plan 02/03/04 create them), so at this point the only e2e file to typecheck is whatever exists; the config + tsconfig wiring is the deliverable here, full e2e typecheck is re-verified per spec plan.
</action>
<acceptance_criteria>
- `apps/pwa/vitest.config.ts` `test:` block contains `exclude: ['e2e/**', 'node_modules/**']`
- `pnpm --filter @familysync/pwa test` (vitest run) does NOT attempt to run any `e2e/*.spec.ts` file (no `@playwright/test` import errors); existing unit suite still passes
- `pnpm --filter @familysync/pwa exec tsc --noEmit` exits 0 with `playwright.config.ts` in scope
- `apps/pwa/tsconfig.json` include covers `playwright.config.ts` and `e2e`
</acceptance_criteria>
<verify>
<automated>cd apps/pwa && pnpm exec tsc --noEmit && pnpm exec vitest run 2>&1 | grep -vi 'e2e/.*spec' | tail -5</automated>
</verify>
<done>Vitest excludes `e2e/**`, the existing unit suite is green, and `tsc --noEmit` covers `playwright.config.ts` + `e2e/`.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
| -------------------- | -------------------------------------------------------------------------------------------------- |
| harness → dev API | Playwright drives the PWA which calls the API; the API runs with `DEV_AUTH_BYPASS=true` (dev only) |
| repo → CI/production | config + scripts checked into the repo; must not leak dev-only auth posture into production |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | ------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-07-01 | Elevation of Privilege | `DEV_AUTH_BYPASS=true` reaching production | mitigate | Config sets no auth-bypass itself — bypass is API-side and guarded by `NODE_ENV !== 'production'` (devBypass.ts). This plan documents in the README (Plan 02/04) that production compose MUST NOT set `DEV_AUTH_BYPASS`. The harness config only assumes the dev stack already has it. |
| T-07-02 | Information Disclosure | checked-in `storageState.json` with a real OIDC session | accept (designed out) | N/A by D-01 — `playwright.config.ts` deliberately omits `storageState`; auth comes from the dev bypass, never a session file. No session cookie is ever serialized into the repo. |
| T-07-03 | Tampering | `@playwright/test` package install | mitigate | Pinned to 1.60.0 (official Microsoft package, 38.6M wk downloads — RESEARCH.md Package Legitimacy Audit, verdict OK/Approved). No `[ASSUMED]`/`[SUS]`/`[SLOP]` packages introduced; mysql2 (SUS-but-approved) is already a project dep and is not added here. |
</threat_model>
<verification>
- `playwright test --list` reports exactly the two projects `iphone` and `pixel`.
- `vitest run` ignores `e2e/**` (no Playwright import errors); existing unit suite stays green.
- `tsc --noEmit` green in apps/pwa with `playwright.config.ts` in scope.
- Config contains `serviceWorkers: 'block'` (both profiles), env-driven baseURL, no `storageState`, no `toHaveScreenshot`.
</verification>
<success_criteria>
- `@playwright/test@1.60.x` installed as a devDependency in apps/pwa with WebKit + Chromium engines available.
- `playwright.config.ts` defines the iPhone/WebKit + Pixel/Chromium matrix with `serviceWorkers: 'block'`, env baseURL, globalSetup ref, and vite-only webServer.
- Vitest no longer collides with `*.spec.ts`; typecheck gate covers e2e/.
- `test:e2e` scripts exposed at apps/pwa and root.
</success_criteria>
<output>
Create `.planning/phases/07-mobile-test-harness/07-01-SUMMARY.md` when done.
</output>
@@ -0,0 +1,117 @@
---
phase: 07-mobile-test-harness
plan: '01'
subsystem: test-harness
tags: [playwright, e2e, mobile-emulation, vitest, typecheck]
dependency_graph:
requires: []
provides:
- '@playwright/test@1.60.0 devDependency in apps/pwa'
- 'playwright.config.ts with iPhone/WebKit + Pixel/Chromium matrix'
- 'test:e2e scripts in apps/pwa and root package.json'
- 'vitest glob isolation from e2e/**'
- 'tsconfig.e2e.json typecheck gate covering playwright.config.ts + e2e/'
- 'e2e/global-setup.ts stub (Plan 02 will implement)'
affects:
- 'apps/pwa test infrastructure'
- 'Phase 07 plans 0204 (all import from @playwright/test)'
tech_stack:
added:
- '@playwright/test@1.60.0 — Playwright E2E runner with device emulation'
- '@types/node@^22.19.19 — Node type defs for playwright.config.ts'
- 'WebKit browser engine (downloaded to ~/.cache/ms-playwright/webkit-2287)'
- 'Chromium browser engine (downloaded to ~/.cache/ms-playwright/chromium-1223)'
patterns:
- 'tsconfig.e2e.json — separate tsconfig extending main tsconfig with node types, covers e2e/ and playwright.config.ts without contaminating src/ DOM types'
- "vitest exclude: ['e2e/**'] — prevents Playwright *.spec.ts glob collision with jsdom runner"
key_files:
created:
- apps/pwa/playwright.config.ts
- apps/pwa/e2e/global-setup.ts
- apps/pwa/tsconfig.e2e.json
modified:
- apps/pwa/package.json
- apps/pwa/vitest.config.ts
- package.json
- pnpm-lock.yaml
decisions:
- 'D-DEV-TSCONFIG: Added tsconfig.e2e.json (separate tsconfig) rather than polluting apps/pwa/tsconfig.json with Node types — playwright.config.ts uses process.env which requires @types/node; DOM+Node type coexistence in the same tsconfig causes issues for browser-targeted src/**/*'
- 'D-DEV-GLOBALSETUP-STUB: Created e2e/global-setup.ts stub immediately because Playwright resolves globalSetup at config load time (not run time); --list and all config validation requires the file to exist'
- 'D-DEV-TYPECHECK-SCRIPT: Updated typecheck script to run both tsc passes sequentially (src + e2e) so the root pnpm -r typecheck gate covers both'
- 'D-DEV-BROWSERS-NO-DEPS: Used playwright install without --with-deps (requires sudo on this host); system deps for WebKit assumed already present; CI Dockerfile must use --with-deps'
metrics:
duration_seconds: 310
completed_date: '2026-06-11'
tasks_completed: 3
files_changed: 7
---
# Phase 07 Plan 01: Playwright Harness Foundation Summary
**One-liner:** Playwright test harness bootstrap — @playwright/test@1.60.0 with iPhone/WebKit + Pixel/Chromium device matrix, SW block, env-driven baseURL, and vitest/tsc isolation.
## What Was Built
The foundation for the Phase 7 mobile test harness:
- `@playwright/test@1.60.0` installed as a `devDependency` in `apps/pwa` (pinned, not floated)
- WebKit (webkit-2287) and Chromium (chromium-1223) browser engines downloaded to `~/.cache/ms-playwright/`
- `apps/pwa/playwright.config.ts` with two projects (`iphone`/WebKit, `pixel`/Chromium), `serviceWorkers: 'block'` on both, env-driven `PLAYWRIGHT_BASE_URL`, `globalSetup` ref, vite-only `webServer` with `reuseExistingServer`
- `apps/pwa/e2e/global-setup.ts` stub (Plan 02 implements health poll + DB seed)
- `apps/pwa/vitest.config.ts` exclude to prevent Playwright `*.spec.ts` glob collision
- `apps/pwa/tsconfig.e2e.json` for typecheck coverage of `playwright.config.ts` + `e2e/**/*`
- `test:e2e`, `test:e2e:ui`, `test:e2e:headed` scripts in `apps/pwa/package.json`
- Root workspace `test:e2e` delegate script
## Verification Evidence
- `pnpm --filter @familysync/pwa exec playwright --version``Version 1.60.0`
- `playwright test --project=invalid``Available projects: "iphone", "pixel"` (exactly two)
- `pnpm exec vitest run``17 passed (17), 191 passed (191)` — no e2e files attempted
- `pnpm run typecheck` (src + e2e passes) → exits 0
- `playwright.config.ts` grep: `serviceWorkers: 'block'` appears in both project `use` blocks; no `storageState` key; no `toHaveScreenshot`
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] globalSetup path resolves at config load time, not run time**
- **Found during:** Task 2 verification (`playwright test --list`)
- **Issue:** The plan stated "the reference is forward-declared and resolves at run time" but Playwright resolves `globalSetup` at config load time. `--list` failed with `Cannot find module './e2e/global-setup.ts'`.
- **Fix:** Created `e2e/global-setup.ts` as a minimal stub exporting an empty async function. Plan 02 replaces this with the full health poll + DB seed implementation.
- **Files modified:** `apps/pwa/e2e/global-setup.ts` (created)
- **Commit:** 44fea2c
**2. [Rule 3 - Blocking] playwright.config.ts uses process.env — requires @types/node**
- **Found during:** Task 3 `tsc --noEmit` run
- **Issue:** `tsconfig.json` targets `lib: ["ES2023", "DOM", "DOM.Iterable"]` with no Node types. `playwright.config.ts` uses `process.env` which TS resolves from `@types/node`. Running `tsc --noEmit` with `playwright.config.ts` in scope produced 6 `Cannot find name 'process'` errors.
- **Fix:** Created `tsconfig.e2e.json` extending the main tsconfig with `types: ["node"]` and `lib: ["ES2023"]` (no DOM), scoped to `playwright.config.ts` and `e2e/**/*`. Added `@types/node@^22.0.0` to `devDependencies`. Updated `typecheck` script to run both passes. The main `tsconfig.json` `include` stays at `["src/**/*"]` — no DOM/Node type contamination.
- **Files modified:** `apps/pwa/tsconfig.e2e.json` (created), `apps/pwa/package.json`, `apps/pwa/vitest.config.ts`
- **Commit:** 4536987
## Known Stubs
| Stub | File | Line | Reason |
| ------------------------------ | ------------------------------ | ---- | ------------------------------------------------------------------------------------------------------- |
| Empty `globalSetup()` function | `apps/pwa/e2e/global-setup.ts` | 14 | Stub to satisfy Playwright config path resolution; Plan 02 implements health poll + DB seed (D-07/D-08) |
The stub does not prevent this plan's goal (harness foundation). Plan 02 is the direct dependent that resolves it.
## Threat Surface Scan
No new network endpoints, auth paths, file access patterns, or schema changes introduced. The `playwright.config.ts` and `e2e/global-setup.ts` stub are test-infrastructure files only. Threat mitigations from plan threat model:
- T-07-01 (DEV_AUTH_BYPASS in production): Config sets no auth-bypass itself — no new surface.
- T-07-02 (storageState leak): `storageState` key is absent from config — designed out.
- T-07-03 (package legitimacy): `@playwright/test@1.60.0` pinned (38.6M/wk, Microsoft); `@types/node@^22` is a Microsoft DefinitelyTyped package. No slop packages.
## Self-Check: PASSED
- `apps/pwa/playwright.config.ts` — exists
- `apps/pwa/e2e/global-setup.ts` — exists
- `apps/pwa/tsconfig.e2e.json` — exists
- Task 1 commit `0c24f77` — exists
- Task 2 commit `44fea2c` — exists
- Task 3 commit `4536987` — exists
@@ -0,0 +1,154 @@
---
phase: 07-mobile-test-harness
plan: 02
type: execute
wave: 2
depends_on: ['07-01']
files_modified:
- apps/pwa/e2e/global-setup.ts
- apps/pwa/e2e/README.md
autonomous: true
requirements: [TEST-02]
user_setup: []
must_haves:
truths:
- 'global-setup polls baseURL+/health and only proceeds once it returns 200 (fails fast with a clear message on timeout)'
- 'global-setup deterministically resets (TRUNCATE) then seeds: >=1 calendar_event on calendar_id=10, >=1 list owned by user 1, >=2 list_items, >=1 list_shares row for user 1 (D-05 populated half / D-07 seeding in global-setup)'
- 'Seeding is idempotent run-over-run (a second run produces the same row counts, no stale rows, no duplicate-key errors)'
- 'calendar_id=10 is guaranteed present via INSERT IGNORE INTO calendars before the event insert (works on a fresh CI DB and a populated dev DB)'
artifacts:
- path: 'apps/pwa/e2e/global-setup.ts'
provides: 'Playwright globalSetup: /health readiness poll + mysql2 reset-and-seed against dev MariaDB'
contains: 'TRUNCATE'
- path: 'apps/pwa/e2e/README.md'
provides: 'Operator/CI run instructions + the DEV_AUTH_BYPASS / NODE_ENV production guardrail documentation'
contains: 'DEV_AUTH_BYPASS'
key_links:
- from: 'apps/pwa/e2e/global-setup.ts'
to: 'dev MariaDB :3306'
via: 'mysql2 createConnection with DB_* env vars'
pattern: 'mysql.*createConnection'
- from: 'apps/pwa/e2e/global-setup.ts'
to: 'calendar_events.calendar_id=10'
via: 'INSERT IGNORE calendars guard then INSERT calendar_events'
pattern: 'INSERT IGNORE INTO calendars'
---
<objective>
Create the Playwright `globalSetup` (seeding runs in global-setup per D-07) that the harness runs once before any spec: poll the PWA `/health` endpoint until the dev stack is ready (D-08 readiness gate), then deterministically reset-and-seed the dev MariaDB (D-06) so dev-bypass user 1 — who natively has no calendars or lists — renders populated calendar and list views. This is the populated half of the D-05 hybrid strategy (the explicit empty-state assertions live in Plan 04). Document the run/CI invocation and the `DEV_AUTH_BYPASS`/production guardrail.
Purpose: Without seeding, user 1's views are empty and the "populated state" assertions (UI-SPEC Rules 3/5) have nothing to assert against. Without the readiness poll, specs flake on ECONNREFUSED when the stack is still booting (especially in Phase 8 CI). This is the data + readiness precondition every spec plan depends on (TEST-02).
Output: `apps/pwa/e2e/global-setup.ts` and `apps/pwa/e2e/README.md`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-mobile-test-harness/07-CONTEXT.md
@.planning/phases/07-mobile-test-harness/07-RESEARCH.md
@.planning/phases/07-mobile-test-harness/07-PATTERNS.md
@.planning/phases/07-mobile-test-harness/07-VALIDATION.md
@apps/pwa/playwright.config.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: global-setup.ts — /health readiness poll + deterministic reset-and-seed</name>
<files>apps/pwa/e2e/global-setup.ts</files>
<read_first>
- .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/e2e/global-setup.ts" — full global-setup shape, exact column names, the INSERT IGNORE calendars guard, the seed SQL
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Pattern 2" + § "Pitfall 4" + § "Pitfall 2" — health poll, seed, calendar_id=10 FK guard, globalSetup has no Playwright fixtures (plain Node only)
- apps/api/src/db/client.ts — EXACT mysql2 env-var names to reuse: `DB_HOST` (default 127.0.0.1, NOT localhost — per memory api-integration-test-db), `DB_PORT` (3306), `DB_USER` (familysync), `DB_PASSWORD`, `DB_NAME` (familysync)
- apps/api/src/db/schema.ts — confirm exact column names: calendars(`id`,`user_id`,`url`,`display_name`,`color`,`is_shared`); calendar_events(`calendar_id`,`uid`,`etag`,`raw_vevent`,`title`,`dtstart_utc` TIMESTAMP,`dtstart_date` DATE,`all_day`,`has_rrule`); lists(`id`,`owner_id`,`name`,`is_shared`); list_shares(`list_id`,`user_id`); list_items(`list_id`,`text`,`checked`,`rank`)
- apps/api/src/auth/devBypass.ts — DEV_USER.id === 1 (seed targets user_id=1 / owner_id=1)
- apps/api/tests/routes/lists.test.ts — existing seed-helper INSERT shapes for lists/list_items/list_shares (Drizzle there; global-setup uses raw mysql2 but the table/column shape is identical)
</read_first>
<action>
Create `apps/pwa/e2e/global-setup.ts` exporting a default async function (Playwright globalSetup signature; seeding-in-global-setup is D-07). Use ONLY plain Node APIs — `fetch` (native in Node 22) and `mysql2/promise` — NO `@playwright/test` imports (globalSetup runs outside the worker context; importing `page`/`test` throws — Pitfall 2). Step 1 (readiness, D-08): read `baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'`; loop with a 60s deadline polling `fetch(baseURL + '/health')`, break on `res.ok`, swallow ECONNREFUSED, sleep 1000ms between attempts; if the deadline passes without a 200, throw an Error with a clear message (e.g. ``health check never returned 200 at ${baseURL}/health — is the dev stack up?``) so the run fails fast. Step 2 (seed the populated half of the D-05 hybrid, deterministically per D-06): `mysql.createConnection` using the exact env-var names from db/client.ts (`DB_HOST` default `'127.0.0.1'`, `DB_PORT` 3306, `DB_USER` familysync, `DB_PASSWORD`, `DB_NAME` familysync). In a try/finally (finally calls `conn.end()`): `SET FOREIGN_KEY_CHECKS=0`; TRUNCATE in FK-safe order `list_items`, `list_shares`, `lists`, `calendar_events`; `SET FOREIGN_KEY_CHECKS=1`. Then the calendar_id=10 FK guard (Pitfall 4): `INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared) VALUES (10, 1, 'https://caldav.fastmail.com/dav/calendars/e2e/', 'FamilySync', '#4A90D9', true)` — IGNORE makes it a no-op on the dev DB where row 10 already exists and creates it on a fresh CI DB. Then seed one TIMED (not all-day) calendar_event onto calendar_id=10 with a deterministic uid `'e2e-seed-event-001'`, title `'Seeded Test Event'`, a future `dtstart_utc` (ISO UTC string ~tomorrow), a minimal VCALENDAR/VEVENT `raw_vevent`, `etag='e2e-etag-001'`, `all_day=false`, `has_rrule=false`. Then seed one list `INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`, capture `insertId` as `listId`, then `INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)` and `INSERT INTO list_items (list_id, text, checked, rank) VALUES (?, 'Milk', false, 'a0'), (?, 'Eggs', false, 'a1')`. Use fractional-indexing rank strings `'a0'`/`'a1'` (active items render top-section). Seed BOTH `lists.owner_id=1` AND a `list_shares` row (Open Question 3 — belt-and-suspenders so `/api/lists` returns the list whether it filters by owner or by share). Determinism note: because every run truncates first, a second run yields identical row counts — no INSERT IGNORE on the seed rows themselves (D-06 mandates truncate+insert, NOT insert-if-absent). The list name `'E2E Grocery List'` and item texts `'Milk'`/`'Eggs'` are the stable anchors the lists spec (Plan 04) asserts on — do not change them without updating that spec.
</action>
<acceptance_criteria>
- `apps/pwa/e2e/global-setup.ts` imports `mysql2/promise` and contains NO `@playwright/test` import
- it polls `${baseURL}/health` in a bounded loop and throws on timeout
- it executes TRUNCATE on list_items, list_shares, lists, calendar_events (FK checks toggled around it)
- it contains `INSERT IGNORE INTO calendars` with `VALUES (10, 1, ...)` before the calendar_events insert
- it inserts a calendar_event with `calendar_id` 10, a list owned by user 1, a list_shares row (user_id 1), and exactly two list_items (`Milk`, `Eggs`)
- running it twice in a row against the dev DB leaves exactly: 1 calendar_event on cal 10, 1 list, 1 list_shares, 2 list_items (idempotent) — verify with the SQL count in the verify block
</acceptance_criteria>
<verify>
<automated>cd apps/pwa && PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:5173}" node --import tsx -e "import('./e2e/global-setup.ts').then(m=>m.default()).then(async()=>{const mysql=await import('mysql2/promise');const c=await mysql.createConnection({host:process.env.DB_HOST??'127.0.0.1',port:Number(process.env.DB_PORT??3306),user:process.env.DB_USER??'familysync',password:process.env.DB_PASSWORD??'',database:process.env.DB_NAME??'familysync'});const[e]=await c.query('SELECT COUNT(*) n FROM calendar_events WHERE calendar_id=10');const[li]=await c.query('SELECT COUNT(*) n FROM list_items');console.log('events_cal10=',e[0].n,'list_items=',li[0].n);await c.end();process.exit((e[0].n>=1&&li[0].n>=2)?0:1)})"</automated>
</verify>
<done>global-setup polls /health, then deterministically resets and seeds calendar_id=10 (with the INSERT IGNORE guard), one shared list owned by user 1, a list_shares row, and two list_items — idempotent run-over-run.</done>
</task>
<task type="auto">
<name>Task 2: e2e/README.md — run instructions + DEV_AUTH_BYPASS / production guardrail</name>
<files>apps/pwa/e2e/README.md</files>
<read_first>
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Security Domain" + § "Pitfall 5" — the DEV_AUTH_BYPASS production guardrail and propagation requirement
- .planning/phases/07-mobile-test-harness/07-CONTEXT.md § "D-09" — stack bring-up is the caller's responsibility (operator locally, compose in CI)
- .planning/phases/07-mobile-test-harness/07-VALIDATION.md § "Manual-Only Verifications" — the auth-bypass-off manual check
- docs/deployment.md (the "Running locally (host-side, no Docker)" subsection) — the canonical local dev-stack bring-up command to reference, not duplicate
</read_first>
<action>
Create `apps/pwa/e2e/README.md` documenting how to run the harness and the security guardrails. Cover: (1) Prerequisites — the caller brings up the dev stack first (D-09): API + PWA dev servers + dev MariaDB (:3306) + Redis, with `DEV_AUTH_BYPASS=true` set in the API's environment BEFORE the API starts (Pitfall 5 — the harness cannot set it; it must already be active for the API to resolve to Dev User id 1). Reference docs/deployment.md's host-side run command rather than copying it. (2) Run commands: full suite `pnpm --filter @familysync/pwa test:e2e`; single fast profile `pnpm --filter @familysync/pwa exec playwright test --project=pixel`; headed debug `--headed`. (3) Env vars the harness reads: `PLAYWRIGHT_BASE_URL` (default http://localhost:5173), `DB_HOST` (default 127.0.0.1), `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` — all DB creds come from env, NEVER hardcoded (Information Disclosure mitigation). (4) SECURITY GUARDRAIL (Elevation of Privilege): `DEV_AUTH_BYPASS=true` is dev-only — the API guards it behind `NODE_ENV !== 'production'`; the production compose file MUST NOT set `DEV_AUTH_BYPASS`. State this explicitly. (5) No `storageState`: the harness never reads or writes a session-state file (D-01); there is no expiring cookie to refresh, which is why it runs repeatably day-over-day (SC #3). (6) Note that Phase 8 CI consumes these specs unchanged and owns only the stack bring-up + readiness wait. Keep it concise — this is operator-facing reference, not a tutorial.
</action>
<acceptance_criteria>
- `apps/pwa/e2e/README.md` exists and documents the `pnpm --filter @familysync/pwa test:e2e` run command
- it states `DEV_AUTH_BYPASS=true` must be set before the API starts and MUST NOT be set in production (NODE_ENV !== 'production' guard)
- it lists the DB_* and PLAYWRIGHT_BASE_URL env vars and states DB creds are env-only (never hardcoded)
- it states no storageState file is used (D-01)
</acceptance_criteria>
<verify>
<automated>grep -E 'DEV_AUTH_BYPASS|NODE_ENV|storageState|PLAYWRIGHT_BASE_URL|test:e2e' apps/pwa/e2e/README.md | grep -vc '^#'</automated>
</verify>
<done>e2e/README.md documents run commands, the env-var contract, and the DEV_AUTH_BYPASS/production + no-storageState guardrails.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
| -------------------------- | -------------------------------------------------------------------------------------------------- |
| global-setup → dev MariaDB | direct mysql2 connection writes seed rows; credentials cross this boundary |
| harness → dev API | the API runs with `DEV_AUTH_BYPASS=true`; the bypass must never be active in production |
| repo → production | README + seed code checked into the repo; must not normalize the dev-bypass posture for production |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-07-04 | Elevation of Privilege | `DEV_AUTH_BYPASS=true` leaking to production | mitigate | README documents that the bypass is dev-only and guarded by `NODE_ENV !== 'production'` (devBypass.ts); production compose MUST NOT set `DEV_AUTH_BYPASS`. global-setup does not set it (it cannot — it must already be active on the API). |
| T-07-05 | Information Disclosure | DB seed credentials | mitigate | global-setup reads `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` from env exclusively (mirrors db/client.ts); no credential is hardcoded in the seed script or README. |
| T-07-06 | Information Disclosure | checked-in OIDC session state | accept (designed out) | N/A by D-01 — no `storageState.json` is written; the seed touches only fixture rows, never an auth artifact. README states this explicitly. |
</threat_model>
<verification>
- global-setup polls /health and fails fast on timeout.
- Reset-and-seed yields >=1 calendar_event on calendar_id=10 and >=2 list_items, idempotent across two consecutive runs.
- INSERT IGNORE calendars guard satisfies the FK on both a fresh CI DB and the populated dev DB.
- README documents the DEV_AUTH_BYPASS/production guardrail, the env-only DB creds, and no-storageState.
</verification>
<success_criteria>
- `apps/pwa/e2e/global-setup.ts` provides a readiness gate + deterministic reset-and-seed using only plain Node (fetch + mysql2), targeting user 1 / calendar 10.
- Seeding is repeatable run-over-run with no stale rows or duplicate-key failures.
- `apps/pwa/e2e/README.md` documents run, env, and security guardrails.
</success_criteria>
<output>
Create `.planning/phases/07-mobile-test-harness/07-02-SUMMARY.md` when done.
</output>
@@ -0,0 +1,121 @@
---
phase: 07-mobile-test-harness
plan: '02'
subsystem: test-harness
tags: [playwright, e2e, global-setup, db-seed, mysql2, readiness-gate]
dependency_graph:
requires:
- 'apps/pwa/playwright.config.ts (07-01) — globalSetup path reference'
- 'apps/api dev MariaDB :3306 — seed target'
- 'apps/api DEV_AUTH_BYPASS=true — required in API process before harness runs'
provides:
- 'global-setup.ts — /health readiness poll + deterministic reset-and-seed'
- 'e2e/README.md — run instructions and security guardrails'
- 'mysql2@3.22.4 devDependency in apps/pwa'
affects:
- 'Phase 07 plans 03-04 (specs depend on this seed for populated-state assertions)'
- 'Phase 08 CI (globalSetup runs unchanged in the CI runner)'
tech_stack:
added:
- 'mysql2@3.22.4 devDependency in apps/pwa — enables mysql2/promise in global-setup.ts'
patterns:
- 'TRUNCATE + INSERT (not INSERT IGNORE) for seed rows — D-06 deterministic reset'
- 'INSERT IGNORE INTO calendars guard — ensures calendar_id=10 FK satisfied on fresh CI DB (Pitfall 4)'
- 'SET FOREIGN_KEY_CHECKS=0/1 around TRUNCATE — FK-safe truncate ordering'
- 'fetch() for /health poll — native Node.js 22, no @playwright/test import (Pitfall 2)'
key_files:
created:
- apps/pwa/e2e/README.md
modified:
- apps/pwa/e2e/global-setup.ts
- apps/pwa/package.json
- pnpm-lock.yaml
decisions:
- 'D-07-02-mysql2-in-pwa: Added mysql2@3.22.4 as devDependency to apps/pwa — global-setup.ts needs mysql2/promise for TypeScript types; the package was already in the monorepo (apps/api), so pnpm install just linked it without downloading'
- "D-07-02-deadline-check: Added explicit deadline check after the health poll loop to distinguish 'loop exited via break (success)' from 'loop exited via deadline expiry' — ensures throw fires correctly on timeout"
- 'D-07-02-dtend-in-vevent: Added DTEND line to the minimal VCALENDAR seed string for spec compatibility — some CalDAV parsers reject VEVENTs without DTEND'
metrics:
duration_seconds: 196
completed_date: '2026-06-11'
tasks_completed: 2
files_changed: 4
---
# Phase 07 Plan 02: globalSetup Readiness Gate + DB Seed Summary
**One-liner:** Playwright globalSetup with 60s /health readiness poll and deterministic TRUNCATE+INSERT seed onto calendar_id=10 and user_id=1 lists — idempotent run-over-run.
## What Was Built
- `apps/pwa/e2e/global-setup.ts` — full implementation replacing the Plan 01 stub:
- Step 1 (D-08): polls `${PLAYWRIGHT_BASE_URL}/health` with a 60-second deadline; swallows ECONNREFUSED; breaks on first `res.ok`; throws with a clear diagnostic message if the deadline passes
- Step 2 (D-06/D-07): direct mysql2 connection using exact env-var names from `apps/api/src/db/client.ts`; `SET FOREIGN_KEY_CHECKS=0` → TRUNCATE list_items/list_shares/lists/calendar_events → `SET FOREIGN_KEY_CHECKS=1` → INSERT IGNORE calendars guard (id=10) → one timed calendar_event → E2E Grocery List (owner_id=1, is_shared=true) + list_shares row + Milk/Eggs items
- No `@playwright/test` imports — plain Node.js (Pitfall 2 compliant)
- `apps/pwa/e2e/README.md` — operator reference documenting:
- Prerequisites: dev stack (API + PWA + MariaDB + Redis) with DEV_AUTH_BYPASS=true
- Run commands: `pnpm --filter @familysync/pwa test:e2e`, single profile, headed, UI mode
- Env var contract: PLAYWRIGHT_BASE_URL, DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME — credentials env-only, never hardcoded (T-07-05)
- Security guardrail: DEV_AUTH_BYPASS is dev-only, NODE_ENV !== 'production' hard guard, production compose MUST NOT set it (T-07-04)
- No storageState (D-01 — designed out)
- CI scope note: Phase 8 brings up the stack; harness handles its own readiness gate
- `apps/pwa/package.json` — mysql2@3.22.4 added as devDependency (same version as apps/api; pnpm linked without downloading)
## Verification Evidence
- `grep "^import mysql from 'mysql2/promise'" apps/pwa/e2e/global-setup.ts` — found
- `grep "from '@playwright/test'" apps/pwa/e2e/global-setup.ts` — absent (Pitfall 2 pass)
- `grep "TRUNCATE TABLE" apps/pwa/e2e/global-setup.ts` — 4 tables (list_items, list_shares, lists, calendar_events)
- `grep "INSERT IGNORE INTO calendars" apps/pwa/e2e/global-setup.ts` — found with VALUES (10, 1, ...)
- `grep "list_shares\|Milk\|Eggs" apps/pwa/e2e/global-setup.ts` — all present
- `grep -c 'DEV_AUTH_BYPASS|NODE_ENV|storageState|PLAYWRIGHT_BASE_URL|test:e2e' apps/pwa/e2e/README.md` → 15 (acceptance criteria: non-zero count)
- `tsc --noEmit --project tsconfig.e2e.json` (apps/pwa) → 0 errors
- `tsc --noEmit` (apps/pwa src) → 0 errors
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] mysql2 not available in apps/pwa**
- **Found during:** Task 1 TypeScript check — `tsc --noEmit --project tsconfig.e2e.json` emitted `TS2307: Cannot find module 'mysql2/promise'`
- **Issue:** mysql2 is in `apps/api/dependencies` but not linked to `apps/pwa`. The global-setup imports `mysql2/promise` which requires the package to be a direct or devDependency of apps/pwa for TypeScript resolution.
- **Fix:** Added `"mysql2": "3.22.4"` to `apps/pwa/devDependencies` (same version as apps/api to stay in sync). `pnpm install` linked it from the pnpm store in 4s with zero downloads — the binary was already present from apps/api.
- **Files modified:** `apps/pwa/package.json`, `pnpm-lock.yaml`
- **Commit:** 53498e3
**2. [Rule 2 - Missing Critical] Explicit deadline-exceeded throw after poll loop**
- **Found during:** Task 1 implementation review — the research pattern's while loop exits via `break` on success OR when `Date.now() >= deadline`. After the loop, without an explicit check, code would silently proceed to the DB seed on a timed-out poll, causing confusing mysql2 errors rather than a clear "stack is not up" message.
- **Fix:** Added `if (Date.now() >= deadline) { throw new Error(...) }` immediately after the while loop so timeout is distinguishable from success.
- **Files modified:** `apps/pwa/e2e/global-setup.ts`
- **Commit:** 53498e3
**3. [Rule 2 - Missing Critical] DTEND in minimal VCALENDAR seed string**
- **Found during:** Task 1 implementation — minimal VCALENDAR without DTEND may fail CalDAV/ical.js parsing in some spec paths. Plan said "minimal VCALENDAR/VEVENT" but no explicit DTEND.
- **Fix:** Added DTEND line (futureStart + 1 hour) to the VCALENDAR seed string for spec compatibility. Does not affect seed idempotency.
- **Files modified:** `apps/pwa/e2e/global-setup.ts`
- **Commit:** 53498e3
## Known Stubs
None — the Plan 01 stub in global-setup.ts is fully replaced with the real implementation.
## Threat Surface Scan
No new network endpoints, auth paths, or schema changes. All changes are test-infrastructure files only.
Threat mitigations from plan threat model:
- **T-07-04 (Elevation of Privilege / DEV_AUTH_BYPASS):** README explicitly documents that DEV_AUTH_BYPASS is dev-only, that the API guards on `NODE_ENV !== 'production'`, and that the production compose MUST NOT set it. global-setup does not set the env var (it cannot — it runs after the API is already up).
- **T-07-05 (Information Disclosure / DB credentials):** global-setup reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME from env exclusively, mirroring `apps/api/src/db/client.ts`. No credential is hardcoded. README states this explicitly.
- **T-07-06 (Information Disclosure / OIDC session state):** No storageState.json is written by the harness. README states this. Designed out per D-01.
## Self-Check: PASSED
- `apps/pwa/e2e/global-setup.ts` — exists
- `apps/pwa/e2e/README.md` — exists
- Task 1 commit `53498e3` — exists
- Task 2 commit `535ba11` — exists
@@ -0,0 +1,155 @@
---
phase: 07-mobile-test-harness
plan: 03
type: execute
wave: 3
depends_on: ['07-01', '07-02']
files_modified:
- apps/pwa/e2e/layout.spec.ts
autonomous: true
requirements: [TEST-01]
user_setup: []
must_haves:
truths:
- 'On both iphone and pixel profiles (D-03/D-04), BottomTabBar Calendar/Lists tabs each measure >=44x44 CSS px'
- 'The New Event FAB measures >=56x56 and the PhoneNav settings button >=44x44 on both profiles'
- 'Neither /calendar nor /lists has horizontal overflow (documentElement.scrollWidth <= clientWidth) on either profile'
- 'BottomTabBar is visible and fully in-viewport (bottom edge <= viewport height) on both mobile profiles'
- 'Every asserted interactive element is locatable by ARIA role + accessible name (no CSS-selector fallback)'
- 'The harness PROVABLY fails on injected defects: a forced 20px tap target fails Rule 1; a forced 2000px body width fails Rule 2; both pass after the injection is removed'
artifacts:
- path: 'apps/pwa/e2e/layout.spec.ts'
provides: 'UI-SPEC Rules 1-4 assertions (tap targets, overflow, in-viewport, accessible names) + harness self-validation injected-defect proofs'
contains: 'boundingBox'
key_links:
- from: 'apps/pwa/e2e/layout.spec.ts'
to: "BottomTabBar aria-label='Main navigation' + 'Calendar'/'Lists' links"
via: "getByRole('navigation'/'link', { name })"
pattern: 'getByRole'
- from: 'apps/pwa/e2e/layout.spec.ts'
to: 'page.addStyleTag injected-defect proof'
via: 'self-validation must-fail assertions'
pattern: 'addStyleTag'
---
<objective>
Author `apps/pwa/e2e/layout.spec.ts`: the cross-route structural quality-bar assertions (UI-SPEC Rules 1-4) running on both the iPhone/WebKit and Pixel/Chromium profiles (per D-03 two-profile matrix + D-04 faithful engines) with `serviceWorkers: 'block'` on each context (D-02) — tap targets ≥44px, no horizontal overflow, critical elements visible and in-viewport, accessible names present. Bake in the harness self-validation: prove each core assertion FAILS on a deliberately injected defect, then PASSES once removed. This is the proof that the harness measures rendered geometry, not CSS source (TEST-01).
Purpose: These are the mobile-only defect classes the harness exists to catch (sub-44px touch targets, Schedule-X horizontal overflow). A green suite alone does not prove the assertions are live — the injected-defect proofs are the acceptance bar (07-VALIDATION.md § Harness Self-Validation).
Output: `apps/pwa/e2e/layout.spec.ts`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-mobile-test-harness/07-CONTEXT.md
@.planning/phases/07-mobile-test-harness/07-RESEARCH.md
@.planning/phases/07-mobile-test-harness/07-PATTERNS.md
@.planning/phases/07-mobile-test-harness/07-UI-SPEC.md
@.planning/phases/07-mobile-test-harness/07-VALIDATION.md
@apps/pwa/playwright.config.ts
@apps/pwa/src/components/BottomTabBar.tsx
@apps/pwa/src/components/CalendarShell.tsx
</context>
<tasks>
<task type="auto">
<name>Task 1: layout.spec.ts — Rules 1-4 (tap targets, overflow, in-viewport, accessible names) on both profiles</name>
<files>apps/pwa/e2e/layout.spec.ts</files>
<read_first>
- .planning/phases/07-mobile-test-harness/07-UI-SPEC.md § "Rule 1" (explicit element table + 44/56px thresholds + locator strategies), § "Rule 2" (overflow), § "Rule 3" (in-viewport + safe-area-inset), § "Rule 4" (accessible names table), § "Copywriting Contract" (exact aria-labels) — the authoritative assertion contract
- .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/e2e/layout.spec.ts" — Playwright getByRole/boundingBox/page.evaluate pattern, analog from CalendarShell.test.tsx
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Pattern 3" + § "Pattern 4" + § "layout.spec.ts skeleton" — boundingBox + overflow eval idioms
- apps/pwa/src/components/BottomTabBar.tsx — confirms `aria-label="Main navigation"` (nav), `aria-label="Calendar"` / `aria-label="Lists"` links, `minHeight: '44px'`, and that it renders null on desktop via matchMedia('(max-width: 767px)')
- apps/pwa/src/components/AppNav.tsx — settings button `aria-label` contains `— open settings`; AppNav ALSO exposes a nav with aria-label="Main navigation" AND Calendar/Lists links (see action: scope to avoid strict-mode double match)
- apps/pwa/src/components/CalendarShell.tsx — FAB `aria-label="New Event"`; Retry `<button>Retry</button>` (text name); error heading `<h2>Couldn't load events</h2>`
</read_first>
<action>
Create `apps/pwa/e2e/layout.spec.ts` importing `test, expect` from `@playwright/test`. Tests run against BOTH projects automatically (config matrix per D-03/D-04) — write profile-agnostic specs; do not hardcode viewport widths (read `page.viewportSize()` when needed). STRICT-MODE CAVEAT: both `BottomTabBar` (phone) and `AppNav`/`PhoneNav` expose a `navigation` landmark named "Main navigation" and `link`s named "Calendar"/"Lists" — a bare `getByRole('link', { name: 'Calendar' })` may match 2 elements and throw a strict-mode violation. Scope tap-target assertions to the BottomTabBar specifically: locate the bar via its nav landmark, then query links WITHIN it (e.g. `const bar = page.getByRole('navigation', { name: 'Main navigation' }).last()` or scope by the bottom-bar container, then `bar.getByRole('link', { name: 'Calendar' })`). Confirm the correct scoping by reading BottomTabBar.tsx vs AppNav.tsx before writing the locator; if both share the exact landmark name, disambiguate by position (bottom bar is the fixed-bottom one) or add a `.last()`/filter — document the chosen disambiguation in a comment. Implement, on `/calendar` (and `/lists` where the route applies):
Rule 1 (tap targets, UI-SPEC table): BottomTabBar Calendar tab ≥44×44, Lists tab ≥44×44, PhoneNav settings button (`getByRole('button', { name: /open settings/i })`) ≥44×44, New Event FAB (`getByRole('button', { name: 'New Event' })`) ≥56×56 — measure via `await locator.boundingBox()`, assert non-null and width/height thresholds. (Retry button tap target is covered in the calendar error-state spec, Plan 04 — do not duplicate here.)
Rule 2 (overflow): on `/calendar` and `/lists`, `page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth }))`, assert `scrollWidth <= clientWidth`. No allowed exceptions (Schedule-X overflow is the defect to catch).
Rule 3 (in-viewport): assert the BottomTabBar nav `isVisible()` is true and its `boundingBox().y + height <= page.viewportSize()!.height` (safe-area-inset is 0 in emulation); assert the PhoneNav header is visible.
Rule 4 (accessible names): the fact that the Rule 1 locators resolve by role+name already proves accessible names exist; additionally assert the navigation landmark `getByRole('navigation', { name: 'Main navigation' })` is present (scoped per the caveat above). Use relative `page.goto('/calendar')` / `page.goto('/lists')` — NEVER an absolute URL (resolves against config baseURL, Rule 8). Add the standard file header comment (PATTERNS.md § "Test file header comment convention") naming TEST-01 and the run command.
</action>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa exec playwright test layout.spec.ts` passes on BOTH `iphone` and `pixel` projects (dev stack + seed up)
- the spec asserts boundingBox width AND height ≥44 for BottomTabBar Calendar and Lists tabs and the settings button, and ≥56 for the New Event FAB
- the spec asserts `scrollWidth <= clientWidth` on both `/calendar` and `/lists`
- every interactive locator uses `getByRole(...)` with a `name` (no `page.locator('css=...')` / testid fallback for the asserted elements)
- no absolute URL appears in the file (`grep -E "https?://" layout.spec.ts` returns nothing)
- no strict-mode "resolved to N elements" error appears in the run output
</acceptance_criteria>
<verify>
<automated>cd apps/pwa && pnpm exec playwright test layout.spec.ts 2>&1 | tail -15; grep -cE "https?://localhost" e2e/layout.spec.ts</automated>
</verify>
<done>layout.spec.ts asserts UI-SPEC Rules 1-4 (tap targets, overflow, in-viewport, accessible names) on both profiles with role+name locators and relative URLs, no strict-mode collisions.</done>
</task>
<task type="auto">
<name>Task 2: Harness self-validation — injected-defect must-fail proofs (Rules 1 and 2)</name>
<files>apps/pwa/e2e/layout.spec.ts</files>
<read_first>
- .planning/phases/07-mobile-test-harness/07-VALIDATION.md § "Harness Self-Validation" — the four self-validation proofs; items 1 (tap-target injection) and 2 (overflow injection) are automatable here
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Validation Architecture — Harness Self-Validation" — addStyleTag injection approach
- Context7 /microsoft/playwright.dev — `page.addStyleTag`, `expect(...).rejects` / asserting a failing expectation, `test.step` (use Query Documentation if the addStyleTag + must-fail-then-pass pattern needs confirmation)
</read_first>
<action>
Add a `test.describe('harness self-validation — injected defects', ...)` block to `layout.spec.ts` proving the Rule 1 and Rule 2 assertions are LIVE (measuring rendered geometry, not passing vacuously). Approach: do NOT structure these as tests that intentionally fail the suite — structure each as a single passing test that internally proves the assertion would have failed under a defect and passes after removal. For the tap-target proof: navigate to `/calendar`, inject `page.addStyleTag({ content: 'nav[aria-label="Main navigation"] a { min-height: 20px !important; height: 20px !important; }' })` (or the equivalently-scoped BottomTabBar selector), measure the Calendar tab boundingBox, assert its height is now < 44 (proving the measurement tracks the rendered box, not the source CSS). Then remove the injected style — use `page.addStyleTag` returning a handle and `handle.evaluate(el => el.remove())`, OR reload the page to drop the injected tag — re-measure and assert height ≥ 44 again. For the overflow proof: on `/calendar`, inject `page.addStyleTag({ content: 'body { width: 2000px !important; }' })`, evaluate scrollWidth/clientWidth, assert `scrollWidth > clientWidth` (defect detected), then remove/reload and assert `scrollWidth <= clientWidth` (clean). Each proof is one test that PASSES by demonstrating the fail→clean transition; the suite stays green while proving the assertions detect real defects. Keep these in the same file so they share the config matrix (run on both profiles). Confirm the addStyleTag-remove / reload approach against Context7 before finalizing if uncertain about handle lifecycle.
</action>
<acceptance_criteria>
- `layout.spec.ts` contains a self-validation describe block using `page.addStyleTag`
- the tap-target proof asserts boundingBox height < 44 WHILE the 20px style is injected, and ≥ 44 after removal/reload
- the overflow proof asserts scrollWidth > clientWidth WHILE the 2000px-width style is injected, and ≤ clientWidth after removal/reload
- the full `layout.spec.ts` suite (Rules 1-4 + self-validation) is green on both profiles
</acceptance_criteria>
<verify>
<automated>cd apps/pwa && pnpm exec playwright test layout.spec.ts 2>&1 | tail -15; grep -c "addStyleTag" e2e/layout.spec.ts</automated>
</verify>
<done>Self-validation proofs in layout.spec.ts demonstrate the tap-target and overflow assertions fail under injected defects and pass once removed — confirming the harness measures rendered geometry. Suite green on both profiles.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
| ------------------ | -------------------------------------------------------------------------------------------- |
| spec → dev PWA/API | Playwright drives the authed PWA (DEV_AUTH_BYPASS); read-only assertions, no form submission |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | ------------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-07-07 | Tampering | injected `addStyleTag` defect styles leaking between tests | mitigate | Each self-validation proof removes its injected style (handle.remove or page.reload) within the same test before completing; styles are page-scoped and do not persist across navigations/contexts. No global state is mutated. |
| T-07-08 | Information Disclosure | spec hardcoding a host/credential | mitigate | All navigation uses relative paths against the env-driven baseURL (Rule 8); no absolute URL or credential appears in the spec (grep-gated in acceptance). |
| T-07-12 | Tampering | `serviceWorkers: 'block'` (D-02) not applied → SW intercepts and masks a real layout defect | mitigate | The block is set per-context in playwright.config.ts (Plan 01); these specs assume it and Plan 04 asserts no SW controller. A stale Workbox response cannot satisfy a boundingBox/overflow measurement, so the geometry assertions remain authoritative. |
</threat_model>
<verification>
- `playwright test layout.spec.ts` green on both `iphone` and `pixel`.
- Tap-target (≥44/≥56) + overflow + in-viewport + accessible-name assertions present, role+name locators only, relative URLs only.
- Self-validation proves Rule 1 and Rule 2 assertions fail under injected defects and recover.
</verification>
<success_criteria>
- layout.spec.ts enforces UI-SPEC Rules 1-4 on both device profiles (D-03/D-04).
- Harness self-validation proves the assertions are live (injected-defect must-fail-then-pass).
- No strict-mode collisions; no absolute URLs.
</success_criteria>
<output>
Create `.planning/phases/07-mobile-test-harness/07-03-SUMMARY.md` when done.
</output>
@@ -0,0 +1,131 @@
---
phase: 07-mobile-test-harness
plan: '03'
subsystem: test-harness
tags: [playwright, e2e, layout, tap-targets, overflow, accessibility, self-validation]
dependency_graph:
requires:
- "apps/pwa/playwright.config.ts (07-01) — iphone/pixel project matrix, serviceWorkers: 'block'"
- 'apps/pwa/e2e/global-setup.ts (07-02) — /health readiness gate + DB seed (calendar_id=10, E2E Grocery List)'
provides:
- 'apps/pwa/e2e/layout.spec.ts — UI-SPEC Rules 1-4 assertions (tap targets, overflow, in-viewport, accessible names)'
- 'Harness self-validation: addStyleTag injected-defect proofs for Rule 1 + Rule 2'
- '30 tests (15 per profile) — all passing on iphone (WebKit) and pixel (Chromium)'
affects:
- 'Phase 07 plan 04 (calendar.spec.ts / lists.spec.ts share the same harness foundation)'
- 'Phase 08 CI (layout.spec.ts is a PR regression step)'
tech_stack:
added: []
patterns:
- 'boundingBox() — rendered geometry measurement, not CSS-declared values'
- 'page.evaluate(() => scrollWidth/clientWidth) — DOM overflow measurement'
- 'page.addStyleTag + handle.evaluate(el => el.remove()) — injected-defect proof pattern'
- "getByRole('navigation', { name }) scoping — avoids strict-mode collision between BottomTabBar and DesktopNav"
- "getByText('FamilySync', { exact: true }) — avoids matching 'Install FamilySync' install-prompt"
key_files:
created:
- apps/pwa/e2e/layout.spec.ts
modified:
- apps/pwa/e2e/global-setup.ts
decisions:
- "D-03-SCOPE-NAV: On mobile profiles (390px/412px), AppNav renders PhoneNav as <header> (not a nav landmark) — only BottomTabBar exposes <nav aria-label='Main navigation'>. No strict-mode collision in practice, but tap-target locators are scoped inside the nav landmark for robustness."
- "D-03-PHONENAV-TEXT: getByText('FamilySync', { exact: true }) required — the InstallPrompt renders 'Install FamilySync', which getByText('FamilySync') without exact:true matches as a substring, causing a strict-mode violation on WebKit."
- 'D-03-SELF-VALIDATION: Self-validation proofs use addStyleTag + handle.evaluate(el => el.remove()) to inject and remove the defect style within the same test. No page.reload() needed — handle removal is synchronous and immediately clears the injected CSS.'
metrics:
duration_seconds: 480
completed_date: '2026-06-11'
tasks_completed: 2
files_changed: 2
---
# Phase 07 Plan 03: layout.spec.ts Layout Assertions Summary
**One-liner:** layout.spec.ts enforcing UI-SPEC Rules 1-4 (tap targets ≥44/56px, no horizontal overflow, in-viewport, accessible names) on iPhone/WebKit + Pixel/Chromium with addStyleTag injected-defect proofs — 30 tests, 0 failures.
## What Was Built
`apps/pwa/e2e/layout.spec.ts` with four describe blocks covering:
**Rule 1/3/4 — BottomTabBar on /calendar (8 tests per profile)**
- Navigation landmark visible (Rule 4 — accessible name proof)
- Calendar tab boundingBox ≥ 44×44px (Rule 1)
- Lists tab boundingBox ≥ 44×44px (Rule 1)
- BottomTabBar bottom edge ≤ viewport height (Rule 3 — in-viewport, safe-area-inset)
- PhoneNav header "FamilySync" visible (Rule 3)
- Settings button boundingBox ≥ 44×44px (`getByRole('button', { name: /open settings/i })`)
- New Event FAB boundingBox ≥ 56×56px (Rule 1 — larger threshold)
**Rule 1/3/4 — BottomTabBar on /lists (4 tests per profile)**
- Navigation landmark visible on /lists
- Calendar tab ≥ 44×44px on /lists
- Lists tab ≥ 44×44px on /lists
- BottomTabBar in-viewport on /lists
**Rule 2 — No horizontal overflow (2 tests per profile)**
- `scrollWidth ≤ clientWidth` on /calendar
- `scrollWidth ≤ clientWidth` on /lists
**Harness self-validation — injected defects (2 tests per profile)**
- Rule 1 proof: injects `nav[aria-label="Main navigation"] a { height: 20px !important }`, asserts height < 44, removes, asserts height ≥ 44 — proves boundingBox tracks rendered geometry
- Rule 2 proof: injects `body { width: 2000px !important }`, asserts scrollWidth > clientWidth, removes, asserts scrollWidth ≤ clientWidth — proves overflow detection is live
**Total: 30 tests (15 iphone, 15 pixel), 0 failures.**
## Verification Evidence
- `playwright test e2e/layout.spec.ts` (both profiles): `30 passed`
- `grep -E "https?://" apps/pwa/e2e/layout.spec.ts` → empty (no absolute URLs)
- `grep -c addStyleTag apps/pwa/e2e/layout.spec.ts` → 2 (both self-validation proofs present)
- All locators use `getByRole(..., { name })` or scoped-within-nav — no CSS selector fallback
- No strict-mode "resolved to N elements" errors in either profile run
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] WebKit strict-mode violation: `getByText('FamilySync')` matched 2 elements**
- **Found during:** Task 1 first iphone run
- **Issue:** `getByText('FamilySync')` without `exact:true` also matched the `<div>Install FamilySync</div>` text in the InstallPrompt component, causing a strict-mode violation on WebKit (where the install prompt was visible).
- **Fix:** Changed to `getByText('FamilySync', { exact: true })` — matches only the `<span>FamilySync</span>` in PhoneNav.
- **Files modified:** `apps/pwa/e2e/layout.spec.ts`
- **Commit:** 52e14a8
**2. [Rule 3 - Blocking] global-setup.ts: MariaDB TIMESTAMP rejected ISO 8601 format**
- **Found during:** Task 1 execution — global-setup failed before any spec could run
- **Issue:** `futureStart.toISOString().replace(/\.\d+Z$/, 'Z')` produces `'2026-06-12T05:58:35Z'` (with `T` separator), which MariaDB TIMESTAMP rejects with `Incorrect datetime value`. MariaDB requires `'YYYY-MM-DD HH:MM:SS'` format.
- **Fix:** Added `.replace('T', ' ')` and removed the trailing `Z` — produces `'2026-06-12 05:58:35'` which MariaDB TIMESTAMP accepts.
- **Files modified:** `apps/pwa/e2e/global-setup.ts`
- **Commit:** d3c6726 (fix(07-02))
**3. [Observation] DesktopNav nav landmark absent on mobile profiles — no strict-mode risk**
- **Found during:** Component analysis before writing locators
- **Issue:** The plan warned about strict-mode collision between BottomTabBar nav and DesktopNav nav, both named "Main navigation". In practice, on mobile profiles (390px/412px), `AppNav` renders `PhoneNav` (a `<header>`, not a nav), so DesktopNav's nav is absent. No collision occurs.
- **Fix:** Still scoped tap-target locators inside `getByRole('navigation', { name: 'Main navigation' })` for defensive robustness against any future layout change.
- **Files modified:** None (design decision, no code change)
## Known Stubs
None.
## Threat Surface Scan
No new network endpoints, auth paths, or schema changes. `layout.spec.ts` is a test-only file. Threat mitigations from plan:
- **T-07-07 (injected style leakage):** Each self-validation test removes the injected style via `handle.evaluate(el => el.remove())` within the same test before completing. Styles are page-scoped and do not persist across navigations or test contexts.
- **T-07-08 (hardcoded host):** `grep -E "https?://" apps/pwa/e2e/layout.spec.ts` returns empty — all navigation uses relative paths (`/calendar`, `/lists`) that resolve against `playwright.config.ts` `baseURL`.
- **T-07-12 (SW intercept):** `serviceWorkers: 'block'` is set per-context in `playwright.config.ts` (Plan 01). Geometry assertions (boundingBox, scrollWidth) cannot be satisfied by a cached SW response, so the assertions remain authoritative even if the block were bypassed.
## Self-Check: PASSED
- `apps/pwa/e2e/layout.spec.ts` — exists (`git show --stat 52e14a8`)
- `apps/pwa/e2e/global-setup.ts` — modified (fix commit d3c6726)
- Fix commit `d3c6726` — exists
- Task commit `52e14a8` — exists
- 30 tests passing on both profiles — verified by final run output
@@ -0,0 +1,169 @@
---
phase: 07-mobile-test-harness
plan: 04
type: execute
wave: 3
depends_on: ['07-01', '07-02']
files_modified:
- apps/pwa/e2e/calendar.spec.ts
- apps/pwa/e2e/lists.spec.ts
autonomous: true
requirements: [TEST-01, TEST-02]
user_setup: []
must_haves:
truths:
- "On both profiles, /calendar renders the populated (seeded) calendar: the Schedule-X grid is visible and the EmptyState 'Nothing here' is NOT present"
- "On both profiles, /calendar error state (API mocked to 500) shows the 'Couldn't load events' heading and a Retry button >=44px, with no horizontal overflow"
- "On both profiles, /lists renders the populated (seeded) list: the 'E2E Grocery List' card is visible and ListsEmptyState 'No lists yet' is NOT present"
- "On both profiles, /lists empty state (after seed teardown) shows 'No lists yet' + 'Tap + to create...'"
- 'The authed PWA is reached via DEV_AUTH_BYPASS — no Authelia login page, no OIDC mock — and the run produces no SW-sourced responses'
artifacts:
- path: 'apps/pwa/e2e/calendar.spec.ts'
provides: 'Calendar populated + empty + error states (UI-SPEC Rules 4/5) + auth-bypass precondition assertion'
contains: "Couldn't load events"
- path: 'apps/pwa/e2e/lists.spec.ts'
provides: 'Lists populated + empty states (UI-SPEC Rules 4/5)'
contains: 'No lists yet'
key_links:
- from: 'apps/pwa/e2e/calendar.spec.ts'
to: "page.route('/api/events*') fulfill 500"
via: 'error-state simulation registered before goto'
pattern: 'page.route'
- from: 'apps/pwa/e2e/lists.spec.ts'
to: "seeded 'E2E Grocery List' card (role=listitem / link 'Open list: ...')"
via: 'getByRole / getByText on seeded data'
pattern: 'E2E Grocery List'
---
<objective>
Author `apps/pwa/e2e/calendar.spec.ts` and `apps/pwa/e2e/lists.spec.ts`: the route-specific populated / empty / error state assertions (UI-SPEC Rules 4/5) on both device profiles, plus the TEST-02 precondition assertion that the harness reached the authenticated PWA via `DEV_AUTH_BYPASS` (no Authelia login, no OIDC mock) with no service-worker-sourced responses.
Purpose: These specs prove the seeded data (Plan 02) renders correctly, that empty and error states degrade gracefully, and that the auth + SW-block preconditions (D-01/D-02) actually hold at runtime — the heart of TEST-01 (state coverage) and TEST-02 (authed reach).
Output: `apps/pwa/e2e/calendar.spec.ts`, `apps/pwa/e2e/lists.spec.ts`.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-mobile-test-harness/07-CONTEXT.md
@.planning/phases/07-mobile-test-harness/07-RESEARCH.md
@.planning/phases/07-mobile-test-harness/07-PATTERNS.md
@.planning/phases/07-mobile-test-harness/07-UI-SPEC.md
@.planning/phases/07-mobile-test-harness/07-VALIDATION.md
@apps/pwa/playwright.config.ts
@apps/pwa/e2e/global-setup.ts
@apps/pwa/src/components/CalendarShell.tsx
@apps/pwa/src/components/EmptyState.tsx
@apps/pwa/src/components/ListsEmptyState.tsx
@apps/pwa/src/routes/ListsIndex.tsx
</context>
<tasks>
<task type="auto">
<name>Task 1: calendar.spec.ts — populated + error states + auth-bypass / SW-block precondition (TEST-01, TEST-02)</name>
<files>apps/pwa/e2e/calendar.spec.ts</files>
<read_first>
- .planning/phases/07-mobile-test-harness/07-UI-SPEC.md § "Rule 5" (state assertion table for /calendar) + § "Copywriting Contract" (exact strings) + § "Rule 7" (auth + SW preconditions)
- .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Pattern 5" (page.route error-state simulation) + § "layout.spec.ts skeleton" (error-state example) + § "Pitfall 5" (auth-bypass propagation)
- .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/e2e/calendar.spec.ts" — analog CalendarShell.test.tsx, seeded event title 'Seeded Test Event', route is /calendar, prefer ARIA roles over data-testid
- apps/pwa/src/components/CalendarShell.tsx — error branch renders `<h2>Couldn't load events</h2>` + `<button>Retry</button>` (minHeight 44px); FAB `aria-label="New Event"`; the Schedule-X grid wrapper; isError triggers after `retries: 2` on the events query
- apps/pwa/src/components/EmptyState.tsx — empty heading `Nothing here`, body `No events in this period. Try a different date or switch views.`
- apps/pwa/e2e/global-setup.ts (Plan 02) — confirms the seeded event title 'Seeded Test Event' on calendar_id=10 that the populated assertion targets
</read_first>
<action>
Create `apps/pwa/e2e/calendar.spec.ts` importing `test, expect` from `@playwright/test`; runs on both profiles via the config matrix; relative `page.goto('/calendar')` only (Rule 8 — no absolute URL).
POPULATED state (Rule 5, after global-setup seed): goto `/calendar`; assert the Schedule-X calendar grid is visible (prefer a stable ARIA/role anchor; if none exists the codebase exposes `data-testid="schedule-x-calendar"` — use role/landmark first, testid only as the documented fallback for the widget wrapper since Schedule-X provides no semantic role); assert the EmptyState text `Nothing here` is NOT present (`await expect(page.getByText('Nothing here')).toHaveCount(0)`). Optionally assert the seeded event title `Seeded Test Event` is visible — but note Schedule-X renders the current week/month by default and the seed is ~tomorrow, so the chip is only guaranteed visible if tomorrow falls in the default view; if it may not, assert grid-present + empty-absent rather than chip-visible to keep the spec date-stable (DO NOT introduce date-dependent flakiness — this is exactly the Schedule-X drift the phase avoids).
ERROR state (Rule 5): register `await page.route('/api/events*', route => route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }))` BEFORE `page.goto('/calendar')` (route must be registered before navigation — Pattern 5); the events query retries twice (config), so allow time for the error branch; assert `getByRole('heading', { name: "Couldn't load events" })` is visible and `getByRole('button', { name: 'Retry' })` is visible; assert the Retry button boundingBox height ≥44 (Rule 1 within error state); assert no horizontal overflow (scrollWidth ≤ clientWidth) in the error state too. Call `await page.unroute('/api/events*')` (or use `{ times: ... }`) so the mock does not leak to later tests (Pattern 5 caution).
AUTH-BYPASS / SW precondition (TEST-02, Rule 7): add a test that, on goto `/calendar`, asserts the page did NOT land on the Authelia login (assert authed content is present — the BottomTabBar nav `getByRole('navigation', { name: 'Main navigation' })` and that the URL is not redirected to an external auth host) — proving DEV_AUTH_BYPASS reached the authed PWA without an OIDC mock. For SW-block evidence (D-02/Pitfall 15): the `serviceWorkers: 'block'` config option prevents registration; assert no service worker is registered via `await page.evaluate(() => navigator.serviceWorker?.controller)` returning null (no controlling SW), documenting in a comment that trace-level SW-source audit is the post-hoc check per UI-SPEC Rule 7. Header comment names TEST-01/TEST-02 + the run command (PATTERNS.md convention).
</action>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa exec playwright test calendar.spec.ts` passes on both `iphone` and `pixel`
- populated test asserts the calendar grid visible AND `getByText('Nothing here')` count is 0
- error test registers `page.route('/api/events*', ...500)` before goto, asserts the `Couldn't load events` heading + `Retry` button visible, Retry ≥44px, and no horizontal overflow; then unroutes
- auth test asserts authed content (Main navigation landmark) present and no redirect to an external auth host
- SW assertion confirms `navigator.serviceWorker.controller` is null (no controlling SW)
- no absolute URL in the file
</acceptance_criteria>
<verify>
<automated>cd apps/pwa && pnpm exec playwright test calendar.spec.ts 2>&1 | tail -15; grep -cE "https?://localhost" e2e/calendar.spec.ts</automated>
</verify>
<done>calendar.spec.ts asserts populated + error states on both profiles, the Retry tap target + overflow in the error state, and the DEV_AUTH_BYPASS + no-SW-controller preconditions.</done>
</task>
<task type="auto">
<name>Task 2: lists.spec.ts — populated + empty states (TEST-01)</name>
<files>apps/pwa/e2e/lists.spec.ts</files>
<read_first>
- .planning/phases/07-mobile-test-harness/07-UI-SPEC.md § "Rule 5" (state table for /lists) + § "Copywriting Contract" (lists empty heading 'No lists yet', body 'Tap + to create your first shared list')
- .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/e2e/lists.spec.ts" — analog ListDetail.test.tsx, seeded list name 'E2E Grocery List', items 'Milk'/'Eggs', the empty-vs-populated distinction
- apps/pwa/src/routes/ListsIndex.tsx — container `role="list"`; renders `<ListsEmptyState/>` when `lists.length === 0`; list cards render via ListCard
- apps/pwa/src/components/ListCard.tsx — each card is `role="listitem"` with a link `aria-label={`Open list: ${list.name}`}` and the name as visible text
- apps/pwa/src/components/ListsEmptyState.tsx — heading `No lists yet`, body contains `Tap + to create your first shared list`
- apps/pwa/e2e/global-setup.ts (Plan 02) — the seeded list is named 'E2E Grocery List' (owner_id 1, is_shared, + list_shares row) — the stable anchor for the populated assertion
</read_first>
<action>
Create `apps/pwa/e2e/lists.spec.ts` importing `test, expect` from `@playwright/test`; both profiles via the matrix; relative `page.goto('/lists')` only.
POPULATED state (Rule 5, after global-setup seed): goto `/lists`; assert the seeded list card is visible — locate by its accessible link name `getByRole('link', { name: 'Open list: E2E Grocery List' })` (preferred, stable aria-label) OR `getByText('E2E Grocery List')`; assert `getByRole('listitem')` count ≥1; assert the ListsEmptyState text `No lists yet` is NOT present (`toHaveCount(0)`). Assert no horizontal overflow on the populated list view.
EMPTY state (Rule 5): the harness needs an emptied lists view. DO NOT mutate the shared seeded DB mid-suite (that would race the populated test and break determinism — D-06). Instead simulate the empty response at the network layer: register `await page.route('/api/lists', route => route.fulfill({ status: 200, body: JSON.stringify([]) }))` BEFORE goto `/lists`, then assert `getByText('No lists yet')` is visible and `getByText(/Tap \+ to create/)` is visible; assert no horizontal overflow in the empty state; then `page.unroute('/api/lists')`. (Confirm the exact lists endpoint path — `/api/lists` — by reading ListsIndex.tsx's query before finalizing the route glob.) This keeps the seeded populated state intact for the rest of the suite while still proving the empty state renders.
Both states must also satisfy Rule 2 (overflow) — assert it in each. Header comment names TEST-01 + run command.
</action>
<acceptance_criteria>
- `pnpm --filter @familysync/pwa exec playwright test lists.spec.ts` passes on both `iphone` and `pixel`
- populated test asserts the `E2E Grocery List` card visible (by link aria-label or text) AND `getByText('No lists yet')` count is 0
- empty test routes `/api/lists` to a 200 empty array before goto, asserts `No lists yet` + `Tap + to create` visible, then unroutes
- both states assert `scrollWidth <= clientWidth`
- no absolute URL in the file; the seeded DB is not mutated by the spec (empty state is network-simulated)
</acceptance_criteria>
<verify>
<automated>cd apps/pwa && pnpm exec playwright test lists.spec.ts 2>&1 | tail -15; grep -cE "https?://localhost" e2e/lists.spec.ts</automated>
</verify>
<done>lists.spec.ts asserts the seeded populated list and the (network-simulated) empty state on both profiles, with overflow checks and no mutation of the shared seed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| spec → dev PWA/API | Playwright drives the authed PWA via DEV_AUTH_BYPASS; read-only assertions + in-process page.route mocks; no real form writes |
| harness → production | the auth posture asserted here (dev bypass) must never be the production posture |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| T-07-09 | Elevation of Privilege | DEV_AUTH_BYPASS reaching production | mitigate | The spec asserts the authed PWA was reached via the bypass on the DEV stack only; the bypass is API-side and guarded by `NODE_ENV !== 'production'` (devBypass.ts). Production compose must not set it (documented in Plan 02 README). The spec does not enable the bypass; it depends on the dev stack having it. |
| T-07-10 | Spoofing | OIDC/auth mocking masking a broken auth path | accept (designed out) | No OIDC mock is used (D-01) — auth comes from the real dev-bypass middleware; the auth-precondition test asserts genuine authed content, not a faked session. |
| T-07-11 | Tampering | page.route mocks leaking between tests | mitigate | Every `page.route` (events 500, lists empty) is paired with `page.unroute` (or `{ times }`) so the mock does not bleed into the populated/auth tests; the shared seeded DB is never mutated by a spec (empty state is network-simulated, not a DB delete). |
</threat_model>
<verification>
- `playwright test calendar.spec.ts lists.spec.ts` green on both `iphone` and `pixel`.
- Calendar: populated (grid visible, empty-absent), error (heading + Retry ≥44px + no overflow, mock unrouted), auth-bypass reached + no SW controller.
- Lists: populated (seeded card visible, empty-absent), empty (network-simulated 'No lists yet'), overflow clean in both.
- No absolute URLs; no DB mutation from specs.
</verification>
<success_criteria>
- calendar.spec.ts + lists.spec.ts cover populated / empty / error states on both profiles (UI-SPEC Rules 4/5).
- TEST-02 precondition (authed reach via DEV_AUTH_BYPASS, no OIDC mock, no SW controller) asserted at runtime.
- Mocks are scoped and unrouted; seeded data is left intact.
</success_criteria>
<output>
Create `.planning/phases/07-mobile-test-harness/07-04-SUMMARY.md` when done.
</output>
@@ -0,0 +1,118 @@
---
phase: 07-mobile-test-harness
plan: '04'
subsystem: test-harness
tags:
[
playwright,
e2e,
calendar,
lists,
populated-state,
error-state,
empty-state,
auth-bypass,
service-worker,
]
dependency_graph:
requires:
- "apps/pwa/playwright.config.ts (07-01) — iphone/pixel project matrix, serviceWorkers: 'block', globalSetup path"
- "apps/pwa/e2e/global-setup.ts (07-02) — /health readiness gate + DB seed (calendar_id=10 'Seeded Test Event', 'E2E Grocery List' for user_id=1)"
- 'apps/pwa/e2e/layout.spec.ts (07-03) — locator patterns and conventions mirrored'
provides:
- 'apps/pwa/e2e/calendar.spec.ts — TEST-01 (populated + error) + TEST-02 (auth-bypass + SW precondition) assertions for /calendar'
- 'apps/pwa/e2e/lists.spec.ts — TEST-01 (populated + network-simulated empty) assertions for /lists'
- '20 tests total (8 calendar + 12 lists, per profile) — all passing on iphone/WebKit and pixel/Chromium'
affects:
- 'Phase 08 CI (both specs run as regression gates)'
tech_stack:
added: []
patterns:
- "page.route('/api/*', fulfill 500) registered BEFORE page.goto — error-state simulation (Pattern 5)"
- 'page.unroute() immediately after assertion — route mocks scoped to single test (T-07-11)'
- "page.locator('.sx-react-calendar-wrapper') — CSS class fallback for widget wrapper with no semantic role"
- "getByRole('button', { name: 'Open list: E2E Grocery List' }) — aria-label stable anchor on ListCard"
- "page.route('/api/lists', fulfill 200 []) — network-simulated empty state without DB mutation (D-06)"
- 'page.evaluate(() => navigator.serviceWorker.controller) — runtime SW controller assertion'
key_files:
created:
- apps/pwa/e2e/calendar.spec.ts
- apps/pwa/e2e/lists.spec.ts
modified: []
key_decisions:
- "D-04-SCHEDULE-X-LOCATOR: Asserted .sx-react-calendar-wrapper via CSS class (page.locator) since Schedule-X's React adapter emits no semantic role on the outer wrapper div — documented in index.css. No data-testid added to source; the CSS class is stable within @schedule-x/react."
- "D-04-POPULATED-NO-EVENT-CHIP: Populated calendar test asserts grid visible + empty-absent (NOT event chip text) — chip visibility depends on Schedule-X's default view and the seed event date relative to today; date-dependent assertions are exactly the drift the phase avoids (UI-SPEC Rule 6 rationale)."
- 'D-04-EMPTY-STATE-NETWORK-SIM: Lists empty state simulated via page.route to 200 [] rather than DB mutation — preserves seeded populated state for concurrent test workers and satisfies D-06 deterministic seed / T-07-11 mock isolation.'
- "D-04-LISTCARD-ARIA-LABEL: Lists populated test locates card by getByRole('button', { name: 'Open list: E2E Grocery List' }) — ListCard.tsx renders a <button> (not <a>) with that exact aria-label; no link role collision."
patterns-established:
- 'Error-state simulation: register page.route BEFORE page.goto, assert heading+button, then page.unroute'
- 'Empty-state simulation (no DB mutation): page.route to 200+empty-body BEFORE goto, assert empty UI, then page.unroute'
- 'SW-block assertion: page.evaluate(() => navigator.serviceWorker?.controller) — null confirms no controlling SW'
- "Auth reach: getByRole('navigation', { name: 'Main navigation' }) visible + URL hostname check against external auth host"
requirements-completed: [TEST-01, TEST-02]
duration: 22min
completed: '2026-06-11'
---
# Phase 07 Plan 04: calendar.spec.ts + lists.spec.ts State Coverage Summary
**calendar.spec.ts and lists.spec.ts asserting populated/error/empty states on iPhone/WebKit and Pixel/Chromium, with TEST-02 DEV_AUTH_BYPASS and service-worker-block precondition assertions at runtime.**
## Performance
- **Duration:** 22 min
- **Started:** 2026-06-11T05:49:00Z
- **Completed:** 2026-06-11T06:11:47Z
- **Tasks:** 2
- **Files modified:** 2
## Accomplishments
- `apps/pwa/e2e/calendar.spec.ts` — 8 tests per profile (16 total) covering: TEST-02 auth-bypass reach + SW-controller null assertion; populated state (Schedule-X grid visible, EmptyState absent, no overflow); error state (mocked /api/events\* 500, 'Couldn't load events' heading, Retry ≥44px, no overflow, mock unrouted)
- `apps/pwa/e2e/lists.spec.ts` — 6 tests per profile (12 total) covering: populated state (seeded 'E2E Grocery List' card by aria-label, listitem count ≥1, 'No lists yet' absent, no overflow); empty state (network-simulated via page.route to 200 [], 'No lists yet' + 'Tap + to create' visible, no overflow, mock unrouted)
- All 28 tests pass on both iphone (WebKit) and pixel (Chromium); `pnpm --filter @familysync/pwa typecheck` exits 0; no absolute URLs; seeded DB not mutated by any spec
## Task Commits
1. **Task 1: calendar.spec.ts** - `17b625b` (feat)
2. **Task 2: lists.spec.ts** - `b074b4a` (feat)
## Files Created/Modified
- `apps/pwa/e2e/calendar.spec.ts` — TEST-01 + TEST-02 assertions for /calendar (populated, error, auth-bypass, SW-block)
- `apps/pwa/e2e/lists.spec.ts` — TEST-01 assertions for /lists (populated and network-simulated empty)
## Decisions Made
- **D-04-SCHEDULE-X-LOCATOR:** `page.locator('.sx-react-calendar-wrapper')` used to assert calendar grid — the Schedule-X React adapter emits a div with this class but no semantic ARIA role. This is documented in `apps/pwa/src/styles/index.css` as the canonical outer wrapper class. No `data-testid` added to source code.
- **D-04-POPULATED-NO-CHIP:** Populated calendar test asserts grid visible + `'Nothing here'` absent rather than the seeded event chip text `'Seeded Test Event'`. Schedule-X renders only events in the current view window; the seed event is tomorrow UTC but the default view and timezone rendering makes chip visibility date-dependent. The plan explicitly flagged this risk.
- **D-04-EMPTY-NETWORK-SIM:** Lists empty state simulated with `page.route('/api/lists', fulfill 200 { lists: [] })` before `page.goto` rather than by deleting the seeded row. This preserves the deterministic seed for parallel test workers and avoids DB state mutation in specs (D-06 / T-07-11).
- **D-04-LISTCARD-BUTTON:** `ListCard.tsx` renders the card as `<button aria-label="Open list: ...">` (not `<a>`), so the locator uses `getByRole('button', { name: 'Open list: E2E Grocery List' })`.
## Deviations from Plan
None — plan executed exactly as written. All implementation choices were documented as decisions (listed above).
## Known Stubs
None — both spec files are complete implementations with no placeholders.
## Threat Surface Scan
No new network endpoints, auth paths, or schema changes. Both files are test-only.
Threat mitigations confirmed active:
- **T-07-09 (DEV_AUTH_BYPASS elevation):** TEST-02 precondition spec asserts the bypass reached the authed PWA — confirms the dev-only guard is working. The spec does not enable the bypass; it depends on the running dev stack.
- **T-07-10 (OIDC mock spoofing):** No storageState and no OIDC mock used — auth comes from the real DEV_AUTH_BYPASS middleware. Auth reach is asserted via nav landmark presence + URL hostname check (not a faked session).
- **T-07-11 (route mock leakage):** Every `page.route` call in calendar.spec.ts and lists.spec.ts is paired with `page.unroute` immediately after the assertion block. Mocks are page-scoped and do not persist across test contexts.
## Self-Check: PASSED
- `apps/pwa/e2e/calendar.spec.ts` — exists
- `apps/pwa/e2e/lists.spec.ts` — exists
- Task 1 commit `17b625b` — exists
- Task 2 commit `b074b4a` — exists
- 28 tests passing on both profiles — verified by final combined run
- `pnpm --filter @familysync/pwa typecheck` — exits 0
- No absolute URLs: `grep -cE "https?://localhost" e2e/calendar.spec.ts e2e/lists.spec.ts` → both 0
@@ -0,0 +1,117 @@
# Phase 7: Mobile Test Harness - Context
**Gathered:** 2026-06-10
**Status:** Ready for planning
<domain>
## Phase Boundary
Deliver an automated, mobile-emulated, authenticated Playwright harness that drives the FamilySync PWA against the host-side dev stack, so mobile-only layout / tap-target / flow defects are caught automatically rather than only by the operator on real devices. The same specs are the artifact Phase 8 (Gitea CI) runs as its PR UI-regression step.
**In scope:** mobile-emulated browser driving (`@playwright/test`, new dev dep in `apps/pwa`), authenticated via `DEV_AUTH_BYPASS`, structured to run headlessly in CI against a stack the runner brings up.
**Out of scope (stays a human/device gate):** real production-service-worker behavior, iOS-Safari standalone-PWA behavior (Home-Screen install, standalone OIDC redirect, iOS push), live event-create against Fastmail (dev-bypass user 1 has no CalDAV credential / calendars). No backend changes.
</domain>
<decisions>
## Implementation Decisions
### Auth & Service Worker (locked by ROADMAP / PITFALLS — not re-discussed)
- **D-01:** Auth via `DEV_AUTH_BYPASS=true` on the host-side dev stack — **never** a checked-in `storage-state.json` with an expiring session cookie (Pitfall 14). No Authelia/OIDC mocking. Dev-bypass resolves to Dev User id 1.
- **D-02:** Playwright context uses `serviceWorkers: 'block'` so the PWA's `injectManifest` SW (`sw.js`, `registerType: 'autoUpdate'`) cannot intercept requests / return stale cached responses (Pitfall 15). Verify the trace shows no SW-sourced responses.
### Device Emulation
- **D-03:** Run a **two-profile matrix: iPhone + Pixel** — covers both household ecosystems (Apple + Android/Fastmail). The iPhone profile satisfies the hard non-technical-Apple-member UX constraint; Pixel covers Chrome-viewport defects.
- **D-04:** Use **faithful browser engines** per profile: iPhone → **WebKit**, Pixel → **Chromium**. Adds a WebKit browser to the harness/CI image. (Note: this exceeds the existing global `playwright-cli` Chromium tooling — the harness brings its own `@playwright/test` browsers.) SW-block + dev-bypass apply to both profiles.
### Test Data
- **D-05:** **Hybrid** — seed deterministic DB fixtures for populated views **and** keep explicit empty-state assertions. Dev-bypass user 1 natively has no calendars (calendar/list views render empty, live create 422s), so populated coverage requires seeding.
- **D-06:** Seeding is **deterministic and reset per run** (truncate/reset → insert, not insert-if-absent) to guarantee repeatable day-over-day results with no stale state (SC #3). Seed onto the shared calendar (id 10, per prior project memory) + list items so user 1's views render populated.
- **D-07:** Seeding runs in **global-setup** against the dev MariaDB (already port-bound on 3306 via `docker-compose.dev.yml`); teardown/reset keeps runs idempotent.
### Stack Lifecycle / Connection
- **D-08:** Harness targets a **configurable `baseURL`** (env-driven: operator's vite dev server locally, CI service host in Phase 8) with a **readiness gate in global-setup** (wait on `/health` before any spec; mirrors the PITFALLS CI-readiness guidance to avoid flaky ECONNREFUSED).
- **D-09:** **Stack bring-up is the caller's responsibility** — operator's already-running dev stack locally, compose orchestration in Phase 8 CI. The harness never depends on a pre-running stack; it waits for one. Satisfies SC #4.
- **D-10:** Optionally use Playwright `webServer` for **vite only** with `reuseExistingServer: !process.env.CI` (reuse the operator's `pnpm dev` locally, start vite fresh in CI). The API + MariaDB + Redis always stay compose-managed — `webServer` cannot own a multi-container stack.
### Claude's Discretion
- **Assertion approach (D-08-area) — deferred to research.** User wants a robust, low-maintenance, host↔CI-portable pattern and expects this is well-documented prior art. **Steer:** lead with structural / role-based locator assertions + explicit tap-target measurements (computed box ≥ 44px, no horizontal overflow, visibility/position) which are stable across environments. Add `toHaveScreenshot` visual snapshots **only** if research finds a well-established way to keep them non-flaky across host↔CI rendering (CI-generated baselines + tolerance config); otherwise omit screenshots. The Schedule-X calendar widget makes naive pixel snapshots especially drift-prone — weigh that heavily.
- **Stack lifecycle (D-08D-10):** user said "you decide" — decisions above are Claude's recommendation; planner may refine the exact env-var name and webServer wiring.
- Spec file location/structure, trace/artifact capture on failure, and npm-script + Makefile wiring were not discussed — planner's discretion (follow existing conventions: `apps/pwa`, pnpm filters, Makefile-first per global instructions).
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase scope & requirements
- `.planning/ROADMAP.md` § "Phase 7: Mobile Test Harness" — goal, success criteria (4), phase-owned pitfalls, dependency notes.
- `.planning/REQUIREMENTS.md` — TEST-01 (mobile-emulated viewport), TEST-02 (DEV_AUTH_BYPASS auth, dev-build scope, consumed by Phase 8 CI).
### Pitfalls this phase owns (MUST read — they lock D-01/D-02)
- `.planning/research/PITFALLS.md` § "Pitfall 14: Playwright Authed-Mobile Harness Reusing a Stale storage-state" (≈L354) — why `DEV_AUTH_BYPASS`, not storage-state.
- `.planning/research/PITFALLS.md` § "Pitfall 15: Production Service Worker Intercepting Playwright Requests" (≈L375) — `serviceWorkers: 'block'`, verify trace has no SW-sourced responses.
- `.planning/research/PITFALLS.md` quick-reference rows (≈L424425, L491) and the CI-readiness-wait row (≈L409) — readiness gate before specs.
### Codebase conventions
- `.planning/codebase/TESTING.md` — current Vitest setup, test locations, the "E2E not implemented; playwright-cli skill used for smoke tests" gap this phase fills.
- `apps/pwa/vite.config.ts` — vite dev-server proxy (`/api`, `/health`, `/callback` → :3000), `injectManifest` SW config (the SW that D-02 blocks).
- `docker-compose.dev.yml` — dev override exposing MariaDB :3306 / Redis :6379, API `dev` target. The stack the harness targets.
- Project `CLAUDE.md` § "Browser-based verification" — playwright-cli is global Chromium; `@playwright/test` is NOT yet a repo dep (this phase adds it to `apps/pwa`).
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `DEV_AUTH_BYPASS` already wired across the API (`apps/api/src/auth/devBypass.ts`, `apps/api/src/index.ts`, route handlers) and used in API tests — the harness rides the existing bypass, no new auth code.
- Shared calendar id 10 + dev MariaDB on :3306 (per prior project memory `dev-data-user1-no-calendars` / `dev-stack-bringup`) — the seed target.
- Existing `apps/pwa` Vitest config + test conventions to mirror for harness file layout/naming (note: Playwright specs are typically `*.spec.ts`, distinct from Vitest `*.test.ts` globs — keep them separate so runners don't collide).
### Established Patterns
- Vite dev server proxies `/api`, `/health`, `/callback` to the API on :3000 — `baseURL` points at the vite origin; readiness gate hits proxied `/health`.
- Dev API `dev` target needs its own build (dist can be stale, per prior memory) — relevant when CI brings up the stack.
### Integration Points
- Phase 8 (Gitea CI) consumes these specs as its PR UI-regression step against a CI-brought-up dev stack — keep the harness stack-agnostic via `baseURL` + readiness gate (D-08/D-09).
</code_context>
<specifics>
## Specific Ideas
- User explicitly wants the assertion strategy to be **robust and low-maintenance**, grounded in established/documented prior art rather than a bespoke approach — flagged as the primary research question.
- Faithful WebKit-for-iPhone fidelity was a deliberate choice over Chromium-only emulation, accepting the heavier browser image.
</specifics>
<deferred>
## Deferred Ideas
### Reviewed Todos (not folded)
- **"Gitea CI — full regression on PR to main + build/publish Docker image"** (`.planning/todos/2026-06-10-gitea-ci-regression-and-docker-publish.md`, match score 0.6) — belongs to **Phase 8 (Gitea CI)**, which _consumes_ this harness. Not folded; Phase 7 only produces CI-runnable specs, it does not own the CI pipeline.
</deferred>
---
_Phase: 7-Mobile Test Harness_
_Context gathered: 2026-06-10_
@@ -0,0 +1,80 @@
# Phase 7: Mobile Test Harness - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-10
**Phase:** 7-Mobile Test Harness
**Areas discussed:** Device profile(s), Test-data strategy, Assertion approach, Stack lifecycle / baseURL
---
## Device profile(s)
| Option | Description | Selected |
| ---------------------- | ------------------------------------------------------------------------------------------------ | -------- |
| iPhone only | Single iPhone profile; matches Apple-member UX constraint; fastest, misses Android-Chrome layout | |
| iPhone + Pixel | Two-profile matrix covering both ecosystems; ~2x runtime | ✓ |
| iPhone + small-Android | iPhone + narrow Android profile to stress tightest viewport | |
**User's choice:** iPhone + Pixel
### Follow-up: engine fidelity
| Option | Description | Selected |
| ---------------- | ----------------------------------------------------------------------------- | -------- |
| Faithful engines | iPhone → WebKit, Pixel → Chromium; most faithful; adds WebKit to CI image | ✓ |
| Chromium-only | Both Chromium, iPhone viewport/UA/touch only; lighter, matches playwright-cli | |
**User's choice:** Faithful engines
**Notes:** Accepts heavier browser image for true WebKit/Chromium rendering fidelity.
---
## Test-data strategy
| Option | Description | Selected |
| ------------------------ | ---------------------------------------------------------------------------- | -------- |
| Seed DB fixtures | Insert deterministic rows before run; realistic end-to-end render path | |
| Mock API routes | Playwright route-fulfill canned JSON; hermetic, bypasses real API | |
| Chrome/empty-states only | No population; assert nav/drawers/tap-targets/empty copy; smallest scope | |
| Hybrid: seed + empty | Seed DB for populated views + keep empty-state assertions; broadest coverage | ✓ |
**User's choice:** Hybrid: seed + empty
**Notes:** Captured constraint — seed must be deterministic and reset per run (SC #3, repeatable day-over-day); runs in global-setup against MariaDB :3306, targets shared calendar id 10 + lists.
---
## Assertion approach
| Option | Description | Selected |
| ------------------------------- | ---------------------------------------------------------------------------------------------- | -------- |
| Structural + tap-targets | Role/locator + measured box checks (≥44px, no overflow); portable, stable; misses visual drift | |
| Both (structural + screenshots) | Add toHaveScreenshot; catches visual regressions but flaky cross-env | |
| Screenshots-primary | Lean on visual snapshots; highest flakiness/maintenance | |
**User's choice:** Other (free text) — "Defer this decision to research and for you to decide as it needs to be robust and low maintenance. I have to imagine this has been done elsewhere before and should be well documented."
**Notes:** Marked as research question, not locked. Claude's steer: lead with structural + tap-target measurement; add screenshots only if research finds a non-flaky CI-baseline pattern. Schedule-X widget makes naive pixel snapshots drift-prone.
---
## Stack lifecycle / baseURL
| Option | Description | Selected |
| ------------------------ | -------------------------------------------------------------------------------------------- | ---------------------------- |
| baseURL + readiness wait | Configurable baseURL, readiness gate; caller owns stack bring-up; matches existing dev stack | ✓ (Claude, per "you decide") |
| Playwright webServer | Auto-start vite; can't own multi-container API/DB/Redis stack | (partial — vite only) |
| You decide | Pick best fit for SC #4 + local ergonomics | ✓ |
**User's choice:** You decide
**Notes:** Claude's recommendation — baseURL (env-driven) + global-setup readiness gate on /health; caller (operator locally / compose in CI) brings up the stack; optional webServer for vite only with `reuseExistingServer: !CI`; API+MariaDB+Redis stay compose-managed. Satisfies SC #4.
## Claude's Discretion
- **Assertion approach** — deferred to research (robust/low-maintenance, host↔CI portable).
- **Stack lifecycle** — "you decide"; recommendation captured above, planner may refine env-var name / webServer wiring.
- Spec file location/structure, failure trace/artifact capture, npm-script + Makefile wiring — not discussed; planner's discretion following existing conventions.
## Deferred Ideas
- "Gitea CI — full regression + Docker publish" todo (score 0.6) — belongs to Phase 8, which consumes this harness. Reviewed, not folded.
@@ -0,0 +1,522 @@
# Phase 7: Mobile Test Harness — Pattern Map
**Mapped:** 2026-06-10
**Files analyzed:** 7 (5 new, 2 modified)
**Analogs found:** 7 / 7
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
| -------------------------------------- | ------- | -------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| `apps/pwa/playwright.config.ts` | config | request-response | `apps/pwa/vitest.config.ts` + `apps/api/vitest.config.ts` | role-match (same config-file shape, different runner) |
| `apps/pwa/e2e/global-setup.ts` | utility | CRUD (DB seed + HTTP poll) | `apps/api/src/db/client.ts` (mysql2 connection) + `apps/api/tests/routes/lists.test.ts` (seed helpers) | partial-match (same DB driver + env-var pattern) |
| `apps/pwa/e2e/layout.spec.ts` | test | request-response | `apps/pwa/src/components/CalendarShell.test.tsx` (role/name locators, screen queries) | role-match |
| `apps/pwa/e2e/calendar.spec.ts` | test | request-response | `apps/pwa/src/components/CalendarShell.test.tsx` | role-match |
| `apps/pwa/e2e/lists.spec.ts` | test | request-response | `apps/pwa/src/routes/ListDetail.test.tsx` | role-match |
| `apps/pwa/vitest.config.ts` _(modify)_ | config | — | `apps/pwa/vitest.config.ts` (self — add `exclude`) | exact |
| `apps/pwa/package.json` _(modify)_ | config | — | `apps/pwa/package.json` (self) + root `package.json` (script conventions) | exact |
---
## Pattern Assignments
### `apps/pwa/playwright.config.ts` (config, new)
**Analog:** `apps/pwa/vitest.config.ts` (lines 114) — `defineConfig` wrapper convention; and `apps/api/vitest.config.ts` (lines 114) — `fileParallelism: false` and `setupFiles` equivalents.
**Config structure pattern** (`apps/pwa/vitest.config.ts`, lines 114):
```typescript
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
env: { TZ: 'UTC' },
},
});
```
Key observation: no explicit `include` — Vitest defaults catch `*.spec.ts` too, which is why `exclude` must be added.
**Serial execution pattern** (`apps/api/vitest.config.ts`, lines 114):
```typescript
export default defineConfig({
test: {
environment: 'node',
globals: true,
setupFiles: ['./test/setup.ts'],
fileParallelism: false, // ← serial DB tests; analogous to workers:1 in CI
},
});
```
**Playwright config shape to produce** (from RESEARCH.md Architecture Patterns §Pattern 1):
```typescript
// apps/pwa/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'list',
globalSetup: './e2e/global-setup.ts',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
trace: 'on-first-retry',
video: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'iphone',
use: {
...devices['iPhone 14'],
serviceWorkers: 'block', // D-02 / Pitfall 15
},
},
{
name: 'pixel',
use: {
...devices['Pixel 7'],
serviceWorkers: 'block',
},
},
],
webServer: {
command: 'pnpm --filter @familysync/pwa dev',
url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
reuseExistingServer: !process.env.CI, // D-10
timeout: 120_000,
},
});
```
---
### `apps/pwa/e2e/global-setup.ts` (utility, new)
**Analog 1 — mysql2 connection env-var pattern:** `apps/api/src/db/client.ts` (lines 116)
```typescript
// apps/api/src/db/client.ts lines 6-14 — exact env-var names to copy
const pool = mysql.createPool({
host: process.env.DB_HOST ?? 'localhost',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
waitForConnections: true,
connectionLimit: 10,
});
```
The global-setup uses `mysql.createConnection` (single connection, not pool) with identical env-var names. `DB_HOST` defaults to `127.0.0.1` (not `localhost`) per project memory `api-integration-test-db`.
**Analog 2 — seed helper pattern:** `apps/api/tests/routes/lists.test.ts` (lines 5085) — shows Drizzle-based seed helpers. The global-setup uses raw `mysql2` instead (no Drizzle outside API), but the INSERT shape and table names are confirmed here:
- `lists`: `(owner_id, name, is_shared)``ownerId=1`, `isShared=true`
- `list_shares`: `(list_id, user_id)` — join table, seed one row for user 1
- `list_items`: `(list_id, text, checked, rank)``rank` is fractional-indexing string (e.g. `'a0'`, `'a1'`)
**Schema column names** (confirmed from `apps/api/src/db/schema.ts`):
| Table | Relevant columns |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `calendars` | `id`, `user_id`, `url`, `display_name`, `color`, `is_shared` |
| `calendar_events` | `calendar_id`, `uid`, `etag`, `raw_vevent`, `title`, `dtstart_utc` (TIMESTAMP), `dtstart_date` (DATE), `all_day`, `has_rrule` |
| `lists` | `id`, `owner_id`, `name`, `is_shared` |
| `list_shares` | `list_id`, `user_id` |
| `list_items` | `list_id`, `text`, `checked`, `rank` (utf8mb4_bin varchar) |
**DEV_USER confirmed** (`apps/api/src/auth/devBypass.ts`, lines 3036):
```typescript
export const DEV_USER = {
id: 1,
oidcIss: 'dev',
oidcSub: 'dev-user',
displayName: 'Dev User',
color: '#4A90D9',
} as const;
```
Seeds must target `user_id = 1` and `owner_id = 1`.
**Guard for production** (`apps/api/src/auth/devBypass.ts`, lines 6166):
```typescript
if (process.env.NODE_ENV === 'production') {
return async (_c, next) => next();
}
if (process.env.DEV_AUTH_BYPASS !== 'true') {
return async (_c, next) => next();
}
```
The bypass requires both `NODE_ENV !== 'production'` AND `DEV_AUTH_BYPASS=true`. The harness does not control these; they must be set before the API process starts.
**Full global-setup shape** (from RESEARCH.md §Pattern 2):
```typescript
// apps/pwa/e2e/global-setup.ts
import mysql from 'mysql2/promise';
export default async function globalSetup() {
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173';
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseURL}/health`);
if (res.ok) break;
} catch {
/* ECONNREFUSED — not ready */
}
await new Promise((r) => setTimeout(r, 1_000));
}
const conn = await mysql.createConnection({
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
});
try {
await conn.execute('SET FOREIGN_KEY_CHECKS=0');
await conn.execute('TRUNCATE TABLE list_items');
await conn.execute('TRUNCATE TABLE list_shares');
await conn.execute('TRUNCATE TABLE lists');
await conn.execute('TRUNCATE TABLE calendar_events');
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
// CI guard: ensure calendars row id=10 exists (Pitfall 4)
await conn.execute(
`INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared)
VALUES (10, 1, 'https://caldav.fastmail.com/dav/calendars/e2e/', 'FamilySync', '#4A90D9', true)`,
);
// Seed one timed calendar event on shared calendar id=10
const uid = 'e2e-seed-event-001';
const futureStart = new Date(Date.now() + 24 * 60 * 60 * 1000);
const futureStartUtc = futureStart.toISOString().replace(/\.\d+Z$/, 'Z');
const rawVevent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTART:${futureStart
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d+Z$/, 'Z')}`,
'SUMMARY:Seeded Test Event',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
await conn.execute(
`INSERT INTO calendar_events
(calendar_id, uid, etag, raw_vevent, title, dtstart_utc, all_day, has_rrule)
VALUES (10, ?, 'e2e-etag-001', ?, 'Seeded Test Event', ?, false, false)`,
[uid, rawVevent, futureStartUtc],
);
// Seed one list with two items for user 1
const [listResult] = (await conn.execute(
`INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`,
)) as any[];
const listId = (listResult as any).insertId;
await conn.execute(`INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)`, [listId]);
await conn.execute(
`INSERT INTO list_items (list_id, text, checked, rank) VALUES (?, 'Milk', false, 'a0'), (?, 'Eggs', false, 'a1')`,
[listId, listId],
);
} finally {
await conn.end();
}
}
```
---
### `apps/pwa/e2e/layout.spec.ts` (test, new)
**Analog:** `apps/pwa/src/components/CalendarShell.test.tsx` — the closest existing file using `screen.findByRole`, `getByRole`, and `waitFor` patterns with role/name locator assertions.
**Test file structure** (`CalendarShell.test.tsx`, lines 1418, 140158):
```typescript
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
// ...
describe('CalendarShell — CAL-03 render smoke', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
})
it('renders without throwing...', () => { ... })
it('mounts the ScheduleXCalendar...', async () => { ... })
})
```
**Role-based locator pattern** (`CalendarShell.test.tsx`, lines 220230):
```typescript
const tapToRetry = await screen.findByText(/Tap here to try again/i);
expect(tapToRetry).toBeDefined();
```
**Playwright equivalents** (from RESEARCH.md §Patterns 35) — `@playwright/test` uses `page.getByRole()`, not `screen`:
```typescript
import { test, expect } from '@playwright/test';
test.describe('BottomTabBar presence and tap targets', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/calendar');
});
test('Calendar tab meets 44px touch target', async ({ page }) => {
const tab = page.getByRole('link', { name: 'Calendar' });
const box = await tab.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
});
test('no horizontal overflow on /calendar', async ({ page }) => {
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
});
});
```
**Error state via `page.route()`** (from RESEARCH.md §Pattern 5):
```typescript
// Register BEFORE page.goto() — route intercepts the matching request
await page.route('/api/events*', (route) =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }),
);
await page.goto('/calendar');
await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVisible();
```
---
### `apps/pwa/e2e/calendar.spec.ts` (test, new)
**Analog:** `apps/pwa/src/components/CalendarShell.test.tsx` — same component under test; provides fixture data shapes and the expected ARIA landmark (`data-testid="schedule-x-calendar"`, navigation role).
**Fixture data shape confirmed** (`CalendarShell.test.tsx`, lines 83113):
```typescript
// Timed event shape returned by /api/events
const TIMED_OCCURRENCE = {
id: 'timed-uid::2026-06-15T10:00:00',
title: 'Team Standup',
start: '2026-06-15T10:00:00-04:00[America/New_York]',
end: '2026-06-15T10:30:00-04:00[America/New_York]',
allDay: false,
};
```
**Key insight:** The Playwright spec navigates to `/calendar` and asserts structural elements (Schedule-X wrapper present and visible, event chip text visible for seeded event) via role/text locators — not by data-testid (prefer stable ARIA roles). The seeded event title is `'Seeded Test Event'`.
**Query client wrapper convention** (`CalendarShell.test.tsx`, lines 117136) — not directly applicable in Playwright (no React wrapper needed), but confirms the route path is `/calendar`.
---
### `apps/pwa/e2e/lists.spec.ts` (test, new)
**Analog:** `apps/pwa/src/routes/ListDetail.test.tsx` — the closest file testing the lists data shape; confirms list item text (`'bread'`, `'Milk'`, `'Eggs'`), the two-section layout (active / completed), and the `rank` fractional-indexing strings.
**List item shape** (`ListDetail.test.tsx`, lines 2131):
```typescript
function makeItem(overrides: Partial<ListItem> = {}): ListItem {
return {
id: 1,
listId: 10,
text: 'bread',
checked: false,
rank: 'a0',
};
}
```
**Section assertion pattern** (`ListDetail.test.tsx`, lines 178193):
```typescript
const activeItems = items.filter((i) => !i.checked);
const completedItems = items.filter((i) => i.checked);
expect(activeItems).toHaveLength(1);
expect(completedItems).toHaveLength(1);
```
In Playwright: assert `page.getByRole('listitem', { name: 'Milk' })` is visible (seeded active item) and that the "No items yet" empty text is NOT visible when seeded.
---
### `apps/pwa/vitest.config.ts` _(modify)_
**Analog:** Self — read at lines 114 above. Change is additive: add `exclude` array to prevent Vitest from picking up `e2e/**/*.spec.ts`.
**Current file** (`apps/pwa/vitest.config.ts`, lines 114):
```typescript
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
env: { TZ: 'UTC' },
// ADD: exclude to prevent Vitest glob collision with Playwright specs
// exclude: ['e2e/**', 'node_modules/**'],
},
});
```
**Diff to apply:** add one line inside the `test:` block:
```typescript
exclude: ['e2e/**', 'node_modules/**'],
```
---
### `apps/pwa/package.json` _(modify)_
**Analog:** `apps/pwa/package.json` (self, lines 611) + root `package.json` (lines 412) for naming conventions.
**Current scripts block** (`apps/pwa/package.json`, lines 611):
```json
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
```
**Root workspace convention** (`package.json`, lines 412): scripts use `pnpm --filter @familysync/<app> <script>` and follow `verb` or `verb:modifier` naming (`dev:api`, `dev:pwa`, `typecheck`).
**Additions to `apps/pwa/package.json`:**
```json
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
```
**Addition to `devDependencies`:**
```json
"@playwright/test": "1.60.0"
```
**Root `package.json` addition** (propagate to workspace-level scripts):
```json
"test:e2e": "pnpm --filter @familysync/pwa test:e2e"
```
---
## Shared Patterns
### Dev bypass — resolves to user id 1
**Source:** `apps/api/src/auth/devBypass.ts` lines 3036, 6176
**Apply to:** `global-setup.ts` (seed targets `user_id=1`, `owner_id=1`); all spec files (asserted data belongs to user 1)
```typescript
// DEV_USER.id === 1 — seed and assert against this identity
export const DEV_USER = { id: 1, displayName: 'Dev User', color: '#4A90D9' } as const;
// Guard: requires NODE_ENV !== 'production' AND DEV_AUTH_BYPASS=true
```
### mysql2 env-var connection pattern
**Source:** `apps/api/src/db/client.ts` lines 614
**Apply to:** `global-setup.ts`
```typescript
// Exact env-var names used across the project — use same names in global-setup
host: process.env.DB_HOST ?? '127.0.0.1', // NOT 'localhost' (per memory api-integration-test-db)
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
```
### Test file header comment convention
**Source:** `apps/pwa/src/components/CalendarShell.test.tsx` lines 114; `apps/api/tests/routes/lists.test.ts` lines 117
**Apply to:** all `e2e/*.spec.ts` files and `global-setup.ts`
```typescript
/**
* <Component/route> — <requirement ID>
*
* <what it tests>
*
* Run:
* pnpm --filter @familysync/pwa test:e2e
*/
```
### Role/name locator convention (Vitest + Testing Library → Playwright equivalent)
**Source:** `apps/pwa/src/components/CalendarShell.test.tsx` lines 217231
```typescript
// Testing Library (Vitest) — existing pattern
const tapToRetry = await screen.findByText(/Tap here to try again/i);
// ↓ Playwright equivalent in e2e specs:
await expect(page.getByRole('button', { name: /Retry/i })).toBeVisible();
await expect(page.getByText(/Tap here to try again/i)).toBeVisible();
```
### No hardcoded absolute URLs in specs
**Source:** RESEARCH.md §Anti-Patterns; consistent with `apps/pwa/vite.config.ts` proxy pattern
**Apply to:** all `e2e/*.spec.ts` files
```typescript
// Wrong — breaks CI
await page.goto('http://localhost:5173/calendar');
// Correct — resolves against playwright.config.ts baseURL
await page.goto('/calendar');
```
---
## No Analog Found
None. All files have at least a role-match analog in the codebase.
---
## Metadata
**Analog search scope:** `apps/pwa/src/`, `apps/api/src/`, `apps/api/tests/`
**Files read:** 12
**Pattern extraction date:** 2026-06-10
@@ -0,0 +1,870 @@
# Phase 7: Mobile Test Harness — Research
**Researched:** 2026-06-10
**Domain:** Playwright E2E test infrastructure — mobile device emulation, authenticated dev bypass, DB seeding, CI portability
**Confidence:** HIGH (primary Playwright API verified via Context7 + npm registry)
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Auth via `DEV_AUTH_BYPASS=true` on the host-side dev stack — **never** a checked-in `storage-state.json` with an expiring session cookie (Pitfall 14). No Authelia/OIDC mocking. Dev-bypass resolves to Dev User id 1.
- **D-02:** Playwright context uses `serviceWorkers: 'block'` so the PWA's `injectManifest` SW (`sw.js`, `registerType: 'autoUpdate'`) cannot intercept requests / return stale cached responses (Pitfall 15). Verify the trace shows no SW-sourced responses.
- **D-03:** Run a **two-profile matrix: iPhone + Pixel** — covers both household ecosystems (Apple + Android/Fastmail). The iPhone profile satisfies the hard non-technical-Apple-member UX constraint; Pixel covers Chrome-viewport defects.
- **D-04:** Use **faithful browser engines** per profile: iPhone → **WebKit**, Pixel → **Chromium**. Adds a WebKit browser to the harness/CI image. (Note: this exceeds the existing global `playwright-cli` Chromium tooling — the harness brings its own `@playwright/test` browsers.) SW-block + dev-bypass apply to both profiles.
- **D-05:** **Hybrid** — seed deterministic DB fixtures for populated views **and** keep explicit empty-state assertions. Dev-bypass user 1 natively has no calendars (calendar/list views render empty, live create 422s), so populated coverage requires seeding.
- **D-06:** Seeding is **deterministic and reset per run** (truncate/reset → insert, not insert-if-absent) to guarantee repeatable day-over-day results with no stale state (SC #3). Seed onto the shared calendar (id 10, per prior project memory) + list items so user 1's views render populated.
- **D-07:** Seeding runs in **global-setup** against the dev MariaDB (already port-bound on 3306 via `docker-compose.dev.yml`); teardown/reset keeps runs idempotent.
- **D-08:** Harness targets a **configurable `baseURL`** (env-driven: operator's vite dev server locally, CI service host in Phase 8) with a **readiness gate in global-setup** (wait on `/health` before any spec; mirrors the PITFALLS CI-readiness guidance to avoid flaky ECONNREFUSED).
- **D-09:** **Stack bring-up is the caller's responsibility** — operator's already-running dev stack locally, compose orchestration in Phase 8 CI. The harness never depends on a pre-running stack; it waits for one. Satisfies SC #4.
- **D-10:** Optionally use Playwright `webServer` for **vite only** with `reuseExistingServer: !process.env.CI` (reuse the operator's `pnpm dev` locally, start vite fresh in CI). The API + MariaDB + Redis always stay compose-managed — `webServer` cannot own a multi-container stack.
### Claude's Discretion
- **Assertion approach:** lead with structural/role-based locator assertions + explicit tap-target measurements; add `toHaveScreenshot` only if non-flaky cross-environment snapshots are achievable. Schedule-X drift risk weighted heavily.
- **Stack lifecycle (D-08D-10):** planner may refine exact env-var name and webServer wiring.
- Spec file location/structure, trace/artifact capture on failure, and npm-script + Makefile wiring — planner's discretion (follow existing conventions).
### Deferred Ideas (OUT OF SCOPE)
- Gitea CI pipeline itself (Phase 8 owns it).
- Real production-service-worker behavior.
- iOS-Safari standalone-PWA behavior (Home-Screen install, standalone OIDC redirect, iOS push).
- Live event-create against Fastmail (dev-bypass user 1 has no CalDAV credential).
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| TEST-01 | The assistant can drive the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) for automated UI/layout verification. | `devices['iPhone 14']` + `devices['Pixel 7']` confirmed in Playwright `@playwright/test` 1.60.0; `projects:` config pattern documented via Context7. |
| TEST-02 | Automated runs reach the authenticated PWA via the existing `DEV_AUTH_BYPASS` on the host-side dev stack (no manual login, no Authelia/OIDC mocking). Harness specs consumed by Phase 8 CI as UI-regression step. | `DEV_AUTH_BYPASS` already wired in API; global-setup pattern for readiness gate + DB seeding documented; `baseURL` env-var pattern confirmed. |
</phase_requirements>
---
## Summary
This phase adds `@playwright/test` as a `devDependency` in `apps/pwa` and creates a mobile-emulated E2E harness. The harness runs two device profiles (iPhone 14/WebKit and Pixel 7/Chromium), authenticates via the existing `DEV_AUTH_BYPASS=true` mechanism, seeds deterministic fixtures into the dev MariaDB in `globalSetup`, and asserts structural quality rules (tap targets, overflow, visibility, accessible names, empty/error states). No visual screenshot assertions are included — Schedule-X's date-driven dynamic layout makes cross-environment snapshots unworkable without a high false-positive rate.
The primary research question — assertion strategy — is answered: **use structural assertions only** (role/name locators + `boundingBox()` measurements + `scrollWidth ≤ clientWidth` + `isVisible()` + `page.route()` for error-state simulation). This is the well-documented Playwright-idiomatic approach; `toHaveScreenshot` is explicitly excluded for this phase due to Schedule-X date-dependent rendering and font-pipeline variance across host↔CI WebKit.
`@playwright/test` 1.60.0 is the current release. [VERIFIED: npm registry] The `devices` descriptors for `'iPhone 14'` (390×844 viewport, WebKit UA) and `'Pixel 7'` (412×915 viewport, Chrome Android UA) are confirmed in the Playwright source. [VERIFIED: playwright deviceDescriptorsSource.json via WebFetch] `mysql2` is the existing project DB driver; the same credentials pattern (`DB_HOST=127.0.0.1`, `DB_PASSWORD` from env) used by the API integration tests applies to the global-setup seed script.
**Primary recommendation:** One `playwright.config.ts` in `apps/pwa/` with two projects (`iphone`/`pixel`), `globalSetup` for health-polling + DB seeding, `serviceWorkers: 'block'` on both contexts, env-driven `baseURL`, `webServer` for vite with `reuseExistingServer: !process.env.CI`, and spec files under `apps/pwa/e2e/` using `*.spec.ts` glob (isolated from Vitest's `*.test.ts` glob).
---
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
| -------------------------------------------------------------- | -------------------------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| Mobile viewport + UA emulation | Test Harness (`@playwright/test`) | — | Playwright `devices[...]` spread into project `use:` options; no app-layer change needed |
| Auth bypass | API (existing `devBypass.ts`) | Test Harness (sets `DEV_AUTH_BYPASS=true`) | Bypass is already implemented; harness only ensures env var is set before API starts |
| DB seeding | Test Harness (`globalSetup`) | Dev MariaDB (port 3306) | Direct mysql2 connection from global-setup; no API endpoint for seed data |
| Stack readiness gate | Test Harness (`globalSetup`) | — | `GET /health` poll via `fetch` with retry loop before any spec runs |
| Service worker suppression | Test Harness (context option) | — | `serviceWorkers: 'block'` in `playwright.config.ts` context options; stops Workbox intercept |
| Structural assertions (tap target, overflow, visibility, a11y) | Test Harness (spec files) | — | `boundingBox()`, `page.evaluate(scrollWidth)`, `isVisible()`, role-based locators |
| API error-state simulation | Test Harness (`page.route()`) | — | Fulfill `/api/events*` with status 500 for error-state tests; no backend change needed |
| Vite dev server lifecycle | Test Harness (`webServer`) or Operator | — | `webServer` starts vite if not running; `reuseExistingServer: !process.env.CI` avoids double-start locally |
| CI portability | Test Harness (env-driven config) | — | `PLAYWRIGHT_BASE_URL` + `DB_HOST` env vars; no hardcoded `localhost` in spec files |
---
## Standard Stack
### Core (new additions for this phase)
| Library | Version | Purpose | Why Standard |
| ------------------ | ------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@playwright/test` | 1.60.0 | Mobile-emulated E2E test runner + assertions | Official Playwright test runner; includes device descriptors, `projects:`, `globalSetup`, `page.route()`, `boundingBox()`, `toHaveScreenshot` (omitted this phase) — the only credible option for WebKit-on-Linux emulation [VERIFIED: npm registry] |
### Supporting (already in project, used in harness)
| Library | Version | Purpose | When to Use |
| -------- | ------------------------------ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mysql2` | 3.22.5 (already a project dep) | DB connection in global-setup seed script | Direct mysql2 `createConnection` (not Drizzle — global-setup runs outside the API; Drizzle schema not needed for raw INSERT/TRUNCATE) [VERIFIED: npm registry, `SUS` flag — see audit] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
| ------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `mysql2` in global-setup | Drizzle ORM | Drizzle is fine but adds unnecessary indirection for 3 TRUNCATE + INSERT statements; raw mysql2 is simpler and already project-resident |
| `serviceWorkers: 'block'` | Manual SW unregister in test | `block` is one line of config; unregister requires per-test async setup and is easy to forget |
| `page.route()` for error states | Mocking API server | route interception is in-process and doesn't require a separate mock server; the canonical Playwright approach |
**Installation:**
```bash
pnpm --filter @familysync/pwa add -D @playwright/test
# Install browser engines (both projects: WebKit + Chromium)
pnpm --filter @familysync/pwa exec playwright install --with-deps webkit chromium
```
**Version verification:**
```bash
npm view @playwright/test version # → 1.60.0 (verified 2026-06-10)
npm view mysql2 version # → 3.22.5 (verified 2026-06-10)
```
---
## Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
| ------------------ | -------- | ------------------------------------ | --------- | -------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `@playwright/test` | npm | Published 2026-05-11 (latest 1.60.0) | 38.6M/wk | github.com/microsoft/playwright | OK | Approved |
| `mysql2` | npm | Published 2026-06-06 (latest 3.22.5) | 11.4M/wk | github.com/sidorares/node-mysql2 | SUS (too-new flag for latest patch) | Approved — already a project dependency; legitimate package, flag is recency of latest patch, not package itself |
**Packages removed due to SLOP verdict:** none
**Packages flagged as suspicious SUS:** `mysql2` — the SUS flag is triggered by the `too-new` heuristic on the latest patch (3.22.5, published 2026-06-06). This package is well-established (11.4M weekly downloads, Drizzle explicit dependency, already in the project), and the harness uses the same version already present in `apps/api`. No additional review needed; the SUS flag is a false positive caused by a very recent patch release. [ASSUMED: "false positive" characterisation — gsd-tools `SUS` verdict cannot be overridden by provenance alone; planner notes no `checkpoint:human-verify` is required since mysql2 is already installed in the project.]
---
## Architecture Patterns
### System Architecture Diagram
```
Operator / CI runner
|
| sets env: PLAYWRIGHT_BASE_URL, DB_HOST, DB_PASSWORD, DEV_AUTH_BYPASS=true
v
[ playwright.config.ts ]
|
|-- globalSetup ──────────────────────────────────────────────────┐
| 1. poll GET {baseURL}/health until 200 (60s timeout) |
| 2. mysql2.createConnection(DB_HOST:3306, creds) |
| 3. TRUNCATE calendar_events, lists, list_items, ... |
| 4. INSERT deterministic fixtures onto calendar id=10 |
| 5. INSERT list (owner_id=1) + list_items (≥2 items) |
| 6. INSERT list_shares (list_id, user_id=1) |
| connection.end() |
| |
|-- webServer (vite, reuseExistingServer: !CI) ───────────────────┘
| starts vite :5173 if not already running
|
|-- project: iphone ──────────────────────────────────────────────┐
| engine: WebKit |
| use: devices['iPhone 14'] |
| contextOptions: { serviceWorkers: 'block' } |
| |
|-- project: pixel ───────────────────────────────────────────────┘
engine: Chromium
use: devices['Pixel 7']
contextOptions: { serviceWorkers: 'block' }
|
| both projects run against same spec files
v
[ apps/pwa/e2e/*.spec.ts ]
|
| page.goto(baseURL + '/calendar'), assertions
| page.goto(baseURL + '/lists'), assertions
| page.route('/api/events*', fulfill 500), goto /calendar, assertions
v
[ Vite dev server :5173 ] ← proxy /api,/health,/callback → :3000
|
v
[ API :3000 (DEV_AUTH_BYPASS=true, NODE_ENV=development) ]
|
v
[ Dev MariaDB :3306 ] ← seeded fixtures from globalSetup
```
### Recommended Project Structure
```
apps/pwa/
├── e2e/ # Playwright E2E specs — *.spec.ts glob
│ ├── calendar.spec.ts # calendar route: populated, empty, error state
│ ├── lists.spec.ts # lists route: populated, empty state
│ ├── layout.spec.ts # cross-route: tap targets, overflow, BottomTabBar
│ └── global-setup.ts # health poll + DB seed (no Playwright deps)
├── playwright.config.ts # project matrix, globalSetup, webServer, artifacts
├── vitest.config.ts # unchanged — *.test.ts glob, jsdom env
└── package.json # add @playwright/test devDependency + e2e script
```
**Key isolation rule:** `vitest.config.ts` has no explicit `include` pattern, so by default Vitest scans for `*.test.ts` / `*.test.tsx` files. Playwright's `testMatch` in `playwright.config.ts` targets `e2e/**/*.spec.ts`. These globs do not overlap — no runner collision. [CITED: TESTING.md — existing convention uses `*.test.ts` for Vitest]
### Pattern 1: Two-Project Device Matrix with `serviceWorkers: 'block'`
```typescript
// apps/pwa/playwright.config.ts
// Source: Context7 /microsoft/playwright.dev — emulation.mdx + test-global-setup-teardown.mdx
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
globalSetup: './e2e/global-setup.ts',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
trace: 'on-first-retry',
video: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'iphone',
use: {
...devices['iPhone 14'],
// serviceWorkers: 'block' prevents the injectManifest sw.js from
// intercepting any requests — satisfies D-02 / Pitfall 15
serviceWorkers: 'block',
},
},
{
name: 'pixel',
use: {
...devices['Pixel 7'],
serviceWorkers: 'block',
},
},
],
// D-10: manage Vite only; API+MariaDB+Redis are compose-managed
webServer: {
command: 'pnpm --filter @familysync/pwa dev',
url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
```
**Device descriptor confirmed properties:**
- `devices['iPhone 14']`: viewport `390×844`, userAgent `Mobile Safari`, `hasTouch: true`, `isMobile: true`, `defaultBrowserType: 'webkit'` [VERIFIED: playwright deviceDescriptorsSource.json]
- `devices['Pixel 7']`: viewport `412×915`, userAgent `Chrome/Android`, `hasTouch: true`, `isMobile: true`, `defaultBrowserType: 'chromium'` [VERIFIED: playwright deviceDescriptorsSource.json]
### Pattern 2: `globalSetup` — Health Poll + DB Seed
```typescript
// apps/pwa/e2e/global-setup.ts
// Source: Context7 /microsoft/playwright.dev — test-global-setup-teardown.mdx
import mysql from 'mysql2/promise';
export default async function globalSetup() {
// Step 1: Wait for /health — D-08 readiness gate
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173';
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseURL}/health`);
if (res.ok) break;
} catch {
// ECONNREFUSED — not ready yet
}
await new Promise((r) => setTimeout(r, 1_000));
}
// will throw if never resolved — test run fails fast with a clear message
// Step 2: Seed — D-06 deterministic reset-per-run
const conn = await mysql.createConnection({
host: process.env.DB_HOST ?? '127.0.0.1',
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
});
try {
// Disable FK checks for TRUNCATE ordering
await conn.execute('SET FOREIGN_KEY_CHECKS=0');
await conn.execute('TRUNCATE TABLE list_items');
await conn.execute('TRUNCATE TABLE list_shares');
await conn.execute('TRUNCATE TABLE lists');
await conn.execute('TRUNCATE TABLE calendar_events');
await conn.execute('SET FOREIGN_KEY_CHECKS=1');
// Seed: one calendar event on shared calendar id=10 (timed, not all-day)
// Minimal VCALENDAR string — enough for the API to expand and the UI to show it
const futureStart = new Date(Date.now() + 24 * 60 * 60 * 1000); // tomorrow
const futureStartUtc = futureStart
.toISOString()
.replace('T', 'T')
.replace(/\.\d+Z$/, 'Z');
const uid = 'e2e-seed-event-001';
const rawVevent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTART:${futureStart
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d+Z$/, 'Z')}`,
`SUMMARY:Seeded Test Event`,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
await conn.execute(
`INSERT INTO calendar_events
(calendar_id, uid, etag, raw_vevent, title, dtstart_utc, all_day, has_rrule)
VALUES (10, ?, 'e2e-etag-001', ?, 'Seeded Test Event', ?, false, false)`,
[uid, rawVevent, futureStartUtc],
);
// Seed: one list with two items for user 1
const [listResult] = (await conn.execute(
`INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`,
)) as any[];
const listId = listResult.insertId;
await conn.execute(`INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)`, [listId]);
await conn.execute(
`INSERT INTO list_items (list_id, text, checked, rank) VALUES (?, 'Milk', false, 'a0'), (?, 'Eggs', false, 'a1')`,
[listId, listId],
);
} finally {
await conn.end();
}
}
```
**Notes for planner:**
- The `calendar_events.dtstart_utc` type is `TIMESTAMP`, which MariaDB stores in UTC. Pass an ISO UTC string.
- `calendar_id=10` is the confirmed shared calendar from project memory `dev-data-user1-no-calendars`. The seed assumes this row pre-exists (it does on the dev stack); the planner may add an `INSERT IGNORE INTO calendars ...` guard for CI resilience.
- `list_shares` ensures user 1 can see the list in `/api/lists`.
- `fractional-indexing` rank strings `'a0'`, `'a1'` are valid initial ranks per the project's sort pattern. [ASSUMED: exact rank string format — verify against a real row or the fractional-indexing library docs if different from `'a0'/'a1'`]
### Pattern 3: Structural Assertions — Tap Targets (Rule 1)
```typescript
// Source: Context7 /microsoft/playwright.dev — api/class-locator.mdx + UI-SPEC.md Rule 1
import { test, expect } from '@playwright/test';
test('BottomTabBar tabs meet 44px touch target', async ({ page }) => {
await page.goto('/calendar');
const calTab = page.getByRole('link', { name: 'Calendar' });
const listsTab = page.getByRole('link', { name: 'Lists' });
for (const el of [calTab, listsTab]) {
const box = await el.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
}
});
```
**API confirmation:** `locator.boundingBox()` returns `{ x, y, width, height }` in CSS pixels (logical pixels at `scale: 'css'`). Returns `null` if element not visible. [CITED: context7 /microsoft/playwright.dev — api/class-locator.mdx]
### Pattern 4: Structural Assertions — No Horizontal Overflow (Rule 2)
```typescript
// Source: UI-SPEC.md Rule 2 — confirmed as standard Playwright JS evaluation pattern
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
```
This is `page.evaluate()` — standard Playwright API, no special library needed. [CITED: context7 /microsoft/playwright.dev]
### Pattern 5: API Error-State Simulation via `page.route()`
```typescript
// Source: Context7 /microsoft/playwright.dev — network.mdx
// Use BEFORE page.goto() — route must be registered before navigation
await page.route('/api/events*', (route) =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }),
);
await page.goto('/calendar');
await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
```
**Important:** Call `await page.unroute('/api/events*')` or use `page.route` with `{ times: 1 }` to prevent the mock from leaking to subsequent tests. [CITED: context7 /microsoft/playwright.dev — network.mdx]
### Pattern 6: Vite `webServer` with `reuseExistingServer` (D-10)
```typescript
// Source: Context7 /microsoft/playwright.dev — playwright.config.ts example
webServer: {
command: 'pnpm --filter @familysync/pwa dev',
url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
```
- Locally: Playwright checks if :5173 is already answering; if yes, it reuses the operator's `pnpm dev` session without starting a new one.
- In CI: `process.env.CI` is set by Gitea Actions → `reuseExistingServer: false` → Playwright starts its own vite process from scratch. [CITED: context7 /microsoft/playwright.dev — playwright.config.ts]
**Interaction with DEV_AUTH_BYPASS:** The API process is not managed by `webServer`. `DEV_AUTH_BYPASS=true` must be set in the environment that starts the API (compose env or runner env). Locally, the operator sets it; in Phase 8 CI, the workflow YAML sets it before starting the compose stack. The harness itself does not control API env.
### Anti-Patterns to Avoid
- **`storageState` in playwright.config.ts `use.storageState`:** stores OIDC session cookies that expire. Using `DEV_AUTH_BYPASS` eliminates the need entirely. [CITED: PITFALLS.md §Pitfall 14]
- **Omitting `serviceWorkers: 'block'`:** Workbox cache-first responses from a prior Playwright run will appear as SW-sourced in traces. The `block` option prevents registration entirely. [CITED: PITFALLS.md §Pitfall 15]
- **Hardcoded `localhost:5173` in spec files:** breaks CI where baseURL may differ. Use `page.goto('/calendar')` with a configured `baseURL` — relative paths resolve against it. [CITED: context7 /microsoft/playwright.dev — test-parameterize.mdx]
- **`webServer` managing API + compose services:** `webServer` can only manage one process. API needs `DEV_AUTH_BYPASS=true`, MariaDB, and Redis — use compose for those. [ASSUMED: webServer single-process limitation — consistent with docs pattern]
- **Calling `pnpm playwright install` without `--with-deps` in CI:** WebKit on Linux requires system libraries. `playwright install --with-deps webkit chromium` installs both engines and their system deps. [CITED: context7 /microsoft/playwright.dev — browsers.mdx]
- **`INSERT IGNORE` instead of `TRUNCATE + INSERT` for seed:** insert-if-absent leaves stale rows from a prior run. D-06 mandates truncate-first for determinism. [CITED: 07-CONTEXT.md D-06]
- **Vitest picking up `*.spec.ts` files:** The existing `vitest.config.ts` has no explicit `include`, so Vitest uses its default `**/*.{test,spec}.{js,ts,tsx}` glob. This means `*.spec.ts` files in `e2e/` WOULD be picked up by Vitest unless excluded. The planner must add `exclude: ['e2e/**']` to `vitest.config.ts`. [VERIFIED: vitest.config.ts read — no explicit include; spec files would be caught by default glob]
---
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
| ----------------------------------- | ------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Mobile viewport + touch + UA string | Custom browser launch flags | `devices['iPhone 14']` spread | `devices` includes DPR, hasTouch, isMobile, UA — reproducing this by hand misses fields and drifts with Playwright updates |
| Service worker suppression | Per-test SW unregister evaluate | `serviceWorkers: 'block'` context option | One config line; unregister requires async setup in every test and is easy to omit |
| Stack readiness polling | `sleep 10 && run tests` | `globalSetup` health-poll loop | Sleep is non-deterministic; a poll with timeout gives fast-pass and hard-fail |
| API error states | Separate mock API server | `page.route()` inline fulfill | route() is in-process, zero infrastructure, the Playwright-canonical approach |
| DB seeding from the API | POST requests to API endpoints | Direct mysql2 INSERT in globalSetup | Dev-bypass user 1 cannot create calendar events via API (422, no CalDAV credential); direct DB insert bypasses that constraint and is faster |
**Key insight:** Playwright's device descriptors, `serviceWorkers` context option, and `page.route()` network interception are designed precisely for this use case. The only custom code needed is the globalSetup health poll and DB seed script.
---
## Primary Research Question: Assertion Strategy
**Recommendation: structural assertions only — no `toHaveScreenshot` for this phase.**
### Why structural assertions are sufficient and correct
The UI-SPEC.md defines five concrete quality rules, all of which map directly to Playwright structural APIs with zero rendering-pipeline variance:
| Rule | Playwright API | Variance Risk |
| ---------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Touch target ≥ 44px | `locator.boundingBox()` → measure width/height | None — CSS pixel dimensions are layout-engine output, consistent across OS font settings |
| No horizontal overflow | `page.evaluate(() => scrollWidth/clientWidth)` | None — DOM measurement, not pixel comparison |
| Elements in viewport on load | `locator.isVisible()` + `boundingBox().y + height ≤ viewportHeight` | None — geometry check |
| Accessible names present | `page.getByRole(role, { name })` — if locatable, name exists | None — ARIA tree query |
| Empty/error states render | `getByText()`, `getByRole()` visibility | None — presence check |
### Why `toHaveScreenshot` is excluded
1. **Schedule-X date-dependent layout.** The calendar widget highlights today's date, places event chips by its internal layout engine, and renders the current week/month by default. The "current date" changes every day, so a snapshot taken on 2026-06-10 will fail on 2026-06-11 even with identical code. [CITED: 07-UI-SPEC.md §Rule 6]
2. **WebKit font rendering on Linux vs macOS.** The iPhone project uses WebKit engine. Font hinting on Linux (CI runner likely Ubuntu) differs from macOS — sub-pixel differences accumulate across text-heavy layouts. Even with `maxDiffPixelRatio: 0.03`, Schedule-X's event chip labels cause breaches. [ASSUMED: Linux WebKit font difference vs macOS — this is well-documented in the Playwright community but not formally cited; LOW confidence]
3. **CI-generated baseline workflow adds operational burden.** Using `--update-snapshots` in CI on first run, committing baselines, and keeping them per-engine (`snapshots/webkit/`, `snapshots/chromium/`) is achievable — but adds a mandatory workflow step that is not self-healing when the app's UI legitimately changes. For a two-person household app with a small team, this maintenance overhead outweighs the pixel-accuracy benefit.
4. **The structural assertions catch actual defects.** Schedule-X has historically caused horizontal overflow on narrow viewports (memory entry `schedule-x-allday-event-styling`). The `scrollWidth ≤ clientWidth` assertion catches that. Tap targets below 44px are the other mobile-only class of defect — `boundingBox()` catches that. Screenshots would add noise without catching additional real bugs.
**If snapshots are added in a later phase:** scope to static, non-dynamic regions only (e.g., BottomTabBar clipped to its bounding box, not the full viewport). Use CI-generated baselines committed by a dedicated "update-snapshots" workflow. Mask the calendar grid area with `mask: [page.locator('.sx__calendar-wrapper')]`.
---
## Common Pitfalls
### Pitfall 1: Vitest Glob Collision with `*.spec.ts`
**What goes wrong:** Vitest's default `testMatch` includes `**/*.spec.ts`. Adding `apps/pwa/e2e/*.spec.ts` files without an explicit `exclude` in `vitest.config.ts` causes Vitest to pick them up and fail (Playwright APIs like `devices` are not available in the Vitest jsdom environment).
**Why it happens:** `vitest.config.ts` has no explicit `include`/`exclude` — relies on Vitest defaults. [VERIFIED: vitest.config.ts read]
**How to avoid:** Add `exclude: ['e2e/**']` to the `test:` block in `apps/pwa/vitest.config.ts`. Alternatively, scope Vitest's `include` to `src/**/*.test.ts`. Either prevents collision.
**Warning signs:** Vitest run fails with `ReferenceError: devices is not defined` or Playwright import errors.
### Pitfall 2: `globalSetup` has no access to Playwright fixtures
**What goes wrong:** `globalSetup` runs outside the Playwright worker context. It cannot use `page`, `browser`, or any Playwright fixture. Only plain Node.js (fetch, mysql2, fs) is available.
**Why it happens:** `globalSetup` runs once before any worker is spawned. [CITED: context7 /microsoft/playwright.dev — test-global-setup-teardown.mdx]
**How to avoid:** The health poll and DB seed use only `fetch` (global in Node 18+) and `mysql2` — both are plain Node.js. No Playwright imports in `global-setup.ts`.
**Warning signs:** `ReferenceError: test is not defined` in global-setup.
### Pitfall 3: Vite Proxy Not Active When `webServer` Starts Fresh Vite
**What goes wrong:** In CI, `webServer` starts `pnpm dev` for the PWA. The API at `:3000` must already be running (compose-managed) before Playwright navigates to `/calendar` — the Vite proxy to `:3000` will 502 if the API is not up.
**Why it happens:** `webServer` only gates on the Vite URL being reachable, not on the proxied API being up. The globalSetup `/health` poll gates on the health endpoint (which IS proxied through Vite to `:3000`), so it handles this correctly — but only if the health poll runs AFTER Vite is started by `webServer`.
**How to avoid:** Playwright starts `webServer` before running `globalSetup`, so the ordering is: compose brings up API+DB+Redis → Playwright starts Vite (webServer) → globalSetup polls `/health` (proxied to API). In CI, the workflow must start compose before running `npx playwright test`. [ASSUMED: Playwright webServer starts before globalSetup — verify in docs; treat as LOW confidence]
**Warning signs:** globalSetup health poll times out in CI even though the API is healthy, because Vite isn't started yet when the poll begins.
### Pitfall 4: `calendar_id=10` Not Present in CI MariaDB
**What goes wrong:** The seed script does `INSERT INTO calendar_events (calendar_id=10, ...)`. In the developer's local MariaDB, calendar row 10 exists (created by the broker poller after D-16). In a fresh CI MariaDB with only Drizzle migrations applied, there is no calendar row 10.
**Why it happens:** The CI DB starts from migrations only — no production data, no broker-seeded calendar rows.
**How to avoid:** The globalSetup should `INSERT IGNORE INTO calendars (id, user_id, url, display_name, is_shared) VALUES (10, 1, ...)` before inserting calendar_events. This ensures the FK constraint is satisfied in both fresh and populated environments. [CITED: 07-CONTEXT.md D-06 + schema.ts FK reference]
**Warning signs:** globalSetup throws `ER_NO_REFERENCED_ROW_2: Cannot add or update a child row: a foreign key constraint fails` on the calendar_events INSERT.
### Pitfall 5: `DEV_AUTH_BYPASS` Not Propagated to API Process
**What goes wrong:** The harness assumes `DEV_AUTH_BYPASS=true` is active in the API process. If the API was started without it (or the env var was not exported), all `/api/*` calls return 401/302 and the PWA renders an auth redirect instead of the calendar.
**Why it happens:** `DEV_AUTH_BYPASS` is checked at API startup and gated by `NODE_ENV !== 'production'`. The harness cannot set it — it must be set in the API's environment before the API process starts.
**How to avoid:** Document in the harness README that the dev stack must be started with `DEV_AUTH_BYPASS=true`. In CI (Phase 8), the workflow YAML must set it in the environment before launching the compose stack. The globalSetup can assert `DEV_AUTH_BYPASS` is active by checking that `GET /health` returns `{ ok: true }` — if the API is running without bypass, `/api/me` will redirect, which isn't directly testable in globalSetup, but the first spec failing on unexpected auth redirect is a clear signal.
**Warning signs:** All specs fail with unexpected redirect to Authelia login page.
### Pitfall 6: WebKit Not Installed in CI Image
**What goes wrong:** Running `playwright install` without `--with-deps` in CI installs the Playwright browser binaries but not the system-level libraries WebKit needs on Linux. WebKit then fails to launch with library errors.
**Why it happens:** WebKit on Linux requires `libwebkit2gtk` or similar system deps that are not present in the base CI runner image.
**How to avoid:** Use `playwright install --with-deps webkit chromium` in CI. This is the documented approach for CI environments. Expect this to add ~500MB to the CI step. [CITED: Playwright docs on browsers.mdx — `--with-deps` flag]
**Warning signs:** CI step fails with `libnss3.so: cannot open shared object file` or similar.
---
## Code Examples
### playwright.config.ts (complete)
```typescript
// apps/pwa/playwright.config.ts
// Source: Context7 /microsoft/playwright.dev
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? 'github' : 'list',
globalSetup: './e2e/global-setup.ts',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
trace: 'on-first-retry',
video: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'iphone',
use: {
...devices['iPhone 14'],
serviceWorkers: 'block',
},
},
{
name: 'pixel',
use: {
...devices['Pixel 7'],
serviceWorkers: 'block',
},
},
],
webServer: {
command: 'pnpm --filter @familysync/pwa dev',
url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
```
### vitest.config.ts patch (add exclude)
```typescript
// apps/pwa/vitest.config.ts — add exclude to prevent Vitest from picking up e2e specs
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
env: { TZ: 'UTC' },
exclude: ['e2e/**', 'node_modules/**'], // ← ADD THIS
},
});
```
### package.json scripts additions
```json
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:update-snapshots": "playwright test --update-snapshots"
}
}
```
**Root workspace script (for pnpm filter):**
```bash
pnpm --filter @familysync/pwa test:e2e
```
### layout.spec.ts skeleton
```typescript
// apps/pwa/e2e/layout.spec.ts
// Source: UI-SPEC.md Rules 1-4 + Context7 /microsoft/playwright.dev
import { test, expect } from '@playwright/test';
test.describe('BottomTabBar presence and tap targets', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/calendar');
});
test('BottomTabBar is present at mobile width', async ({ page }) => {
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible();
});
test('Calendar tab meets 44px touch target', async ({ page }) => {
const tab = page.getByRole('link', { name: 'Calendar' });
const box = await tab.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
});
test('no horizontal overflow on /calendar', async ({ page }) => {
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
});
});
test.describe('Error state — /calendar', () => {
test('shows error heading and Retry button when API returns 500', async ({ page }) => {
await page.route('/api/events*', (route) =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }),
);
await page.goto('/calendar');
await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
// error state must also pass overflow rule
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
});
});
```
---
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `playwright-cli` global tool (Chromium desktop only) | `@playwright/test` with `devices[...]` projects (WebKit + Chromium, mobile viewport) | This phase | Playwright-cli remains for interactive assistant smoke tests; `@playwright/test` is for automated regression |
| No E2E tests — `playwright-cli` used ad-hoc | Structured `e2e/` spec files with globalSetup + device matrix | This phase | Mobile layout defects caught automatically instead of by operator on real devices |
| `toHaveScreenshot` visual regression | Structural assertions (boundingBox, overflow eval, role/name locators) | Deliberate decision — UI-SPEC §Rule 6 | Lower maintenance, zero rendering-pipeline variance, sufficient quality coverage for this app |
**Deprecated/outdated patterns for this codebase:**
- `storageState.json` for Playwright auth: never appropriate here; `DEV_AUTH_BYPASS` is the correct pattern. [CITED: PITFALLS.md §Pitfall 14]
- `serviceWorkers: 'allow'` (default): would allow Workbox cache-first to intercept API calls. [CITED: PITFALLS.md §Pitfall 15]
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
| --- | ------------------------------------------------------------------------------------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| A1 | `fractional-indexing` rank strings `'a0'`, `'a1'` are valid initial ranks for seed items | Pattern 2 (global-setup) | Seed succeeds but list items sort incorrectly; items may not appear in expected order in UI |
| A2 | Playwright `webServer` starts before `globalSetup` is called | Pitfall 3 | globalSetup health poll would time out if Vite isn't started yet; ordering must be confirmed against docs |
| A3 | `mysql2` `SUS` verdict is a false positive due to recent patch release | Package Audit | Not a concern — package is already in the project; would only matter if upgrading to the latest patch caused issues |
| A4 | Linux WebKit font rendering differs from macOS enough to cause `toHaveScreenshot` failures | Primary Research Q | If wrong, screenshots could be added with `maxDiffPixelRatio: 0.03`; structural assertions remain the lower-risk choice |
---
## Open Questions
1. **`calendar_id=10` in CI DB — confirmed guard needed**
- What we know: dev DB has calendar row 10 from production poller. CI DB starts fresh from migrations.
- What's unclear: does the CI compose stack do any data seeding beyond migrations?
- Recommendation: globalSetup does `INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared) VALUES (10, 1, 'https://caldav.fastmail.com/dav/calendars/e2e/', 'FamilySync', '#4A90D9', true)` before inserting calendar_events. Safe even on local dev (IGNORE avoids duplicate key error).
2. **`webServer` ordering relative to `globalSetup`**
- What we know: Context7 docs show `webServer` and `globalSetup` as separate config options but don't document relative ordering explicitly.
- What's unclear: does Playwright guarantee `webServer` starts before `globalSetup` runs?
- Recommendation: if unsure, move the Vite readiness check INTO globalSetup (poll `:5173` before polling `/health`). This is belt-and-suspenders but eliminates the ordering ambiguity.
3. **`list_shares` row required vs. `isShared=true` flag alone**
- What we know: `lists.is_shared=true` is the flag; `list_shares` is the join table. The API `/api/lists` route may return lists via `listShares` join or via `is_shared` flag — need to check route handler.
- What's unclear: does user 1 see a list they own (ownerId=1) without a listShares row, or only via listShares?
- Recommendation: seed both `lists.owner_id=1` and a `list_shares` row for safety; the seed is idempotent either way.
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
| ----------------------------------- | ----------------------------------- | --------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------- |
| Node.js 22 LTS | global-setup (fetch native, mysql2) | ✓ (assumed) | 22.x | — |
| Dev MariaDB :3306 (port-bound) | global-setup DB seed | ✓ when dev stack is up via `docker-compose.dev.yml` | MariaDB 11 | Seed step skipped gracefully — tests run with empty DB (empty-state assertions still valid) |
| Vite dev server :5173 | All spec files | ✓ via `webServer` or operator's `pnpm dev` | Vite 8.0.16 | — |
| API :3000 with DEV_AUTH_BYPASS=true | All spec files (via Vite proxy) | ✓ when dev stack is up | Node 22 + Hono | — |
| WebKit browser binary | iPhone project | ✗ (not yet installed) | — | Must run `playwright install --with-deps webkit` |
| Chromium browser binary | Pixel project | ✓ (used by playwright-cli skill) | Chromium (via playwright-cli) | May need re-install via `@playwright/test`'s own browser store |
**Missing dependencies with no fallback:**
- WebKit browser binary — required for the `iphone` project. Must be installed via `playwright install --with-deps webkit` as part of Phase 7 Wave 0.
**Missing dependencies with fallback:**
- Dev MariaDB port binding — if compose isn't up, the seed is skipped; specs run with empty DB, exercising empty-state assertions only (partial coverage, but not a hard failure).
---
## Validation Architecture
### Test Framework
| Property | Value |
| ------------------------------- | -------------------------------------------------------------------- |
| Framework | `@playwright/test` 1.60.0 |
| Config file | `apps/pwa/playwright.config.ts` (Wave 0 — new file) |
| Quick run command (one profile) | `pnpm --filter @familysync/pwa exec playwright test --project=pixel` |
| Full suite command | `pnpm --filter @familysync/pwa exec playwright test` |
| Headed (local debug) | `pnpm --filter @familysync/pwa exec playwright test --headed` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
| ------- | ------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| TEST-01 | PWA loads in mobile-emulated viewport (390px / 412px), touch-capable, mobile UA | E2E (Playwright) | `pnpm --filter @familysync/pwa exec playwright test` | ❌ Wave 0 — create `e2e/layout.spec.ts` |
| TEST-01 | Tap targets ≥ 44px on BottomTabBar, FAB, Retry, settings button | E2E (Playwright) | same | ❌ Wave 0 — `e2e/layout.spec.ts` |
| TEST-01 | No horizontal overflow on `/calendar`, `/lists` | E2E (Playwright) | same | ❌ Wave 0 — `e2e/layout.spec.ts` |
| TEST-02 | Reaches authenticated PWA via `DEV_AUTH_BYPASS` (no manual login) | E2E (Playwright) | same | ❌ Wave 0 — `e2e/global-setup.ts` enforces auth precondition |
| TEST-02 | Harness runs headlessly, CI-portable (env-driven baseURL, readiness gate) | E2E (Playwright) | `CI=true pnpm --filter @familysync/pwa exec playwright test` | ❌ Wave 0 — `playwright.config.ts` |
### Harness Self-Validation (the harness must prove it works)
This phase's deliverable IS the test infrastructure. The harness is validated when it detects real defects. Recommended self-validation approach:
1. **Broken layout fixture test:** temporarily reduce BottomTabBar `minHeight` to `20px` in a test — the tap-target assertion MUST fail. Restore and verify it passes. This proves `boundingBox()` is measuring the rendered element, not the CSS declaration.
2. **Overflow injection test:** add `body { overflow-x: auto; width: 2000px; }` via `page.addStyleTag` before the overflow assertion — it MUST fail. Remove and verify it passes.
3. **SW-block verification:** after a run, `trace: 'on-first-retry'` generates trace artifacts. Review one trace with the Playwright trace viewer and confirm zero responses have `(ServiceWorker)` as source.
4. **Auth bypass verification:** without `DEV_AUTH_BYPASS=true`, the API redirects to Authelia. Run with bypass disabled — specs MUST fail on expected content not found. With bypass enabled, specs pass. (Manual verification step.)
### Sampling Rate
- **Per task commit:** `pnpm --filter @familysync/pwa exec playwright test --project=pixel` (Chromium only, faster)
- **Per wave merge:** `pnpm --filter @familysync/pwa exec playwright test` (both profiles)
- **Phase gate:** both profiles green on the full spec suite before marking Phase 7 complete
### Wave 0 Gaps
- [ ] `apps/pwa/playwright.config.ts` — project matrix, globalSetup, webServer, artifact config
- [ ] `apps/pwa/e2e/global-setup.ts` — health poll + DB seed (calendar id 10 guard + list + items)
- [ ] `apps/pwa/e2e/layout.spec.ts` — tap targets, overflow, BottomTabBar visibility (Rules 1, 2, 3)
- [ ] `apps/pwa/e2e/calendar.spec.ts` — populated state, empty state, error state (Rules 4, 5 for calendar)
- [ ] `apps/pwa/e2e/lists.spec.ts` — populated state, empty state (Rules 4, 5 for lists)
- [ ] `apps/pwa/vitest.config.ts` — add `exclude: ['e2e/**']` to prevent glob collision
- [ ] `apps/pwa/package.json` — add `@playwright/test` devDependency + `test:e2e` script
- [ ] Browser install: `pnpm --filter @familysync/pwa exec playwright install --with-deps webkit chromium`
---
## Security Domain
> `security_enforcement` not set to false — section required.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
| --------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| V2 Authentication | Yes (test auth path) | `DEV_AUTH_BYPASS=true` — never real credentials in test env; bypass is dev-only (guarded by `NODE_ENV !== 'production'`) |
| V3 Session Management | No | DEV_AUTH_BYPASS bypasses session cookies entirely |
| V4 Access Control | No | Harness tests as user 1; no privilege escalation in scope |
| V5 Input Validation | No | Harness is read-only; no form submission in scope |
| V6 Cryptography | No | No crypto operations in test harness |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
| ------------------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `DEV_AUTH_BYPASS=true` active in production | Elevation of Privilege | API guards on `NODE_ENV !== 'production'` — production compose MUST NOT set this variable. The harness README must document this. |
| `storage-state.json` with real OIDC session committed to repo | Information Disclosure | Not applicable — `storageState` is never used in this harness (D-01). |
| DB seed credentials in test script | Information Disclosure | Use env vars for DB credentials in globalSetup (`DB_HOST`, `DB_PASSWORD`); no hardcoded credentials. |
---
## Project Constraints (from CLAUDE.md)
| Directive | Impact on Phase 7 |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| MariaDB only — no PostgreSQL | seed script uses `mysql2`; no pg driver |
| pnpm workspace | all installs via `pnpm --filter @familysync/pwa add`; scripts via `pnpm --filter @familysync/pwa exec playwright` |
| No Makefile (root Makefile does not exist) | scripts exposed via `package.json` `scripts` in `apps/pwa` and root workspace; no Makefile to update |
| playwright-cli is global Chromium only | `@playwright/test` brings its own browser store; no conflict with playwright-cli; the two tools coexist |
| playwright-cli skill exception for iOS-Safari-standalone | real device checks (Home Screen install, iOS push) remain human gates — NOT in scope for this harness |
| Vitest for unit tests | `*.spec.ts` glob collision must be resolved via `vitest.config.ts` exclude |
| `tsc --noEmit` gate (both apps) | `playwright.config.ts` and `e2e/*.ts` files must pass typecheck; add to root `typecheck` script or ensure `apps/pwa/tsconfig.json` includes `e2e/` |
---
## Sources
### Primary (MEDIUM confidence — Context7/High reputation source)
- `/microsoft/playwright.dev` via Context7 — device emulation config, projects matrix, globalSetup pattern, `page.route()`, `trace: 'on-first-retry'`, `webServer` + `reuseExistingServer`, `baseURL` env config, `toHaveScreenshot` options
### Verified (via direct tool calls)
- npm registry `@playwright/test` — version 1.60.0 confirmed, 38.6M weekly downloads, Microsoft GitHub source [VERIFIED: npm registry]
- npm registry `mysql2` — version 3.22.5 confirmed, 11.4M weekly downloads, `SUS` (too-new flag on latest patch) [VERIFIED: npm registry]
- `playwright/deviceDescriptorsSource.json` via WebFetch — `devices['iPhone 14']` and `devices['Pixel 7']` confirmed present [VERIFIED: playwright deviceDescriptorsSource.json]
- `apps/pwa/vitest.config.ts` — no explicit `include`; default glob catches `*.spec.ts`; `exclude` needed [VERIFIED: file read]
- `apps/pwa/package.json` — no `@playwright/test` present; no `e2e` script [VERIFIED: file read]
- `apps/api/src/db/schema.ts``calendars`, `calendar_events`, `lists`, `list_items`, `list_shares` table structure confirmed [VERIFIED: file read]
- `apps/api/src/auth/devBypass.ts` — DEV_USER id=1, `NODE_ENV !== 'production'` guard confirmed [VERIFIED: file read]
- `apps/api/src/db/client.ts` — DB connection reads `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` from env [VERIFIED: file read]
### Cited (project documentation)
- `07-CONTEXT.md` — locked decisions D-01 through D-10
- `07-UI-SPEC.md` — assertion contract Rules 18, device matrix, locator anchors
- `PITFALLS.md §Pitfall 14, §Pitfall 15` — storage-state stale, SW intercept
- `TESTING.md` — existing Vitest setup, `*.test.ts` convention, E2E gap
- `memory/dev-stack-bringup.md` — dev stack DB_HOST override, DEV_AUTH_BYPASS pattern
- `memory/api-integration-test-db.md` — DB_HOST=127.0.0.1, mysql2 connection pattern
---
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — `@playwright/test` 1.60.0 verified via npm; device descriptors verified via source file; mysql2 confirmed existing dep
- Architecture: HIGH — patterns derived from existing project conventions (vitest config, DB client env vars, devBypass) + Context7 Playwright docs
- Pitfalls: HIGH — Pitfalls 1/2/5/6 derived from reading actual project files; Pitfall 3/4 from reasoning about CI ordering
**Research date:** 2026-06-10
**Valid until:** 2026-09-10 (Playwright releases frequently but the device emulation and globalSetup APIs are stable)
@@ -0,0 +1,106 @@
---
phase: 07-mobile-test-harness
fixed_at: 2026-06-11T08:05:00Z
review_path: .planning/phases/07-mobile-test-harness/07-REVIEW.md
iteration: 1
findings_in_scope: 12
fixed: 6
skipped: 6
status: partial
---
# Phase 7: Code Review Fix Report
**Fixed at:** 2026-06-11T08:05:00Z
**Source review:** .planning/phases/07-mobile-test-harness/07-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope (fix_scope=all): 12 open/actionable + info; CR-01/BL-01/BL-02 already resolved (left intact)
- Fixed: 6 (WR-01, WR-02, WR-05, WR-06, WR-07 — and WR-02/WR-01 share one commit)
- Skipped: 6 (WR-03, WR-04, IN-01..IN-05) — by-design / positive notes, no net-positive edit available
**Verification evidence (all fixes):**
- Full E2E suite (both profiles, iphone/WebKit + pixel/Chromium): **58 passed** (29.4s), suite exit 0.
- `pnpm --filter @familysync/pwa typecheck` (both `tsconfig.json` and `tsconfig.e2e.json`): **exit 0**.
- SW test (WR-07) confirmed passing on BOTH iphone(WebKit) and pixel(Chromium) — re-run in isolation: 2 passed.
- Suite was run from the isolated worktree with `.env` sourced from the main repo (worktree `.env` is gitignored/absent) + `DEV_AUTH_BYPASS=true DB_HOST=127.0.0.1 DB_PORT=3306`.
## Fixed Issues
### WR-01: readiness gate accepts the SPA shell, not a working DEV_AUTH_BYPASS API
**Files modified:** `apps/pwa/e2e/global-setup.ts`
**Commit:** 9c38dd3 (shared with WR-02)
**Applied fix:** Added a Step 1b probe after the `/health` gate: `fetch(baseURL + '/api/me', { redirect: 'manual' })` and throw with a clear, actionable message unless it returns 200. If the API was started without `DEV_AUTH_BYPASS=true`, `/api/me` redirects (302) to Authelia; the gate now fails loudly in setup instead of producing ~40 confusing spec failures. Verified: the seed ran and all 58 specs passed, proving the new gate does not false-positive against the correctly-configured dev stack.
### WR-02: readiness-gate success misreported as timeout near the deadline
**Files modified:** `apps/pwa/e2e/global-setup.ts`
**Commit:** 9c38dd3 (shared with WR-01)
**Applied fix:** Replaced the post-loop `if (Date.now() >= deadline) throw` (which can misclassify a success that arrived in the final second as a timeout, because `await fetch` itself consumes time) with an explicit `let ready = false` flag set inside the loop on `res.ok`; throw only `if (!ready)`. Removes the clock-inference race. Verified by full green suite (globalSetup executes once at suite start).
> Note: WR-01 and WR-02 are committed together because both edits live in the same contiguous readiness-gate hunk in `global-setup.ts` (no `gsd-tools` / interactive hunk-split available to separate one hunk into two commits). Both are readiness-gate robustness changes.
### WR-05: `page.unroute` not in `finally` — misleading dead cleanup
**Files modified:** `apps/pwa/e2e/calendar.spec.ts`, `apps/pwa/e2e/lists.spec.ts`
**Commit:** 2b745ad
**Applied fix:** Removed the 5 trailing `page.unroute(...)` calls (calendar: error-heading, retry-44px, error-overflow tests; lists: empty-state, empty-overflow tests) and replaced each with a one-line comment explaining that Playwright gives each test a fresh page/context, so route handlers do not leak across tests — and that a trailing unroute never runs anyway if an `expect` above throws. Chose "drop redundant calls" over "wrap in try/finally" per the reviewer's stated options; it is the lower-noise option and matches real per-test isolation. Verified: all route-mocked error/empty-state tests still pass on both profiles.
### WR-06: self-validation "remove style by reload" comment is wrong
**Files modified:** `apps/pwa/e2e/layout.spec.ts`
**Commit:** 5322cfc
**Applied fix:** Corrected both misleading comments (Rule 1 proof ~L209, Rule 2 proof ~L250) that claimed the injected `<style>` is removed "by navigating / page.reload drops inline style tags". The code actually removes it via `styleHandle.evaluate((el) => el.remove())` with no reload. Comment-only change. Tier-2 typecheck + full suite green.
### WR-07: SW-controller assertion near-vacuous on WebKit (iPhone) profile
**Files modified:** `apps/pwa/e2e/calendar.spec.ts`
**Commit:** c564fc6
**Applied fix:** Rewrote the test from asserting `navigator.serviceWorker.controller === null` (which passes for unrelated reasons: SW absent on WebKit/http, or null controller on any first uncontrolled load) to: (1) probe `'serviceWorker' in navigator`; (2) `test.skip(!swAvailable, ...)` so an unavailable API does not masquerade as a passing block (does NOT throw on WebKit); (3) where available, assert `navigator.serviceWorker.getRegistration()` resolves to `undefined`, which actually proves `serviceWorkers: 'block'` prevented registration. Renamed the test to "no service-worker registration". **Verified on BOTH profiles** — re-ran in isolation: `2 passed` (iphone + pixel); WebKit does not throw.
## Skipped Issues
### WR-03: webServer manages Vite only; proxied API not managed
**File:** `apps/pwa/playwright.config.ts:59-64`
**Reason:** skipped — by design (D-09/D-10: operator brings up the stack, harness waits via globalSetup `/health` gate). Reviewer itself states "No code defect; documentation-coupling risk." WR-01's `/api/me` gate already strengthens the deferred-failure path. No net-positive code change.
### WR-04: `page.route('/api/lists')` exact match
**File:** `apps/pwa/e2e/lists.spec.ts:74, 96`
**Reason:** skipped — already DOWNGRADED to resolved-correct in the review. `fetchLists()` requests the bare `/api/lists` (no query string), and the exact matcher is intentionally narrow so it does not swallow `/api/lists/:id/items`. Converting to a glob would be a regression. No change needed.
### IN-01: `mysql2` as PWA devDependency
**File:** `apps/pwa/package.json:38`
**Reason:** skipped — placement is correct and acceptable (dev/test-only, never bundled; vitest excludes `e2e/**`). The only caveat is keeping the version pin in lockstep with `apps/api`; both are currently `3.22.4`. Not a defect.
### IN-02: `tsconfig.e2e.json` `types: ["node"]` narrows ambient types
**File:** `apps/pwa/tsconfig.e2e.json:4-5`
**Reason:** skipped — positive "this is sound, no action" note from the reviewer. DOM globals come from `lib`, `@playwright/test` types via direct import. Confirmed by typecheck exit 0.
### IN-03: vitest `exclude: ['e2e/**']` isolation
**File:** `apps/pwa/vitest.config.ts:17`
**Reason:** skipped — positive "no action" note; the two runners are cleanly partitioned.
### IN-04: `typecheck` script covers the e2e tsconfig
**File:** `apps/pwa/package.json:10`
**Reason:** skipped — positive "good, no action" note; confirmed `typecheck` runs both tsconfigs (exit 0).
### IN-05: CR-01 guard protects production, not "the wrong dev DB"
**File:** `apps/pwa/e2e/global-setup.ts:34-44`
**Reason:** skipped — by design (D-06 deterministic reseed; documented in README). The dev-DB-wipe is intended. Adding an `E2E_ALLOW_TRUNCATE`/`*_test`-name gate would contradict the locked deterministic-reseed design and add operator friction for no production-safety gain (production is already hard-blocked). Per scope guidance, not a net-positive change.
---
_Fixed: 2026-06-11T08:05:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,236 @@
---
phase: 07-mobile-test-harness
reviewed: 2026-06-11T12:30:00Z
depth: deep
files_reviewed: 9
files_reviewed_list:
- apps/pwa/playwright.config.ts
- apps/pwa/e2e/global-setup.ts
- apps/pwa/e2e/layout.spec.ts
- apps/pwa/e2e/calendar.spec.ts
- apps/pwa/e2e/lists.spec.ts
- apps/pwa/e2e/README.md
- apps/pwa/tsconfig.e2e.json
- apps/pwa/vitest.config.ts
- apps/pwa/package.json
findings:
critical: 0
critical_resolved: 1
blocker: 0
blocker_resolved: 2
warning: 0
warning_resolved: 7
info: 0
info_bydesign: 5
total: 0
status: clean
---
# Phase 7: Code Review Report (DEEP) — Iteration 2 (--auto re-review)
**Reviewed:** 2026-06-11
**Depth:** deep (cross-file call-chain analysis + live-stack verification)
**Files Reviewed:** 9 (harness)
**Status:** clean — zero open actionable findings
## Summary
This is the iteration-2 re-review after the fixer applied 5 changes (commits `c564fc6` WR-07,
`9c38dd3` WR-01+WR-02, `5322cfc` WR-06, `2b745ad` WR-05). The prior pass had resolved CR-01,
BL-01, and BL-02; those resolution records are preserved below.
**Verification performed this pass:**
- Ran the full suite against the live dev stack (MariaDB :3306, API :3000 `DEV_AUTH_BYPASS=true`,
Vite auto-started by `webServer`): **58 passed (55s)**.
- Typecheck (`tsc --noEmit` + `tsc --project tsconfig.e2e.json --noEmit`): **exit 0**.
- Probed `navigator.serviceWorker` availability on **both** engines to confirm the WR-07 fix is
non-vacuous (see WR-07 below).
- Probed `redirect:'manual'` response semantics to confirm the WR-01 gate distinguishes a
dev-bypass 200 from an Authelia redirect.
**Result:** all 7 prior warnings are resolved by the fixes (5 actionable + WR-03/WR-04 by-design),
no fix introduced a regression or new defect, and no new cross-file issue was exposed.
Setting `status: clean`. The 5 IN-\* items remain advisory/by-design and are listed under
"Resolved / By-design"; none are actionable.
---
## Critical Issues (resolved — record preserved)
### CR-01 (RESOLVED in commit `fcc680e`): global-setup TRUNCATE had no fail-closed guard
**File:** `apps/pwa/e2e/global-setup.ts:26-44`
**Status:** RESOLVED — re-verified sound this pass.
`globalSetup` TRUNCATEs `list_items`, `list_shares`, `lists`, `calendar_events` against whatever
`DB_*` points at. The fix throws **before** opening any DB connection:
1. `NODE_ENV === 'production'` → throw (checked first).
2. `DEV_AUTH_BYPASS !== 'true'` → throw.
This mirrors the API guard (`apps/api/src/auth/devBypass.ts`) ordering exactly and is coupled to
the same switch that makes the API serve Dev User 1 without OIDC (`index.ts:24-25, 51-55`).
Residual scope note carried as IN-05 (guard protects production, not "the wrong dev DB" — by design).
---
## Blocker Findings (resolved — record preserved)
### BL-01 (RESOLVED in commit `53c3ca5`): calendar populated-state assertions were vacuous
**File:** `apps/pwa/e2e/calendar.spec.ts`
**Status:** RESOLVED — re-verified non-vacuous this pass.
The dead-`EmptyState` / always-rendered-wrapper assertions were replaced with a real DB→UI proof:
`getByText('Seeded Test Event').first()` must be visible in the grid (`calendar.spec.ts:90-97`).
Verified live: passes on both `iphone` (WebKit) and `pixel` (Chromium). With `/api/events`
mocked empty the title is absent, so the assertion genuinely tracks the seed flowing
DB → API → query → grid. The wrapper-visibility test (`:80-88`) was kept but its docstring now
correctly states it only proves the grid mounts, not that the seed reached the UI.
### BL-02 (RESOLVED in commit `53c3ca5`): seed↔view month-boundary fragility
**File:** `apps/pwa/e2e/global-setup.ts:119-154`
**Status:** RESOLVED — re-verified deterministic this pass.
The seed event is re-anchored to **noon-today (UTC)** (`global-setup.ts:127-129`) — always today's
local calendar date, always inside the current-month view both phone-width profiles render. The
prior `now+24h` could roll into the next month on a month's last day, making any "seeded event is
visible" assertion date-fragile. The seed shape (`all_day=false`, `dtstart_utc` set, recurring
flags false) matches the API's non-recurring-timed WHERE branch. Verified live on both engines.
---
## Resolved this iteration (fixer commits — verified, no regression)
### WR-01 (RESOLVED in `9c38dd3`): `/api/me` dev-bypass reachability gate
**File:** `apps/pwa/e2e/global-setup.ts:75-90`
The gate now probes `fetch(${baseURL}/api/me, { redirect: 'manual' })` after the `/health` poll
and throws unless `res.ok`. Verified correct end-to-end:
- **Dev-bypass-reachable API → 200.** `me.ts:30-42` short-circuits on `c.get('user')` (DEV_USER)
with no DB round-trip, so the gate passes regardless of seed state and regardless of ordering
(the probe runs before the seed — confirmed safe because `/api/me` has no DB dependency under
bypass). The full suite passed with this gate live.
- **Authelia-redirecting API → fails loudly.** With `redirect:'manual'`, a cross-origin 302 to
Authelia surfaces as `type:'opaqueredirect'`, `status:0`, `ok:false` → gate throws. A
same-origin redirect (e.g. `c.redirect('/')`) surfaces as `type:'basic'`, `status:302`,
`ok:false` → also throws. Confirmed empirically against `/api/login` (302, `ok=false`).
- **No false-fail in the supported setup:** in the dev-bypass stack the OIDC middleware is not
mounted (`index.ts:51`), so `/api/me` always returns 200. No regression.
The error message string contains `opaqueredirect` with no space — cosmetic only (it is the exact
`Response.type` token undici emits); not actionable.
### WR-02 (RESOLVED in `9c38dd3`): explicit readiness flag
**File:** `apps/pwa/e2e/global-setup.ts:54-73`
The loop now uses an explicit `let ready = false` set inside the `res.ok` branch, and the
post-loop check is `if (!ready) throw` — success is no longer inferred from `Date.now() >= deadline`.
This removes both the false-positive-timeout (a success arriving in the final second can no longer
be misreported as a timeout) and any false-positive-ready (the flag is only set on an actual
`res.ok`). Timeout logic verified correct by reading; the gate ran green in the live suite.
### WR-05 (RESOLVED in `2b745ad`): dropped redundant `unroute` calls
**Files:** `apps/pwa/e2e/calendar.spec.ts:133-135, 154, 176`; `apps/pwa/e2e/lists.spec.ts:90-92, 118`
The trailing `page.unroute(...)` calls were removed and replaced with comments explaining that
per-test context isolation handles cleanup. Verified this is correct, not a leak risk:
- Every `page.route(...)` is registered **inside an individual test body**, never in a shared
`beforeEach`/`beforeAll`. Playwright assigns each test a fresh `page`/`BrowserContext`, and route
handlers are scoped to that page/context — they cannot leak into sibling tests.
- The suite runs under `fullyParallel: true` with no `describe.serial`, so there is no shared-page
path that could carry a route forward.
- Cross-test isolation confirmed empirically: the populated-state calendar/lists tests (no mock)
and the error/empty-state tests (with mock) all pass in the same run with no interference.
The removed `unroute` calls were genuinely dead — they never ran when an `expect` threw (the whole
point of those tests), so they had guaranteed nothing. Dropping them is strictly an improvement.
### WR-06 (RESOLVED in `5322cfc`): self-validation comment corrected
**File:** `apps/pwa/e2e/layout.spec.ts:209-211, 250-252`
The misleading "remove by reload" comments now read "REMOVE the injected style by deleting the
`<style>` element via evaluate (`styleHandle.evaluate(el => el.remove())` — no page reload)", which
matches the actual code (`styleHandle.evaluate((el) => (el as Element).remove())`). Comment matches
code. Trivial, confirmed.
### WR-07 (RESOLVED in `c564fc6`): SW-block test is now non-vacuous and honestly skips
**File:** `apps/pwa/e2e/calendar.spec.ts:41-68`
The test now (a) computes `swAvailable = 'serviceWorker' in navigator`, (b) `test.skip(!swAvailable, ...)`
when absent, and (c) otherwise asserts `getRegistration()` resolves to `undefined`. Verified all three
concerns live:
- **(a) Not vacuous on Chromium/pixel — AND not vacuous on WebKit/iphone either.** I probed both
engines directly: `swAvailable=true` and `getRegistration()=undefined` on **both** `iphone`
(WebKit) and `pixel` (Chromium) over `http://localhost`. So the genuine assertion runs on both
profiles in this environment — `getRegistration()` is available and returns `undefined` under
`serviceWorkers:'block'`. The SW test shows `✓ passed` (not `skipped`) on iphone, confirming the
real assertion executed rather than being silently skipped.
- **(b) `test.skip` is honest.** It is a real `test.skip(condition, reason)` that, when
`serviceWorker` is genuinely absent (e.g. a future WebKit/runner where http://localhost is not a
secure context), marks the test **skipped/visible** in the reporter — it does not let an
unavailable API masquerade as a pass. In the current stack the skip branch is never taken, so it
is correct dead-fallback, not a silent pass.
- **(c) `getRegistration()` is the right probe under `serviceWorkers:'block'`.** With the block in
effect no registration is ever created, so the promise resolves to `undefined`; if the block were
lifted and the app registered `sw.js`, this would become a `ServiceWorkerRegistration` and the
`toBeUndefined()` assertion would fail. This is a real, regression-sensitive signal (unlike the
old `controller === null`, which was null on any first uncontrolled load regardless of the block).
No regression. The fix strictly strengthens the assertion.
---
## Resolved / By-design (advisory — NOT actionable)
These were never code defects; they are design notes carried for traceability. None block shipping.
- **WR-03 (by-design):** `webServer` manages Vite only; the API/DB/Redis are compose-managed per
D-10. Playwright considers the server ready when Vite answers, before `globalSetup` polls
`/health`; a missing API is deferred to the `/health` gate (now also the `/api/me` gate, WR-01).
This is the intended D-09 contract. Documentation-coupling only.
- **WR-04 (by-design):** `page.route('/api/lists')` exact-match is correct — `fetchLists()` requests
the bare path with no query string, and the narrow matcher intentionally avoids swallowing
`/api/lists/:id/items`. A glob would be brittle. No change.
- **IN-01 (advisory):** `mysql2@3.22.4` is a PWA `devDependency` used only by the seed; correct
placement (never bundled). Note: pinned independently from `apps/api`'s copy — keep in lockstep.
- **IN-02 (advisory):** `tsconfig.e2e.json` `types:["node"]` + `lib:["DOM",...]` correctly types the
Node seed while still typing `page.evaluate` DOM callbacks. `@playwright/test` types come via
direct import. Sound.
- **IN-03 (advisory):** vitest `exclude:['e2e/**']` and Playwright `testDir:'./e2e'` cleanly
partition the two runners. Sound.
- **IN-04 (advisory):** `typecheck` covers both tsconfigs (re-verified exit 0 this pass). Good.
- **IN-05 (advisory):** the CR-01 guard protects _production_, not "the wrong dev DB" — pointing
`DB_*` at a populated dev DB with `DEV_AUTH_BYPASS=true` will still TRUNCATE it. By design (D-06
deterministic reseed) and documented. A defense-in-depth `E2E_ALLOW_TRUNCATE`/DB-name-pattern
opt-in remains an optional hardening, not a defect.
---
## Live-run evidence (iteration 2)
| Check | Result |
| ----------------------------------------- | ------------------------------------------ |
| Full suite (both profiles) | 58 passed (55.0s) |
| `iphone` SW-block test | ✓ passed (real assertion ran; not skipped) |
| `pixel` SW-block test | ✓ passed |
| Seeded-event DB→UI proof (iphone + pixel) | ✓ passed both |
| `swAvailable` probe (both engines) | `true` / `getRegistration()=undefined` |
| `redirect:'manual'` on a 302 | `ok=false` (gate throws — correct) |
| `tsc --noEmit` + e2e tsconfig | exit 0 |
---
_Reviewed: 2026-06-11T12:30:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep (iteration 2 — --auto re-review)_
@@ -0,0 +1,395 @@
---
phase: 7
slug: mobile-test-harness
status: draft
shadcn_initialized: false
preset: none
created: 2026-06-11
framing: quality-bar-contract
---
# Phase 7 — Mobile UI Quality-Bar Contract
> This phase builds **no new UI**. The harness asserts against the existing
> FamilySync PWA. This document is a **quality-bar contract**, not a design
> system spec. Its job is to pin every measurable threshold the harness must
> enforce so the planner can turn each rule into a concrete Playwright
> assertion. Template sections that have no assertable content for a test
> harness are marked N/A with a one-line reason.
---
## Design System
N/A — test harness, no new UI. The existing design system is declared in
`apps/pwa/src/styles/tokens.css` and consumed by the assertions below.
| Property | Value |
| ----------------- | ---------------------------------------------------------------------- |
| Tool | none (no shadcn; inline CSS custom properties) |
| Preset | not applicable |
| Component library | none (lucide-react icons; Schedule-X calendar widget) |
| Icon library | lucide-react (via npm dep, no CDN) |
| Font | `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` |
---
## Spacing Scale
N/A — test harness, no new UI. Spacing tokens are declared in `tokens.css`
and are not re-specified here. Assertions reference computed pixel values
derived from those tokens where needed (e.g. BottomTabBar height = 56px +
safe-area-inset).
---
## Typography
N/A — test harness, no new UI. Typography tokens exist in `tokens.css`; the
harness does not assert on font metrics unless a visible-text / accessible-name
check requires it (captured in Assertion Contract below).
---
## Color
N/A — test harness, no new UI. The 60/30/10 color split is declared in
`tokens.css`. The harness does not assert computed colors — color drift is
out of scope and prone to rendering-pipeline variance.
---
## Copywriting Contract
Copywriting that the harness **must** be able to locate by text in assertions.
These are the exact strings emitted by the existing components; the harness
uses them as stable locator anchors.
| Element | Exact String | Source Component |
| ---------------------------- | ----------------------------------------------------------------- | ------------------------- |
| Calendar empty-state heading | `Nothing here` | `EmptyState.tsx` |
| Calendar empty-state body | `No events in this period. Try a different date or switch views.` | `EmptyState.tsx` |
| Lists empty-state heading | `No lists yet` | `ListsEmptyState.tsx` |
| Lists empty-state body | `Tap + to create your first shared list` (contains) | `ListsEmptyState.tsx` |
| Calendar error heading | `Couldn't load events` | `CalendarShell.tsx` |
| Calendar error CTA | `Retry` (button text) | `CalendarShell.tsx` |
| New Event FAB | `aria-label="New Event"` | `CalendarShell.tsx` |
| Bottom nav — Calendar tab | `aria-label="Calendar"` | `BottomTabBar.tsx` |
| Bottom nav — Lists tab | `aria-label="Lists"` | `BottomTabBar.tsx` |
| Top nav (phone) | `FamilySync` (visible text) | `AppNav.tsx``PhoneNav` |
| Settings button | `aria-label` contains `open settings` | `AppNav.tsx``PhoneNav` |
> Stable copywriting anchor rule: **always locate interactive elements by
> `aria-label` or `role` + accessible name first.** Text-content locators
> (`getByText`) are second resort — acceptable for static headings/bodies
> that have no ARIA role.
---
## Registry Safety
N/A — test harness, no new UI components. `@playwright/test` is a new dev
dependency in `apps/pwa`; it is the official Playwright package from the
Playwright team and requires no safety vetting under this gate.
---
## Assertion Contract
This section is the primary deliverable for Phase 7. It replaces the
design-system sections of the standard template with the measurable
quality-bar rules that the harness enforces.
### Device / Viewport Matrix
| Profile ID | Playwright Descriptor | Engine | Viewport | UA Type |
| ---------- | --------------------- | -------- | ------------------ | -------------- |
| `iphone` | `'iPhone 14'` | WebKit | 390×844 logical px | Mobile Safari |
| `pixel` | `'Pixel 7'` | Chromium | 412×915 logical px | Chrome Android |
**Source:** D-03 (iPhone + Pixel matrix), D-04 (WebKit for iPhone, Chromium
for Pixel). These are the exact Playwright device descriptor strings to pass
to `devices['iPhone 14']` and `devices['Pixel 7']` in `playwright.config.ts`.
Both profiles run with `serviceWorkers: 'block'` (D-02 / Pitfall 15) and
`DEV_AUTH_BYPASS=true` (D-01 / Pitfall 14). No `storageState` file.
**CI note:** Both engines must be installed in the Phase 8 CI image. The
harness adds WebKit beyond the existing global `playwright-cli` (Chromium
only). Accept the larger CI image cost — this was a deliberate call (D-04,
07-CONTEXT.md §Specifics).
---
### Rule 1 — Touch-Target Minimum
**Threshold:** Every interactive element (button, link, `role="button"`) must
have a computed bounding box of **≥ 44 × 44 logical pixels**.
**Basis:**
- Apple Human Interface Guidelines: minimum touch target 44×44 pt.
- WCAG 2.5.5 (Level AAA): minimum 44×44 CSS px.
- The existing codebase declares this as a hard constraint: `BottomTabBar`
uses `minHeight: '44px'`; `AppNav` `PhoneNav` settings button uses
`minWidth: '44px', minHeight: '44px'`; calendar FAB is `56×56px`; Retry
button uses `minHeight: '44px'`; nav links use `minHeight: '44px'`.
**Measurement approach:**
```typescript
// Use boundingBox() on the element handle, not CSS-declared values.
const box = await element.boundingBox();
expect(box!.width).toBeGreaterThanOrEqual(44);
expect(box!.height).toBeGreaterThanOrEqual(44);
```
**What counts as an interactive target:**
- `<button>` elements (including FAB, Retry, settings avatar button)
- `<a>` and `NavLink` elements (BottomTabBar tabs, sidebar nav links)
- Any element with `role="button"`, `role="link"`, or `tabindex="0"` that
has a click/tap handler
**Explicit elements to assert on both profiles:**
| Element | Expected min size | Locator strategy |
| -------------------------- | ----------------- | ------------------------------------------------- |
| BottomTabBar Calendar tab | 44×44 | `getByRole('link', { name: 'Calendar' })` |
| BottomTabBar Lists tab | 44×44 | `getByRole('link', { name: 'Lists' })` |
| PhoneNav settings button | 44×44 | `getByRole('button', { name: /open settings/i })` |
| New Event FAB | 56×56 | `getByRole('button', { name: 'New Event' })` |
| Retry button (error state) | 44×44 | `getByRole('button', { name: 'Retry' })` |
**BottomTabBar phone-only gate:** `BottomTabBar` renders `null` on desktop
(`matchMedia('(max-width: 767px)')`). Assert it is present on both mobile
profiles (390px and 412px width) and absent on desktop (1280px). Both test
profiles qualify as phone-width so the bar must be visible.
---
### Rule 2 — No Horizontal Overflow
**Threshold:** On every tested route, `document.documentElement.scrollWidth`
must equal `document.documentElement.clientWidth`. No horizontal scrollbar;
no content overflow.
**Measurement approach:**
```typescript
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
```
**Routes to assert on both profiles:**
| Route | State to assert |
| ----------- | ------------------------------------------------------- |
| `/calendar` | populated (seeded events) |
| `/calendar` | error state (simulated — mock API to 500) |
| `/lists` | populated (seeded list + items) |
| `/lists` | empty state (no lists — dev-bypass user 1 native state) |
**Allowed exceptions:** none. The Schedule-X calendar widget historically
caused overflow on narrow viewports (see memory entry `schedule-x-allday-event-styling`).
If a Schedule-X internal element overflows, the assertion must still fail —
this is the defect the harness exists to catch.
---
### Rule 3 — Critical Elements Visible and In-Viewport
Assertion: each element below must be visible (`isVisible() === true`) **and**
within the viewport (`boundingBox().y >= 0`, `boundingBox().y + height <=
viewport.height`) on initial load, before any scroll.
| Element | Route | Profile |
| --------------------------- | ----------------------- | -------------- |
| BottomTabBar | `/calendar`, `/lists` | iPhone + Pixel |
| PhoneNav header | `/calendar`, `/lists` | iPhone + Pixel |
| Schedule-X calendar grid | `/calendar` (populated) | iPhone + Pixel |
| New Event FAB | `/calendar` | iPhone + Pixel |
| Lists index cards (≥1 card) | `/lists` (seeded) | iPhone + Pixel |
**BottomTabBar position assertion (safe-area-inset):** the bar uses
`env(safe-area-inset-bottom, 0px)`. In the emulated context there is no
safe-area-inset, so the bar's bottom edge must be ≤ the viewport height.
Assert `boundingBox().y + boundingBox().height <= page.viewportSize().height`.
---
### Rule 4 — Accessible Names on All Interactive Elements
Assertion: every interactive element exposed to the assertions above must
have a non-empty accessible name, locatable via Playwright's ARIA role
queries without needing a CSS selector fallback.
**Required accessible names (exact or pattern):**
| Element | Role | Expected accessible name |
| ------------------------- | ------------ | -------------------------- |
| BottomTabBar Calendar tab | `link` | `"Calendar"` |
| BottomTabBar Lists tab | `link` | `"Lists"` |
| PhoneNav settings button | `button` | matches `/open settings/i` |
| New Event FAB | `button` | `"New Event"` |
| Retry button | `button` | `"Retry"` |
| Main navigation landmark | `navigation` | `"Main navigation"` |
Locator pattern:
```typescript
page.getByRole('link', { name: 'Calendar' });
page.getByRole('button', { name: /open settings/i });
```
If an element cannot be found by role + name, the test fails. This doubles as
a regression gate for accessible-name regressions (e.g. a button losing its
`aria-label`).
---
### Rule 5 — Empty States Render Correctly
**Context (D-05):** DEV_AUTH_BYPASS user 1 natively has no CalDAV credentials
or calendars. Without seeding, calendar views render empty and list views
render empty. This is the "native empty" state.
**Seeded populated state:** D-06 seeds deterministic fixtures before each run
(global-setup truncate/insert). Seeding targets shared calendar id 10 (from
project memory `dev-data-user1-no-calendars`) and creates ≥1 list with ≥2
items for user 1 (via direct MariaDB insert, not the API, since live
event-create 422s for user 1).
**Assertions:**
| State | Route | Assert |
| -------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Populated calendar | `/calendar` (after seeding) | Schedule-X grid is visible; `<EmptyState>` is NOT in DOM |
| Populated lists | `/lists` (after seeding) | ≥1 list card is visible; `ListsEmptyState` is NOT in DOM |
| Empty lists (pre-seed teardown or clean run) | `/lists` | `getByText('No lists yet')` is visible; `getByText(/Tap \+ to create/)` is visible |
| Calendar error | `/calendar` (API mocked to 500) | `getByRole('heading', { name: "Couldn't load events" })` is visible; `getByRole('button', { name: 'Retry' })` is visible |
**Empty-state assertion depth:** each empty state must additionally pass Rule
1 (touch targets on any interactive elements within it) and Rule 2 (no
horizontal overflow).
---
### Rule 6 — Visual Snapshots
**Decision: OMIT `toHaveScreenshot()` assertions entirely for this phase.**
Rationale (from D-08-area steer in 07-CONTEXT.md):
- The Schedule-X calendar widget renders dynamic content (current date
highlighted, event chips placed by the widget's internal layout engine)
that will differ between host and CI renderers on different dates and OS
font-rendering pipelines.
- CI-generated baselines + tolerance configuration (`maxDiffPixelRatio`,
`threshold`) address pixel variance but not date-dependent layout changes
(today's date highlight shifts every day; event chip wrapping varies by
viewport pixel density).
- There is no established prior art in this codebase for non-flaky
Schedule-X snapshot tests across host↔CI WebKit.
- The structural + role-based locator assertions in Rules 15 cover the
quality bar with zero rendering-pipeline variance.
**If added later:** snapshots must use CI-generated baselines only
(`--update-snapshots` run in the CI environment on first run), store baselines
per browser engine under `apps/pwa/e2e/snapshots/{browser}/`, and set
`maxDiffPixelRatio: 0.03`. Snapshots must be scoped to static UI elements
(e.g. BottomTabBar only, clipped), not the full viewport containing
Schedule-X.
---
### Rule 7 — Auth and Service Worker Preconditions
These are not UI-quality assertions but are preconditions that must hold for
all other assertions to be valid. They are enforced in global-setup and
browser context options.
| Precondition | Enforcement | Source |
| ------------------------------------------ | -------------------------------------------------- | ----------------- |
| `DEV_AUTH_BYPASS=true` in API process | Env var set before dev-server launch | D-01 / Pitfall 14 |
| `serviceWorkers: 'block'` on every context | `playwright.config.ts` contextOptions | D-02 / Pitfall 15 |
| No `storageState` file | `playwright.config.ts` — omit `storageState` | D-01 / Pitfall 14 |
| PWA reachable before specs run | global-setup polls `GET /health` until 200 | D-08 / SC #3 |
| DB fixtures reset before run | global-setup truncate + insert | D-06 |
| No SW-sourced responses | Playwright trace shows no `(ServiceWorker)` source | D-02 / Pitfall 15 |
**SW-source verification (in trace):** after a run, if a test fails with
unexpected data, inspect the `.zip` trace artifact. Any response with source
`(ServiceWorker)` is a contract violation — the `serviceWorkers: 'block'`
option should prevent this. Log a test failure if detected programmatically:
```typescript
// In each test: attach a route listener to flag SW-sourced responses
page.on('response', (resp) => {
// Playwright does not expose SW-source in the Response object directly;
// rely on serviceWorkers: 'block' and trace inspection for post-hoc audit.
});
```
---
### Rule 8 — CI Portability
Assertions and harness configuration must produce identical pass/fail results
when run:
1. Locally against the operator's already-running dev stack (Vite PWA +
API + compose MariaDB/Redis).
2. In Gitea CI against a runner-brought-up dev stack (Phase 8).
**Contract rules:**
| Rule | Enforcement |
| ----------------------------------------------------------------------------------- | ----------------------------------------- |
| `baseURL` is env-driven (`PLAYWRIGHT_BASE_URL`, fallback `http://localhost:5173`) | `playwright.config.ts` `use.baseURL` |
| No hardcoded `localhost:5173` in spec files | Lint / code review gate |
| Readiness gate in global-setup polls `baseURL + '/health'` until 200 or timeout 60s | `playwright.config.ts` `globalSetup` |
| DB seed uses `DB_HOST` env (fallback `127.0.0.1`), port 3306, same `.env` creds | global-setup `mysql2` connection |
| No spec imports a dev-only module path that does not exist in CI | Jest/Playwright import resolution |
| Browser binaries installed at `apps/pwa` level via `@playwright/test` dep | `apps/pwa/package.json` `devDependencies` |
---
## Checker Sign-Off
> For this phase the checker validates the quality-bar contract dimensions,
> not the standard design-system dimensions.
- [ ] Dimension 1 Copywriting: stable text anchors declared for all empty/error/nav states
- [ ] Dimension 2 Structural: role+name locators declared for all interactive elements
- [ ] Dimension 3 Touch Targets: ≥44px threshold declared with measurement approach
- [ ] Dimension 4 Overflow: `scrollWidth ≤ clientWidth` rule declared with approach
- [ ] Dimension 5 Viewport Matrix: two profiles with correct engines declared (D-03/D-04)
- [ ] Dimension 6 Registry Safety: N/A — `@playwright/test` is official, no vetting required
**Approval:** pending
---
## Source Decisions
| Decision | Source |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| D-01 DEV_AUTH_BYPASS, no storage-state | 07-CONTEXT.md |
| D-02 serviceWorkers: 'block' | 07-CONTEXT.md |
| D-03 iPhone + Pixel two-profile matrix | 07-CONTEXT.md |
| D-04 WebKit for iPhone, Chromium for Pixel | 07-CONTEXT.md |
| D-05 hybrid seed strategy | 07-CONTEXT.md |
| D-06 deterministic reset-per-run seed | 07-CONTEXT.md |
| D-07 global-setup for seeding | 07-CONTEXT.md |
| D-08 readiness gate + configurable baseURL | 07-CONTEXT.md |
| D-09 stack lifecycle is caller's responsibility | 07-CONTEXT.md |
| D-10 optional webServer for Vite | 07-CONTEXT.md |
| 44px threshold | Apple HIG; WCAG 2.5.5; existing codebase pattern |
| Screenshot omission | D-08-area steer; Schedule-X drift risk; 07-CONTEXT.md |
| Pitfall 14 (storage-state stale) | PITFALLS.md §Pitfall 14 |
| Pitfall 15 (SW intercept) | PITFALLS.md §Pitfall 15 |
| Existing tokens/copy strings | `tokens.css`, `EmptyState.tsx`, `ListsEmptyState.tsx`, `CalendarShell.tsx`, `AppNav.tsx`, `BottomTabBar.tsx` |
@@ -0,0 +1,100 @@
---
phase: 07
slug: mobile-test-harness
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-06-10
---
# Phase 07 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
> This phase's deliverable IS the test infrastructure, so "validation" here means
> proving the harness itself detects real defects (see Harness Self-Validation).
---
## Test Infrastructure
| Property | Value |
| ---------------------- | -------------------------------------------------------------------- |
| **Framework** | `@playwright/test` 1.60.0 (new dev dep in `apps/pwa`) |
| **Config file** | `apps/pwa/playwright.config.ts` (none today — Wave 0 creates it) |
| **Quick run command** | `pnpm --filter @familysync/pwa exec playwright test --project=pixel` |
| **Full suite command** | `pnpm --filter @familysync/pwa exec playwright test` |
| **CI invocation** | `CI=true pnpm --filter @familysync/pwa exec playwright test` |
| **Estimated runtime** | ~3060s full suite (two profiles, host stack already up) |
> Existing Vitest unit suite (`apps/pwa`, `*.test.ts`) remains the per-commit unit gate; this phase adds a **separate** E2E suite (`e2e/*.spec.ts`). The two must not share a glob — Wave 0 adds `exclude: ['e2e/**']` to `vitest.config.ts`.
---
## Sampling Rate
- **After every task commit:** Run `pnpm --filter @familysync/pwa exec playwright test --project=pixel` (Chromium-only, faster feedback)
- **After every plan wave:** Run `pnpm --filter @familysync/pwa exec playwright test` (both iPhone/WebKit + Pixel/Chromium profiles)
- **Before `/gsd-verify-work`:** Full suite green on **both** profiles
- **Max feedback latency:** ~60 seconds (full suite, host stack running)
---
## Per-Task Verification Map
> Task IDs resolve when PLAN.md files are written; rows below are keyed by requirement + target file so the planner can attach `<automated>` verify blocks. Every Phase-7 task must map to one of these or declare a Wave 0 dependency.
| Plan/Wave | Requirement | Behavior verified | Test Type | Automated Command | File (Wave 0) | Status |
| --------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------ | ------------------------------- | ---------- |
| W0 | TEST-01/02 | `@playwright/test` installed; config matrix (iPhone WebKit + Pixel Chromium), `serviceWorkers: 'block'`, env baseURL | config | `pnpm --filter @familysync/pwa exec playwright test --list` | `apps/pwa/playwright.config.ts` | ⬜ pending |
| W0 | TEST-02 | global-setup polls `/health` then deterministically seeds (calendar id 10 `INSERT IGNORE` FK guard + list + items, reset-per-run) | infra | run produces seeded rows; spec reads populated views | `apps/pwa/e2e/global-setup.ts` | ⬜ pending |
| W1 | TEST-01 | Tap targets ≥ 44px (BottomTabBar, FAB, Retry, settings); no horizontal overflow on `/calendar` `/lists` | E2E | `pnpm --filter @familysync/pwa exec playwright test` | `apps/pwa/e2e/layout.spec.ts` | ⬜ pending |
| W1 | TEST-01 | Calendar populated + empty + error states (UI-SPEC Rules 4/5) | E2E | same | `apps/pwa/e2e/calendar.spec.ts` | ⬜ pending |
| W1 | TEST-01 | Lists populated + empty states (UI-SPEC Rules 4/5) | E2E | same | `apps/pwa/e2e/lists.spec.ts` | ⬜ pending |
| W1 | TEST-02 | Authenticated PWA reached via `DEV_AUTH_BYPASS` (no manual login, no OIDC mock); trace shows no SW-sourced responses | E2E | `CI=true pnpm --filter @familysync/pwa exec playwright test` | spec preconditions + trace | ⬜ pending |
_Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky_
---
## Harness Self-Validation (the harness must prove it works)
The deliverable is test infrastructure — a green suite alone does not prove it would catch a real defect. The phase is validated only when the assertions provably fail on injected defects:
1. **Tap-target failure injection** — temporarily shrink a touch target (e.g. BottomTabBar `minHeight: 20px` via `page.addStyleTag`); the ≥44px assertion MUST fail. Restore → passes. Proves `boundingBox()` measures the rendered element, not the CSS source.
2. **Overflow failure injection** — add `body { width: 2000px }` via `page.addStyleTag` before the overflow assertion; it MUST fail. Remove → passes. Proves the `scrollWidth > clientWidth` check is live.
3. **SW-block verification** — with `trace: 'on-first-retry'`, inspect one trace and confirm **zero** responses sourced from `(ServiceWorker)` (D-02 / Pitfall 15).
4. **Auth-bypass verification (manual)** — with `DEV_AUTH_BYPASS` disabled the API redirects to Authelia and specs MUST fail on missing authed content; with it enabled they pass (D-01 / SC #2).
---
## Wave 0 Requirements
- [ ] `apps/pwa/playwright.config.ts` — project matrix (iPhone WebKit + Pixel Chromium), `globalSetup`, optional vite-only `webServer` with `reuseExistingServer: !process.env.CI`, env-driven `baseURL`, artifact/trace config
- [ ] `apps/pwa/e2e/global-setup.ts``/health` readiness poll + deterministic DB seed (calendar id 10 `INSERT IGNORE` guard + list + items via `mysql2`, reset-per-run)
- [ ] `apps/pwa/vitest.config.ts` — add `exclude: ['e2e/**']` to prevent the `*.spec.ts` glob collision
- [ ] `apps/pwa/package.json` — add `@playwright/test` devDependency + `test:e2e` script
- [ ] Browser install: `pnpm --filter @familysync/pwa exec playwright install --with-deps webkit chromium`
- [ ] `apps/pwa/tsconfig`/typecheck — ensure `e2e/**` passes the `tsc --noEmit` gate
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
| ----------------------------------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Auth-bypass off → Authelia redirect | TEST-02 / SC #2 | Requires flipping `DEV_AUTH_BYPASS` off on the dev stack (env change outside the spec) | Disable bypass, run suite, confirm specs fail on missing authed content; re-enable, confirm green |
| Real prod-service-worker behavior | (out of scope) | Harness blocks the SW by design (D-02); prod SW is a device gate | Human/device check — not automated here |
| iOS-Safari standalone-PWA (Home-Screen install, standalone OIDC redirect, iOS push) | (out of scope) | Cannot be driven by Playwright/WebKit emulation | Human/device gate per project CLAUDE.md exception |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or a Wave 0 dependency
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references (config, global-setup, browser install)
- [ ] No watch-mode flags in committed commands
- [ ] Feedback latency < 60s
- [ ] `nyquist_compliant: true` set in frontmatter (by planner once task map is complete)
**Approval:** pending
@@ -0,0 +1,132 @@
---
phase: 07-mobile-test-harness
verified: 2026-06-11T02:30:00Z
status: passed
score: 4/4 must-haves verified
overrides_applied: 0
---
# Phase 7: Mobile Test Harness Verification Report
**Phase Goal:** The assistant can drive the PWA in a mobile-emulated, authenticated browser context against the host-side dev stack, so mobile-only layout and flow defects can be caught automatically instead of only by the operator on real devices. This harness is also the artifact Phase 8 (CI) runs for UI regression.
**Verified:** 2026-06-11T02:30:00Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| 1 | An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets. | VERIFIED | `playwright.config.ts` defines `devices['iPhone 14']` (WebKit, 390×844, Mobile Safari UA, hasTouch) and `devices['Pixel 7']` (Chromium, 412×915, Chrome Android UA, hasTouch). `layout.spec.ts` asserts boundingBox geometry (≥44px tap targets, ≥56px FAB, no overflow). All 58 tests pass on both profiles. Harness self-validation proves assertions track rendered geometry, not CSS source. |
| 2 | The automated run reaches the authenticated PWA via the existing DEV_AUTH_BYPASS on the host-side dev stack — no manual login and no Authelia/OIDC mocking. | VERIFIED | `playwright.config.ts` has no `storageState` key. `global-setup.ts` has no OIDC mock. `calendar.spec.ts` includes an explicit runtime assertion: `getByRole('navigation', { name: 'Main navigation' })` is visible and `page.url()` hostname matches `/^(localhost | 127\.0\.0\.1)$/`. Live run confirms both profiles reach authenticated content via DEV_AUTH_BYPASS with no redirect to Authelia. |
| 3 | The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures). | VERIFIED | No `storageState` key exists anywhere in `playwright.config.ts`, `global-setup.ts`, or any spec. `global-setup.ts` issues TRUNCATE+seed on every run. Two consecutive full runs both produced 58/58 passing in 2728s with identical results. No auth artifact on disk; the bypass is stateless per-request. |
| 4 | The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack. | VERIFIED | `baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'` (env-driven). `reuseExistingServer: !process.env.CI` (CI starts fresh). `retries: process.env.CI ? 2 : 0`, `workers: process.env.CI ? 1 : undefined`, `reporter: process.env.CI ? 'github' : 'list'`. No hardcoded hosts in any spec file (grep confirms 0 absolute URLs). DB credentials all env-driven via `DB_*` vars. `e2e/README.md` documents exact CI env-var contract. |
**Score:** 4/4 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
| ------------------------------- | -------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps/pwa/playwright.config.ts` | Two-project device matrix, serviceWorkers block, env baseURL, globalSetup ref, vite-only webServer | VERIFIED | Contains `devices['iPhone 14']`, `devices['Pixel 7']`, `serviceWorkers: 'block'` on both projects, `globalSetup: './e2e/global-setup.ts'`, `PLAYWRIGHT_BASE_URL` env pattern, `reuseExistingServer: !process.env.CI` |
| `apps/pwa/e2e/global-setup.ts` | /health readiness poll, TRUNCATE+seed for user 1/calendar 10, fail-closed guard | VERIFIED | Fail-closed guard (NODE_ENV=production throws, DEV_AUTH_BYPASS!='true' throws) runs before any DB connection. Polls `${baseURL}/health` 60s. TRUNCATE list_items/list_shares/lists/calendar_events with FK checks disabled. INSERT IGNORE calendars id=10. Seeds one calendar_event, one shared list, list_shares row, two list_items. No `@playwright/test` import. |
| `apps/pwa/e2e/layout.spec.ts` | UI-SPEC Rules 1-4 + injected-defect self-validation | VERIFIED | Contains `boundingBox` assertions (≥44px Calendar/Lists/Settings tabs, ≥56px FAB), overflow eval on /calendar and /lists, in-viewport check, role+name locators throughout, two `addStyleTag` self-validation proofs, no absolute URLs. |
| `apps/pwa/e2e/calendar.spec.ts` | Populated + error states, auth-bypass + SW-block precondition assertions | VERIFIED | Contains "Couldn't load events" heading assertion, Retry button ≥44px, `page.route('/api/events*', ...500)` before goto, auth-bypass URL hostname assertion, `navigator.serviceWorker.controller` null assertion. |
| `apps/pwa/e2e/lists.spec.ts` | Populated + empty states, no DB mutation | VERIFIED | Contains "E2E Grocery List" card assertion, "No lists yet" presence/absence checks, network-simulated empty state via `page.route('/api/lists', ...)`, overflow checks in both states. |
| `apps/pwa/e2e/README.md` | Run instructions, DEV_AUTH_BYPASS guardrail, no-storageState documentation | VERIFIED | Documents the full run command, security guardrail (production compose MUST NOT set DEV*AUTH_BYPASS), all DB*\* and PLAYWRIGHT_BASE_URL env vars, no storageState file policy, CI usage notes. |
| `apps/pwa/vitest.config.ts` | `exclude: ['e2e/**']` to prevent Vitest/Playwright spec collision | VERIFIED | Line 17: `exclude: ['e2e/**', 'node_modules/**']` inside the `test:` block. |
| `apps/pwa/tsconfig.e2e.json` | Separate tsconfig bringing playwright.config.ts and e2e/\*\* into the typecheck gate | VERIFIED | Extends `./tsconfig.json`, `include: ["playwright.config.ts", "e2e/**/*"]`. The `typecheck` script runs both: `tsc --noEmit && tsc --project tsconfig.e2e.json --noEmit`. |
| `apps/pwa/package.json` | `@playwright/test` devDependency + `test:e2e` scripts | VERIFIED | `@playwright/test: 1.60.0` in devDependencies. `test:e2e`, `test:e2e:ui`, `test:e2e:headed` scripts present. |
| Root `package.json` | `test:e2e` workspace delegation script | VERIFIED | `"test:e2e": "pnpm --filter @familysync/pwa test:e2e"` |
### Key Link Verification
| From | To | Via | Status | Details |
| ---------------------- | --------------------------------- | --------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `playwright.config.ts` | `e2e/global-setup.ts` | `globalSetup: './e2e/global-setup.ts'` | VERIFIED | File exists, is a valid default export async function, runs before any spec |
| `playwright.config.ts` | `PLAYWRIGHT_BASE_URL` env | `baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'` | VERIFIED | Same pattern used in `webServer.url` and inside `global-setup.ts` |
| `global-setup.ts` | dev MariaDB :3306 | `mysql2.createConnection` with `DB_*` env vars | VERIFIED | Uses `DB_HOST ?? '127.0.0.1'`, `DB_PORT ?? 3306`, `DB_USER ?? 'familysync'`, `DB_PASSWORD ?? ''`, `DB_NAME ?? 'familysync'` — mirrors `apps/api/src/db/client.ts` |
| `global-setup.ts` | `calendar_events.calendar_id=10` | `INSERT IGNORE INTO calendars` guard before event insert | VERIFIED | Line 90: `INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared) VALUES (10, 1, ...)` — works on fresh CI DB and populated dev DB |
| `layout.spec.ts` | `BottomTabBar` nav | `getByRole('navigation', { name: 'Main navigation' })` | VERIFIED | Tests resolve on both profiles; no strict-mode collision (DesktopNav at ≥768px is not rendered on 390/412px viewports) |
| `calendar.spec.ts` | `page.route('/api/events*', ...)` | Error-state simulation registered before goto | VERIFIED | Line 98: route registered before `page.goto('/calendar')`, unrouted on line 116 |
| `lists.spec.ts` | seeded 'E2E Grocery List' card | `getByRole('button', { name: 'Open list: E2E Grocery List' })` | VERIFIED | Card resolves on both profiles after global-setup seed |
### Data-Flow Trace (Level 4)
Not applicable — this phase produces a test harness (spec files and config), not a UI component that renders dynamic data from an API. The harness itself is the data producer for downstream assertions.
### Behavioral Spot-Checks (Step 7b)
| Behavior | Command | Result | Status |
| ----------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- | ------ |
| Exactly two projects (iphone, pixel) reported | `playwright test --list` | Lists 29 iphone + 29 pixel = 58 tests; both project names confirmed | PASS |
| Full 58-test suite passes (run 1) | `pnpm --filter @familysync/pwa test:e2e` | 58 passed (28.0s) | PASS |
| Full 58-test suite passes (run 2 — idempotency) | `pnpm --filter @familysync/pwa test:e2e` | 58 passed (27.4s) | PASS |
| Typecheck gate covers e2e files | `pnpm --filter @familysync/pwa typecheck` | Exit 0 (both `tsc --noEmit` and `tsc --project tsconfig.e2e.json --noEmit`) | PASS |
| No storageState or toHaveScreenshot in harness | grep across config + all specs | 0 matches (1 comment-only hit in config) | PASS |
| No absolute URLs in spec files | grep for `https?://localhost` in e2e/\*.spec.ts | 0 matches | PASS |
### Probe Execution
No conventional `scripts/*/tests/probe-*.sh` probes declared for this phase.
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
| ----------- | ---------------- | --------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| TEST-01 | Plans 01, 03, 04 | Drive PWA in mobile-emulated viewport (device profile + mobile UA + touch) for automated UI/layout verification | SATISFIED | `devices['iPhone 14']` + `devices['Pixel 7']` in config with hasTouch; layout.spec.ts measures boundingBox; 29 tests per profile pass |
| TEST-02 | Plans 01, 02, 04 | Automated runs reach authenticated PWA via DEV_AUTH_BYPASS (no manual login, no OIDC mock) | SATISFIED | No storageState; fail-closed guard requires DEV_AUTH_BYPASS=true; calendar.spec.ts asserts auth-bypass at runtime; live runs confirm |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
| ---------- | ---- | ------------------------------------------------------------- | -------- | ------ |
| None found | — | No TBD/FIXME/XXX/TODO/PLACEHOLDER markers in any harness file | — | — |
Code review WR-04 identified that `lists.spec.ts` uses the exact string `/api/lists` instead of the glob `/api/lists*` for the empty-state route mock (inconsistent with the calendar spec's `/api/events*` glob). This is an advisory warning from the review — the harness currently passes because the endpoint has no query params. This is catalogued but does not block goal achievement; the phase goal is achieved and this is a robustness concern for future maintenance.
### Human Verification Required
None. All success criteria are verifiable programmatically and were verified via live execution.
The following items from the code review are advisory warnings (not blockers for phase goal):
- WR-01: Readiness gate accepts any 2xx — could mis-fire if Authelia redirects to a 200 login page. Mitigated in practice by the fail-closed DEV_AUTH_BYPASS guard which ensures the API is running in bypass mode before the test process starts.
- WR-02: Deadline-expiry uses `Date.now() >= deadline` inference instead of an explicit boolean. Low practical risk given the 60s window and 1s poll interval.
- WR-03: Health gate polls Vite's proxied /health rather than the API directly. Works correctly via the Vite proxy; a downed API would produce a 5xx not a 200.
- WR-04: `/api/lists` exact match vs glob — brittle to future query param additions.
- WR-05: `unroute` not in `finally` — an assertion failure could leave a mock active for subsequent tests in the same worker.
- WR-06: Self-validation comment says "page.reload drops inline style tags" but code uses `el.remove()` — stale comment; code is correct.
- WR-07: SW-controller assertion passes vacuously on WebKit where `navigator.serviceWorker` may be undefined.
These are carried from the code review as advisory only; none block the phase goal.
### Gaps Summary
No gaps against the four success criteria — all verified against the actual codebase and confirmed by live execution.
---
## Post-verification addendum (deep code review, 2026-06-11)
A deep cross-file code review run _after_ this verification found that two `calendar.spec.ts`
"populated state" assertions were **vacuous** — they targeted `CalendarShell`'s `EmptyState`
(dead code, never rendered) and the always-rendered Schedule-X wrapper, so they could not have
failed if the seed regressed. This did **not** invalidate the four success criteria (SC-1's
layout/tap-target coverage is `layout.spec.ts`, which carries its own injected-defect
self-validation and remains sound), but it was a real coverage gap in the calendar
populated-state tests.
Resolved in commit `53c3ca5`: replaced with a genuine DB→UI proof (`getByText('Seeded Test
Event')` visible in the grid), verified non-vacuous (passes with the seed on both profiles; with
`/api/events` mocked to `[]` the title is absent, so the assertion would fail), and re-anchored
the seed to noon-today so it sits deterministically inside the rendered current-month view. Full
58-test suite passes on both profiles. See `07-REVIEW.md` BL-01/BL-02.
---
_Verified: 2026-06-11T02:30:00Z_
_Verifier: Claude (gsd-verifier)_
_Addendum: 2026-06-11 — deep review BL-01/BL-02 resolved (commit 53c3ca5)_