style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
@@ -16,29 +16,29 @@ 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"
- '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"
- 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"
- 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"
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"
- 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>
@@ -144,20 +144,22 @@ Output: `apps/pwa/playwright.config.ts`, the `@playwright/test` dev dep + instal
</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 |
| 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 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>
@@ -168,11 +170,12 @@ Output: `apps/pwa/playwright.config.ts`, the `@playwright/test` dev dep + instal
</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>
</success_criteria>
<output>
Create `.planning/phases/07-mobile-test-harness/07-01-SUMMARY.md` when done.
@@ -1,28 +1,28 @@
---
phase: 07-mobile-test-harness
plan: "01"
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)"
- '@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)"
- '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)"
- '@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"
- '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:
@@ -35,13 +35,13 @@ key_files:
- 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"
- '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"
completed_date: '2026-06-11'
tasks_completed: 3
files_changed: 7
---
@@ -76,6 +76,7 @@ The foundation for the Phase 7 mobile test harness:
### 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.
@@ -83,6 +84,7 @@ The foundation for the Phase 7 mobile test harness:
- **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.
@@ -91,9 +93,9 @@ The foundation for the Phase 7 mobile test harness:
## 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) |
| 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.
@@ -3,7 +3,7 @@ phase: 07-mobile-test-harness
plan: 02
type: execute
wave: 2
depends_on: ["07-01"]
depends_on: ['07-01']
files_modified:
- apps/pwa/e2e/global-setup.ts
- apps/pwa/e2e/README.md
@@ -13,26 +13,26 @@ 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)"
- '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"
- 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"
- 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>
@@ -116,21 +116,23 @@ Output: `apps/pwa/e2e/global-setup.ts` and `apps/pwa/e2e/README.md`.
</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 |
| 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 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>
@@ -141,10 +143,11 @@ Output: `apps/pwa/e2e/global-setup.ts` and `apps/pwa/e2e/README.md`.
</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>
</success_criteria>
<output>
Create `.planning/phases/07-mobile-test-harness/07-02-SUMMARY.md` when done.
@@ -1,28 +1,28 @@
---
phase: 07-mobile-test-harness
plan: "02"
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"
- '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"
- '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)"
- '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"
- '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)"
- '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
@@ -31,12 +31,12 @@ key_files:
- 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-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"
- '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"
completed_date: '2026-06-11'
tasks_completed: 2
files_changed: 4
---
@@ -78,6 +78,7 @@ metrics:
### 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.
@@ -85,12 +86,14 @@ metrics:
- **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`
@@ -105,6 +108,7 @@ None — the Plan 01 stub in global-setup.ts is fully replaced with the real imp
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.
@@ -3,7 +3,7 @@ phase: 07-mobile-test-harness
plan: 03
type: execute
wave: 3
depends_on: ["07-01", "07-02"]
depends_on: ['07-01', '07-02']
files_modified:
- apps/pwa/e2e/layout.spec.ts
autonomous: true
@@ -12,25 +12,25 @@ 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"
- '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"
- 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"
- 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"
pattern: 'getByRole'
- from: 'apps/pwa/e2e/layout.spec.ts'
to: 'page.addStyleTag injected-defect proof'
via: 'self-validation must-fail assertions'
pattern: 'addStyleTag'
---
<objective>
@@ -120,19 +120,21 @@ Output: `apps/pwa/e2e/layout.spec.ts`.
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 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 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>
@@ -142,10 +144,11 @@ Output: `apps/pwa/e2e/layout.spec.ts`.
</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>
</success_criteria>
<output>
Create `.planning/phases/07-mobile-test-harness/07-03-SUMMARY.md` when done.
@@ -1,25 +1,25 @@
---
phase: 07-mobile-test-harness
plan: "03"
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)"
- '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)"
- '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)"
- '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"
- '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:
@@ -30,10 +30,10 @@ key_files:
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."
- '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"
completed_date: '2026-06-11'
tasks_completed: 2
files_changed: 2
---
@@ -47,6 +47,7 @@ metrics:
`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)
@@ -56,16 +57,19 @@ metrics:
- 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
@@ -84,6 +88,7 @@ metrics:
### 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.
@@ -91,6 +96,7 @@ metrics:
- **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.
@@ -98,6 +104,7 @@ metrics:
- **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.
@@ -3,7 +3,7 @@ phase: 07-mobile-test-harness
plan: 04
type: execute
wave: 3
depends_on: ["07-01", "07-02"]
depends_on: ['07-01', '07-02']
files_modified:
- apps/pwa/e2e/calendar.spec.ts
- apps/pwa/e2e/lists.spec.ts
@@ -17,23 +17,23 @@ must_haves:
- "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"
- '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"
- 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"
- 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"
- 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"
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"
via: 'getByRole / getByText on seeded data'
pattern: 'E2E Grocery List'
---
<objective>
@@ -132,20 +132,22 @@ Output: `apps/pwa/e2e/calendar.spec.ts`, `apps/pwa/e2e/lists.spec.ts`.
</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 |
| 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 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>
@@ -156,10 +158,11 @@ Output: `apps/pwa/e2e/calendar.spec.ts`, `apps/pwa/e2e/lists.spec.ts`.
</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>
</success_criteria>
<output>
Create `.planning/phases/07-mobile-test-harness/07-04-SUMMARY.md` when done.
@@ -1,28 +1,39 @@
---
phase: 07-mobile-test-harness
plan: "04"
plan: '04'
subsystem: test-harness
tags: [playwright, e2e, calendar, lists, populated-state, error-state, empty-state, auth-bypass, service-worker]
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"
- '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"
- '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)"
- '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.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"
- 'page.evaluate(() => navigator.serviceWorker.controller) — runtime SW controller assertion'
key_files:
created:
- apps/pwa/e2e/calendar.spec.ts
@@ -31,16 +42,16 @@ key_files:
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-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"
- '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"
completed: '2026-06-11'
---
# Phase 07 Plan 04: calendar.spec.ts + lists.spec.ts State Coverage Summary
@@ -57,7 +68,7 @@ completed: "2026-06-11"
## 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/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
@@ -18,24 +18,29 @@ Deliver an automated, mobile-emulated, authenticated Playwright harness that dri
## 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).
@@ -43,20 +48,24 @@ Deliver an automated, mobile-emulated, authenticated Playwright harness that dri
</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.
@@ -65,18 +74,22 @@ Deliver an automated, mobile-emulated, authenticated Playwright harness that dri
</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>
@@ -93,11 +106,12 @@ Deliver an automated, mobile-emulated, authenticated Playwright harness that dri
## 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.
- **"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*
_Phase: 7-Mobile Test Harness_
_Context gathered: 2026-06-10_
@@ -11,20 +11,20 @@
## 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 | |
| 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 | |
| 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.
@@ -33,12 +33,12 @@
## 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 | ✓ |
| 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.
@@ -47,11 +47,11 @@
## 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 | |
| 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.
@@ -60,11 +60,11 @@
## Stack lifecycle / baseURL
| Option | Description | Selected |
|--------|-------------|----------|
| 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 | ✓ |
| 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.
@@ -8,15 +8,15 @@
## 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 |
| 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 |
---
@@ -27,8 +27,9 @@
**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'
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
@@ -37,26 +38,29 @@ export default defineConfig({
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
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'
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
@@ -79,7 +83,7 @@ export default defineConfig({
name: 'iphone',
use: {
...devices['iPhone 14'],
serviceWorkers: 'block', // D-02 / Pitfall 15
serviceWorkers: 'block', // D-02 / Pitfall 15
},
},
{
@@ -94,10 +98,10 @@ export default defineConfig({
webServer: {
command: 'pnpm --filter @familysync/pwa dev',
url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173',
reuseExistingServer: !process.env.CI, // D-10
reuseExistingServer: !process.env.CI, // D-10
timeout: 120_000,
},
})
});
```
---
@@ -116,26 +120,29 @@ const pool = mysql.createPool({
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` |
| 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) |
| `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,
@@ -143,35 +150,41 @@ export const DEV_USER = {
oidcSub: 'dev-user',
displayName: 'Dev User',
color: '#4A90D9',
} as const
} 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()
return async (_c, next) => next();
}
if (process.env.DEV_AUTH_BYPASS !== 'true') {
return async (_c, next) => next()
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'
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
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 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({
@@ -180,50 +193,57 @@ export default async function globalSetup() {
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')
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)`
)
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 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',
'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')
`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(
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])
)) 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()
await conn.end();
}
}
```
@@ -235,6 +255,7 @@ export default async function globalSetup() {
**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'
// ...
@@ -249,44 +270,49 @@ describe('CalendarShell — CAL-03 render smoke', () => {
```
**Role-based locator pattern** (`CalendarShell.test.tsx`, lines 220230):
```typescript
const tapToRetry = await screen.findByText(/Tap here to try again/i)
expect(tapToRetry).toBeDefined()
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'
import { test, expect } from '@playwright/test';
test.describe('BottomTabBar presence and tap targets', () => {
test.beforeEach(async ({ page }) => { await page.goto('/calendar') })
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)
})
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)
})
})
}));
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()
);
await page.goto('/calendar');
await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVisible();
```
---
@@ -296,6 +322,7 @@ await expect(page.getByRole('heading', { name: "Couldn't load events" })).toBeVi
**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 = {
@@ -304,7 +331,7 @@ const TIMED_OCCURRENCE = {
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'`.
@@ -318,6 +345,7 @@ const TIMED_OCCURRENCE = {
**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 {
@@ -326,29 +354,31 @@ function makeItem(overrides: Partial<ListItem> = {}): ListItem {
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)
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)*
### `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'
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
@@ -359,21 +389,23 @@ export default defineConfig({
// 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)*
### `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",
@@ -387,6 +419,7 @@ exclude: ['e2e/**', 'node_modules/**'],
**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",
@@ -394,11 +427,13 @@ exclude: ['e2e/**', 'node_modules/**'],
```
**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"
```
@@ -408,17 +443,21 @@ exclude: ['e2e/**', 'node_modules/**'],
## 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
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)
@@ -429,8 +468,10 @@ 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>
@@ -443,23 +484,27 @@ database: process.env.DB_NAME ?? 'familysync',
```
### 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)
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()
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')
await page.goto('http://localhost:5173/calendar');
// Correct — resolves against playwright.config.ts baseURL
await page.goto('/calendar')
await page.goto('/calendar');
```
---
@@ -7,9 +7,11 @@
---
<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.
@@ -22,26 +24,30 @@
- **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>
</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. |
| 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>
---
@@ -60,17 +66,17 @@ The primary research question — assertion strategy — is answered: **use stru
## 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 |
| 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 |
---
@@ -78,25 +84,26 @@ The primary research question — assertion strategy — is answered: **use stru
### 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] |
| 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 |
|---------|---------|---------|-------------|
| 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 |
| 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)
@@ -104,6 +111,7 @@ pnpm --filter @familysync/pwa exec playwright install --with-deps webkit chromiu
```
**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)
@@ -113,10 +121,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 |
| 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
@@ -196,7 +204,7 @@ apps/pwa/
```typescript
// apps/pwa/playwright.config.ts
// Source: Context7 /microsoft/playwright.dev — emulation.mdx + test-global-setup-teardown.mdx
import { defineConfig, devices } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
@@ -239,10 +247,11 @@ export default defineConfig({
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]
@@ -251,20 +260,20 @@ export default defineConfig({
```typescript
// apps/pwa/e2e/global-setup.ts
// Source: Context7 /microsoft/playwright.dev — test-global-setup-teardown.mdx
import mysql from 'mysql2/promise'
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
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
const res = await fetch(`${baseURL}/health`);
if (res.ok) break;
} catch {
// ECONNREFUSED — not ready yet
}
await new Promise((r) => setTimeout(r, 1_000))
await new Promise((r) => setTimeout(r, 1_000));
}
// will throw if never resolved — test run fails fast with a clear message
@@ -275,59 +284,63 @@ export default async function globalSetup() {
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')
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 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')}`,
`DTSTART:${futureStart
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d+Z$/, 'Z')}`,
`SUMMARY:Seeded Test Event`,
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n')
].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(
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],
)
)) 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()
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`.
@@ -337,21 +350,21 @@ export default async function globalSetup() {
```typescript
// Source: Context7 /microsoft/playwright.dev — api/class-locator.mdx + UI-SPEC.md Rule 1
import { test, expect } from '@playwright/test'
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' })
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)
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]
@@ -363,8 +376,8 @@ test('BottomTabBar tabs meet 44px touch target', async ({ page }) => {
const overflow = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth)
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
```
This is `page.evaluate()` — standard Playwright API, no special library needed. [CITED: context7 /microsoft/playwright.dev]
@@ -376,10 +389,10 @@ This is `page.evaluate()` — standard Playwright API, no special library needed
// 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()
);
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]
@@ -415,13 +428,13 @@ webServer: {
## 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 |
| 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.
@@ -435,13 +448,13 @@ webServer: {
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 |
| 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
@@ -528,7 +541,7 @@ The UI-SPEC.md defines five concrete quality rules, all of which map directly to
```typescript
// apps/pwa/playwright.config.ts
// Source: Context7 /microsoft/playwright.dev
import { defineConfig, devices } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
@@ -569,14 +582,14 @@ export default defineConfig({
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'
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
@@ -584,9 +597,9 @@ export default defineConfig({
globals: true,
setupFiles: ['./src/test-setup.ts'],
env: { TZ: 'UTC' },
exclude: ['e2e/**', 'node_modules/**'], // ← ADD THIS
exclude: ['e2e/**', 'node_modules/**'], // ← ADD THIS
},
})
});
```
### package.json scripts additions
@@ -602,6 +615,7 @@ export default defineConfig({
```
**Root workspace script (for pnpm filter):**
```bash
pnpm --filter @familysync/pwa test:e2e
```
@@ -611,63 +625,64 @@ pnpm --filter @familysync/pwa test:e2e
```typescript
// apps/pwa/e2e/layout.spec.ts
// Source: UI-SPEC.md Rules 1-4 + Context7 /microsoft/playwright.dev
import { test, expect } from '@playwright/test'
import { test, expect } from '@playwright/test';
test.describe('BottomTabBar presence and tap targets', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/calendar')
})
await page.goto('/calendar');
});
test('BottomTabBar is present at mobile width', async ({ page }) => {
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible()
})
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)
})
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)
})
})
}));
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()
);
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)
})
})
}));
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 |
| 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]
@@ -675,12 +690,12 @@ test.describe('Error state — /calendar', () => {
## 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 |
| # | 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 |
---
@@ -705,19 +720,21 @@ test.describe('Error state — /calendar', () => {
## 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 |
| 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).
---
@@ -726,23 +743,23 @@ test.describe('Error state — /calendar', () => {
### Test Framework
| Property | Value |
|----------|-------|
| Framework | `@playwright/test` 1.60.0 |
| Config file | `apps/pwa/playwright.config.ts` (Wave 0 — new file) |
| 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` |
| 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` |
| 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)
@@ -781,35 +798,35 @@ This phase's deliverable IS the test infrastructure. The harness is validated wh
### 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 |
| 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. |
| 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/` |
| 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/` |
---
@@ -844,6 +861,7 @@ This phase's deliverable IS the test infrastructure. The harness is validated wh
## 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
@@ -16,11 +16,13 @@ status: partial
**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.
@@ -40,6 +40,7 @@ This is the iteration-2 re-review after the fixer applied 5 changes (commits `c5
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**.
@@ -50,7 +51,7 @@ BL-01, and BL-02; those resolution records are preserved below.
**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
Setting `status: clean`. The 5 IN-\* items remain advisory/by-design and are listed under
"Resolved / By-design"; none are actionable.
---
@@ -64,6 +65,7 @@ Setting `status: clean`. The 5 IN-* items remain advisory/by-design and are list
`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.
@@ -108,6 +110,7 @@ flags false) matches the API's non-recurring-timed WHERE branch. Verified live o
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
@@ -138,6 +141,7 @@ be misreported as a timeout) and any false-positive-ready (the flag is only set
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.
@@ -206,7 +210,7 @@ These were never code defects; they are design notes carried for traceability. N
- **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
- **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.
@@ -215,15 +219,15 @@ These were never code defects; they are design notes carried for traceability. N
## 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 |
| 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 |
---
@@ -24,13 +24,13 @@ framing: quality-bar-contract
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` |
| 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` |
---
@@ -65,19 +65,19 @@ 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` |
| 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
@@ -102,10 +102,10 @@ 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 |
| 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
@@ -127,6 +127,7 @@ only). Accept the larger CI image cost — this was a deliberate call (D-04,
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`
@@ -135,14 +136,16 @@ have a computed bounding box of **≥ 44 × 44 logical pixels**.
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)
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
@@ -150,13 +153,13 @@ expect(box!.height).toBeGreaterThanOrEqual(44)
**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' })` |
| 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
@@ -172,22 +175,23 @@ 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)
}));
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) |
| 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`).
@@ -202,13 +206,13 @@ 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 |
| 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
@@ -225,19 +229,20 @@ 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"` |
| 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 })
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
@@ -260,12 +265,12 @@ 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 |
| 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
@@ -278,6 +283,7 @@ horizontal overflow).
**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
@@ -306,14 +312,14 @@ 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 |
| 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
@@ -325,7 +331,7 @@ option should prevent this. Log a test failure if detected programmatically:
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.
})
});
```
---
@@ -334,20 +340,21 @@ page.on('response', (resp) => {
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` |
| 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` |
---
@@ -369,20 +376,20 @@ when run:
## 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` |
| 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` |
@@ -17,14 +17,14 @@ created: 2026-06-10
## 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) |
| 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`.
@@ -43,16 +43,16 @@ created: 2026-06-10
> 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 |
| 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*
_Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky_
---
@@ -80,11 +80,11 @@ The deliverable is test infrastructure — a green suite alone does not prove it
## 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 |
| 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 |
---
@@ -17,41 +17,41 @@ overrides_applied: 0
### 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. |
| # | 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"` |
| 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 |
| 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)
@@ -59,14 +59,14 @@ Not applicable — this phase produces a test harness (spec files and config), n
### 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 |
| 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
@@ -74,16 +74,16 @@ 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 |
| 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 | — | — |
| 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.
@@ -111,7 +111,7 @@ No gaps against the four success criteria — all verified against the actual co
## 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`
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