diff --git a/.dockerignore b/.dockerignore index f96ceb4..6e8a3f7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,7 @@ LICENSE test-qa.js test-ui-save.js capture-screenshots.js +dev-helpers.js scenario_template.md Clinical-Simulation-Scenario-Master-Template.docx public/screenshots/ diff --git a/capture-screenshots.js b/capture-screenshots.js index 51e8369..cdbc9e9 100644 --- a/capture-screenshots.js +++ b/capture-screenshots.js @@ -1,34 +1,24 @@ const puppeteer = require('puppeteer-core'); const path = require('path'); const fs = require('fs'); +const { getChromePath, ensureRotatedLogin } = require('./dev-helpers'); -const PORT = 3000; +const PORT = process.env.PORT || 3000; const URL = `http://localhost:${PORT}`; const SCREENSHOTS_DIR = path.join(__dirname, 'public', 'screenshots'); -// Find local Google Chrome executable -function getChromePath() { - const commonPaths = [ - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', - path.join(process.env.USERPROFILE || 'C:\\Users\\thom', 'AppData\\Local\\Google\\Chrome\\Application\\chrome.exe') - ]; - - for (const p of commonPaths) { - if (fs.existsSync(p)) { - return p; - } - } - return null; -} - async function run() { const chromePath = getChromePath(); if (!chromePath) { - console.error('❌ Could not find Google Chrome installation. Skipping headless UI tests.'); + console.error('❌ Could not find Google Chrome installation. Set CHROME_PATH to your Chrome/Chromium binary.'); process.exit(1); } + // Fresh installs seed the admin account with a provisional password that + // forces a rotation modal on first login; complete the rotation over the + // API first so the captures below show the normal dashboard flow. + await ensureRotatedLogin(URL, 'admin@simhub.local', 'admin123'); + console.log(`Using Google Chrome at: ${chromePath}`); console.log(`Screenshots will be saved to: ${SCREENSHOTS_DIR}`); @@ -116,11 +106,6 @@ async function run() { const endRunBtn = await page.waitForSelector('.hud-layout button.btn-emerald'); await endRunBtn.click(); - // Handle standard JS confirm prompt - page.on('dialog', async dialog => { - await dialog.accept(); - }); - await page.waitForSelector('.debrief-layout', { timeout: 5000 }); await new Promise(r => setTimeout(r, 1000)); diff --git a/dev-helpers.js b/dev-helpers.js new file mode 100644 index 0000000..40eee5d --- /dev/null +++ b/dev-helpers.js @@ -0,0 +1,60 @@ +// Shared helpers for the dev/test scripts (test-qa.js, test-ui-save.js, +// capture-screenshots.js). Not part of the runtime server and never copied +// into the Docker image. + +const fs = require('fs'); +const path = require('path'); + +// Find a local Chrome/Chromium executable for puppeteer-core across +// platforms. Set CHROME_PATH to override discovery entirely. +function getChromePath() { + if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) { + return process.env.CHROME_PATH; + } + const commonPaths = process.platform === 'darwin' ? [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium' + ] : process.platform === 'win32' ? [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + path.join(process.env.LOCALAPPDATA || '', 'Google\\Chrome\\Application\\chrome.exe') + ] : [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser' + ]; + return commonPaths.find(p => p && fs.existsSync(p)) || null; +} + +async function postJson(url, body, token = null) { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }, + body: JSON.stringify(body) + }); + let parsed = null; + try { parsed = await res.json(); } catch { /* non-JSON body */ } + return { status: res.status, body: parsed }; +} + +// Seeded and admin-provisioned accounts start on a provisional password, and +// the server blocks every endpoint except the rotation flow until it is +// changed. For dev/test runs, rotate to a throwaway password and straight +// back: the account keeps its documented password but becomes fully usable. +async function ensureRotatedLogin(baseUrl, email, password) { + const login = () => postJson(`${baseUrl}/api/login`, { email, password }); + const first = await login(); + if (first.status !== 200 || !first.body?.user?.mustChangePassword) return first; + + const temp = `Rotate!${Date.now()}`; + const token = first.body.token; + await postJson(`${baseUrl}/api/me/password`, { currentPassword: password, newPassword: temp }, token); + await postJson(`${baseUrl}/api/me/password`, { currentPassword: temp, newPassword: password }, token); + return login(); +} + +module.exports = { getChromePath, postJson, ensureRotatedLogin }; diff --git a/package.json b/package.json index 05601bb..2658434 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "A beautiful, premium Clinical Simulation Scenario Storage & running system designed to align with ASPiH standards.", "main": "server.js", "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "scripts": { "start": "node server.js", diff --git a/public/css/style.css b/public/css/style.css index 11826d6..4797fcd 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -389,6 +389,12 @@ header { color: #8b5cf6; } +/* Failed / unmet status (e.g. an ASPiH standard marked "Not Met") */ +.badge-danger { + background: rgba(239, 68, 68, 0.12); + color: var(--accent-red); +} + /* Programme allocation checklist in the user edit modal */ .prog-alloc-list { display: flex; diff --git a/public/js/components.js b/public/js/components.js index 67cab67..137ae1a 100644 --- a/public/js/components.js +++ b/public/js/components.js @@ -2416,6 +2416,9 @@ const components = { // source so every panel that reads runState.scenario is XSS-safe. Numeric // vitals are unaffected by escaping so monitor parsing still works. const scenario = deepEscape(await api.getScenario(id)); + // Minimal or imported scenarios may lack progression phases entirely; + // default to an empty timeline so the HUD renders instead of crashing. + if (!Array.isArray(scenario.progression)) scenario.progression = []; this.runState.scenario = scenario; this.runState.activePhaseIndex = 0; this.runState.elapsedSeconds = 0; @@ -2692,6 +2695,9 @@ const components = { const animate = () => { const el = document.getElementById('ecg-strip'); if (!el || el !== canvas) return; // exited HUD or re-rendered + // Halt when the user navigates away (startRunHUD re-renders on return); + // otherwise the loop keeps burning CPU behind other views. + if (app.currentView !== 'run-hud') return; const w = canvas.width; const h = canvas.height; diff --git a/test-qa.js b/test-qa.js index 87ea804..1abdab9 100644 --- a/test-qa.js +++ b/test-qa.js @@ -1,6 +1,7 @@ const http = require('http'); +const { ensureRotatedLogin } = require('./dev-helpers'); -const PORT = 3000; +const PORT = process.env.PORT || 3000; const BASE_URL = `http://localhost:${PORT}`; // Helper to make HTTP requests in Node.js without external dependencies @@ -73,11 +74,12 @@ async function runTests() { // ------------------------------------------------------------- // TEST 1: Login as Admin // ------------------------------------------------------------- + // On a fresh install the seeded accounts are on a provisional password + // that blocks all other endpoints; ensureRotatedLogin completes the + // forced rotation (ending on the same documented password) so the rest + // of the suite can exercise the API. console.log('Testing Admin Authentication...'); - const adminLoginRes = await request('/api/login', 'POST', { - email: 'admin@simhub.local', - password: 'admin123' - }); + const adminLoginRes = await ensureRotatedLogin(BASE_URL, 'admin@simhub.local', 'admin123'); assert(adminLoginRes.status === 200, 'Admin login returns 200 OK'); assert(adminLoginRes.body.token !== undefined, 'Admin login returns a session token'); @@ -89,10 +91,7 @@ async function runTests() { // TEST 2: Login as Read-Only Faculty // ------------------------------------------------------------- console.log('\nTesting Faculty Authentication...'); - const facultyLoginRes = await request('/api/login', 'POST', { - email: 'faculty@simhub.local', - password: 'faculty123' - }); + const facultyLoginRes = await ensureRotatedLogin(BASE_URL, 'faculty@simhub.local', 'faculty123'); assert(facultyLoginRes.status === 200, 'Faculty login returns 200 OK'); assert(facultyLoginRes.body.token !== undefined, 'Faculty login returns a session token'); diff --git a/test-ui-save.js b/test-ui-save.js index 203bfa2..813c657 100644 --- a/test-ui-save.js +++ b/test-ui-save.js @@ -1,33 +1,21 @@ const puppeteer = require('puppeteer-core'); -const path = require('path'); -const fs = require('fs'); +const { getChromePath, ensureRotatedLogin } = require('./dev-helpers'); -const PORT = 3000; +const PORT = process.env.PORT || 3000; const URL = `http://localhost:${PORT}`; -// Find local Google Chrome executable -function getChromePath() { - const commonPaths = [ - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', - path.join(process.env.USERPROFILE || 'C:\\Users\\thom', 'AppData\\Local\\Google\\Chrome\\Application\\chrome.exe') - ]; - - for (const p of commonPaths) { - if (fs.existsSync(p)) { - return p; - } - } - return null; -} - async function run() { const chromePath = getChromePath(); if (!chromePath) { - console.error('❌ Google Chrome not found.'); + console.error('❌ Google Chrome not found. Set CHROME_PATH to your Chrome/Chromium binary.'); process.exit(1); } + // Fresh installs seed the admin account with a provisional password that + // forces a rotation modal on first login; complete the rotation over the + // API first so the UI flow below goes straight to the dashboard. + await ensureRotatedLogin(URL, 'admin@simhub.local', 'admin123'); + const browser = await puppeteer.launch({ executablePath: chromePath, headless: 'new',