pediatric-ai-scribe-v3/e2e/tests/extensions-crud.spec.js
Daniel 8ea79c7f30 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.
2026-04-22 19:27:05 +02:00

236 lines
9.9 KiB
JavaScript

// ============================================================
// 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/);
});
});