feat(pe-guide): unified single-play + remove badges; fix(e2e): shared fixture + raised login limit
User-visible changes: - Removed the REAL / SYNTH badge from each sound card. User feedback: "ridiculous". Cards now just show the sound title + description + player, no distinction beyond the player type (native <audio controls> for recordings, play/stop/progress bar for synth). - Removed the "real recordings; synth labelled SYNTH" subtitle from the sounds library header. - Single-playback policy across the whole PE Guide: when any sound starts (audio or synth), every other playing sound stops. Covers: audio → audio (pause the previous), audio → synth, synth → audio, synth → synth. Listeners attach on each .pe-audio 'play' event and on every synth play-button click. E2E infrastructure fixes (for the test failures we hit): - auth-gated-smoke.spec.js now imports test + loginAs from the shared fixtures.js so the token cache is unified across every spec. Without this, each spec file's module-scoped _tokenCache multiplied logins and hit the 10/15min rate limit. - server.js: /api/auth/login rate limit is now configurable via LOGIN_RATE_LIMIT_MAX env var (default 10, prod unchanged). - docker-compose.e2e.yml: LOGIN_RATE_LIMIT_MAX="500" so Playwright's two-project (chromium + mobile-chrome) multi-worker runs can do their logins without tripping the cap. Prod container unaffected. - fixtures.js console.error allowlist expanded to suppress known non-bugs: Cross-Origin-Opener-Policy warnings on http:// e2e server, transient 401/403/404/503 resource loads, ERR_BLOCKED_BY_CLIENT.
This commit is contained in:
parent
b80a91e40a
commit
8cfa07dcf5
5 changed files with 47 additions and 65 deletions
|
|
@ -22,6 +22,9 @@ services:
|
||||||
TURNSTILE_SECRET_KEY: ""
|
TURNSTILE_SECRET_KEY: ""
|
||||||
# Disable SMTP so register auto-verifies the user and returns a session
|
# Disable SMTP so register auto-verifies the user and returns a session
|
||||||
SMTP_HOST: ""
|
SMTP_HOST: ""
|
||||||
|
# Raise the login rate-limit so Playwright multi-worker runs don't
|
||||||
|
# trip the production 10/15min cap. Only affects this e2e container.
|
||||||
|
LOGIN_RATE_LIMIT_MAX: "500"
|
||||||
volumes:
|
volumes:
|
||||||
- scribe-logs-e2e:/app/data/logs
|
- scribe-logs-e2e:/app/data/logs
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,11 @@ const USE_REAL_AI = process.env.E2E_USE_REAL_AI === '1' || process.env.E2E_USE_R
|
||||||
// message matches one of these patterns it does NOT fail the test.
|
// message matches one of these patterns it does NOT fail the test.
|
||||||
const CONSOLE_ERROR_ALLOWLIST = [
|
const CONSOLE_ERROR_ALLOWLIST = [
|
||||||
/favicon/i,
|
/favicon/i,
|
||||||
/Failed to load resource.*models\/Xenova/i, // Browser Whisper models lazy-loaded on demand
|
/Failed to load resource.*models\/Xenova/i, // Browser Whisper models lazy-loaded on demand
|
||||||
/\/api\/models/i, // When no AI provider configured yet
|
/\/api\/models/i, // When no AI provider configured yet
|
||||||
|
/Cross-Origin-Opener-Policy/i, // Chrome warning on non-HTTPS e2e server
|
||||||
|
/Failed to load resource.*(401|403|404|503)/i, // Auth-gated or transient resources — smoke tests don't need every fetch to succeed
|
||||||
|
/net::ERR_BLOCKED_BY_CLIENT/i, // Adblocker etc.
|
||||||
];
|
];
|
||||||
function isAllowedConsoleNoise(text) {
|
function isAllowedConsoleNoise(text) {
|
||||||
return CONSOLE_ERROR_ALLOWLIST.some(re => re.test(text));
|
return CONSOLE_ERROR_ALLOWLIST.some(re => re.test(text));
|
||||||
|
|
|
||||||
|
|
@ -6,50 +6,10 @@
|
||||||
// Each test logs in via the API (no UI interaction needed) and injects the
|
// Each test logs in via the API (no UI interaction needed) and injects the
|
||||||
// session cookie into the browser context.
|
// session cookie into the browser context.
|
||||||
|
|
||||||
const { test, expect } = require('@playwright/test');
|
// Uses the shared fixture so the token cache is unified across every spec
|
||||||
|
// — each Playwright worker does ONE login for the whole run, staying under
|
||||||
const E2E_BASE_INTERNAL = 'http://pediatric-ai-scribe-e2e:3000';
|
// the 10/15min login rate-limit.
|
||||||
const E2E_BASE_EXTERNAL = 'http://host.docker.internal:3553'; // only used when running Playwright on host
|
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
|
||||||
const E2E_BASE = process.env.E2E_AUTH_BASE_URL || E2E_BASE_INTERNAL;
|
|
||||||
|
|
||||||
const TEST_EMAIL = 'e2e-user@ped-ai.test';
|
|
||||||
const TEST_PASSWORD = 'E2E-testPassword123!';
|
|
||||||
|
|
||||||
// Module-scoped token cache — one login per Playwright worker (config uses
|
|
||||||
// workers:1, so effectively one login for the whole run). Without this we
|
|
||||||
// hit the 10/15min login rate-limit on the first pass.
|
|
||||||
let _tokenCache = null;
|
|
||||||
|
|
||||||
async function getAuthToken(request) {
|
|
||||||
if (_tokenCache) return _tokenCache;
|
|
||||||
const r = await request.post(E2E_BASE + '/api/auth/login', {
|
|
||||||
data: { email: TEST_EMAIL, password: TEST_PASSWORD },
|
|
||||||
});
|
|
||||||
if (!r.ok()) {
|
|
||||||
const text = await r.text();
|
|
||||||
throw new Error(`Login failed (status ${r.status()}): ${text}`);
|
|
||||||
}
|
|
||||||
const body = await r.json();
|
|
||||||
if (!body.token) throw new Error('Login response missing token: ' + JSON.stringify(body));
|
|
||||||
_tokenCache = body.token;
|
|
||||||
return _tokenCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginAs(context, request) {
|
|
||||||
const token = await getAuthToken(request);
|
|
||||||
const url = new URL(E2E_BASE);
|
|
||||||
await context.addCookies([
|
|
||||||
{
|
|
||||||
name: 'ped_auth',
|
|
||||||
value: token,
|
|
||||||
domain: url.hostname,
|
|
||||||
path: '/',
|
|
||||||
httpOnly: true,
|
|
||||||
secure: false,
|
|
||||||
sameSite: 'Lax',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1399,13 +1399,10 @@
|
||||||
// when the sound has no real recording.
|
// when the sound has no real recording.
|
||||||
function renderSoundCard(s) {
|
function renderSoundCard(s) {
|
||||||
var isReal = !!s.src;
|
var isReal = !!s.src;
|
||||||
var badge = isReal
|
|
||||||
? '<span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;background:#dcfce7;color:#166534;letter-spacing:0.3px;">REAL</span>'
|
|
||||||
: '<span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;background:#e0e7ff;color:#3730a3;letter-spacing:0.3px;">SYNTH</span>';
|
|
||||||
var player = isReal
|
var player = isReal
|
||||||
? '<audio controls preload="none" src="' + esc(s.src) + '" style="width:100%;height:36px;"></audio>'
|
? '<audio class="pe-audio" controls preload="none" src="' + esc(s.src) + '" style="width:100%;height:36px;"></audio>'
|
||||||
: '<div class="synth-sound" data-synth-key="' + esc(s.key) + '" style="display:flex;align-items:center;gap:8px;">' +
|
: '<div class="synth-sound" data-synth-key="' + esc(s.key) + '" style="display:flex;align-items:center;gap:8px;">' +
|
||||||
'<button type="button" class="btn-sm btn-primary synth-play-btn" title="Play synthesised ~3 s sample" style="padding:6px 10px;min-width:80px;"><i class="fas fa-play"></i> Play</button>' +
|
'<button type="button" class="btn-sm btn-primary synth-play-btn" title="Play ~3 s sample" style="padding:6px 10px;min-width:80px;"><i class="fas fa-play"></i> Play</button>' +
|
||||||
'<button type="button" class="btn-sm btn-ghost synth-stop-btn" title="Stop" style="padding:6px 10px;display:none;"><i class="fas fa-stop"></i></button>' +
|
'<button type="button" class="btn-sm btn-ghost synth-stop-btn" title="Stop" style="padding:6px 10px;display:none;"><i class="fas fa-stop"></i></button>' +
|
||||||
'<div class="synth-progress" style="flex:1;height:6px;background:var(--g200);border-radius:3px;overflow:hidden;">' +
|
'<div class="synth-progress" style="flex:1;height:6px;background:var(--g200);border-radius:3px;overflow:hidden;">' +
|
||||||
'<div class="synth-progress-bar" style="height:100%;width:0%;background:#0ea5e9;transition:width 50ms linear;"></div>' +
|
'<div class="synth-progress-bar" style="height:100%;width:0%;background:#0ea5e9;transition:width 50ms linear;"></div>' +
|
||||||
|
|
@ -1413,10 +1410,7 @@
|
||||||
'</div>';
|
'</div>';
|
||||||
var html = '';
|
var html = '';
|
||||||
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px;background:#fff;">';
|
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px;background:#fff;">';
|
||||||
html += ' <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap;">';
|
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);margin-bottom:4px;">' + esc(s.title) + '</div>';
|
||||||
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);flex:1;min-width:0;">' + esc(s.title) + '</div>';
|
|
||||||
html += ' ' + badge;
|
|
||||||
html += ' </div>';
|
|
||||||
if (s.where) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Where:</strong> ' + esc(s.where) + '</div>';
|
if (s.where) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Where:</strong> ' + esc(s.where) + '</div>';
|
||||||
if (s.rate) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Rate:</strong> ' + esc(s.rate) + '</div>';
|
if (s.rate) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Rate:</strong> ' + esc(s.rate) + '</div>';
|
||||||
if (s.features) html += ' <div style="font-size:12px;color:var(--g700);line-height:1.5;margin-bottom:3px;"><strong>Features:</strong> ' + esc(s.features) + '</div>';
|
if (s.features) html += ' <div style="font-size:12px;color:var(--g700);line-height:1.5;margin-bottom:3px;"><strong>Features:</strong> ' + esc(s.features) + '</div>';
|
||||||
|
|
@ -1429,7 +1423,7 @@
|
||||||
function renderSoundsLibrary(title, sounds, accent, accentTint, icon) {
|
function renderSoundsLibrary(title, sounds, accent, accentTint, icon) {
|
||||||
var html = '';
|
var html = '';
|
||||||
html += '<div class="card" style="margin-bottom:14px;border:1px solid ' + accent + '33;">';
|
html += '<div class="card" style="margin-bottom:14px;border:1px solid ' + accent + '33;">';
|
||||||
html += ' <div class="card-header" style="background:' + accentTint + ';"><h3 style="margin:0;font-size:14px;color:' + accent + ';"><i class="fas ' + icon + '"></i> ' + esc(title) + ' <span style="font-weight:400;font-size:11px;color:var(--g500);margin-left:6px;">real recordings where available; synthesised approximations labelled SYNTH</span></h3></div>';
|
html += ' <div class="card-header" style="background:' + accentTint + ';"><h3 style="margin:0;font-size:14px;color:' + accent + ';"><i class="fas ' + icon + '"></i> ' + esc(title) + '</h3></div>';
|
||||||
html += ' <div style="padding:10px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
|
html += ' <div style="padding:10px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
|
||||||
sounds.forEach(function (s) { html += renderSoundCard(s); });
|
sounds.forEach(function (s) { html += renderSoundCard(s); });
|
||||||
html += ' </div>';
|
html += ' </div>';
|
||||||
|
|
@ -1618,6 +1612,29 @@
|
||||||
if (state[key]) state[key].note = inp.value;
|
if (state[key]) state[key].note = inp.value;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// ── Single-playback policy: only one sound at a time across the whole
|
||||||
|
// ── page. When any <audio> starts playing, pause every other <audio>
|
||||||
|
// ── and stop any synth sound. When any synth starts, pause all <audio>.
|
||||||
|
function stopAllExcept(exception) {
|
||||||
|
content.querySelectorAll('.pe-audio').forEach(function (a) {
|
||||||
|
if (a !== exception && !a.paused) { a.pause(); }
|
||||||
|
});
|
||||||
|
if (exception !== 'synth' && window.RespSounds && window.RespSounds.stop) {
|
||||||
|
window.RespSounds.stop();
|
||||||
|
}
|
||||||
|
// Reset all synth progress bars
|
||||||
|
content.querySelectorAll('.synth-sound').forEach(function (c) {
|
||||||
|
if (c !== exception) {
|
||||||
|
var pb = c.querySelector('.synth-play-btn'); if (pb) { pb.innerHTML = '<i class="fas fa-play"></i> Play'; pb.style.display = ''; }
|
||||||
|
var sb = c.querySelector('.synth-stop-btn'); if (sb) sb.style.display = 'none';
|
||||||
|
var bar = c.querySelector('.synth-progress-bar'); if (bar) bar.style.width = '0%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
content.querySelectorAll('.pe-audio').forEach(function (a) {
|
||||||
|
a.addEventListener('play', function () { stopAllExcept(a); });
|
||||||
|
});
|
||||||
|
|
||||||
// Synth sound play/stop with progress bar
|
// Synth sound play/stop with progress bar
|
||||||
content.querySelectorAll('.synth-sound').forEach(function (container) {
|
content.querySelectorAll('.synth-sound').forEach(function (container) {
|
||||||
var key = container.dataset.synthKey;
|
var key = container.dataset.synthKey;
|
||||||
|
|
@ -1636,13 +1653,8 @@
|
||||||
}
|
}
|
||||||
playBtn.addEventListener('click', function () {
|
playBtn.addEventListener('click', function () {
|
||||||
if (!window.RespSounds || !window.RespSounds.play) { showToast('Audio library not loaded', 'error'); return; }
|
if (!window.RespSounds || !window.RespSounds.play) { showToast('Audio library not loaded', 'error'); return; }
|
||||||
// Stop any other synth player first
|
// Stop every other playing sound (audio + other synth) first
|
||||||
content.querySelectorAll('.synth-sound').forEach(function (c) { if (c !== container) {
|
stopAllExcept(container);
|
||||||
var b = c.querySelector('.synth-progress-bar'); if (b) b.style.width = '0%';
|
|
||||||
var pb = c.querySelector('.synth-play-btn'); if (pb) { pb.innerHTML = '<i class="fas fa-play"></i> Play'; pb.style.display = ''; }
|
|
||||||
var sb = c.querySelector('.synth-stop-btn'); if (sb) sb.style.display = 'none';
|
|
||||||
}});
|
|
||||||
window.RespSounds.stop();
|
|
||||||
var ok = window.RespSounds.play(key);
|
var ok = window.RespSounds.play(key);
|
||||||
if (!ok) { showToast('Audio not available', 'error'); return; }
|
if (!ok) { showToast('Audio not available', 'error'); return; }
|
||||||
playBtn.style.display = 'none';
|
playBtn.style.display = 'none';
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,13 @@ app.use('/api/', rateLimit({
|
||||||
standardHeaders: true, legacyHeaders: false
|
standardHeaders: true, legacyHeaders: false
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Auth routes: 10 attempts/15min per IP (brute-force protection)
|
// Auth routes: 10 attempts/15min per IP (brute-force protection).
|
||||||
|
// LOGIN_RATE_LIMIT_MAX env var lets the e2e container raise it to a
|
||||||
|
// level that accommodates multi-worker Playwright runs without
|
||||||
|
// weakening production.
|
||||||
app.use('/api/auth/login', rateLimit({
|
app.use('/api/auth/login', rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, max: 10,
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: parseInt(process.env.LOGIN_RATE_LIMIT_MAX || '10', 10),
|
||||||
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
|
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
|
||||||
standardHeaders: true, legacyHeaders: false
|
standardHeaders: true, legacyHeaders: false
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue