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:
Daniel 2026-04-22 22:57:09 +02:00
parent b80a91e40a
commit 8cfa07dcf5
5 changed files with 47 additions and 65 deletions

View file

@ -22,6 +22,9 @@ services:
TURNSTILE_SECRET_KEY: ""
# Disable SMTP so register auto-verifies the user and returns a session
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:
- scribe-logs-e2e:/app/data/logs
depends_on:

View file

@ -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.
const CONSOLE_ERROR_ALLOWLIST = [
/favicon/i,
/Failed to load resource.*models\/Xenova/i, // Browser Whisper models lazy-loaded on demand
/\/api\/models/i, // When no AI provider configured yet
/Failed to load resource.*models\/Xenova/i, // Browser Whisper models lazy-loaded on demand
/\/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) {
return CONSOLE_ERROR_ALLOWLIST.some(re => re.test(text));

View file

@ -6,50 +6,10 @@
// Each test logs in via the API (no UI interaction needed) and injects the
// session cookie into the browser context.
const { test, expect } = require('@playwright/test');
const E2E_BASE_INTERNAL = 'http://pediatric-ai-scribe-e2e:3000';
const E2E_BASE_EXTERNAL = 'http://host.docker.internal:3553'; // only used when running Playwright on host
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',
},
]);
}
// 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
// the 10/15min login rate-limit.
const { test, expect, E2E_BASE, loginAs } = require('../fixtures');
// ── Tests ────────────────────────────────────────────────────────────

View file

@ -1399,13 +1399,10 @@
// when the sound has no real recording.
function renderSoundCard(s) {
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
? '<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;">' +
'<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>' +
'<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>' +
@ -1413,10 +1410,7 @@
'</div>';
var html = '';
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);flex:1;min-width:0;">' + esc(s.title) + '</div>';
html += ' ' + badge;
html += ' </div>';
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);margin-bottom:4px;">' + esc(s.title) + '</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.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) {
var html = '';
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;">';
sounds.forEach(function (s) { html += renderSoundCard(s); });
html += ' </div>';
@ -1618,6 +1612,29 @@
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
content.querySelectorAll('.synth-sound').forEach(function (container) {
var key = container.dataset.synthKey;
@ -1636,13 +1653,8 @@
}
playBtn.addEventListener('click', function () {
if (!window.RespSounds || !window.RespSounds.play) { showToast('Audio library not loaded', 'error'); return; }
// Stop any other synth player first
content.querySelectorAll('.synth-sound').forEach(function (c) { if (c !== 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();
// Stop every other playing sound (audio + other synth) first
stopAllExcept(container);
var ok = window.RespSounds.play(key);
if (!ok) { showToast('Audio not available', 'error'); return; }
playBtn.style.display = 'none';

View file

@ -97,9 +97,13 @@ app.use('/api/', rateLimit({
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({
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.' },
standardHeaders: true, legacyHeaders: false
}));