Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
31 changes: 8 additions & 23 deletions capture-screenshots.js
Original file line number Diff line number Diff line change
@@ -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}`);

Expand Down Expand Up @@ -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));

Expand Down
60 changes: 60 additions & 0 deletions dev-helpers.js
Original file line number Diff line number Diff line change
@@ -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 };
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions public/js/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 8 additions & 9 deletions test-qa.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down
28 changes: 8 additions & 20 deletions test-ui-save.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
Loading