test(e2e): +44 tests — sick visit, hospital course, auth screen,

session persistence, per-tab model selector

Brings detailed coverage to sections that were previously only smoke-
tested:

- sickvisit-workflow.spec.js: generate → mocked note, refine bar
  round-trip, load popover, New button clears demographics + transcript.
- hospitalcourse-workflow.spec.js: fill H&P → generate → mocked
  narrative renders via /api/generate-hospital-course, refine updates
  text, load popover open/close.
- auth-screen.spec.js: unauthenticated landing visible, login form
  structure, forgot-password swap + return, register link is
  intentionally display:none on this instance (pinned), register form
  DOM still wired correctly if the link is manually unhidden, reg-
  password has minlength=8 + type=password.
- session-persistence.spec.js: clear cookie simulates logout (UI login
  is gated by Turnstile which can't be completed in the e2e container);
  re-login via loginAs restores the last tab + sub-pill via localStorage.
- model-selector.spec.js: tab-model-select dropdowns render with >0
  options across 7 tabs.

Fixture corrections:
- /api/sick-visit/note and /api/well-visit/note were not being mocked
  at all — the wrong /api/generate-sick-visit pattern was intercepting
  nothing, so tests fell through to the real AI backend. Both now have
  correct patterns and response shapes (sick-visit returns `note`,
  well-visit note returns `note`).
- /api/generate-hospital-course response key updated from `narrative`
  to `hospitalCourse` to match what the frontend actually reads.

wellvisit-workflow: the Visit Note test now asserts a concrete
waitForResponse on /api/well-visit/note + text render, instead of the
previous "hit either endpoint" fallback.

Suite: 294 passed / 0 failed in 5m30s.
This commit is contained in:
Daniel 2026-04-23 17:52:00 +02:00
parent 69dedbb635
commit 3e3b4866be
7 changed files with 374 additions and 7 deletions

View file

@ -81,8 +81,9 @@ async function mockAI(page, overrides = {}) {
{ 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/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 } } },

View file

@ -0,0 +1,73 @@
// ============================================================
// AUTH SCREEN — unauthenticated landing page structure.
// These tests do NOT use the `authedPage` fixture; they visit the
// app with a fresh context (no cookie) and assert the sign-in
// form + register + forgot-password transitions render correctly.
// ============================================================
const { test, expect, E2E_BASE } = require('../fixtures');
test.describe('Unauthenticated auth screen', () => {
// Use the base test that doesn't auto-login.
test('landing shows login form with email + password fields', async ({ page }) => {
await page.goto(E2E_BASE + '/');
await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#login-email')).toBeVisible();
await expect(page.locator('#login-password')).toBeVisible();
await expect(page.locator('#btn-local-login')).toBeVisible();
// main app body must be hidden while unauthenticated
await expect(page.locator('#main-app')).toBeHidden();
});
test('register link is present but currently disabled (display:none)', async ({ page }) => {
// Daniel's instance has invite-only registration — the "Create account"
// link is explicitly hidden via inline style, so the HTML is there but
// users can't reach the register form through the UI. Verify the hidden
// state so flipping the style to re-enable it fails loudly.
await page.goto(E2E_BASE + '/');
await page.waitForSelector('#auth-screen', { timeout: 10000 });
const display = await page.locator('#show-register').evaluate(el => el.style.display);
expect(display).toBe('none');
// The register form element still exists in the DOM for programmatic access
await expect(page.locator('#register-form')).toHaveCount(1);
});
test('register form DOM is wired correctly if manually unhidden', async ({ page }) => {
await page.goto(E2E_BASE + '/');
await page.waitForSelector('#auth-screen', { timeout: 10000 });
// Force the link visible so we can exercise the swap path — useful for
// future tests that want to validate the full register flow.
await page.locator('#show-register').evaluate(el => { el.style.display = ''; });
await page.click('#show-register');
await expect(page.locator('#register-form')).toBeVisible();
await expect(page.locator('#reg-name')).toBeVisible();
await expect(page.locator('#reg-email')).toBeVisible();
await expect(page.locator('#reg-password')).toBeVisible();
await page.click('#show-login');
await expect(page.locator('#login-form')).toBeVisible();
await expect(page.locator('#register-form')).toBeHidden();
});
test('clicking "Forgot password?" swaps to forgot form', async ({ page }) => {
await page.goto(E2E_BASE + '/');
await page.waitForSelector('#auth-screen', { timeout: 10000 });
await page.click('#show-forgot');
await expect(page.locator('#forgot-form')).toBeVisible();
await expect(page.locator('#forgot-email')).toBeVisible();
await expect(page.locator('#login-form')).toBeHidden();
// Back link returns to login
await page.click('#show-login-2');
await expect(page.locator('#login-form')).toBeVisible();
await expect(page.locator('#forgot-form')).toBeHidden();
});
test('password minlength enforces 8 chars in register form', async ({ page }) => {
await page.goto(E2E_BASE + '/');
await page.waitForSelector('#auth-screen', { timeout: 10000 });
// Attribute check doesn't require the element to be visible.
const pw = page.locator('#reg-password');
await expect(pw).toHaveAttribute('minlength', '8');
await expect(pw).toHaveAttribute('type', 'password');
});
});

View file

@ -0,0 +1,76 @@
// ============================================================
// HOSPITAL COURSE — full workflow: fill inputs → generate → verify
// mocked narrative renders → refine → verify rendered replaces.
// The older soap-hospital-workflow.spec.js only smokes the save bar;
// this one drives the actual AI generate path.
// ============================================================
const { test, expect, E2E_BASE, mockAI } = require('../fixtures');
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="hospital"]');
await page.waitForFunction(() => {
const el = document.getElementById('hospital-tab');
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
}, { timeout: 15000 });
}
test.describe('Hospital Course — generate + refine', () => {
test('minimum inputs → generate → mocked narrative renders', async ({ authedPage: _, page }) => {
await mockAI(page);
await openTab(page);
await page.fill('#hc-age', '9 years');
await page.selectOption('#hc-gender', 'Male');
await page.fill('#hc-pmh', 'Asthma, mild intermittent');
// H&P is the minimum "some note" the route needs so it doesn't reject
await page.locator('#hc-hp-content').click();
await page.keyboard.type('Admitted for status asthmaticus. Started on continuous albuterol and steroids.');
const [resp] = await Promise.all([
page.waitForResponse('**/api/generate-hospital-course', { timeout: 15000 }),
page.click('#hc-generate-btn'),
]);
expect(resp.status()).toBe(200);
await expect(page.locator('#hc-output')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#hc-course-text')).toContainText('MOCK hospital course narrative');
});
test('refine button on generated course fires /api/refine + updates text', async ({ authedPage: _, page }) => {
await mockAI(page, {
'**/api/refine': { success: true, refined: 'REFINED-HC course — emphasised hospital day 1 events.', model: 'mock-gpt' },
});
await openTab(page);
await page.fill('#hc-age', '5 years');
await page.locator('#hc-hp-content').click();
await page.keyboard.type('Admission for pneumonia, improving on IV cefriaxone.');
await page.click('#hc-generate-btn');
await expect(page.locator('#hc-course-text')).toContainText('MOCK hospital course', { timeout: 10000 });
// Refine with an instruction
const refineInput = page.locator('#hc-refine-input, [id*="hc-refine"]').first();
await refineInput.fill('Tighten the prose.');
const [resp] = await Promise.all([
page.waitForResponse('**/api/refine'),
page.click('#hc-refine-btn'),
]);
expect(resp.status()).toBe(200);
await expect(page.locator('#hc-course-text')).toContainText('REFINED-HC course', { timeout: 10000 });
});
test('load popover: opens and closes from both triggers', async ({ authedPage: _, page }) => {
await openTab(page);
await page.click('#btn-hosp-load');
await expect(page.locator('#hosp-load-popover')).not.toHaveClass(/hidden/, { timeout: 3000 });
await page.locator('#hosp-load-popover .enc-pop-close').click();
await expect(page.locator('#hosp-load-popover')).toHaveClass(/hidden/);
});
});

View file

@ -0,0 +1,43 @@
// ============================================================
// MODEL SELECTOR — each tab has its own <select.tab-model-select>
// populated by window._buildModelOptions. Verify the dropdowns
// render across tabs that should have one.
// ============================================================
const { test, expect, E2E_BASE } = require('../fixtures');
async function openTab(page, name) {
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="${name}"]`);
await page.waitForFunction((t) => {
const el = document.getElementById(t + '-tab');
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
}, name, { timeout: 15000 });
}
test.describe('Per-tab model selector', () => {
const tabsWithModelPicker = ['encounter', 'dictation', 'chart', 'soap', 'hospital', 'sickvisit', 'wellvisit'];
for (const tab of tabsWithModelPicker) {
test(`${tab}: model picker renders and has at least one option`, async ({ authedPage: _, page }) => {
await openTab(page, tab);
// Well Visit has four sub-panels each with their own picker; three of
// them are hidden until you switch to that sub-tab, so visibility is
// unreliable. Instead: require that the active tab contains at least
// one picker AND that at least one picker inside the tab has >0
// options populated by window._buildModelOptions.
const pickers = page.locator(`#${tab}-tab select.tab-model-select`);
await expect.poll(async () => pickers.count(), { timeout: 10000 })
.toBeGreaterThan(0);
await expect.poll(async () => {
return await pickers.evaluateAll(list => list.reduce((max, el) =>
Math.max(max, el.options ? el.options.length : 0), 0));
}, { timeout: 10000 }).toBeGreaterThan(0);
});
}
});

View file

@ -0,0 +1,86 @@
// ============================================================
// SESSION PERSISTENCE — full logout → login → still on the same
// tab + same sub-pill.
//
// The UI's login form is gated by a Cloudflare Turnstile token
// whose site key is hardcoded in index.html, which can't be
// completed in the e2e container (Turnstile rejects the non-prod
// origin). So the test does a programmatic logout (clear the
// ped_auth cookie, same effect server-side as clicking Logout)
// followed by a fresh programmatic login — this exercises the
// same localStorage persistence path a real logout/login would,
// without depending on the bot challenge.
// ============================================================
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
async function openDesktopTab(page, name) {
const vp = page.viewportSize();
if (vp && vp.width <= 768) {
await page.click('#btn-menu-toggle').catch(() => {});
}
await page.click(`button.tab-btn[data-tab="${name}"]`);
await page.waitForFunction((t) => {
const el = document.getElementById(t + '-tab');
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
}, name, { timeout: 15000 });
}
async function logoutClearsCookie(context) {
// Remove the ped_auth cookie — identical server-side to hitting /logout.
const cookies = await context.cookies();
const keep = cookies.filter(c => c.name !== 'ped_auth');
await context.clearCookies();
if (keep.length) await context.addCookies(keep);
}
test.describe('Logout → login restores last tab + sub-pill', () => {
test('tab choice + calc pill survive a full logout/login cycle', async ({ context, request, page }) => {
await loginAs(context, request);
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
await openDesktopTab(page, 'calculators');
await page.click('button.calc-nav-pill[data-calc="bili"]');
await expect(page.locator('button.calc-nav-pill[data-calc="bili"].active')).toBeVisible();
// Simulate logout (clear session cookie)
await logoutClearsCookie(context);
// Visiting the app with no cookie lands on the auth screen
await page.goto(E2E_BASE + '/');
await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 });
// Log back in (fresh cookie) and revisit the app
await loginAs(context, request);
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
// Restore must put us back on Calculators / Bilirubin pill
await expect(page.locator('#calculators-tab.active')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('button.calc-nav-pill[data-calc="bili"].active')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#calc-bili')).not.toHaveClass(/hidden/);
});
test('bedside sub-pill survives logout/login', async ({ context, request, page }) => {
await loginAs(context, request);
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
await openDesktopTab(page, 'bedside');
await page.click('button.calc-pill[data-em="sepsis"]');
await expect(page.locator('button.calc-pill[data-em="sepsis"].active')).toBeVisible();
await logoutClearsCookie(context);
await page.goto(E2E_BASE + '/');
await expect(page.locator('#auth-screen')).toBeVisible({ timeout: 10000 });
await loginAs(context, request);
await page.goto(E2E_BASE + '/');
await page.waitForSelector('button.tab-btn', { timeout: 15000 });
await expect(page.locator('#bedside-tab.active')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('button.calc-pill[data-em="sepsis"].active')).toBeVisible({ timeout: 10000 });
await expect.poll(async () =>
await page.locator('#em-sepsis').evaluate(el => el.style.display),
{ timeout: 5000 }).not.toBe('none');
});
});

View file

@ -0,0 +1,89 @@
// ============================================================
// SICK VISIT — detailed workflow tests
// Fills the form, generates a mocked note, verifies output render
// and the refine + clear flows.
// ============================================================
const { test, expect, E2E_BASE, mockAI } = require('../fixtures');
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="sickvisit"]');
await page.waitForFunction(() => {
const el = document.getElementById('sickvisit-tab');
return el && el.classList.contains('active') && el.innerHTML.trim().length > 100;
}, { timeout: 15000 });
}
test.describe('Sick Visit — generate workflow', () => {
test('fill demographics + CC + transcript → mocked note renders', async ({ authedPage: _, page }) => {
await mockAI(page);
await openTab(page);
await page.fill('#sick-age', '6 years');
await page.selectOption('#sick-gender', 'Male');
await page.fill('#sick-cc', 'Sore throat x 3 days');
await page.locator('#sick-transcript').click();
await page.keyboard.type('6yo with 3 days sore throat, fever, fatigue. No rash.');
const [resp] = await Promise.all([
page.waitForResponse('**/api/sick-visit/note', { timeout: 15000 }),
page.click('#btn-sick-generate'),
]);
expect(resp.status()).toBe(200);
await expect(page.locator('#sick-note-output')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#sick-note-text')).toContainText('MOCK sick visit note');
});
test('refine button fires /api/refine and updates the rendered note', async ({ authedPage: _, page }) => {
await mockAI(page, {
'**/api/refine': { success: true, refined: 'REFINED sick-visit note — added return precautions.', model: 'mock-gpt' },
});
await openTab(page);
await page.fill('#sick-age', '4 years');
await page.fill('#sick-cc', 'Cough');
await page.locator('#sick-transcript').click();
await page.keyboard.type('Cough x 2 days, no fever, hydrating OK.');
await page.click('#btn-sick-generate');
await expect(page.locator('#sick-note-text')).toContainText('MOCK sick visit note', { timeout: 10000 });
await page.fill('#sick-refine-input', 'Add return precautions.');
const [resp] = await Promise.all([
page.waitForResponse('**/api/refine'),
page.click('#sick-refine-btn'),
]);
expect(resp.status()).toBe(200);
await expect(page.locator('#sick-note-text')).toContainText('REFINED sick-visit note', { timeout: 10000 });
});
test('load popover opens and closes', async ({ authedPage: _, page }) => {
await openTab(page);
await page.click('#btn-sick-load');
await expect(page.locator('#sick-load-popover')).not.toHaveClass(/hidden/, { timeout: 3000 });
// Close by clicking the close button inside the popover
await page.locator('#sick-load-popover .enc-pop-close').click();
await expect(page.locator('#sick-load-popover')).toHaveClass(/hidden/);
});
test('New button clears demographics + transcript for a new patient', async ({ authedPage: _, page }) => {
await mockAI(page);
await openTab(page);
await page.fill('#sick-age', '5 years');
await page.selectOption('#sick-gender', 'Male');
await page.locator('#sick-transcript').click();
await page.keyboard.type('Temporary transcript');
await page.click('#btn-sick-new');
// clearTab() resets demographics + transcript (chief complaint is left so a
// rapid "next patient with same complaint" doesn't have to retype it).
await expect(page.locator('#sick-age')).toHaveValue('');
await expect(page.locator('#sick-gender')).toHaveValue('');
const txt = await page.locator('#sick-transcript').innerText();
expect(txt.trim()).toBe('');
});
});

View file

@ -123,12 +123,11 @@ test.describe('Well Visit — detailed workflows', () => {
await page.keyboard.type('Parent reports child is doing well; no concerns.');
const [resp] = await Promise.all([
page.waitForResponse('**/api/generate-sick-visit', { timeout: 15000 }).catch(() => null),
// Note: the well-visit generate button hits either /generate-sick-visit or a
// dedicated endpoint depending on the code — just ensure it doesn't crash.
page.waitForResponse('**/api/well-visit/note', { timeout: 15000 }),
page.click('#btn-wv-generate'),
]);
// The output container should become visible with SOME text (mocked)
await expect(page.locator('#wv-note-output')).toBeVisible({ timeout: 15000 });
expect(resp.status()).toBe(200);
await expect(page.locator('#wv-note-output')).toBeVisible({ timeout: 10000 });
await expect(page.locator('#wv-note-text')).toContainText('MOCK well visit note', { timeout: 10000 });
});
});