feat: A1+A2 — Playwright smoke suite + index.html mtime-based caching

Safety net for upcoming refactors:
- 26 Playwright smoke tests via @playwright/test 1.50.0 in an official
  Playwright container (no host Node needed). Covers every Bedside sub-pill,
  the age→weight estimator, dose calculators (seizure/sepsis/anaphylaxis/
  burns/airway), and interactive widgets (lightbox, vent SVG).
- `npm run e2e` wrapper runs tests inside mcr.microsoft.com/playwright:v1.50.0-noble
  on the ped-ai_default Docker network so no host port mapping is needed.
- public/e2e-harness.html + public/js/e2e-bootstrap.js load the calculators
  component without the SPA auth wall (scripts external to satisfy CSP).

Server:
- server.js now re-reads public/index.html on mtime change instead of
  caching at boot. Fixes the "edit HTML, restart container" friction.
- CSP upgradeInsecureRequests disabled in helmet config; Caddy still
  enforces HTTPS at the reverse-proxy layer in production.
This commit is contained in:
Daniel 2026-04-20 03:51:16 +02:00
parent 441a872186
commit 1024b4741b
10 changed files with 354 additions and 12 deletions

5
.gitignore vendored
View file

@ -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/

78
e2e/package-lock.json generated Normal file
View file

@ -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"
}
}
}
}

9
e2e/package.json Normal file
View file

@ -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"
}
}

24
e2e/playwright.config.js Normal file
View file

@ -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'] } },
],
});

View file

@ -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/);
});
});

View file

@ -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",

22
public/e2e-harness.html Normal file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<!-- E2E test harness — loads the calculators component + JS in isolation
(no auth wall). Playwright points here instead of the full SPA.
All scripts are external because the app's CSP blocks inline scripts. -->
<html lang="en">
<head>
<meta charset="UTF-8">
<title>E2E harness — calculators</title>
<link rel="stylesheet" href="/css/styles.css">
<style>
body { margin: 0; padding: 16px; background: #f9fafb; }
#calculators-tab { max-width: 1100px; margin: 0 auto; }
</style>
</head>
<body>
<section id="calculators-tab" class="tab-content active" data-component="calculators"></section>
<script defer src="/js/calc-math.js"></script>
<script defer src="/js/calculators.js"></script>
<script defer src="/js/e2e-bootstrap.js"></script>
</body>
</html>

View file

@ -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);
}
})();

22
scripts/e2e.sh Executable file
View file

@ -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"

View file

@ -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