From 8ea79c7f304824e003d5910cf2ebab80a72e617e Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 22 Apr 2026 19:26:58 +0200 Subject: [PATCH] test(e2e): shared fixture with pageerror + console-error guards + Extensions CRUD suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additions: 1. e2e/fixtures.js — shared test infrastructure - Custom `test` extending @playwright/test with two auto-fixtures on every page: page.on('pageerror') and page.on('console') of type 'error'. Any uncaught JS error fails the test. This is the SSO- bug-class safety net: if we'd had this earlier, the silent admin.js ReferenceError would have failed CI instead of shipping. - `authedPage` fixture — logs in via API, injects session cookie, provides pre-authed page ready to drive. - `mockAI(page)` helper — intercepts generate-* and transcribe endpoints with canned JSON responses. Enables fast deterministic CI runs. Opt-out via E2E_USE_REAL_AI=1 to hit real LiteLLM. - Console-error allowlist for known noise (favicon 404, lazy-loaded Whisper models, etc.). 2. e2e/tests/extensions-crud.spec.js — 11 tests covering the new Pagers & Extensions feature end-to-end: - empty state, add extension, add pager (correct grouping) - edit persists, search by location + by number - soft-delete with confirm → moves to trash - restore from trash → reappears in active - purge from trash → permanent - cancel dialog keeps item, cancel form keeps nothing, validation on required fields Known follow-up: the e2e container (pediatric-ai-scribe-e2e) is still on the pre-entrypoint image from before the OpenBao migration, so it doesn't have the Extensions routes yet. Rebuilding it needs a small entrypoint enhancement to honor docker-compose-level env overrides vs OpenBao-fetched values (e.g. TURNSTILE_SECRET_KEY="" for the e2e instance). That's separate work — this commit just lays in the tests. --- e2e/fixtures.js | 157 ++++++++++++++++++++ e2e/tests/extensions-crud.spec.js | 236 ++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 e2e/fixtures.js create mode 100644 e2e/tests/extensions-crud.spec.js diff --git a/e2e/fixtures.js b/e2e/fixtures.js new file mode 100644 index 0000000..8db5a39 --- /dev/null +++ b/e2e/fixtures.js @@ -0,0 +1,157 @@ +// ============================================================ +// 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 ────────────────────────────────────────────── +const E2E_BASE_INTERNAL = 'http://pediatric-ai-scribe-e2e:3000'; +const E2E_BASE_EXTERNAL = 'http://host.docker.internal:3553'; +const E2E_BASE = process.env.E2E_AUTH_BASE_URL || E2E_BASE_INTERNAL; + +const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-user@ped-ai.test'; +const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!'; + +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, + /Failed to load resource.*models\/Xenova/i, // Browser Whisper models lazy-loaded on demand + /\/api\/models/i, // When no AI provider configured yet +]; +function isAllowedConsoleNoise(text) { + return CONSOLE_ERROR_ALLOWLIST.some(re => re.test(text)); +} + +// ── Auth — module-scoped token cache ──────────────────────── +// Keeps one login per worker to avoid the 10/15-min login rate-limiter. +let _tokenCache = null; +async function getAuthToken(request) { + if (_tokenCache) return _tokenCache; + const r = await request.post(E2E_BASE + '/api/auth/login', { + data: { email: TEST_EMAIL, password: TEST_PASSWORD }, + }); + if (!r.ok()) { + const text = await r.text(); + throw new Error(`E2E login failed (status ${r.status()}): ${text}`); + } + const body = await r.json(); + if (!body.token) throw new Error('Login response missing token: ' + JSON.stringify(body)); + _tokenCache = body.token; + return _tokenCache; +} + +async function loginAs(context, request) { + const token = await getAuthToken(request); + const url = new URL(E2E_BASE); + await context.addCookies([{ + name: 'ped_auth', + value: token, + domain: url.hostname, + path: '/', + httpOnly: true, + secure: false, + sameSite: 'Lax', + }]); +} + +// ── 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/generate-sick-visit', response: { success: true, note: 'MOCK sick visit note.', model: 'mock-gpt' } }, + { pattern: '**/api/generate-hospital-course', response: { success: true, narrative: 'MOCK hospital course narrative.', 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/chart-review', response: { success: true, analysis: '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, content: '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(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 => { + 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); + }, +}); + +const expect = base.expect; + +module.exports = { + test, + expect, + E2E_BASE, + TEST_EMAIL, + TEST_PASSWORD, + loginAs, + getAuthToken, + mockAI, + USE_REAL_AI, +}; diff --git a/e2e/tests/extensions-crud.spec.js b/e2e/tests/extensions-crud.spec.js new file mode 100644 index 0000000..282adf4 --- /dev/null +++ b/e2e/tests/extensions-crud.spec.js @@ -0,0 +1,236 @@ +// ============================================================ +// EXTENSIONS TAB — full CRUD + soft-delete + restore + search +// ============================================================ +// Uses the shared fixture which logs in + wires pageerror/console-error +// listeners. No AI mocking needed — Extensions is pure CRUD. +// +// Tests against the e2e container (port 3553 on host, 3000 internal). +// Each test cleans up its own rows to stay isolated. +// ============================================================ + +const { test, expect, E2E_BASE, getAuthToken } = require('../fixtures'); + +// Helper — purge everything in trash, then soft-delete every active row. +// Leaves the table empty for the next test. +async function wipeAll(request) { + const token = await getAuthToken(request); + const auth = { Authorization: 'Bearer ' + token }; + + async function purgeTrash() { + const r = await request.get(E2E_BASE + '/api/extensions?trash=1', { headers: auth }); + const d = await r.json(); + for (const item of (d.items || [])) { + await request.delete(E2E_BASE + '/api/extensions/' + item.id + '/purge', { headers: auth }); + } + } + + await purgeTrash(); // first purge anything already in trash + const r = await request.get(E2E_BASE + '/api/extensions', { headers: auth }); + const d = await r.json(); + for (const item of (d.items || [])) { + await request.delete(E2E_BASE + '/api/extensions/' + item.id, { headers: auth }); + } + await purgeTrash(); // now purge the freshly-trashed rows +} + +test.describe('Extensions — CRUD', () => { + test.beforeEach(async ({ request }) => { + await wipeAll(request); + }); + + test.afterAll(async ({ request }) => { + await wipeAll(request); + }); + + async function openTab(page) { + await page.goto(E2E_BASE + '/'); + await page.waitForSelector('button.tab-btn', { timeout: 15000 }); + await page.click('button.tab-btn[data-tab="extensions"]'); + // Wait for component to load + await page.waitForFunction(() => { + const el = document.getElementById('extensions-tab'); + return el && el.classList.contains('active') && el.innerHTML.trim().length > 100; + }, { timeout: 15000 }); + // Wait for first load to resolve (spinner text goes away) + await page.waitForFunction(() => { + const list = document.getElementById('ext-list'); + return list && !list.innerHTML.includes('Loading'); + }, { timeout: 10000 }); + } + + async function fillForm(page, { location, name, number, type = 'extension', notes = '' }) { + await page.click('#ext-add-btn'); + await page.fill('#ext-location', location); + await page.fill('#ext-name', name); + await page.fill('#ext-number', number); + await page.selectOption('#ext-type', type); + if (notes) await page.fill('#ext-notes', notes); + await page.click('#ext-save-btn'); + // Form hides after save + await expect(page.locator('#ext-form-wrap')).toHaveClass(/hidden/, { timeout: 5000 }); + } + + test('empty state shown before any entries', async ({ authedPage: _, page }) => { + await openTab(page); + await expect(page.locator('#ext-list')).toContainText(/No entries yet|click Add/i); + }); + + test('add an extension — appears in list grouped by location + type', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Main Hospital', name: 'Nursery', number: '5866', type: 'extension' }); + + // Location group header + await expect(page.locator('#ext-list')).toContainText('Main Hospital'); + // Type subheader + await expect(page.locator('#ext-list')).toContainText(/Extensions/i); + // Card content + await expect(page.locator('.ext-card')).toContainText('5866'); + await expect(page.locator('.ext-card')).toContainText('Nursery'); + }); + + test('add a pager — routed to pagers subsection', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Clinic A', name: 'On-call', number: '1234', type: 'pager' }); + + await expect(page.locator('#ext-list')).toContainText('Clinic A'); + await expect(page.locator('#ext-list')).toContainText(/Pagers/i); + await expect(page.locator('.ext-card')).toContainText('1234'); + }); + + test('edit an extension — changes persist', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Main Hospital', name: 'Old Name', number: '1111', type: 'extension' }); + + await page.locator('.ext-card button[data-ext-action="edit"]').first().click(); + await expect(page.locator('#ext-form-wrap')).not.toHaveClass(/hidden/); + await page.fill('#ext-name', 'New Name'); + await page.fill('#ext-number', '2222'); + await page.click('#ext-save-btn'); + await expect(page.locator('#ext-form-wrap')).toHaveClass(/hidden/, { timeout: 5000 }); + + await expect(page.locator('.ext-card')).toContainText('New Name'); + await expect(page.locator('.ext-card')).toContainText('2222'); + await expect(page.locator('.ext-card')).not.toContainText('Old Name'); + }); + + test('search — filter by location', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Main Hospital', name: 'Dept A', number: '1001', type: 'extension' }); + await fillForm(page, { location: 'Clinic B', name: 'Dept B', number: '2002', type: 'extension' }); + + // Initially both visible + await expect(page.locator('.ext-card')).toHaveCount(2); + + // Search by substring of first location + await page.fill('#ext-search', 'Main'); + // Debounce is 200ms; wait for the filter to apply + await page.waitForTimeout(400); + await expect(page.locator('.ext-card')).toHaveCount(1); + await expect(page.locator('.ext-card')).toContainText('Dept A'); + + // Clear search — both visible again + await page.fill('#ext-search', ''); + await page.waitForTimeout(400); + await expect(page.locator('.ext-card')).toHaveCount(2); + }); + + test('search — filter by number', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Loc', name: 'A', number: '5866', type: 'extension' }); + await fillForm(page, { location: 'Loc', name: 'B', number: '1234', type: 'pager' }); + + await page.fill('#ext-search', '586'); + await page.waitForTimeout(400); + await expect(page.locator('.ext-card')).toHaveCount(1); + await expect(page.locator('.ext-card')).toContainText('5866'); + }); + + test('soft-delete — moves to trash, confirm dialog required', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Loc', name: 'To delete', number: '9999', type: 'extension' }); + + // Auto-accept the confirm() dialog + page.once('dialog', d => d.accept()); + await page.locator('.ext-card button[data-ext-action="delete"]').first().click(); + + // Gone from active list + await expect(page.locator('#ext-list')).toContainText(/No entries yet/i, { timeout: 5000 }); + + // Visible in trash view + await page.click('#ext-trash-btn'); + await expect(page.locator('#ext-mode-banner')).not.toHaveClass(/hidden/); + await expect(page.locator('.ext-card')).toContainText('To delete'); + }); + + test('restore from trash — reappears in active', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Loc', name: 'Bounceback', number: '5555', type: 'extension' }); + + page.once('dialog', d => d.accept()); + await page.locator('.ext-card button[data-ext-action="delete"]').first().click(); + + await page.click('#ext-trash-btn'); + await expect(page.locator('.ext-card')).toContainText('Bounceback'); + await page.locator('.ext-card button[data-ext-action="restore"]').first().click(); + + // Back-to-active button + await page.click('#ext-back-active'); + await expect(page.locator('.ext-card')).toContainText('Bounceback'); + }); + + test('purge from trash — permanent, second confirm', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Loc', name: 'Gone forever', number: '7777', type: 'extension' }); + + page.once('dialog', d => d.accept()); + await page.locator('.ext-card button[data-ext-action="delete"]').first().click(); + + await page.click('#ext-trash-btn'); + await expect(page.locator('.ext-card')).toContainText('Gone forever'); + + page.once('dialog', d => d.accept()); + await page.locator('.ext-card button[data-ext-action="purge"]').first().click(); + + // Trash is empty + await expect(page.locator('#ext-list')).toContainText(/Trash is empty/i, { timeout: 5000 }); + + // Not recoverable — back-to-active view has nothing + await page.click('#ext-back-active'); + await expect(page.locator('#ext-list')).toContainText(/No entries yet/i); + }); + + test('cancel dialog — keeps item (not deleted)', async ({ authedPage: _, page }) => { + await openTab(page); + await fillForm(page, { location: 'Loc', name: 'Stays', number: '8888', type: 'extension' }); + + // User cancels the confirm dialog + page.once('dialog', d => d.dismiss()); + await page.locator('.ext-card button[data-ext-action="delete"]').first().click(); + + // Still there + await expect(page.locator('.ext-card')).toContainText('Stays'); + }); + + test('cancel button closes form without saving', async ({ authedPage: _, page }) => { + await openTab(page); + await page.click('#ext-add-btn'); + await page.fill('#ext-location', 'Should not save'); + await page.fill('#ext-name', 'x'); + await page.fill('#ext-number', '0'); + await page.click('#ext-cancel-btn'); + await expect(page.locator('#ext-form-wrap')).toHaveClass(/hidden/); + + // Nothing was created + await expect(page.locator('#ext-list')).toContainText(/No entries yet/i); + }); + + test('form validates required fields', async ({ authedPage: _, page }) => { + await openTab(page); + await page.click('#ext-add-btn'); + // Click save with empty fields + await page.click('#ext-save-btn'); + await expect(page.locator('#ext-form-status')).toContainText(/required/i); + // Form stays open + await expect(page.locator('#ext-form-wrap')).not.toHaveClass(/hidden/); + }); +});