session persistence, per-tab model selector Brings detailed coverage to sections that were previously only smoke- tested: - sickvisit-workflow.spec.js: generate → mocked note, refine bar round-trip, load popover, New button clears demographics + transcript. - hospitalcourse-workflow.spec.js: fill H&P → generate → mocked narrative renders via /api/generate-hospital-course, refine updates text, load popover open/close. - auth-screen.spec.js: unauthenticated landing visible, login form structure, forgot-password swap + return, register link is intentionally display:none on this instance (pinned), register form DOM still wired correctly if the link is manually unhidden, reg- password has minlength=8 + type=password. - session-persistence.spec.js: clear cookie simulates logout (UI login is gated by Turnstile which can't be completed in the e2e container); re-login via loginAs restores the last tab + sub-pill via localStorage. - model-selector.spec.js: tab-model-select dropdowns render with >0 options across 7 tabs. Fixture corrections: - /api/sick-visit/note and /api/well-visit/note were not being mocked at all — the wrong /api/generate-sick-visit pattern was intercepting nothing, so tests fell through to the real AI backend. Both now have correct patterns and response shapes (sick-visit returns `note`, well-visit note returns `note`). - /api/generate-hospital-course response key updated from `narrative` to `hospitalCourse` to match what the frontend actually reads. wellvisit-workflow: the Visit Note test now asserts a concrete waitForResponse on /api/well-visit/note + text render, instead of the previous "hit either endpoint" fallback. Suite: 294 passed / 0 failed in 5m30s.
167 lines
8.2 KiB
JavaScript
167 lines
8.2 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 ──────────────────────────────────────────────
|
|
const E2E_BASE_INTERNAL = 'http://pediatric-ai-scribe-e2e:3000';
|
|
const E2E_BASE_EXTERNAL = 'http://host.docker.internal:3553';
|
|
const E2E_BASE = process.env.E2E_AUTH_BASE_URL || E2E_BASE_INTERNAL;
|
|
|
|
const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-user@ped-ai.test';
|
|
const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'E2E-testPassword123!';
|
|
|
|
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,
|
|
/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.*(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 worker to avoid the 10/15-min login rate-limiter.
|
|
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(`E2E 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',
|
|
}]);
|
|
}
|
|
|
|
// ── 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(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);
|
|
},
|
|
});
|
|
|
|
const expect = base.expect;
|
|
|
|
module.exports = {
|
|
test,
|
|
expect,
|
|
E2E_BASE,
|
|
TEST_EMAIL,
|
|
TEST_PASSWORD,
|
|
loginAs,
|
|
getAuthToken,
|
|
mockAI,
|
|
USE_REAL_AI,
|
|
};
|