diff --git a/.gitignore b/.gitignore index 651dcbc..723bf9f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,8 @@ android/.idea/ public/models/ .env.backup-* *.env.backup* + +# e2e test artifacts (keep config + specs, skip results + installed deps) +e2e/node_modules/ +e2e/test-results/ +e2e/playwright-report/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..f914c48 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "ped-ai-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ped-ai-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "1.50.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.0.tgz", + "integrity": "sha512-ZGNXbt+d65EGjBORQHuYKj+XhCewlwpnSd/EDuLPZGSiEWmgOJB5RmMCCYGy5aMfTs9wx61RivfDKi8H/hcMvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.50.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz", + "integrity": "sha512-+GinGfGTrd2IfX1TA4N2gNmeIksSb+IAe589ZH+FlmpV3MYTx6+buChGIuDLQwrGNCw2lWibqV50fU510N7S+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.50.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.0.tgz", + "integrity": "sha512-CXkSSlr4JaZs2tZHI40DsZUN/NIwgaUPsyLuOAaIZp2CyF2sN5MM5NJsyB188lFSSozFxQ5fPT4qM+f0tH/6wQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..be93b79 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,9 @@ +{ + "name": "ped-ai-e2e", + "version": "1.0.0", + "description": "End-to-end smoke tests for PedScribe. Runs inside an official Playwright container; no host Node needed.", + "private": true, + "devDependencies": { + "@playwright/test": "1.50.0" + } +} diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js new file mode 100644 index 0000000..bbe9f0d --- /dev/null +++ b/e2e/playwright.config.js @@ -0,0 +1,24 @@ +// Playwright config — runs smoke tests against the already-running PedScribe +// container (no dev server spin-up). Expects BASE_URL (default +// http://host.docker.internal:3552 when run via scripts/e2e.sh). +const { defineConfig, devices } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: false, + retries: 0, + workers: 1, + reporter: [['list']], + use: { + baseURL: process.env.BASE_URL || 'http://host.docker.internal:3552', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + actionTimeout: 5_000, + navigationTimeout: 15_000, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); diff --git a/e2e/tests/bedside-smoke.spec.js b/e2e/tests/bedside-smoke.spec.js new file mode 100644 index 0000000..2793e43 --- /dev/null +++ b/e2e/tests/bedside-smoke.spec.js @@ -0,0 +1,147 @@ +// Bedside module smoke tests. +// Each test navigates to the Calculators tab, opens Bedside, clicks the target +// sub-pill, and asserts a known string is rendered. Catches "button doesn't +// fire / section goes blank" regressions. + +const { test, expect } = require('@playwright/test'); + +// Helper — open Calculators tab + Bedside panel + (optionally) a sub-pill +async function openCalculators(page) { + await page.goto('/e2e-harness.html'); + await page.waitForFunction(() => window.__harnessReady === true); + // Give the async innerHTML injection + JS init one more microtask tick + await page.waitForSelector('button.calc-nav-pill[data-calc="bedside"]'); +} + +async function openBedside(page, subPill) { + await openCalculators(page); + await page.click('button.calc-nav-pill[data-calc="bedside"]'); + if (subPill) { + await page.click(`button.calc-pill[data-em="${subPill}"]`); + } +} + +test.describe('Bedside — top-level', () => { + test('Calculators tab shows age-weight estimator', async ({ page }) => { + await openCalculators(page); + await expect(page.locator('#bedside-age')).toBeVisible(); + await expect(page.locator('#bedside-formula')).toBeVisible(); + await expect(page.locator('#bedside-weight')).toBeVisible(); + }); + + test('Age → Weight: typing 3y auto-fills weight (APLS)', async ({ page }) => { + await openCalculators(page); + await page.fill('#bedside-age', '3y'); + await expect(page.locator('#bedside-weight')).toHaveValue('14'); + await expect(page.locator('#bedside-estimate-note')).toContainText('APLS'); + }); + + test('Formula switch to Best Guess updates weight', async ({ page }) => { + await openCalculators(page); + await page.fill('#bedside-age', '3y'); + await page.selectOption('#bedside-formula', 'bestguess'); + await expect(page.locator('#bedside-weight')).toHaveValue('16'); // 2 * (3+5) = 16 + await expect(page.locator('#bedside-estimate-note')).toContainText('Best Guess'); + }); + + test('Clear button resets the estimator', async ({ page }) => { + await openCalculators(page); + await page.fill('#bedside-age', '5y'); + await page.click('#btn-bedside-clear'); + await expect(page.locator('#bedside-age')).toHaveValue(''); + await expect(page.locator('#bedside-weight')).toHaveValue(''); + }); +}); + +test.describe('Bedside — every sub-pill renders', () => { + const subPills = [ + { key: 'neonatal', expected: /Neonatal Assessment/i }, + { key: 'airway', expected: /Airway Management/i }, + { key: 'cardiac', expected: /Cardiac Arrest/i }, + { key: 'respiratory', expected: /Respiratory Management/i }, + { key: 'ventilation', expected: /Oxygen & Ventilation/i }, + { key: 'seizure', expected: /Status Epilepticus/i }, + { key: 'sepsis', expected: /Sepsis & Fever/i }, + { key: 'anaphylaxis', expected: /Anaphylaxis/i }, + { key: 'sedation', expected: /Procedural Sedation/i }, + { key: 'agitation', expected: /Acute Agitation/i }, + { key: 'antiemetics', expected: /Antiemetics/i }, + { key: 'antimicrobials', expected: /Empiric Antimicrobials/i }, + { key: 'burns', expected: /Burn Management/i }, + { key: 'toxicology', expected: /Toxicology/i }, + { key: 'trauma', expected: /Trauma/i }, + ]; + + for (const { key, expected } of subPills) { + test(`${key} sub-pill shows header`, async ({ page }) => { + await openBedside(page, key); + await expect(page.locator(`#em-${key}`)).toContainText(expected); + }); + } +}); + +test.describe('Bedside — dose calculators fire', () => { + test('Status Epilepticus: Show Pathway renders timeline with weight', async ({ page }) => { + await openBedside(page, 'seizure'); + await page.fill('#seizure-weight', '20'); + await page.click('#btn-seizure-calc'); + await expect(page.locator('#seizure-result')).toContainText(/20 kg/); + await expect(page.locator('#seizure-result')).toContainText(/Lorazepam/); + await expect(page.locator('#seizure-result')).toContainText(/0\.1 mg\/kg/); // per-kg visible + }); + + test('Sepsis: Show Approach renders Phoenix criteria + first-hour bundle', async ({ page }) => { + await openBedside(page, 'sepsis'); + await page.fill('#sepsis-weight', '25'); + await page.click('#btn-sepsis-show'); + await expect(page.locator('#sepsis-result')).toContainText(/Phoenix/); + await expect(page.locator('#sepsis-result')).toContainText(/first-hour/i); + }); + + test('Anaphylaxis: Calculate Doses shows weight-based epinephrine', async ({ page }) => { + await openBedside(page, 'anaphylaxis'); + await page.fill('#anaph-weight', '25'); + await page.click('#btn-anaph-calc'); + await expect(page.locator('#anaph-result')).toContainText(/Epinephrine/); + await expect(page.locator('#anaph-result')).toContainText(/0\.25 mg/); // 25*0.01 + }); + + test('Burns: body-parts calculator + Parkland', async ({ page }) => { + await openBedside(page, 'burns'); + await page.fill('#burn-weight', '20'); + await page.fill('input[data-burn-region="head"]', '50'); // 50% of 13 (young) = 6.5 + await page.fill('input[data-burn-region="ant_trunk"]', '100'); // 100% of 13 = 13 + await page.click('#btn-burn-calc'); + await expect(page.locator('#burn-result')).toContainText(/Parkland/); + await expect(page.locator('#burn-result')).toContainText(/20 kg/); + }); + + test('Airway: Calculate renders RSI drugs with per-kg', async ({ page }) => { + await openBedside(page, 'airway'); + await page.fill('#airway-weight', '20'); + await page.fill('#airway-age', '5'); + await page.click('#btn-airway-calc'); + await expect(page.locator('#airway-result')).toContainText(/Ketamine/); + await expect(page.locator('#airway-result')).toContainText(/mg\/kg/); // per-kg visible + await expect(page.locator('#airway-result')).toContainText(/ETT/); + }); +}); + +test.describe('Bedside — interactive widgets', () => { + test('Lightbox: View pathway image button opens modal', async ({ page }) => { + await openBedside(page, 'seizure'); + await page.click('button[data-img-src="/img/epilepsy_eiic_pathway.png"]'); + await expect(page.locator('#img-lightbox')).toBeVisible(); + await page.click('#img-lightbox-close'); + await expect(page.locator('#img-lightbox')).toBeHidden(); + }); + + test('Ventilation: Show Reference renders pressure-time SVG', async ({ page }) => { + await openBedside(page, 'ventilation'); + await page.fill('#vent-weight', '20'); + await page.click('#btn-vent-show'); + await expect(page.locator('#vent-result svg')).toBeVisible(); + await expect(page.locator('#vent-result')).toContainText(/Target SpO2/); + await expect(page.locator('#vent-result')).toContainText(/PEEP/); + }); +}); diff --git a/package.json b/package.json index 1f4dfc9..6fc4e65 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "node server.js", "test": "node --test test/", + "e2e": "./scripts/e2e.sh", "maint:check": "node scripts/maintenance.js check", "maint:reindex": "node scripts/maintenance.js reindex", "migrate": "node-pg-migrate", diff --git a/public/e2e-harness.html b/public/e2e-harness.html new file mode 100644 index 0000000..e1aa96b --- /dev/null +++ b/public/e2e-harness.html @@ -0,0 +1,22 @@ + + + +
+ +