docs(07): create mobile-test-harness phase plan (4 plans, 3 waves)

This commit is contained in:
Lucas Berger
2026-06-10 22:39:08 -04:00
parent 7a089421dd
commit ed206c3732
5 changed files with 654 additions and 1 deletions
@@ -0,0 +1,179 @@
---
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,151 @@
---
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,152 @@
---
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,166 @@
---
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>