pediatric-ai-scribe-v3/e2e/fixtures.js
Daniel f0cc537df1
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 18s
test(e2e): the browser suite follows generation into its job, and can log in again
Two faults kept every spec in this file from running, both in the same code
path and both from the SSO-only change. The fixtures minted a session by
shelling out to `docker compose exec`, which cannot work from inside the
Playwright container — it has no docker CLI and no socket — so all seventeen
specs (both viewports) died at the auth fixture before touching the page. The
harness now mints both sessions on the host and passes them in, and the seed's
connection banner is no longer mistaken for the token: it prints before it,
so the token is the last line. The host-side docker path stays as the fallback
for `npx playwright test` run directly on the host.

The specs then move to what generation is now: the click is answered with a
job, so the assertions follow the job list. A generation in flight is listed
and survives a reload, a job that lands reloads the library and says what it
was written from, a job that fails says why, and what was searched for is
reported when the job lands rather than when the button is pressed.
2026-09-16 04:02:10 +02:00

229 lines
12 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';
// 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.
// A request still in flight when Playwright closes the context logs this.
// It is the harness tearing down, not the page failing: a real request that
// fails carries a status code and is matched by the rule above.
/net::ERR_ABORTED/i,
// ERR_NETWORK_CHANGED is the host's network stack reconfiguring under the
// browser — it runs on the host network, so bringing any container up or down
// during a run produces it. Environmental, and unambiguously so: a page that
// is genuinely failing reports a status code.
/Failed to load resource.*net::ERR_(ABORTED|FAILED|CONNECTION_CLOSED|NETWORK_CHANGED)/i,
/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 ────────────────────────
// One session per account per worker. There is no password sign-in to call,
// so the session is minted inside the app container by the seed script (a
// signed token and a session row, exactly what the server writes after the
// provider has vouched for the person). Keyed by email, because there is more
// than one account and a single slot would have each evicting the other.
const _tokenCache = new Map();
// The browser runs in a container with no docker CLI and no socket, so
// scripts/e2e.sh mints these on the host and passes them in. The docker call
// below stays for `npx playwright test` run directly on the host, and takes the
// LAST line: the seed prints its connection banner before the token, and a
// cookie with a banner in it is a login that fails before any spec starts.
const ENV_TOKENS = new Map([
[TEST_EMAIL, process.env.E2E_AUTH_TOKEN],
[ADMIN_EMAIL, process.env.E2E_ADMIN_AUTH_TOKEN],
]);
async function tokenFor(request, email) {
if (_tokenCache.has(email)) return _tokenCache.get(email);
if (ENV_TOKENS.get(email)) {
_tokenCache.set(email, ENV_TOKENS.get(email));
return ENV_TOKENS.get(email);
}
const { execFileSync } = require('child_process');
const path = require('path');
let token;
try {
token = execFileSync('docker', ['compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.e2e.yml',
'exec', '-T', 'pediatric-scribe-e2e', 'node', 'e2e/seed.js', 'token', email],
{ cwd: path.resolve(__dirname, '..'), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim().split('\n').pop().trim();
} catch (err) {
// The overwhelmingly likely cause is an unseeded database, and saying so
// beats leaving someone to work back from a stack trace.
throw new Error(
`E2E could not mint a session for ${email}: ${(err.stderr || err.message || '').toString().trim()}\n` +
'If the account does not exist, seed it: docker compose -f docker-compose.yml -f docker-compose.e2e.yml exec -T pediatric-scribe-e2e node e2e/seed.js'
);
}
if (!token) throw new Error('Seed script printed no token for ' + email);
_tokenCache.set(email, token);
return 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,
ADMIN_EMAIL,
loginAs,
getAuthToken,
getAdminToken,
mockAI,
USE_REAL_AI,
};