pediatric-ai-scribe-v3/e2e/fixtures.js
Daniel 05dcd1146d test(e2e): repair the harness, taking the browser suite from 96 failures to 11
Three separate reasons tests were failing, none of them a defect in the app.

The calculators. e2e-harness.html loaded calculators.js and drugs-loader.js with
`defer` after they were split into ES modules; index.html was updated at the
time and this page was not. A module parsed as a classic script throws "Cannot
use import statement outside a module" before a line runs, so no click handler
was ever attached: the pills rendered from static HTML and did nothing. Only the
first calculator appeared to pass, because it carries `active` in the markup and
needs no click. That was 52 failures.

Settings and FAQ. Both moved from the tab rail into the account-card menu; the
helper still clicked button.tab-btn[data-tab=…] and timed out. Ten more.

The AI mocks, which had stopped intercepting for two independent reasons and so
were calling the real model on every run — spending credits and comparing
genuine output against strings like "MOCK HPI from dictation". A '**/api/x' glob
matches no URL on Playwright 1.50, and page.route fails silently when nothing
matches; measured against a real URL, that glob and '*/**/api/x' both matched
zero times where a regex matched. Fixing that alone was not enough: the app
registers a service worker that answers every /api/ request with its own
fetch(), and a request made inside a service worker never reaches page.route.
Blocking registration in the config puts them back in the page. The mocked
dictation test now finishes in 1.6s rather than 7.5s, which is what a real model
call costs.

Whole suite: 204 passed / 96 failed in 15.8 minutes, now 289 passed / 11 failed
in 6.8. The remaining eleven are spread across nine specs with no shared cause
and are not touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 18:54:03 +02:00

205 lines
10 KiB
JavaScript

// ============================================================
// SHARED PLAYWRIGHT FIXTURES
// ============================================================
// Provides:
// - `test` — augmented @playwright/test with auto-applied uncaught-error
// guards on every page (pageerror + console.error → test fail)
// - `authedPage` fixture — a logged-in page, ready to drive
// - `mockAI(page, overrides)` — installs page.route() handlers that
// intercept AI endpoints and return canned JSON. Pass `{ real: true }`
// or set E2E_USE_REAL_AI=1 to bypass mocking and call real backend.
// ============================================================
const base = require('@playwright/test');
// ── Environment ──────────────────────────────────────────────
// Loopback, not the container hostname. Anything else is an insecure context,
// where crypto.randomUUID does not exist and the app cannot complete a sign-in
// — see the note in playwright.config.js.
const E2E_BASE = process.env.E2E_AUTH_BASE_URL || 'http://127.0.0.1:3553';
const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-user@ped-ai.test';
const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!';
// Seeded with the admin role by e2e/seed.js. Kept as a separate account rather
// than promoting the ordinary user, so a test that asserts something is denied
// to a non-admin still has a non-admin to assert it with.
const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || 'e2e-admin@ped-ai.test';
const USE_REAL_AI = process.env.E2E_USE_REAL_AI === '1' || process.env.E2E_USE_REAL_AI === 'true';
// ── Console-error allowlist ─────────────────────────────────
// Some console messages are expected / noise (e.g. favicon 404). If a
// message matches one of these patterns it does NOT fail the test.
const CONSOLE_ERROR_ALLOWLIST = [
/favicon/i,
/\/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.*(400|401|403|404|500|502|503)/i, // Any HTTP error on subsidiary fetches — smoke tests only verify UI renders, deeper integration tests validate endpoint contracts separately
/net::ERR_BLOCKED_BY_CLIENT/i, // Adblocker etc.
/Cloudflare Turnstile.*110200/i, // Expected on e2e: site key hard-coded in index.html but e2e uses different host → domain mismatch error
/challenges\.cloudflare\.com\/turnstile/i, // Turnstile script errors from same root cause
];
function isAllowedConsoleNoise(text) {
return CONSOLE_ERROR_ALLOWLIST.some(re => re.test(text));
}
// ── Auth — module-scoped token cache ────────────────────────
// Keeps one login per account per worker to avoid the 10/15-min login
// rate-limiter. Keyed by email, because there is more than one account now and
// a single slot would have each login evicting the other's token.
const _tokenCache = new Map();
async function tokenFor(request, email) {
if (_tokenCache.has(email)) return _tokenCache.get(email);
const r = await request.post(E2E_BASE + '/api/auth/login', {
data: { email, password: TEST_PASSWORD },
});
if (!r.ok()) {
const text = await r.text();
// The overwhelmingly likely cause is an unseeded database, and saying so
// beats leaving someone to work back from a 401.
throw new Error(
`E2E login failed for ${email} (status ${r.status()}): ${text}\n` +
'If the account does not exist, seed it: docker exec pediatric-ai-scribe-e2e node e2e/seed.js'
);
}
const body = await r.json();
if (!body.token) throw new Error('Login response missing token: ' + JSON.stringify(body));
_tokenCache.set(email, body.token);
return body.token;
}
async function getAuthToken(request) { return tokenFor(request, TEST_EMAIL); }
async function getAdminToken(request) { return tokenFor(request, ADMIN_EMAIL); }
async function loginAs(context, request, email = TEST_EMAIL) {
const token = await tokenFor(request, email);
const url = new URL(E2E_BASE);
await context.addCookies([{
name: 'ped_auth',
value: token,
domain: url.hostname,
path: '/',
httpOnly: true,
secure: false,
sameSite: 'Lax',
}]);
}
// A '**/api/x' glob stopped matching any URL when Playwright went to 1.50, and
// page.route fails silently: no error, no warning, the request simply goes to
// the server. So every "mocked" AI test was calling the real model and
// comparing its genuine output against a canned string — spending real credits
// on every run and failing for a reason that looked like a UI bug. Measured:
// against http://127.0.0.1:3553/api/health, '**/api/health' and '*/**/api/health'
// both matched zero times; a regex matched.
//
// The patterns are kept as strings because they are also the keys callers pass
// in `overrides`, and turned into anchored regexes here.
function asMatcher(pattern) {
const path = pattern.replace(/^\*\*/, '');
return new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[?#]|$)');
}
// ── AI mock — intercepts generation endpoints ──────────────
// Canned response shape matches what each route's frontend expects.
// Override per-test by passing {pattern: responseFn} in overrides.
async function mockAI(page, overrides = {}) {
if (USE_REAL_AI || overrides.real) return; // opt-out to hit real backend
const routes = [
{ pattern: '**/api/generate-soap', response: { success: true, soap: 'MOCK SOAP NOTE.\nSubjective: ...\nObjective: ...\nAssessment: ...\nPlan: ...', model: 'mock-gpt' } },
{ pattern: '**/api/generate-hpi-encounter', response: { success: true, hpi: 'MOCK HPI from encounter.', model: 'mock-gpt' } },
{ pattern: '**/api/generate-hpi-dictation', response: { success: true, hpi: 'MOCK HPI from dictation.', model: 'mock-gpt' } },
{ pattern: '**/api/sick-visit/note', response: { success: true, note: 'MOCK sick visit note.', model: 'mock-gpt' } },
{ pattern: '**/api/well-visit/note', response: { success: true, note: 'MOCK well visit note.', model: 'mock-gpt' } },
{ pattern: '**/api/generate-hospital-course', response: { success: true, hospitalCourse: 'MOCK hospital course narrative.', format: 'auto', model: 'mock-gpt' } },
{ pattern: '**/api/generate-milestone-narrative', response: { success: true, narrative: 'MOCK developmental narrative.', model: 'mock-gpt', summary: { achieved: 3, notAchieved: 0, notAssessed: 0 } } },
{ pattern: '**/api/generate-milestone-summary', response: { success: true, summary: 'MOCK 3-sentence summary.', model: 'mock-gpt' } },
{ pattern: '**/api/generate-pe-narrative', response: { success: true, narrative: 'Technique:\nMOCK technique.\n\nFindings:\nMOCK findings.', model: 'mock-gpt', summary: { normal: 2, abnormal: 0, notAssessed: 0 } } },
{ pattern: '**/api/generate-chart-review', response: { success: true, review: 'MOCK chart review.', model: 'mock-gpt' } },
{ pattern: '**/api/well-visit/shadess', response: { success: true, assessment: 'MOCK SSHADESS assessment.', model: 'mock-gpt' } },
{ pattern: '**/api/refine', response: { success: true, refined: 'MOCK refined content.', model: 'mock-gpt' } },
{ pattern: '**/api/suggest-billing-codes', response: { success: true, icd10: [], cpt: [], model: 'mock-gpt' } },
{ pattern: '**/api/transcribe', response: { success: true, transcript: 'MOCK transcribed text.' } },
{ pattern: '**/api/tts', response: { success: true, audioBase64: '' } },
];
for (const { pattern, response } of routes) {
const override = overrides[pattern];
await page.route(asMatcher(pattern), async route => {
const resp = typeof override === 'function' ? await override(route.request()) : (override || response);
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(resp) });
});
}
}
// ── Error guards — auto-applied via extended test ──────────
// Any uncaught page JS error or unhandled console.error fails the test.
// This is the safety net for bugs like the SSO ReferenceError.
const test = base.test.extend({
// Replace the default `page` with one that has listeners wired before
// any navigation happens.
page: async ({ page }, use) => {
const errors = [];
const consoleErrors = [];
page.on('pageerror', err => {
// Same allowlist applies to pageerror — third-party scripts (Turnstile)
// can throw uncaught errors that are expected on the e2e host.
const msg = err && (err.message || String(err));
if (isAllowedConsoleNoise(msg)) return;
errors.push(err);
});
page.on('console', msg => {
if (msg.type() !== 'error') return;
const text = msg.text();
if (isAllowedConsoleNoise(text)) return;
consoleErrors.push(text);
});
await use(page);
// After the test finishes, fail if any uncaught errors accumulated.
if (errors.length > 0) {
throw new Error(
'Uncaught page error(s) during test:\n' +
errors.map(e => ' - ' + e.message + '\n ' + (e.stack || '').split('\n').slice(0, 3).join('\n ')).join('\n')
);
}
if (consoleErrors.length > 0) {
throw new Error(
'console.error() during test:\n' +
consoleErrors.map(t => ' - ' + t).join('\n')
);
}
},
// Pre-authed page — login before use.
authedPage: async ({ page, context, request }, use) => {
await loginAs(context, request);
await use(page);
},
// The same thing signed in as an administrator, for the screens an ordinary
// account cannot reach at all.
adminPage: async ({ page, context, request }, use) => {
await loginAs(context, request, ADMIN_EMAIL);
await use(page);
},
});
const expect = base.expect;
module.exports = {
test,
expect,
E2E_BASE,
TEST_EMAIL,
TEST_PASSWORD,
ADMIN_EMAIL,
loginAs,
getAuthToken,
getAdminToken,
mockAI,
USE_REAL_AI,
};