---
phase: 07-mobile-test-harness
verified: 2026-06-11T02:30:00Z
status: passed
score: 4/4 must-haves verified
overrides_applied: 0
---

# Phase 7: Mobile Test Harness Verification Report

**Phase Goal:** The assistant can drive the PWA in a mobile-emulated, authenticated browser context against the host-side dev stack, so mobile-only layout and flow defects can be caught automatically instead of only by the operator on real devices. This harness is also the artifact Phase 8 (CI) runs for UI regression.
**Verified:** 2026-06-11T02:30:00Z
**Status:** passed
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | An automated run can load the PWA in a mobile-emulated viewport (device profile + mobile UA + touch) and assert on responsive layout / tap targets. | VERIFIED | `playwright.config.ts` defines `devices['iPhone 14']` (WebKit, 390×844, Mobile Safari UA, hasTouch) and `devices['Pixel 7']` (Chromium, 412×915, Chrome Android UA, hasTouch). `layout.spec.ts` asserts boundingBox geometry (≥44px tap targets, ≥56px FAB, no overflow). All 58 tests pass on both profiles. Harness self-validation proves assertions track rendered geometry, not CSS source. |
| 2 | The automated run reaches the authenticated PWA via the existing DEV_AUTH_BYPASS on the host-side dev stack — no manual login and no Authelia/OIDC mocking. | VERIFIED | `playwright.config.ts` has no `storageState` key. `global-setup.ts` has no OIDC mock. `calendar.spec.ts` includes an explicit runtime assertion: `getByRole('navigation', { name: 'Main navigation' })` is visible and `page.url()` hostname matches `/^(localhost|127\.0\.0\.1)$/`. Live run confirms both profiles reach authenticated content via DEV_AUTH_BYPASS with no redirect to Authelia. |
| 3 | The harness runs repeatably day-over-day without re-capturing any session state (no stale storage-state failures). | VERIFIED | No `storageState` key exists anywhere in `playwright.config.ts`, `global-setup.ts`, or any spec. `global-setup.ts` issues TRUNCATE+seed on every run. Two consecutive full runs both produced 58/58 passing in 27–28s with identical results. No auth artifact on disk; the bypass is stateless per-request. |
| 4 | The harness specs are structured so they can run headlessly in CI (Phase 8) against a dev stack the runner brings up — no dependence on a developer's already-running host stack. | VERIFIED | `baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'` (env-driven). `reuseExistingServer: !process.env.CI` (CI starts fresh). `retries: process.env.CI ? 2 : 0`, `workers: process.env.CI ? 1 : undefined`, `reporter: process.env.CI ? 'github' : 'list'`. No hardcoded hosts in any spec file (grep confirms 0 absolute URLs). DB credentials all env-driven via `DB_*` vars. `e2e/README.md` documents exact CI env-var contract. |

**Score:** 4/4 truths verified

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/pwa/playwright.config.ts` | Two-project device matrix, serviceWorkers block, env baseURL, globalSetup ref, vite-only webServer | VERIFIED | Contains `devices['iPhone 14']`, `devices['Pixel 7']`, `serviceWorkers: 'block'` on both projects, `globalSetup: './e2e/global-setup.ts'`, `PLAYWRIGHT_BASE_URL` env pattern, `reuseExistingServer: !process.env.CI` |
| `apps/pwa/e2e/global-setup.ts` | /health readiness poll, TRUNCATE+seed for user 1/calendar 10, fail-closed guard | VERIFIED | Fail-closed guard (NODE_ENV=production throws, DEV_AUTH_BYPASS!='true' throws) runs before any DB connection. Polls `${baseURL}/health` 60s. TRUNCATE list_items/list_shares/lists/calendar_events with FK checks disabled. INSERT IGNORE calendars id=10. Seeds one calendar_event, one shared list, list_shares row, two list_items. No `@playwright/test` import. |
| `apps/pwa/e2e/layout.spec.ts` | UI-SPEC Rules 1-4 + injected-defect self-validation | VERIFIED | Contains `boundingBox` assertions (≥44px Calendar/Lists/Settings tabs, ≥56px FAB), overflow eval on /calendar and /lists, in-viewport check, role+name locators throughout, two `addStyleTag` self-validation proofs, no absolute URLs. |
| `apps/pwa/e2e/calendar.spec.ts` | Populated + error states, auth-bypass + SW-block precondition assertions | VERIFIED | Contains "Couldn't load events" heading assertion, Retry button ≥44px, `page.route('/api/events*', ...500)` before goto, auth-bypass URL hostname assertion, `navigator.serviceWorker.controller` null assertion. |
| `apps/pwa/e2e/lists.spec.ts` | Populated + empty states, no DB mutation | VERIFIED | Contains "E2E Grocery List" card assertion, "No lists yet" presence/absence checks, network-simulated empty state via `page.route('/api/lists', ...)`, overflow checks in both states. |
| `apps/pwa/e2e/README.md` | Run instructions, DEV_AUTH_BYPASS guardrail, no-storageState documentation | VERIFIED | Documents the full run command, security guardrail (production compose MUST NOT set DEV_AUTH_BYPASS), all DB_* and PLAYWRIGHT_BASE_URL env vars, no storageState file policy, CI usage notes. |
| `apps/pwa/vitest.config.ts` | `exclude: ['e2e/**']` to prevent Vitest/Playwright spec collision | VERIFIED | Line 17: `exclude: ['e2e/**', 'node_modules/**']` inside the `test:` block. |
| `apps/pwa/tsconfig.e2e.json` | Separate tsconfig bringing playwright.config.ts and e2e/** into the typecheck gate | VERIFIED | Extends `./tsconfig.json`, `include: ["playwright.config.ts", "e2e/**/*"]`. The `typecheck` script runs both: `tsc --noEmit && tsc --project tsconfig.e2e.json --noEmit`. |
| `apps/pwa/package.json` | `@playwright/test` devDependency + `test:e2e` scripts | VERIFIED | `@playwright/test: 1.60.0` in devDependencies. `test:e2e`, `test:e2e:ui`, `test:e2e:headed` scripts present. |
| Root `package.json` | `test:e2e` workspace delegation script | VERIFIED | `"test:e2e": "pnpm --filter @familysync/pwa test:e2e"` |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `playwright.config.ts` | `e2e/global-setup.ts` | `globalSetup: './e2e/global-setup.ts'` | VERIFIED | File exists, is a valid default export async function, runs before any spec |
| `playwright.config.ts` | `PLAYWRIGHT_BASE_URL` env | `baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173'` | VERIFIED | Same pattern used in `webServer.url` and inside `global-setup.ts` |
| `global-setup.ts` | dev MariaDB :3306 | `mysql2.createConnection` with `DB_*` env vars | VERIFIED | Uses `DB_HOST ?? '127.0.0.1'`, `DB_PORT ?? 3306`, `DB_USER ?? 'familysync'`, `DB_PASSWORD ?? ''`, `DB_NAME ?? 'familysync'` — mirrors `apps/api/src/db/client.ts` |
| `global-setup.ts` | `calendar_events.calendar_id=10` | `INSERT IGNORE INTO calendars` guard before event insert | VERIFIED | Line 90: `INSERT IGNORE INTO calendars (id, user_id, url, display_name, color, is_shared) VALUES (10, 1, ...)` — works on fresh CI DB and populated dev DB |
| `layout.spec.ts` | `BottomTabBar` nav | `getByRole('navigation', { name: 'Main navigation' })` | VERIFIED | Tests resolve on both profiles; no strict-mode collision (DesktopNav at ≥768px is not rendered on 390/412px viewports) |
| `calendar.spec.ts` | `page.route('/api/events*', ...)` | Error-state simulation registered before goto | VERIFIED | Line 98: route registered before `page.goto('/calendar')`, unrouted on line 116 |
| `lists.spec.ts` | seeded 'E2E Grocery List' card | `getByRole('button', { name: 'Open list: E2E Grocery List' })` | VERIFIED | Card resolves on both profiles after global-setup seed |

### Data-Flow Trace (Level 4)

Not applicable — this phase produces a test harness (spec files and config), not a UI component that renders dynamic data from an API. The harness itself is the data producer for downstream assertions.

### Behavioral Spot-Checks (Step 7b)

| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Exactly two projects (iphone, pixel) reported | `playwright test --list` | Lists 29 iphone + 29 pixel = 58 tests; both project names confirmed | PASS |
| Full 58-test suite passes (run 1) | `pnpm --filter @familysync/pwa test:e2e` | 58 passed (28.0s) | PASS |
| Full 58-test suite passes (run 2 — idempotency) | `pnpm --filter @familysync/pwa test:e2e` | 58 passed (27.4s) | PASS |
| Typecheck gate covers e2e files | `pnpm --filter @familysync/pwa typecheck` | Exit 0 (both `tsc --noEmit` and `tsc --project tsconfig.e2e.json --noEmit`) | PASS |
| No storageState or toHaveScreenshot in harness | grep across config + all specs | 0 matches (1 comment-only hit in config) | PASS |
| No absolute URLs in spec files | grep for `https?://localhost` in e2e/*.spec.ts | 0 matches | PASS |

### Probe Execution

No conventional `scripts/*/tests/probe-*.sh` probes declared for this phase.

### Requirements Coverage

| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| TEST-01 | Plans 01, 03, 04 | Drive PWA in mobile-emulated viewport (device profile + mobile UA + touch) for automated UI/layout verification | SATISFIED | `devices['iPhone 14']` + `devices['Pixel 7']` in config with hasTouch; layout.spec.ts measures boundingBox; 29 tests per profile pass |
| TEST-02 | Plans 01, 02, 04 | Automated runs reach authenticated PWA via DEV_AUTH_BYPASS (no manual login, no OIDC mock) | SATISFIED | No storageState; fail-closed guard requires DEV_AUTH_BYPASS=true; calendar.spec.ts asserts auth-bypass at runtime; live runs confirm |

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| None found | — | No TBD/FIXME/XXX/TODO/PLACEHOLDER markers in any harness file | — | — |

Code review WR-04 identified that `lists.spec.ts` uses the exact string `/api/lists` instead of the glob `/api/lists*` for the empty-state route mock (inconsistent with the calendar spec's `/api/events*` glob). This is an advisory warning from the review — the harness currently passes because the endpoint has no query params. This is catalogued but does not block goal achievement; the phase goal is achieved and this is a robustness concern for future maintenance.

### Human Verification Required

None. All success criteria are verifiable programmatically and were verified via live execution.

The following items from the code review are advisory warnings (not blockers for phase goal):

- WR-01: Readiness gate accepts any 2xx — could mis-fire if Authelia redirects to a 200 login page. Mitigated in practice by the fail-closed DEV_AUTH_BYPASS guard which ensures the API is running in bypass mode before the test process starts.
- WR-02: Deadline-expiry uses `Date.now() >= deadline` inference instead of an explicit boolean. Low practical risk given the 60s window and 1s poll interval.
- WR-03: Health gate polls Vite's proxied /health rather than the API directly. Works correctly via the Vite proxy; a downed API would produce a 5xx not a 200.
- WR-04: `/api/lists` exact match vs glob — brittle to future query param additions.
- WR-05: `unroute` not in `finally` — an assertion failure could leave a mock active for subsequent tests in the same worker.
- WR-06: Self-validation comment says "page.reload drops inline style tags" but code uses `el.remove()` — stale comment; code is correct.
- WR-07: SW-controller assertion passes vacuously on WebKit where `navigator.serviceWorker` may be undefined.

These are carried from the code review as advisory only; none block the phase goal.

### Gaps Summary

No gaps against the four success criteria — all verified against the actual codebase and confirmed by live execution.

---

## Post-verification addendum (deep code review, 2026-06-11)

A deep cross-file code review run *after* this verification found that two `calendar.spec.ts`
"populated state" assertions were **vacuous** — they targeted `CalendarShell`'s `EmptyState`
(dead code, never rendered) and the always-rendered Schedule-X wrapper, so they could not have
failed if the seed regressed. This did **not** invalidate the four success criteria (SC-1's
layout/tap-target coverage is `layout.spec.ts`, which carries its own injected-defect
self-validation and remains sound), but it was a real coverage gap in the calendar
populated-state tests.

Resolved in commit `53c3ca5`: replaced with a genuine DB→UI proof (`getByText('Seeded Test
Event')` visible in the grid), verified non-vacuous (passes with the seed on both profiles; with
`/api/events` mocked to `[]` the title is absent, so the assertion would fail), and re-anchored
the seed to noon-today so it sits deterministically inside the rendered current-month view. Full
58-test suite passes on both profiles. See `07-REVIEW.md` BL-01/BL-02.

---

_Verified: 2026-06-11T02:30:00Z_
_Verifier: Claude (gsd-verifier)_
_Addendum: 2026-06-11 — deep review BL-01/BL-02 resolved (commit 53c3ca5)_
