--- 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' --- 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`. @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.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 Task 1: global-setup.ts — /health readiness poll + deterministic reset-and-seed apps/pwa/e2e/global-setup.ts - .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) 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. - `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 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)})" 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. Task 2: e2e/README.md — run instructions + DEV_AUTH_BYPASS / production guardrail apps/pwa/e2e/README.md - .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 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. - `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) grep -E 'DEV_AUTH_BYPASS|NODE_ENV|storageState|PLAYWRIGHT_BASE_URL|test:e2e' apps/pwa/e2e/README.md | grep -vc '^#' e2e/README.md documents run commands, the env-var contract, and the DEV_AUTH_BYPASS/production + no-storageState guardrails. ## 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. | - 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. - `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. Create `.planning/phases/07-mobile-test-harness/07-02-SUMMARY.md` when done.