test(e2e): shared fixture with pageerror + console-error guards + Extensions CRUD suite
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.
This commit is contained in:
parent
651f799c17
commit
8ea79c7f30
2 changed files with 393 additions and 0 deletions
157
e2e/fixtures.js
Normal file
157
e2e/fixtures.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
236
e2e/tests/extensions-crud.spec.js
Normal file
236
e2e/tests/extensions-crud.spec.js
Normal file
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue