feat(07-02): implement globalSetup — /health readiness gate + deterministic DB seed
- Poll baseURL/health with 60s timeout; throw on expiry (D-08 readiness gate) - TRUNCATE list_items, list_shares, lists, calendar_events (FK checks toggled) - INSERT IGNORE calendars guard for id=10 (Pitfall 4 CI resilience) - Seed one timed calendar_event on calendar_id=10 (uid: e2e-seed-event-001) - Seed E2E Grocery List (owner_id=1, is_shared=true) + list_shares + Milk/Eggs items - Add mysql2@3.22.4 devDependency to apps/pwa (needed for global-setup types) - No @playwright/test imports — plain Node.js only (Pitfall 2)
This commit is contained in:
@@ -1,15 +1,118 @@
|
|||||||
/**
|
/**
|
||||||
* global-setup.ts — Phase 7 Plan 02 will implement this
|
* global-setup.ts — Playwright globalSetup: /health readiness gate + deterministic DB seed
|
||||||
*
|
*
|
||||||
* Placeholder to satisfy Playwright config path resolution.
|
* Runs ONCE before any spec file. Plain Node.js only (fetch + mysql2/promise).
|
||||||
* Plan 02 replaces this with:
|
* No @playwright/test imports — globalSetup runs outside the worker context (Pitfall 2).
|
||||||
* - health poll on baseURL/health (D-08 readiness gate)
|
*
|
||||||
* - mysql2 DB seed: TRUNCATE + INSERT onto calendar id=10 + lists for user 1 (D-05/D-06/D-07)
|
* 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:
|
* Run:
|
||||||
* pnpm --filter @familysync/pwa test:e2e
|
* pnpm --filter @familysync/pwa test:e2e
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import mysql from 'mysql2/promise'
|
||||||
|
|
||||||
export default async function globalSetup(): Promise<void> {
|
export default async function globalSetup(): Promise<void> {
|
||||||
// Implemented in Plan 02
|
// ── 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<void>((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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.60.0",
|
"@playwright/test": "1.60.0",
|
||||||
|
"mysql2": "3.22.4",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/node": "^22.19.19",
|
"@types/node": "^22.19.19",
|
||||||
|
|||||||
Generated
+3
@@ -147,6 +147,9 @@ importers:
|
|||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^26.1.0
|
specifier: ^26.1.0
|
||||||
version: 26.1.0
|
version: 26.1.0
|
||||||
|
mysql2:
|
||||||
|
specifier: 3.22.4
|
||||||
|
version: 3.22.4(@types/node@22.19.19)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user