---
phase: 07-mobile-test-harness
plan: 02
type: execute
wave: 2
depends_on: ['07-01']
files_modified:
  - apps/pwa/e2e/global-setup.ts
  - apps/pwa/e2e/README.md
autonomous: true
requirements: [TEST-02]
user_setup: []

must_haves:
  truths:
    - 'global-setup polls baseURL+/health and only proceeds once it returns 200 (fails fast with a clear message on timeout)'
    - 'global-setup deterministically resets (TRUNCATE) then seeds: >=1 calendar_event on calendar_id=10, >=1 list owned by user 1, >=2 list_items, >=1 list_shares row for user 1 (D-05 populated half / D-07 seeding in global-setup)'
    - 'Seeding is idempotent run-over-run (a second run produces the same row counts, no stale rows, no duplicate-key errors)'
    - 'calendar_id=10 is guaranteed present via INSERT IGNORE INTO calendars before the event insert (works on a fresh CI DB and a populated dev DB)'
  artifacts:
    - path: 'apps/pwa/e2e/global-setup.ts'
      provides: 'Playwright globalSetup: /health readiness poll + mysql2 reset-and-seed against dev MariaDB'
      contains: 'TRUNCATE'
    - path: 'apps/pwa/e2e/README.md'
      provides: 'Operator/CI run instructions + the DEV_AUTH_BYPASS / NODE_ENV production guardrail documentation'
      contains: 'DEV_AUTH_BYPASS'
  key_links:
    - from: 'apps/pwa/e2e/global-setup.ts'
      to: 'dev MariaDB :3306'
      via: 'mysql2 createConnection with DB_* env vars'
      pattern: 'mysql.*createConnection'
    - from: 'apps/pwa/e2e/global-setup.ts'
      to: 'calendar_events.calendar_id=10'
      via: 'INSERT IGNORE calendars guard then INSERT calendar_events'
      pattern: 'INSERT IGNORE INTO calendars'
---

<objective>
Create the Playwright `globalSetup` (seeding runs in global-setup per D-07) that the harness runs once before any spec: poll the PWA `/health` endpoint until the dev stack is ready (D-08 readiness gate), then deterministically reset-and-seed the dev MariaDB (D-06) so dev-bypass user 1 — who natively has no calendars or lists — renders populated calendar and list views. This is the populated half of the D-05 hybrid strategy (the explicit empty-state assertions live in Plan 04). Document the run/CI invocation and the `DEV_AUTH_BYPASS`/production guardrail.

Purpose: Without seeding, user 1's views are empty and the "populated state" assertions (UI-SPEC Rules 3/5) have nothing to assert against. Without the readiness poll, specs flake on ECONNREFUSED when the stack is still booting (especially in Phase 8 CI). This is the data + readiness precondition every spec plan depends on (TEST-02).

Output: `apps/pwa/e2e/global-setup.ts` and `apps/pwa/e2e/README.md`.
</objective>

<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-mobile-test-harness/07-CONTEXT.md
@.planning/phases/07-mobile-test-harness/07-RESEARCH.md
@.planning/phases/07-mobile-test-harness/07-PATTERNS.md
@.planning/phases/07-mobile-test-harness/07-VALIDATION.md
@apps/pwa/playwright.config.ts
</context>

<tasks>

<task type="auto">
  <name>Task 1: global-setup.ts — /health readiness poll + deterministic reset-and-seed</name>
  <files>apps/pwa/e2e/global-setup.ts</files>
  <read_first>
    - .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/e2e/global-setup.ts" — full global-setup shape, exact column names, the INSERT IGNORE calendars guard, the seed SQL
    - .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Pattern 2" + § "Pitfall 4" + § "Pitfall 2" — health poll, seed, calendar_id=10 FK guard, globalSetup has no Playwright fixtures (plain Node only)
    - apps/api/src/db/client.ts — EXACT mysql2 env-var names to reuse: `DB_HOST` (default 127.0.0.1, NOT localhost — per memory api-integration-test-db), `DB_PORT` (3306), `DB_USER` (familysync), `DB_PASSWORD`, `DB_NAME` (familysync)
    - apps/api/src/db/schema.ts — confirm exact column names: calendars(`id`,`user_id`,`url`,`display_name`,`color`,`is_shared`); calendar_events(`calendar_id`,`uid`,`etag`,`raw_vevent`,`title`,`dtstart_utc` TIMESTAMP,`dtstart_date` DATE,`all_day`,`has_rrule`); lists(`id`,`owner_id`,`name`,`is_shared`); list_shares(`list_id`,`user_id`); list_items(`list_id`,`text`,`checked`,`rank`)
    - apps/api/src/auth/devBypass.ts — DEV_USER.id === 1 (seed targets user_id=1 / owner_id=1)
    - apps/api/tests/routes/lists.test.ts — existing seed-helper INSERT shapes for lists/list_items/list_shares (Drizzle there; global-setup uses raw mysql2 but the table/column shape is identical)
  </read_first>
  <action>
    Create `apps/pwa/e2e/global-setup.ts` exporting a default async function (Playwright globalSetup signature; seeding-in-global-setup is D-07). Use ONLY plain Node APIs — `fetch` (native in Node 22) and `mysql2/promise` — NO `@playwright/test` imports (globalSetup runs outside the worker context; importing `page`/`test` throws — Pitfall 2). Step 1 (readiness, D-08): read `baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'`; loop with a 60s deadline polling `fetch(baseURL + '/health')`, break on `res.ok`, swallow ECONNREFUSED, sleep 1000ms between attempts; if the deadline passes without a 200, throw an Error with a clear message (e.g. ``health check never returned 200 at ${baseURL}/health — is the dev stack up?``) so the run fails fast. Step 2 (seed the populated half of the D-05 hybrid, deterministically per D-06): `mysql.createConnection` using the exact env-var names from db/client.ts (`DB_HOST` default `'127.0.0.1'`, `DB_PORT` 3306, `DB_USER` familysync, `DB_PASSWORD`, `DB_NAME` familysync). In a try/finally (finally calls `conn.end()`): `SET FOREIGN_KEY_CHECKS=0`; TRUNCATE in FK-safe order `list_items`, `list_shares`, `lists`, `calendar_events`; `SET FOREIGN_KEY_CHECKS=1`. Then the calendar_id=10 FK guard (Pitfall 4): `INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared) VALUES (10, 1, 'https://caldav.fastmail.com/dav/calendars/e2e/', 'FamilySync', '#4A90D9', true)` — IGNORE makes it a no-op on the dev DB where row 10 already exists and creates it on a fresh CI DB. Then seed one TIMED (not all-day) calendar_event onto calendar_id=10 with a deterministic uid `'e2e-seed-event-001'`, title `'Seeded Test Event'`, a future `dtstart_utc` (ISO UTC string ~tomorrow), a minimal VCALENDAR/VEVENT `raw_vevent`, `etag='e2e-etag-001'`, `all_day=false`, `has_rrule=false`. Then seed one list `INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`, capture `insertId` as `listId`, then `INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)` and `INSERT INTO list_items (list_id, text, checked, rank) VALUES (?, 'Milk', false, 'a0'), (?, 'Eggs', false, 'a1')`. Use fractional-indexing rank strings `'a0'`/`'a1'` (active items render top-section). Seed BOTH `lists.owner_id=1` AND a `list_shares` row (Open Question 3 — belt-and-suspenders so `/api/lists` returns the list whether it filters by owner or by share). Determinism note: because every run truncates first, a second run yields identical row counts — no INSERT IGNORE on the seed rows themselves (D-06 mandates truncate+insert, NOT insert-if-absent). The list name `'E2E Grocery List'` and item texts `'Milk'`/`'Eggs'` are the stable anchors the lists spec (Plan 04) asserts on — do not change them without updating that spec.
  </action>
  <acceptance_criteria>
    - `apps/pwa/e2e/global-setup.ts` imports `mysql2/promise` and contains NO `@playwright/test` import
    - it polls `${baseURL}/health` in a bounded loop and throws on timeout
    - it executes TRUNCATE on list_items, list_shares, lists, calendar_events (FK checks toggled around it)
    - it contains `INSERT IGNORE INTO calendars` with `VALUES (10, 1, ...)` before the calendar_events insert
    - it inserts a calendar_event with `calendar_id` 10, a list owned by user 1, a list_shares row (user_id 1), and exactly two list_items (`Milk`, `Eggs`)
    - running it twice in a row against the dev DB leaves exactly: 1 calendar_event on cal 10, 1 list, 1 list_shares, 2 list_items (idempotent) — verify with the SQL count in the verify block
  </acceptance_criteria>
  <verify>
    <automated>cd apps/pwa && PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:5173}" node --import tsx -e "import('./e2e/global-setup.ts').then(m=>m.default()).then(async()=>{const mysql=await import('mysql2/promise');const c=await mysql.createConnection({host:process.env.DB_HOST??'127.0.0.1',port:Number(process.env.DB_PORT??3306),user:process.env.DB_USER??'familysync',password:process.env.DB_PASSWORD??'',database:process.env.DB_NAME??'familysync'});const[e]=await c.query('SELECT COUNT(*) n FROM calendar_events WHERE calendar_id=10');const[li]=await c.query('SELECT COUNT(*) n FROM list_items');console.log('events_cal10=',e[0].n,'list_items=',li[0].n);await c.end();process.exit((e[0].n>=1&&li[0].n>=2)?0:1)})"</automated>
  </verify>
  <done>global-setup polls /health, then deterministically resets and seeds calendar_id=10 (with the INSERT IGNORE guard), one shared list owned by user 1, a list_shares row, and two list_items — idempotent run-over-run.</done>
</task>

<task type="auto">
  <name>Task 2: e2e/README.md — run instructions + DEV_AUTH_BYPASS / production guardrail</name>
  <files>apps/pwa/e2e/README.md</files>
  <read_first>
    - .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Security Domain" + § "Pitfall 5" — the DEV_AUTH_BYPASS production guardrail and propagation requirement
    - .planning/phases/07-mobile-test-harness/07-CONTEXT.md § "D-09" — stack bring-up is the caller's responsibility (operator locally, compose in CI)
    - .planning/phases/07-mobile-test-harness/07-VALIDATION.md § "Manual-Only Verifications" — the auth-bypass-off manual check
    - docs/deployment.md (the "Running locally (host-side, no Docker)" subsection) — the canonical local dev-stack bring-up command to reference, not duplicate
  </read_first>
  <action>
    Create `apps/pwa/e2e/README.md` documenting how to run the harness and the security guardrails. Cover: (1) Prerequisites — the caller brings up the dev stack first (D-09): API + PWA dev servers + dev MariaDB (:3306) + Redis, with `DEV_AUTH_BYPASS=true` set in the API's environment BEFORE the API starts (Pitfall 5 — the harness cannot set it; it must already be active for the API to resolve to Dev User id 1). Reference docs/deployment.md's host-side run command rather than copying it. (2) Run commands: full suite `pnpm --filter @familysync/pwa test:e2e`; single fast profile `pnpm --filter @familysync/pwa exec playwright test --project=pixel`; headed debug `--headed`. (3) Env vars the harness reads: `PLAYWRIGHT_BASE_URL` (default http://localhost:5173), `DB_HOST` (default 127.0.0.1), `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` — all DB creds come from env, NEVER hardcoded (Information Disclosure mitigation). (4) SECURITY GUARDRAIL (Elevation of Privilege): `DEV_AUTH_BYPASS=true` is dev-only — the API guards it behind `NODE_ENV !== 'production'`; the production compose file MUST NOT set `DEV_AUTH_BYPASS`. State this explicitly. (5) No `storageState`: the harness never reads or writes a session-state file (D-01); there is no expiring cookie to refresh, which is why it runs repeatably day-over-day (SC #3). (6) Note that Phase 8 CI consumes these specs unchanged and owns only the stack bring-up + readiness wait. Keep it concise — this is operator-facing reference, not a tutorial.
  </action>
  <acceptance_criteria>
    - `apps/pwa/e2e/README.md` exists and documents the `pnpm --filter @familysync/pwa test:e2e` run command
    - it states `DEV_AUTH_BYPASS=true` must be set before the API starts and MUST NOT be set in production (NODE_ENV !== 'production' guard)
    - it lists the DB_* and PLAYWRIGHT_BASE_URL env vars and states DB creds are env-only (never hardcoded)
    - it states no storageState file is used (D-01)
  </acceptance_criteria>
  <verify>
    <automated>grep -E 'DEV_AUTH_BYPASS|NODE_ENV|storageState|PLAYWRIGHT_BASE_URL|test:e2e' apps/pwa/e2e/README.md | grep -vc '^#'</automated>
  </verify>
  <done>e2e/README.md documents run commands, the env-var contract, and the DEV_AUTH_BYPASS/production + no-storageState guardrails.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                   | Description                                                                                        |
| -------------------------- | -------------------------------------------------------------------------------------------------- |
| global-setup → dev MariaDB | direct mysql2 connection writes seed rows; credentials cross this boundary                         |
| harness → dev API          | the API runs with `DEV_AUTH_BYPASS=true`; the bypass must never be active in production            |
| repo → production          | README + seed code checked into the repo; must not normalize the dev-bypass posture for production |

## STRIDE Threat Register

| Threat ID | Category               | Component                                    | Disposition           | Mitigation Plan                                                                                                                                                                                                                             |
| --------- | ---------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-07-04   | Elevation of Privilege | `DEV_AUTH_BYPASS=true` leaking to production | mitigate              | README documents that the bypass is dev-only and guarded by `NODE_ENV !== 'production'` (devBypass.ts); production compose MUST NOT set `DEV_AUTH_BYPASS`. global-setup does not set it (it cannot — it must already be active on the API). |
| T-07-05   | Information Disclosure | DB seed credentials                          | mitigate              | global-setup reads `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` from env exclusively (mirrors db/client.ts); no credential is hardcoded in the seed script or README.                                                              |
| T-07-06   | Information Disclosure | checked-in OIDC session state                | accept (designed out) | N/A by D-01 — no `storageState.json` is written; the seed touches only fixture rows, never an auth artifact. README states this explicitly.                                                                                                 |

</threat_model>

<verification>
- global-setup polls /health and fails fast on timeout.
- Reset-and-seed yields >=1 calendar_event on calendar_id=10 and >=2 list_items, idempotent across two consecutive runs.
- INSERT IGNORE calendars guard satisfies the FK on both a fresh CI DB and the populated dev DB.
- README documents the DEV_AUTH_BYPASS/production guardrail, the env-only DB creds, and no-storageState.
</verification>

<success_criteria>

- `apps/pwa/e2e/global-setup.ts` provides a readiness gate + deterministic reset-and-seed using only plain Node (fetch + mysql2), targeting user 1 / calendar 10.
- Seeding is repeatable run-over-run with no stale rows or duplicate-key failures.
- `apps/pwa/e2e/README.md` documents run, env, and security guardrails.
  </success_criteria>

<output>
Create `.planning/phases/07-mobile-test-harness/07-02-SUMMARY.md` when done.
</output>
