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:
parent
441a872186
commit
1024b4741b
10 changed files with 354 additions and 12 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
78
e2e/package-lock.json
generated
Normal 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
9
e2e/package.json
Normal 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
24
e2e/playwright.config.js
Normal 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'] } },
|
||||
],
|
||||
});
|
||||
147
e2e/tests/bedside-smoke.spec.js
Normal file
147
e2e/tests/bedside-smoke.spec.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
22
public/e2e-harness.html
Normal 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>
|
||||
15
public/js/e2e-bootstrap.js
Normal file
15
public/js/e2e-bootstrap.js
Normal 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
22
scripts/e2e.sh
Executable 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"
|
||||
43
server.js
43
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue