// ============================================================ // SHARED PLAYWRIGHT FIXTURES // ============================================================ // Provides: // - `test` — augmented @playwright/test with auto-applied uncaught-error // guards on every page (pageerror + console.error → test fail) // - `authedPage` fixture — a logged-in page, ready to drive // - `mockAI(page, overrides)` — installs page.route() handlers that // intercept AI endpoints and return canned JSON. Pass `{ real: true }` // or set E2E_USE_REAL_AI=1 to bypass mocking and call real backend. // ============================================================ const base = require('@playwright/test'); // ── Environment ────────────────────────────────────────────── // Loopback, not the container hostname. Anything else is an insecure context, // where crypto.randomUUID does not exist and the app cannot complete a sign-in // — see the note in playwright.config.js. const E2E_BASE = process.env.E2E_AUTH_BASE_URL || 'http://127.0.0.1:3553'; const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-user@ped-ai.test'; const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!'; // Seeded with the admin role by e2e/seed.js. Kept as a separate account rather // than promoting the ordinary user, so a test that asserts something is denied // to a non-admin still has a non-admin to assert it with. const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || 'e2e-admin@ped-ai.test'; const USE_REAL_AI = process.env.E2E_USE_REAL_AI === '1' || process.env.E2E_USE_REAL_AI === 'true'; // ── Console-error allowlist ───────────────────────────────── // Some console messages are expected / noise (e.g. favicon 404). If a // message matches one of these patterns it does NOT fail the test. const CONSOLE_ERROR_ALLOWLIST = [ /favicon/i, /\/api\/models/i, // When no AI provider configured yet /Cross-Origin-Opener-Policy/i, // Chrome warning on non-HTTPS e2e server /Failed to load resource.*(400|401|403|404|500|502|503)/i, // Any HTTP error on subsidiary fetches — smoke tests only verify UI renders, deeper integration tests validate endpoint contracts separately /net::ERR_BLOCKED_BY_CLIENT/i, // Adblocker etc. /Cloudflare Turnstile.*110200/i, // Expected on e2e: site key hard-coded in index.html but e2e uses different host → domain mismatch error /challenges\.cloudflare\.com\/turnstile/i, // Turnstile script errors from same root cause ]; function isAllowedConsoleNoise(text) { return CONSOLE_ERROR_ALLOWLIST.some(re => re.test(text)); } // ── Auth — module-scoped token cache ──────────────────────── // Keeps one login per account per worker to avoid the 10/15-min login // rate-limiter. Keyed by email, because there is more than one account now and // a single slot would have each login evicting the other's token. const _tokenCache = new Map(); async function tokenFor(request, email) { if (_tokenCache.has(email)) return _tokenCache.get(email); const r = await request.post(E2E_BASE + '/api/auth/login', { data: { email, password: TEST_PASSWORD }, }); if (!r.ok()) { const text = await r.text(); // The overwhelmingly likely cause is an unseeded database, and saying so // beats leaving someone to work back from a 401. throw new Error( `E2E login failed for ${email} (status ${r.status()}): ${text}\n` + 'If the account does not exist, seed it: docker exec pediatric-ai-scribe-e2e node e2e/seed.js' ); } const body = await r.json(); if (!body.token) throw new Error('Login response missing token: ' + JSON.stringify(body)); _tokenCache.set(email, body.token); return body.token; } async function getAuthToken(request) { return tokenFor(request, TEST_EMAIL); } async function getAdminToken(request) { return tokenFor(request, ADMIN_EMAIL); } async function loginAs(context, request, email = TEST_EMAIL) { const token = await tokenFor(request, email); const url = new URL(E2E_BASE); await context.addCookies([{ name: 'ped_auth', value: token, domain: url.hostname, path: '/', httpOnly: true, secure: false, sameSite: 'Lax', }]); } // A '**/api/x' glob stopped matching any URL when Playwright went to 1.50, and // page.route fails silently: no error, no warning, the request simply goes to // the server. So every "mocked" AI test was calling the real model and // comparing its genuine output against a canned string — spending real credits // on every run and failing for a reason that looked like a UI bug. Measured: // against http://127.0.0.1:3553/api/health, '**/api/health' and '*/**/api/health' // both matched zero times; a regex matched. // // The patterns are kept as strings because they are also the keys callers pass // in `overrides`, and turned into anchored regexes here. function asMatcher(pattern) { const path = pattern.replace(/^\*\*/, ''); return new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[?#]|$)'); } // ── AI mock — intercepts generation endpoints ────────────── // Canned response shape matches what each route's frontend expects. // Override per-test by passing {pattern: responseFn} in overrides. async function mockAI(page, overrides = {}) { if (USE_REAL_AI || overrides.real) return; // opt-out to hit real backend const routes = [ { pattern: '**/api/generate-soap', response: { success: true, soap: 'MOCK SOAP NOTE.\nSubjective: ...\nObjective: ...\nAssessment: ...\nPlan: ...', model: 'mock-gpt' } }, { pattern: '**/api/generate-hpi-encounter', response: { success: true, hpi: 'MOCK HPI from encounter.', model: 'mock-gpt' } }, { pattern: '**/api/generate-hpi-dictation', response: { success: true, hpi: 'MOCK HPI from dictation.', model: 'mock-gpt' } }, { pattern: '**/api/sick-visit/note', response: { success: true, note: 'MOCK sick visit note.', model: 'mock-gpt' } }, { pattern: '**/api/well-visit/note', response: { success: true, note: 'MOCK well visit note.', model: 'mock-gpt' } }, { pattern: '**/api/generate-hospital-course', response: { success: true, hospitalCourse: 'MOCK hospital course narrative.', format: 'auto', model: 'mock-gpt' } }, { pattern: '**/api/generate-milestone-narrative', response: { success: true, narrative: 'MOCK developmental narrative.', model: 'mock-gpt', summary: { achieved: 3, notAchieved: 0, notAssessed: 0 } } }, { pattern: '**/api/generate-milestone-summary', response: { success: true, summary: 'MOCK 3-sentence summary.', model: 'mock-gpt' } }, { pattern: '**/api/generate-pe-narrative', response: { success: true, narrative: 'Technique:\nMOCK technique.\n\nFindings:\nMOCK findings.', model: 'mock-gpt', summary: { normal: 2, abnormal: 0, notAssessed: 0 } } }, { pattern: '**/api/generate-chart-review', response: { success: true, review: 'MOCK chart review.', model: 'mock-gpt' } }, { pattern: '**/api/well-visit/shadess', response: { success: true, assessment: 'MOCK SSHADESS assessment.', model: 'mock-gpt' } }, { pattern: '**/api/refine', response: { success: true, refined: 'MOCK refined content.', model: 'mock-gpt' } }, { pattern: '**/api/suggest-billing-codes', response: { success: true, icd10: [], cpt: [], model: 'mock-gpt' } }, { pattern: '**/api/transcribe', response: { success: true, transcript: 'MOCK transcribed text.' } }, { pattern: '**/api/tts', response: { success: true, audioBase64: '' } }, ]; for (const { pattern, response } of routes) { const override = overrides[pattern]; await page.route(asMatcher(pattern), async route => { const resp = typeof override === 'function' ? await override(route.request()) : (override || response); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(resp) }); }); } } // ── Error guards — auto-applied via extended test ────────── // Any uncaught page JS error or unhandled console.error fails the test. // This is the safety net for bugs like the SSO ReferenceError. const test = base.test.extend({ // Replace the default `page` with one that has listeners wired before // any navigation happens. page: async ({ page }, use) => { const errors = []; const consoleErrors = []; page.on('pageerror', err => { // Same allowlist applies to pageerror — third-party scripts (Turnstile) // can throw uncaught errors that are expected on the e2e host. const msg = err && (err.message || String(err)); if (isAllowedConsoleNoise(msg)) return; errors.push(err); }); page.on('console', msg => { if (msg.type() !== 'error') return; const text = msg.text(); if (isAllowedConsoleNoise(text)) return; consoleErrors.push(text); }); await use(page); // After the test finishes, fail if any uncaught errors accumulated. if (errors.length > 0) { throw new Error( 'Uncaught page error(s) during test:\n' + errors.map(e => ' - ' + e.message + '\n ' + (e.stack || '').split('\n').slice(0, 3).join('\n ')).join('\n') ); } if (consoleErrors.length > 0) { throw new Error( 'console.error() during test:\n' + consoleErrors.map(t => ' - ' + t).join('\n') ); } }, // Pre-authed page — login before use. authedPage: async ({ page, context, request }, use) => { await loginAs(context, request); await use(page); }, // The same thing signed in as an administrator, for the screens an ordinary // account cannot reach at all. adminPage: async ({ page, context, request }, use) => { await loginAs(context, request, ADMIN_EMAIL); await use(page); }, }); const expect = base.expect; module.exports = { test, expect, E2E_BASE, TEST_EMAIL, TEST_PASSWORD, ADMIN_EMAIL, loginAs, getAuthToken, getAdminToken, mockAI, USE_REAL_AI, };