---
phase: 07-mobile-test-harness
plan: 04
type: execute
wave: 3
depends_on: ['07-01', '07-02']
files_modified:
  - apps/pwa/e2e/calendar.spec.ts
  - apps/pwa/e2e/lists.spec.ts
autonomous: true
requirements: [TEST-01, TEST-02]
user_setup: []

must_haves:
  truths:
    - "On both profiles, /calendar renders the populated (seeded) calendar: the Schedule-X grid is visible and the EmptyState 'Nothing here' is NOT present"
    - "On both profiles, /calendar error state (API mocked to 500) shows the 'Couldn't load events' heading and a Retry button >=44px, with no horizontal overflow"
    - "On both profiles, /lists renders the populated (seeded) list: the 'E2E Grocery List' card is visible and ListsEmptyState 'No lists yet' is NOT present"
    - "On both profiles, /lists empty state (after seed teardown) shows 'No lists yet' + 'Tap + to create...'"
    - 'The authed PWA is reached via DEV_AUTH_BYPASS — no Authelia login page, no OIDC mock — and the run produces no SW-sourced responses'
  artifacts:
    - path: 'apps/pwa/e2e/calendar.spec.ts'
      provides: 'Calendar populated + empty + error states (UI-SPEC Rules 4/5) + auth-bypass precondition assertion'
      contains: "Couldn't load events"
    - path: 'apps/pwa/e2e/lists.spec.ts'
      provides: 'Lists populated + empty states (UI-SPEC Rules 4/5)'
      contains: 'No lists yet'
  key_links:
    - from: 'apps/pwa/e2e/calendar.spec.ts'
      to: "page.route('/api/events*') fulfill 500"
      via: 'error-state simulation registered before goto'
      pattern: 'page.route'
    - from: 'apps/pwa/e2e/lists.spec.ts'
      to: "seeded 'E2E Grocery List' card (role=listitem / link 'Open list: ...')"
      via: 'getByRole / getByText on seeded data'
      pattern: 'E2E Grocery List'
---

<objective>
Author `apps/pwa/e2e/calendar.spec.ts` and `apps/pwa/e2e/lists.spec.ts`: the route-specific populated / empty / error state assertions (UI-SPEC Rules 4/5) on both device profiles, plus the TEST-02 precondition assertion that the harness reached the authenticated PWA via `DEV_AUTH_BYPASS` (no Authelia login, no OIDC mock) with no service-worker-sourced responses.

Purpose: These specs prove the seeded data (Plan 02) renders correctly, that empty and error states degrade gracefully, and that the auth + SW-block preconditions (D-01/D-02) actually hold at runtime — the heart of TEST-01 (state coverage) and TEST-02 (authed reach).

Output: `apps/pwa/e2e/calendar.spec.ts`, `apps/pwa/e2e/lists.spec.ts`.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-mobile-test-harness/07-CONTEXT.md
@.planning/phases/07-mobile-test-harness/07-RESEARCH.md
@.planning/phases/07-mobile-test-harness/07-PATTERNS.md
@.planning/phases/07-mobile-test-harness/07-UI-SPEC.md
@.planning/phases/07-mobile-test-harness/07-VALIDATION.md
@apps/pwa/playwright.config.ts
@apps/pwa/e2e/global-setup.ts
@apps/pwa/src/components/CalendarShell.tsx
@apps/pwa/src/components/EmptyState.tsx
@apps/pwa/src/components/ListsEmptyState.tsx
@apps/pwa/src/routes/ListsIndex.tsx
</context>

<tasks>

<task type="auto">
  <name>Task 1: calendar.spec.ts — populated + error states + auth-bypass / SW-block precondition (TEST-01, TEST-02)</name>
  <files>apps/pwa/e2e/calendar.spec.ts</files>
  <read_first>
    - .planning/phases/07-mobile-test-harness/07-UI-SPEC.md § "Rule 5" (state assertion table for /calendar) + § "Copywriting Contract" (exact strings) + § "Rule 7" (auth + SW preconditions)
    - .planning/phases/07-mobile-test-harness/07-RESEARCH.md § "Pattern 5" (page.route error-state simulation) + § "layout.spec.ts skeleton" (error-state example) + § "Pitfall 5" (auth-bypass propagation)
    - .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/e2e/calendar.spec.ts" — analog CalendarShell.test.tsx, seeded event title 'Seeded Test Event', route is /calendar, prefer ARIA roles over data-testid
    - apps/pwa/src/components/CalendarShell.tsx — error branch renders `<h2>Couldn't load events</h2>` + `<button>Retry</button>` (minHeight 44px); FAB `aria-label="New Event"`; the Schedule-X grid wrapper; isError triggers after `retries: 2` on the events query
    - apps/pwa/src/components/EmptyState.tsx — empty heading `Nothing here`, body `No events in this period. Try a different date or switch views.`
    - apps/pwa/e2e/global-setup.ts (Plan 02) — confirms the seeded event title 'Seeded Test Event' on calendar_id=10 that the populated assertion targets
  </read_first>
  <action>
    Create `apps/pwa/e2e/calendar.spec.ts` importing `test, expect` from `@playwright/test`; runs on both profiles via the config matrix; relative `page.goto('/calendar')` only (Rule 8 — no absolute URL).
    POPULATED state (Rule 5, after global-setup seed): goto `/calendar`; assert the Schedule-X calendar grid is visible (prefer a stable ARIA/role anchor; if none exists the codebase exposes `data-testid="schedule-x-calendar"` — use role/landmark first, testid only as the documented fallback for the widget wrapper since Schedule-X provides no semantic role); assert the EmptyState text `Nothing here` is NOT present (`await expect(page.getByText('Nothing here')).toHaveCount(0)`). Optionally assert the seeded event title `Seeded Test Event` is visible — but note Schedule-X renders the current week/month by default and the seed is ~tomorrow, so the chip is only guaranteed visible if tomorrow falls in the default view; if it may not, assert grid-present + empty-absent rather than chip-visible to keep the spec date-stable (DO NOT introduce date-dependent flakiness — this is exactly the Schedule-X drift the phase avoids).
    ERROR state (Rule 5): register `await page.route('/api/events*', route => route.fulfill({ status: 500, body: JSON.stringify({ error: 'simulated' }) }))` BEFORE `page.goto('/calendar')` (route must be registered before navigation — Pattern 5); the events query retries twice (config), so allow time for the error branch; assert `getByRole('heading', { name: "Couldn't load events" })` is visible and `getByRole('button', { name: 'Retry' })` is visible; assert the Retry button boundingBox height ≥44 (Rule 1 within error state); assert no horizontal overflow (scrollWidth ≤ clientWidth) in the error state too. Call `await page.unroute('/api/events*')` (or use `{ times: ... }`) so the mock does not leak to later tests (Pattern 5 caution).
    AUTH-BYPASS / SW precondition (TEST-02, Rule 7): add a test that, on goto `/calendar`, asserts the page did NOT land on the Authelia login (assert authed content is present — the BottomTabBar nav `getByRole('navigation', { name: 'Main navigation' })` and that the URL is not redirected to an external auth host) — proving DEV_AUTH_BYPASS reached the authed PWA without an OIDC mock. For SW-block evidence (D-02/Pitfall 15): the `serviceWorkers: 'block'` config option prevents registration; assert no service worker is registered via `await page.evaluate(() => navigator.serviceWorker?.controller)` returning null (no controlling SW), documenting in a comment that trace-level SW-source audit is the post-hoc check per UI-SPEC Rule 7. Header comment names TEST-01/TEST-02 + the run command (PATTERNS.md convention).
  </action>
  <acceptance_criteria>
    - `pnpm --filter @familysync/pwa exec playwright test calendar.spec.ts` passes on both `iphone` and `pixel`
    - populated test asserts the calendar grid visible AND `getByText('Nothing here')` count is 0
    - error test registers `page.route('/api/events*', ...500)` before goto, asserts the `Couldn't load events` heading + `Retry` button visible, Retry ≥44px, and no horizontal overflow; then unroutes
    - auth test asserts authed content (Main navigation landmark) present and no redirect to an external auth host
    - SW assertion confirms `navigator.serviceWorker.controller` is null (no controlling SW)
    - no absolute URL in the file
  </acceptance_criteria>
  <verify>
    <automated>cd apps/pwa && pnpm exec playwright test calendar.spec.ts 2>&1 | tail -15; grep -cE "https?://localhost" e2e/calendar.spec.ts</automated>
  </verify>
  <done>calendar.spec.ts asserts populated + error states on both profiles, the Retry tap target + overflow in the error state, and the DEV_AUTH_BYPASS + no-SW-controller preconditions.</done>
</task>

<task type="auto">
  <name>Task 2: lists.spec.ts — populated + empty states (TEST-01)</name>
  <files>apps/pwa/e2e/lists.spec.ts</files>
  <read_first>
    - .planning/phases/07-mobile-test-harness/07-UI-SPEC.md § "Rule 5" (state table for /lists) + § "Copywriting Contract" (lists empty heading 'No lists yet', body 'Tap + to create your first shared list')
    - .planning/phases/07-mobile-test-harness/07-PATTERNS.md § "apps/pwa/e2e/lists.spec.ts" — analog ListDetail.test.tsx, seeded list name 'E2E Grocery List', items 'Milk'/'Eggs', the empty-vs-populated distinction
    - apps/pwa/src/routes/ListsIndex.tsx — container `role="list"`; renders `<ListsEmptyState/>` when `lists.length === 0`; list cards render via ListCard
    - apps/pwa/src/components/ListCard.tsx — each card is `role="listitem"` with a link `aria-label={`Open list: ${list.name}`}` and the name as visible text
    - apps/pwa/src/components/ListsEmptyState.tsx — heading `No lists yet`, body contains `Tap + to create your first shared list`
    - apps/pwa/e2e/global-setup.ts (Plan 02) — the seeded list is named 'E2E Grocery List' (owner_id 1, is_shared, + list_shares row) — the stable anchor for the populated assertion
  </read_first>
  <action>
    Create `apps/pwa/e2e/lists.spec.ts` importing `test, expect` from `@playwright/test`; both profiles via the matrix; relative `page.goto('/lists')` only.
    POPULATED state (Rule 5, after global-setup seed): goto `/lists`; assert the seeded list card is visible — locate by its accessible link name `getByRole('link', { name: 'Open list: E2E Grocery List' })` (preferred, stable aria-label) OR `getByText('E2E Grocery List')`; assert `getByRole('listitem')` count ≥1; assert the ListsEmptyState text `No lists yet` is NOT present (`toHaveCount(0)`). Assert no horizontal overflow on the populated list view.
    EMPTY state (Rule 5): the harness needs an emptied lists view. DO NOT mutate the shared seeded DB mid-suite (that would race the populated test and break determinism — D-06). Instead simulate the empty response at the network layer: register `await page.route('/api/lists', route => route.fulfill({ status: 200, body: JSON.stringify([]) }))` BEFORE goto `/lists`, then assert `getByText('No lists yet')` is visible and `getByText(/Tap \+ to create/)` is visible; assert no horizontal overflow in the empty state; then `page.unroute('/api/lists')`. (Confirm the exact lists endpoint path — `/api/lists` — by reading ListsIndex.tsx's query before finalizing the route glob.) This keeps the seeded populated state intact for the rest of the suite while still proving the empty state renders.
    Both states must also satisfy Rule 2 (overflow) — assert it in each. Header comment names TEST-01 + run command.
  </action>
  <acceptance_criteria>
    - `pnpm --filter @familysync/pwa exec playwright test lists.spec.ts` passes on both `iphone` and `pixel`
    - populated test asserts the `E2E Grocery List` card visible (by link aria-label or text) AND `getByText('No lists yet')` count is 0
    - empty test routes `/api/lists` to a 200 empty array before goto, asserts `No lists yet` + `Tap + to create` visible, then unroutes
    - both states assert `scrollWidth <= clientWidth`
    - no absolute URL in the file; the seeded DB is not mutated by the spec (empty state is network-simulated)
  </acceptance_criteria>
  <verify>
    <automated>cd apps/pwa && pnpm exec playwright test lists.spec.ts 2>&1 | tail -15; grep -cE "https?://localhost" e2e/lists.spec.ts</automated>
  </verify>
  <done>lists.spec.ts asserts the seeded populated list and the (network-simulated) empty state on both profiles, with overflow checks and no mutation of the shared seed.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary             | Description                                                                                                                   |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| spec → dev PWA/API   | Playwright drives the authed PWA via DEV_AUTH_BYPASS; read-only assertions + in-process page.route mocks; no real form writes |
| harness → production | the auth posture asserted here (dev bypass) must never be the production posture                                              |

## STRIDE Threat Register

| Threat ID | Category               | Component                                    | Disposition           | Mitigation Plan                                                                                                                                                                                                                                                                                                    |
| --------- | ---------------------- | -------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| T-07-09   | Elevation of Privilege | DEV_AUTH_BYPASS reaching production          | mitigate              | The spec asserts the authed PWA was reached via the bypass on the DEV stack only; the bypass is API-side and guarded by `NODE_ENV !== 'production'` (devBypass.ts). Production compose must not set it (documented in Plan 02 README). The spec does not enable the bypass; it depends on the dev stack having it. |
| T-07-10   | Spoofing               | OIDC/auth mocking masking a broken auth path | accept (designed out) | No OIDC mock is used (D-01) — auth comes from the real dev-bypass middleware; the auth-precondition test asserts genuine authed content, not a faked session.                                                                                                                                                      |
| T-07-11   | Tampering              | page.route mocks leaking between tests       | mitigate              | Every `page.route` (events 500, lists empty) is paired with `page.unroute` (or `{ times }`) so the mock does not bleed into the populated/auth tests; the shared seeded DB is never mutated by a spec (empty state is network-simulated, not a DB delete).                                                         |

</threat_model>

<verification>
- `playwright test calendar.spec.ts lists.spec.ts` green on both `iphone` and `pixel`.
- Calendar: populated (grid visible, empty-absent), error (heading + Retry ≥44px + no overflow, mock unrouted), auth-bypass reached + no SW controller.
- Lists: populated (seeded card visible, empty-absent), empty (network-simulated 'No lists yet'), overflow clean in both.
- No absolute URLs; no DB mutation from specs.
</verification>

<success_criteria>

- calendar.spec.ts + lists.spec.ts cover populated / empty / error states on both profiles (UI-SPEC Rules 4/5).
- TEST-02 precondition (authed reach via DEV_AUTH_BYPASS, no OIDC mock, no SW controller) asserted at runtime.
- Mocks are scoped and unrouted; seeded data is left intact.
  </success_criteria>

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