Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
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):
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):
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):
// 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)
// 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=truelist_shares:(list_id, user_id)— join table, seed one row for user 1list_items:(list_id, text, checked, rank)—rankis 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):
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):
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):
// 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):
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):
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:
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):
// 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):
// 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):
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):
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):
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:
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):
"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:
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
Addition to devDependencies:
"@playwright/test": "1.60.0"
Root package.json addition (propagate to workspace-level scripts):
"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)
// 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
// 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
/**
* <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
// 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
// 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