Files
familysync/.planning/phases/07-mobile-test-harness/07-PATTERNS.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

523 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 114) — `defineConfig` wrapper convention; and `apps/api/vitest.config.ts` (lines 114) — `fileParallelism: false` and `setupFiles` equivalents.
**Config structure pattern** (`apps/pwa/vitest.config.ts`, lines 114):
```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 114):
```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 116)
```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 5085) — 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 3036):
```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 6166):
```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 1418, 140158):
```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 220230):
```typescript
const tapToRetry = await screen.findByText(/Tap here to try again/i);
expect(tapToRetry).toBeDefined();
```
**Playwright equivalents** (from RESEARCH.md §Patterns 35) — `@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 83113):
```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 117136) — 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 2131):
```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 178193):
```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 114 above. Change is additive: add `exclude` array to prevent Vitest from picking up `e2e/**/*.spec.ts`.
**Current file** (`apps/pwa/vitest.config.ts`, lines 114):
```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 611) + root `package.json` (lines 412) for naming conventions.
**Current scripts block** (`apps/pwa/package.json`, lines 611):
```json
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
```
**Root workspace convention** (`package.json`, lines 412): 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 3036, 6176
**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 614
**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 114; `apps/api/tests/routes/lists.test.ts` lines 117
**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 217231
```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