8 new spec files covering sections previously only smoke-tested:
- ai-endpoints-contract.spec.js — hits 8 real AI endpoints via request
context and fails if the response leaks TypeError / ReferenceError /
'Cannot read properties of undefined' / 'is not defined' / 'is not a
function'. This is the class of bug that shipped the PE-narrative
regression to prod because every page-level mock prevented the real
handler from running.
- encounter-workflow.spec.js — generate HPI, refine, clear transcript.
- encounter-save-load.spec.js — save draft, load popover, repopulate.
- wellvisit-workflow.spec.js — byvisit, milestones, SSHADESS (12+
reveal), visit note.
- vaxschedule-content.spec.js — schedule + catch-up panels populate
beyond "Loading".
- chart-review-workflow.spec.js — generate + load popover.
- learning-tab.spec.js — search filter, category pills, feed.
- settings-faq-dictation.spec.js — voice/password/2FA/Nextcloud
sections, FAQ expand/collapse, dictation generate flow.
Baseline fixes:
- Added CORS_ORIGINS + API_RATE_LIMIT_MAX env overrides so the e2e
container accepts the browser's Origin header and can absorb the
full suite's API traffic without tripping the 200/min guard.
- Server's /api/ rate limit is now configurable via
API_RATE_LIMIT_MAX (default stays 200).
- extensions-crud: replaced native page.on('dialog') listeners with
#confirm-modal-ok clicks (we moved off native confirm()).
- pe-guide-smoke + extensions-crud: mobile viewport opens the hamburger
before clicking sidebar tabs.
- fixtures.js: /api/refine mock uses 'refined' (real API shape), not
'content'. /api/chart-review replaced with /api/generate-chart-review.
Suite: 230 passed / 0 failed in 4m36s.
240 lines
10 KiB
JavaScript
240 lines
10 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 });
|
|
const vp = page.viewportSize();
|
|
if (vp && vp.width <= 768) {
|
|
await page.click('#btn-menu-toggle').catch(() => {});
|
|
}
|
|
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' });
|
|
|
|
await page.locator('.ext-card button[data-ext-action="delete"]').first().click();
|
|
// In-app modal — click confirm
|
|
await page.click('#confirm-modal-ok');
|
|
|
|
// 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' });
|
|
|
|
await page.locator('.ext-card button[data-ext-action="delete"]').first().click();
|
|
await page.click('#confirm-modal-ok');
|
|
|
|
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' });
|
|
|
|
await page.locator('.ext-card button[data-ext-action="delete"]').first().click();
|
|
await page.click('#confirm-modal-ok');
|
|
|
|
await page.click('#ext-trash-btn');
|
|
await expect(page.locator('.ext-card')).toContainText('Gone forever');
|
|
|
|
await page.locator('.ext-card button[data-ext-action="purge"]').first().click();
|
|
await page.click('#confirm-modal-ok');
|
|
|
|
// 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 modal
|
|
await page.locator('.ext-card button[data-ext-action="delete"]').first().click();
|
|
await page.click('#confirm-modal-cancel');
|
|
|
|
// 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/);
|
|
});
|
|
});
|