Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 16s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Adds the admin fixture the Search Sources screen needed, and repairs the reason no browser-driving e2e test could log in at all. The sign-in failure first. The suite drove the app over http on a container hostname, which is not a secure context, so the browser provides no crypto.randomUUID. AccountBoundary calls it to mint a session generation on every sign-in; the call threw, the boot handler's catch swallowed it, and every test landed on the login screen holding a perfectly valid session. Measured: isSecureContext false and randomUUID undefined on http://pediatric-ai-scribe-e2e:3000, both true on http://127.0.0.1:3553, where boundary.enter() returns true and the app enters. Chrome's --unsafely-treat-insecure-origin-as-secure was tried first and does not work: Playwright rejects the --user-data-dir it must be paired with, and the flag alone leaves isSecureContext false. Loopback needs no flags, so the runner now uses the host network and the published port. The seed is new. The e2e user was a registration someone did by hand once that the shared Postgres happened to keep — enough to log in and no more. There was no admin account, so nothing under /api/admin could be tested through a real request, which is how the Search Sources card came to be verified by reading its markup. e2e/seed.js creates both accounts and reconciles an existing one, so a leftover with the wrong role cannot fail the suite for a reason unrelated to the code. It resets passwords and grants admin, so it refuses any address outside @ped-ai.test. The runner seeds before it tests. The new spec covers what markup-reading could not: that an ordinary account is refused the settings and never offered the Admin menu item, that no API key comes back readable, that the Test button reports each source separately, and that every control the save handler reads exists in a real render. Each account gets its own browser context, because AccountBoundary allows one owner per document and freezing the page on a second is the behaviour, not a bug. 10/10 pass on both projects. Two unit tests pin the loopback requirement and the seed's domain guard so neither can be undone quietly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
190 lines
9.4 KiB
JavaScript
190 lines
9.4 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',
|
|
}]);
|
|
}
|
|
|
|
// ── 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);
|
|
},
|
|
|
|
// 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,
|
|
};
|