# 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> = {}): 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/<app> <script>` and follow `verb` or `verb:modifier` naming (`dev:api`, `dev:pwa`, `typecheck`).

**Additions to `apps/pwa/package.json`:**

```json
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
```

**Addition to `devDependencies`:**

```json
"@playwright/test": "1.60.0"
```

**Root `package.json` addition** (propagate to workspace-level scripts):

```json
"test:e2e": "pnpm --filter @familysync/pwa test:e2e"
```

---

## Shared Patterns

### Dev bypass — resolves to user id 1

**Source:** `apps/api/src/auth/devBypass.ts` lines 30–36, 61–76
**Apply to:** `global-setup.ts` (seed targets `user_id=1`, `owner_id=1`); all spec files (asserted data belongs to user 1)

```typescript
// DEV_USER.id === 1 — seed and assert against this identity
export const DEV_USER = { id: 1, displayName: 'Dev User', color: '#4A90D9' } as const;
// Guard: requires NODE_ENV !== 'production' AND DEV_AUTH_BYPASS=true
```

### mysql2 env-var connection pattern

**Source:** `apps/api/src/db/client.ts` lines 6–14
**Apply to:** `global-setup.ts`

```typescript
// Exact env-var names used across the project — use same names in global-setup
host: process.env.DB_HOST ?? '127.0.0.1',   // NOT 'localhost' (per memory api-integration-test-db)
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER ?? 'familysync',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'familysync',
```

### Test file header comment convention

**Source:** `apps/pwa/src/components/CalendarShell.test.tsx` lines 1–14; `apps/api/tests/routes/lists.test.ts` lines 1–17
**Apply to:** all `e2e/*.spec.ts` files and `global-setup.ts`

```typescript
/**
 * <Component/route> — <requirement ID>
 *
 * <what it tests>
 *
 * Run:
 *   pnpm --filter @familysync/pwa test:e2e
 */
```

### Role/name locator convention (Vitest + Testing Library → Playwright equivalent)

**Source:** `apps/pwa/src/components/CalendarShell.test.tsx` lines 217–231

```typescript
// Testing Library (Vitest) — existing pattern
const tapToRetry = await screen.findByText(/Tap here to try again/i);
// ↓ Playwright equivalent in e2e specs:
await expect(page.getByRole('button', { name: /Retry/i })).toBeVisible();
await expect(page.getByText(/Tap here to try again/i)).toBeVisible();
```

### No hardcoded absolute URLs in specs

**Source:** RESEARCH.md §Anti-Patterns; consistent with `apps/pwa/vite.config.ts` proxy pattern
**Apply to:** all `e2e/*.spec.ts` files

```typescript
// Wrong — breaks CI
await page.goto('http://localhost:5173/calendar');
// Correct — resolves against playwright.config.ts baseURL
await page.goto('/calendar');
```

---

## No Analog Found

None. All files have at least a role-match analog in the codebase.

---

## Metadata

**Analog search scope:** `apps/pwa/src/`, `apps/api/src/`, `apps/api/tests/`
**Files read:** 12
**Pattern extraction date:** 2026-06-10
