/** * global-setup.ts — Playwright globalSetup: /health readiness gate + deterministic DB seed * * Runs ONCE before any spec file. Plain Node.js only (fetch + mysql2/promise). * No @playwright/test imports — globalSetup runs outside the worker context (Pitfall 2). * * Step 1 (D-08 readiness gate): * Poll baseURL/health until 200 OK (60s timeout, fail fast on expiry). * * Step 2 (D-05/D-06/D-07 deterministic seed): * TRUNCATE → INSERT seed rows onto calendar_id=10 + lists for user_id=1. * Idempotent: truncate-first guarantees the same row counts on every run. * * Anchor values the specs assert on — do NOT change without updating the specs: * - calendar_events.title = 'Seeded Test Event' * - lists.name = 'E2E Grocery List' * - list_items.text = 'Milk', 'Eggs' * * Run: * pnpm --filter @familysync/pwa test:e2e */ import mysql from 'mysql2/promise' export default async function globalSetup(): Promise { // ── Step 1: Readiness gate (D-08) ─────────────────────────────────────────── 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 or network error — stack not ready yet, keep polling } await new Promise((r) => setTimeout(r, 1_000)) } if (Date.now() >= deadline) { throw new Error( `health check never returned 200 at ${baseURL}/health — is the dev stack up?\n` + `Ensure the API is running with DEV_AUTH_BYPASS=true and the Vite dev server is on ${baseURL}.`, ) } // ── Step 2: Reset-and-seed (D-06 deterministic, D-07 in globalSetup) ──────── // Uses exact env-var names from apps/api/src/db/client.ts. // DB_HOST defaults to '127.0.0.1' (NOT 'localhost') — per project memory api-integration-test-db. 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 { // Disable FK checks so TRUNCATE order is unconstrained 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 (Pitfall 4): ensure calendar row id=10 exists before inserting events. // INSERT IGNORE is a no-op if the row already exists (dev DB), creates it if not (CI fresh DB). 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 (not all-day) calendar event on shared calendar id=10. // Uses a future dtstart_utc so the event is visible in the UI's default "upcoming" view. const uid = 'e2e-seed-event-001' const futureStart = new Date(Date.now() + 24 * 60 * 60 * 1000) // tomorrow UTC const futureStartUtc = futureStart.toISOString().replace(/\.\d+Z$/, 'Z') // trim ms const rawVevent = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//FamilySync E2E//EN', 'BEGIN:VEVENT', `UID:${uid}`, `DTSTART:${futureStart.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')}`, `DTEND:${new Date(futureStart.getTime() + 60 * 60 * 1000).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 shared list owned by user 1 (D-05 populated half). // belt-and-suspenders: seed BOTH owner_id=1 AND a list_shares row for user_id=1 // so /api/lists returns the list regardless of whether it filters by owner or by share. const [listResult] = (await conn.execute( `INSERT INTO lists (owner_id, name, is_shared) VALUES (1, 'E2E Grocery List', true)`, )) as mysql.ResultSetHeader[] const listId = listResult.insertId await conn.execute(`INSERT INTO list_shares (list_id, user_id) VALUES (?, 1)`, [listId]) // Two active items with fractional-indexing rank strings — appear in the active section. // 'Milk' and 'Eggs' are the stable anchor texts that lists.spec.ts asserts on. 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() } }