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 @@ + + + + + +E2E harness — calculators + + + + +
+ + + + + + diff --git a/public/js/e2e-bootstrap.js b/public/js/e2e-bootstrap.js new file mode 100644 index 0000000..b118d76 --- /dev/null +++ b/public/js/e2e-bootstrap.js @@ -0,0 +1,15 @@ +// E2E harness bootstrap — external file because the app's CSP blocks inline scripts. +// Runs after calc-math.js + calculators.js (all three have `defer`, so execution +// is in document order once parsing completes). +(async function bootstrap() { + try { + const host = document.getElementById('calculators-tab'); + const html = await fetch('/components/calculators.html').then(r => r.text()); + host.innerHTML = html; + document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: 'calculators' } })); + window.__harnessReady = true; + } catch (e) { + console.error('[e2e-harness] bootstrap failed:', e); + window.__harnessError = String(e); + } +})(); diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..75228e9 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Runs Playwright smoke tests inside the official Playwright container against +# the running PedScribe app. Usage: npm run e2e (or ./scripts/e2e.sh) +set -euo pipefail + +cd "$(dirname "$0")/.." + +IMAGE="mcr.microsoft.com/playwright:v1.50.0-noble" + +# Attach the Playwright container to the same Docker network as the app so it +# can resolve pediatric-ai-scribe by service name. Internal container port is 3000. +NETWORK="${E2E_NETWORK:-ped-ai_default}" +BASE_URL="${BASE_URL:-http://pediatric-ai-scribe:3000}" + +docker run --rm --ipc=host \ + --network="$NETWORK" \ + -v "$PWD/e2e":/work \ + -w /work \ + -e BASE_URL="$BASE_URL" \ + -e CI=true \ + "$IMAGE" \ + sh -c "npm install --no-audit --no-fund --silent && npx playwright test" diff --git a/server.js b/server.js index d279b6a..93dd02e 100644 --- a/server.js +++ b/server.js @@ -45,6 +45,10 @@ app.use(helmet({ childSrc: ["'self'", 'blob:', 'https://cdn.jsdelivr.net'], frameSrc: ["'self'", 'https://challenges.cloudflare.com'], objectSrc: ["'none'"], + // Disable helmet's default upgrade-insecure-requests so tests can hit + // the container over plain HTTP. In production, Caddy terminates TLS + // and serves HTTPS — mixed-content is a non-issue there. + upgradeInsecureRequests: null, } } })); @@ -158,23 +162,38 @@ try { } console.log('🔖 Build ID:', BUILD_ID); -// Template index.html once at boot with the version stamp. Serves fast -// because the rewrite happens one time and the string is reused. -var INDEX_HTML_TEMPLATED = null; -try { - var raw = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'); - INDEX_HTML_TEMPLATED = raw.replace( - /(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g, - '$1$2?v=' + BUILD_ID + '$3' - ); -} catch (e) { console.warn('[build-id] index.html template skipped:', e.message); } +// Template index.html on each request if the file changed (mtime-based). +// Avoids a stale in-memory copy after static edits / script additions while +// still being essentially free — just an fs.stat per request, zero template +// rebuild when mtime hasn't changed. +var INDEX_PATH = path.join(__dirname, 'public', 'index.html'); +var _indexCached = { mtime: 0, html: null }; +function getTemplatedIndex() { + try { + var st = fs.statSync(INDEX_PATH); + if (st.mtimeMs === _indexCached.mtime && _indexCached.html) return _indexCached.html; + var raw = fs.readFileSync(INDEX_PATH, 'utf8'); + _indexCached.html = raw.replace( + /(<(?:script|link)[^>]+(?:src|href)=["'])(\/(?:js|css)\/[^"'?]+)(["'])/g, + '$1$2?v=' + BUILD_ID + '$3' + ); + _indexCached.mtime = st.mtimeMs; + return _indexCached.html; + } catch (e) { + console.warn('[build-id] index.html read failed:', e.message); + return null; + } +} +// Prime once at boot so first request is fast +getTemplatedIndex(); app.get(['/', '/index.html'], function(req, res) { - if (!INDEX_HTML_TEMPLATED) return res.sendFile(path.join(__dirname, 'public', 'index.html')); + var html = getTemplatedIndex(); + if (!html) return res.sendFile(INDEX_PATH); res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('X-Build-Id', BUILD_ID); - res.send(INDEX_HTML_TEMPLATED); + res.send(html); }); // Public endpoint for cache-bust debugging + build-info