Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The unit tests for this feature read source files and assert patterns. They prove the code says the right thing, not that the screen does it, and nothing exercised the browser at all — so a mismatch between what the form sends and what the route reads passed all of them. Three real bugs shipped through that gap in one session: a modification that updated the markdown but not the deck, generation that failed whenever the slide reviewer was off, and a figure generated for a slide that never referenced it. Every one was found by driving the running server by hand. So these assert the request bodies, not only the rendering: that Generate sends topic, kind, slideCount, refinement, model and all four options as the strings the route compares against; that unticking the library sends 'false' rather than omitting the field, which the route would read as on; and that Modify posts to the right resource with every source option. Plus the screen's own behaviour — availability gating on both cards, the illustration hint switching on and staying off once overruled, the bounded searchable library, the two different empty states, an article never being offered as slides, a local refusal that spends no round trip, and a refused modification surfacing its reason. Fourteen tests, both viewports. The API is stubbed. This is the contract between the screen and the route, and stubbing keeps it fast, free and deterministic. Proven to catch regressions rather than merely pass: renaming useCorpus in the form failed two tests, breaking the availability gating failed one, and truncating the modify picker failed another. Two flakes of my own were fixed rather than retried. openTab slept 400ms for the library and picker instead of waiting for them, which made Modify report "nothing to modify yet" under load. And the console-error guard failed on net::ERR_ABORTED and net::ERR_NETWORK_CHANGED — a request in flight when the context closes, and the host network reconfiguring under a browser that runs on it. Both are the harness, not the page: anything genuinely failing carries a status code and is still caught. Five consecutive clean full runs after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
214 lines
11 KiB
JavaScript
214 lines
11 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.
|
|
// 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 ────────────────────────
|
|
// 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,
|
|
};
|