diff --git a/e2e/fixtures.js b/e2e/fixtures.js index 30adc1a..99a524c 100644 --- a/e2e/fixtures.js +++ b/e2e/fixtures.js @@ -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 } } }, diff --git a/e2e/tests/auth-screen.spec.js b/e2e/tests/auth-screen.spec.js new file mode 100644 index 0000000..9ade9dc --- /dev/null +++ b/e2e/tests/auth-screen.spec.js @@ -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'); + }); +}); diff --git a/e2e/tests/hospitalcourse-workflow.spec.js b/e2e/tests/hospitalcourse-workflow.spec.js new file mode 100644 index 0000000..b8abe94 --- /dev/null +++ b/e2e/tests/hospitalcourse-workflow.spec.js @@ -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/); + }); +}); diff --git a/e2e/tests/model-selector.spec.js b/e2e/tests/model-selector.spec.js new file mode 100644 index 0000000..a8cc6a6 --- /dev/null +++ b/e2e/tests/model-selector.spec.js @@ -0,0 +1,43 @@ +// ============================================================ +// MODEL SELECTOR — each tab has its own +// 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); + }); + } +}); diff --git a/e2e/tests/session-persistence.spec.js b/e2e/tests/session-persistence.spec.js new file mode 100644 index 0000000..b6e9c24 --- /dev/null +++ b/e2e/tests/session-persistence.spec.js @@ -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'); + }); +}); diff --git a/e2e/tests/sickvisit-workflow.spec.js b/e2e/tests/sickvisit-workflow.spec.js new file mode 100644 index 0000000..6e22c7a --- /dev/null +++ b/e2e/tests/sickvisit-workflow.spec.js @@ -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(''); + }); +}); diff --git a/e2e/tests/wellvisit-workflow.spec.js b/e2e/tests/wellvisit-workflow.spec.js index 6bd001d..c93be8f 100644 --- a/e2e/tests/wellvisit-workflow.spec.js +++ b/e2e/tests/wellvisit-workflow.spec.js @@ -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 }); }); });