docs(07-02): complete globalSetup seed plan
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
---
|
||||
phase: 07-mobile-test-harness
|
||||
plan: "02"
|
||||
subsystem: test-harness
|
||||
tags: [playwright, e2e, global-setup, db-seed, mysql2, readiness-gate]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "apps/pwa/playwright.config.ts (07-01) — globalSetup path reference"
|
||||
- "apps/api dev MariaDB :3306 — seed target"
|
||||
- "apps/api DEV_AUTH_BYPASS=true — required in API process before harness runs"
|
||||
provides:
|
||||
- "global-setup.ts — /health readiness poll + deterministic reset-and-seed"
|
||||
- "e2e/README.md — run instructions and security guardrails"
|
||||
- "mysql2@3.22.4 devDependency in apps/pwa"
|
||||
affects:
|
||||
- "Phase 07 plans 03-04 (specs depend on this seed for populated-state assertions)"
|
||||
- "Phase 08 CI (globalSetup runs unchanged in the CI runner)"
|
||||
tech_stack:
|
||||
added:
|
||||
- "mysql2@3.22.4 devDependency in apps/pwa — enables mysql2/promise in global-setup.ts"
|
||||
patterns:
|
||||
- "TRUNCATE + INSERT (not INSERT IGNORE) for seed rows — D-06 deterministic reset"
|
||||
- "INSERT IGNORE INTO calendars guard — ensures calendar_id=10 FK satisfied on fresh CI DB (Pitfall 4)"
|
||||
- "SET FOREIGN_KEY_CHECKS=0/1 around TRUNCATE — FK-safe truncate ordering"
|
||||
- "fetch() for /health poll — native Node.js 22, no @playwright/test import (Pitfall 2)"
|
||||
key_files:
|
||||
created:
|
||||
- apps/pwa/e2e/README.md
|
||||
modified:
|
||||
- apps/pwa/e2e/global-setup.ts
|
||||
- apps/pwa/package.json
|
||||
- pnpm-lock.yaml
|
||||
decisions:
|
||||
- "D-07-02-mysql2-in-pwa: Added mysql2@3.22.4 as devDependency to apps/pwa — global-setup.ts needs mysql2/promise for TypeScript types; the package was already in the monorepo (apps/api), so pnpm install just linked it without downloading"
|
||||
- "D-07-02-deadline-check: Added explicit deadline check after the health poll loop to distinguish 'loop exited via break (success)' from 'loop exited via deadline expiry' — ensures throw fires correctly on timeout"
|
||||
- "D-07-02-dtend-in-vevent: Added DTEND line to the minimal VCALENDAR seed string for spec compatibility — some CalDAV parsers reject VEVENTs without DTEND"
|
||||
metrics:
|
||||
duration_seconds: 196
|
||||
completed_date: "2026-06-11"
|
||||
tasks_completed: 2
|
||||
files_changed: 4
|
||||
---
|
||||
|
||||
# Phase 07 Plan 02: globalSetup Readiness Gate + DB Seed Summary
|
||||
|
||||
**One-liner:** Playwright globalSetup with 60s /health readiness poll and deterministic TRUNCATE+INSERT seed onto calendar_id=10 and user_id=1 lists — idempotent run-over-run.
|
||||
|
||||
## What Was Built
|
||||
|
||||
- `apps/pwa/e2e/global-setup.ts` — full implementation replacing the Plan 01 stub:
|
||||
- Step 1 (D-08): polls `${PLAYWRIGHT_BASE_URL}/health` with a 60-second deadline; swallows ECONNREFUSED; breaks on first `res.ok`; throws with a clear diagnostic message if the deadline passes
|
||||
- Step 2 (D-06/D-07): direct mysql2 connection using exact env-var names from `apps/api/src/db/client.ts`; `SET FOREIGN_KEY_CHECKS=0` → TRUNCATE list_items/list_shares/lists/calendar_events → `SET FOREIGN_KEY_CHECKS=1` → INSERT IGNORE calendars guard (id=10) → one timed calendar_event → E2E Grocery List (owner_id=1, is_shared=true) + list_shares row + Milk/Eggs items
|
||||
- No `@playwright/test` imports — plain Node.js (Pitfall 2 compliant)
|
||||
|
||||
- `apps/pwa/e2e/README.md` — operator reference documenting:
|
||||
- Prerequisites: dev stack (API + PWA + MariaDB + Redis) with DEV_AUTH_BYPASS=true
|
||||
- Run commands: `pnpm --filter @familysync/pwa test:e2e`, single profile, headed, UI mode
|
||||
- Env var contract: PLAYWRIGHT_BASE_URL, DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME — credentials env-only, never hardcoded (T-07-05)
|
||||
- Security guardrail: DEV_AUTH_BYPASS is dev-only, NODE_ENV !== 'production' hard guard, production compose MUST NOT set it (T-07-04)
|
||||
- No storageState (D-01 — designed out)
|
||||
- CI scope note: Phase 8 brings up the stack; harness handles its own readiness gate
|
||||
|
||||
- `apps/pwa/package.json` — mysql2@3.22.4 added as devDependency (same version as apps/api; pnpm linked without downloading)
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
- `grep "^import mysql from 'mysql2/promise'" apps/pwa/e2e/global-setup.ts` — found
|
||||
- `grep "from '@playwright/test'" apps/pwa/e2e/global-setup.ts` — absent (Pitfall 2 pass)
|
||||
- `grep "TRUNCATE TABLE" apps/pwa/e2e/global-setup.ts` — 4 tables (list_items, list_shares, lists, calendar_events)
|
||||
- `grep "INSERT IGNORE INTO calendars" apps/pwa/e2e/global-setup.ts` — found with VALUES (10, 1, ...)
|
||||
- `grep "list_shares\|Milk\|Eggs" apps/pwa/e2e/global-setup.ts` — all present
|
||||
- `grep -c 'DEV_AUTH_BYPASS|NODE_ENV|storageState|PLAYWRIGHT_BASE_URL|test:e2e' apps/pwa/e2e/README.md` → 15 (acceptance criteria: non-zero count)
|
||||
- `tsc --noEmit --project tsconfig.e2e.json` (apps/pwa) → 0 errors
|
||||
- `tsc --noEmit` (apps/pwa src) → 0 errors
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] mysql2 not available in apps/pwa**
|
||||
- **Found during:** Task 1 TypeScript check — `tsc --noEmit --project tsconfig.e2e.json` emitted `TS2307: Cannot find module 'mysql2/promise'`
|
||||
- **Issue:** mysql2 is in `apps/api/dependencies` but not linked to `apps/pwa`. The global-setup imports `mysql2/promise` which requires the package to be a direct or devDependency of apps/pwa for TypeScript resolution.
|
||||
- **Fix:** Added `"mysql2": "3.22.4"` to `apps/pwa/devDependencies` (same version as apps/api to stay in sync). `pnpm install` linked it from the pnpm store in 4s with zero downloads — the binary was already present from apps/api.
|
||||
- **Files modified:** `apps/pwa/package.json`, `pnpm-lock.yaml`
|
||||
- **Commit:** 53498e3
|
||||
|
||||
**2. [Rule 2 - Missing Critical] Explicit deadline-exceeded throw after poll loop**
|
||||
- **Found during:** Task 1 implementation review — the research pattern's while loop exits via `break` on success OR when `Date.now() >= deadline`. After the loop, without an explicit check, code would silently proceed to the DB seed on a timed-out poll, causing confusing mysql2 errors rather than a clear "stack is not up" message.
|
||||
- **Fix:** Added `if (Date.now() >= deadline) { throw new Error(...) }` immediately after the while loop so timeout is distinguishable from success.
|
||||
- **Files modified:** `apps/pwa/e2e/global-setup.ts`
|
||||
- **Commit:** 53498e3
|
||||
|
||||
**3. [Rule 2 - Missing Critical] DTEND in minimal VCALENDAR seed string**
|
||||
- **Found during:** Task 1 implementation — minimal VCALENDAR without DTEND may fail CalDAV/ical.js parsing in some spec paths. Plan said "minimal VCALENDAR/VEVENT" but no explicit DTEND.
|
||||
- **Fix:** Added DTEND line (futureStart + 1 hour) to the VCALENDAR seed string for spec compatibility. Does not affect seed idempotency.
|
||||
- **Files modified:** `apps/pwa/e2e/global-setup.ts`
|
||||
- **Commit:** 53498e3
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — the Plan 01 stub in global-setup.ts is fully replaced with the real implementation.
|
||||
|
||||
## Threat Surface Scan
|
||||
|
||||
No new network endpoints, auth paths, or schema changes. All changes are test-infrastructure files only.
|
||||
|
||||
Threat mitigations from plan threat model:
|
||||
- **T-07-04 (Elevation of Privilege / DEV_AUTH_BYPASS):** README explicitly documents that DEV_AUTH_BYPASS is dev-only, that the API guards on `NODE_ENV !== 'production'`, and that the production compose MUST NOT set it. global-setup does not set the env var (it cannot — it runs after the API is already up).
|
||||
- **T-07-05 (Information Disclosure / DB credentials):** global-setup reads DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME from env exclusively, mirroring `apps/api/src/db/client.ts`. No credential is hardcoded. README states this explicitly.
|
||||
- **T-07-06 (Information Disclosure / OIDC session state):** No storageState.json is written by the harness. README states this. Designed out per D-01.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/pwa/e2e/global-setup.ts` — exists
|
||||
- `apps/pwa/e2e/README.md` — exists
|
||||
- Task 1 commit `53498e3` — exists
|
||||
- Task 2 commit `535ba11` — exists
|
||||
@@ -0,0 +1,477 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user