# Phase 7: Mobile Test Harness — Pattern Map **Mapped:** 2026-06-10 **Files analyzed:** 7 (5 new, 2 modified) **Analogs found:** 7 / 7 --- ## 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 | --- ## Pattern Assignments ### `apps/pwa/playwright.config.ts` (config, new) **Analog:** `apps/pwa/vitest.config.ts` (lines 1–14) — `defineConfig` wrapper convention; and `apps/api/vitest.config.ts` (lines 1–14) — `fileParallelism: false` and `setupFiles` equivalents. **Config structure pattern** (`apps/pwa/vitest.config.ts`, lines 1–14): ```typescript import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', globals: true, 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 1–14): ```typescript export default defineConfig({ test: { environment: 'node', globals: true, setupFiles: ['./test/setup.ts'], 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'; export default defineConfig({ testDir: './e2e', testMatch: '**/*.spec.ts', fullyParallel: true, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: process.env.CI ? 'github' : 'list', globalSetup: './e2e/global-setup.ts', use: { baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173', trace: 'on-first-retry', video: 'on-first-retry', screenshot: 'only-on-failure', }, projects: [ { name: 'iphone', use: { ...devices['iPhone 14'], serviceWorkers: 'block', // D-02 / Pitfall 15 }, }, { name: 'pixel', use: { ...devices['Pixel 7'], serviceWorkers: 'block', }, }, ], webServer: { command: 'pnpm --filter @familysync/pwa dev', url: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:5173', reuseExistingServer: !process.env.CI, // D-10 timeout: 120_000, }, }); ``` --- ### `apps/pwa/e2e/global-setup.ts` (utility, new) **Analog 1 — mysql2 connection env-var pattern:** `apps/api/src/db/client.ts` (lines 1–16) ```typescript // apps/api/src/db/client.ts lines 6-14 — exact env-var names to copy const pool = mysql.createPool({ host: process.env.DB_HOST ?? 'localhost', port: Number(process.env.DB_PORT ?? 3306), user: process.env.DB_USER ?? 'familysync', password: process.env.DB_PASSWORD ?? '', 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 50–85) — 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` | | `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) | **DEV_USER confirmed** (`apps/api/src/auth/devBypass.ts`, lines 30–36): ```typescript export const DEV_USER = { id: 1, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color: '#4A90D9', } as const; ``` Seeds must target `user_id = 1` and `owner_id = 1`. **Guard for production** (`apps/api/src/auth/devBypass.ts`, lines 61–66): ```typescript if (process.env.NODE_ENV === 'production') { return async (_c, next) => next(); } if (process.env.DEV_AUTH_BYPASS !== 'true') { 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'; export default async function globalSetup() { 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 conn = 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', }); 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'); // 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)`, ); // 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 rawVevent = [ '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'); 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( `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]); 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(); } } ``` --- ### `apps/pwa/e2e/layout.spec.ts` (test, new) **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 14–18, 140–158): ```typescript import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' // ... describe('CalendarShell — CAL-03 render smoke', () => { beforeEach(() => { vi.clearAllMocks() sessionStorage.clear() }) it('renders without throwing...', () => { ... }) it('mounts the ScheduleXCalendar...', async () => { ... }) }) ``` **Role-based locator pattern** (`CalendarShell.test.tsx`, lines 220–230): ```typescript const tapToRetry = await screen.findByText(/Tap here to try again/i); expect(tapToRetry).toBeDefined(); ``` **Playwright equivalents** (from RESEARCH.md §Patterns 3–5) — `@playwright/test` uses `page.getByRole()`, not `screen`: ```typescript import { test, expect } from '@playwright/test'; test.describe('BottomTabBar presence and tap targets', () => { 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); }); 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); }); }); ``` **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(); ``` --- ### `apps/pwa/e2e/calendar.spec.ts` (test, new) **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 83–113): ```typescript // Timed event shape returned by /api/events const TIMED_OCCURRENCE = { id: 'timed-uid::2026-06-15T10:00:00', title: 'Team Standup', 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'`. **Query client wrapper convention** (`CalendarShell.test.tsx`, lines 117–136) — not directly applicable in Playwright (no React wrapper needed), but confirms the route path is `/calendar`. --- ### `apps/pwa/e2e/lists.spec.ts` (test, new) **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 21–31): ```typescript function makeItem(overrides: Partial = {}): ListItem { return { id: 1, listId: 10, text: 'bread', checked: false, rank: 'a0', }; } ``` **Section assertion pattern** (`ListDetail.test.tsx`, lines 178–193): ```typescript 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)_ **Analog:** Self — read at lines 1–14 above. Change is additive: add `exclude` array to prevent Vitest from picking up `e2e/**/*.spec.ts`. **Current file** (`apps/pwa/vitest.config.ts`, lines 1–14): ```typescript import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test-setup.ts'], env: { TZ: 'UTC' }, // 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)_ **Analog:** `apps/pwa/package.json` (self, lines 6–11) + root `package.json` (lines 4–12) for naming conventions. **Current scripts block** (`apps/pwa/package.json`, lines 6–11): ```json "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run" } ``` **Root workspace convention** (`package.json`, lines 4–12): scripts use `pnpm --filter @familysync/