test(e2e): the browser suite follows generation into its job, and can log in again
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

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.
This commit is contained in:
Daniel 2026-09-16 04:02:10 +02:00
parent ef574eddcb
commit f0cc537df1
3 changed files with 131 additions and 15 deletions

View file

@ -58,15 +58,28 @@ function isAllowedConsoleNoise(text) {
// provider has vouched for the person). Keyed by email, because there is more // 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. // than one account and a single slot would have each evicting the other.
const _tokenCache = new Map(); 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) { async function tokenFor(request, email) {
if (_tokenCache.has(email)) return _tokenCache.get(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 { execFileSync } = require('child_process');
const path = require('path'); const path = require('path');
let token; let token;
try { try {
token = execFileSync('docker', ['compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.e2e.yml', token = execFileSync('docker', ['compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.e2e.yml',
'exec', '-T', 'pediatric-scribe-e2e', 'node', 'e2e/seed.js', 'token', email], 'exec', '-T', 'pediatric-scribe-e2e', 'node', 'e2e/seed.js', 'token', email],
{ cwd: path.resolve(__dirname, '..'), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); { cwd: path.resolve(__dirname, '..'), encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim().split('\n').pop().trim();
} catch (err) { } catch (err) {
// The overwhelmingly likely cause is an unseeded database, and saying so // The overwhelmingly likely cause is an unseeded database, and saying so
// beats leaving someone to work back from a stack trace. // beats leaving someone to work back from a stack trace.

View file

@ -54,16 +54,35 @@ async function stub(page, overrides = {}) {
searches: [], imageJobs: [], imageFailures: [] }); searches: [], imageJobs: [], imageFailures: [] });
}); });
// Generating answers at once with a job: writing a deck takes minutes, and a
// browser holding one request open that long gives up on its own (measured:
// "NetworkError when attempting to fetch resource" at five minutes while the
// server went on to save the deck). What was written arrives through the job
// list below, which is what these tests drive.
await page.route(/\/api\/my-resources\/generate/, async route => { await page.route(/\/api\/my-resources\/generate/, async route => {
sent.push({ url: route.request().url(), body: route.request().postDataJSON() }); sent.push({ url: route.request().url(), body: route.request().postDataJSON() });
if (overrides.generateStatus) return json(route, overrides.generateBody || { error: 'Generation failed' }, overrides.generateStatus); if (overrides.generateStatus) return json(route, overrides.generateBody || { error: 'Generation failed' }, overrides.generateStatus);
return json(route, { success: true, resource: { id: 9, title: 'New resource', kind: 'presentation' }, return json(route, { success: true, job: { id: 9, topic: 'New resource', kind: 'presentation', status: 'queued' } }, 202);
markdown: '# New\n\n- one', grounding: { used: true, count: 7 },
searches: overrides.searches || [], imageJobs: overrides.imageJobs || [], imageFailures: [] });
}); });
// The bare listing, and nothing longer — /options and /generate are matched above. // The job list the page polls while anything is in flight. `overrides.jobs`
await page.route(/\/api\/my-resources$/, r => json(r, overrides.library || LIBRARY)); // is an array or a function of the poll number, so a test can hold a job at
// "running" and then let it land — the transition the page announces.
let jobPolls = 0;
await page.route(/\/api\/my-resources\/jobs$/, route => {
const jobs = typeof overrides.jobs === 'function' ? overrides.jobs(jobPolls++) : (overrides.jobs || []);
return json(route, { success: true, jobs });
});
await page.route(/\/api\/my-resources\/jobs\/\d+$/, route => json(route, { success: true }));
// The bare listing, and nothing longer — /options, /jobs and /generate are
// matched above. A function here answers the same request differently over
// time, which is how a test sees the library reload when a job lands.
let libraryCalls = 0;
await page.route(/\/api\/my-resources$/, route => {
const body = typeof overrides.library === 'function' ? overrides.library(libraryCalls++) : (overrides.library || LIBRARY);
return json(route, body);
});
return sent; return sent;
} }
@ -156,9 +175,70 @@ test.describe('My Resources', () => {
expect(body.withImages).toBe('false'); expect(body.withImages).toBe('false');
expect(body.model).toBe('model-a'); expect(body.model).toBe('model-a');
// What it was written from is said plainly; ungrounded material presented as // The click is answered now, not when the deck exists: the browser is no
// grounded is the failure worth preventing. // longer the thing doing the waiting, and the button is usable again at once.
await expect(page.locator('#mr-status')).toContainText('7 library excerpts'); await expect(page.locator('#mr-status')).toContainText('Queued');
await expect(page.locator('#btn-mr-generate')).toBeEnabled();
});
test('a generation in flight is listed, and a reload does not lose it', async ({ authedPage: _, page }) => {
// The whole point of the change: the work is a row on the server, so
// leaving the page — or losing it — cannot lose the deck being written.
const job = { id: 9, topic: 'croup in children', kind: 'presentation', status: 'running',
created_at: new Date().toISOString() };
await stub(page, { jobs: [job] });
await openTab(page);
await page.fill('#mr-topic', 'croup in children');
await page.click('#btn-mr-generate');
await expect(page.locator('#mr-jobs')).toBeVisible();
await expect(page.locator('#mr-jobs')).toContainText('croup in children');
await expect(page.locator('#mr-jobs')).toContainText('Writing');
// A reload starts from nothing — the form is empty again — and the job is
// still there, still running, because it never lived in this tab.
await page.reload();
await openTab(page);
await expect(page.locator('#mr-topic')).toHaveValue('');
await expect(page.locator('#mr-jobs')).toBeVisible();
await expect(page.locator('#mr-jobs')).toContainText('croup in children');
await expect(page.locator('#mr-jobs')).toContainText('Writing');
});
test('a job that lands reloads the library and says what it was written from', async ({ authedPage: _, page }) => {
const running = { id: 9, topic: 'croup', kind: 'presentation', status: 'running',
created_at: new Date().toISOString() };
const done = Object.assign({}, running, { status: 'done', resource_id: 9,
result: { resource: { id: 9, title: 'Croup in children (new)' },
grounding: { used: true, count: 7 }, searches: [], imageJobs: [], imageFailures: [] } });
await stub(page, {
jobs: n => [n === 0 ? running : done],
library: n => n === 0 ? LIBRARY : { success: true, resources: LIBRARY.resources.concat([
{ id: 9, title: 'Croup in children (new)', kind: 'presentation', topic: 'croup',
grounded_count: 7, created_at: new Date().toISOString() }]) },
});
await openTab(page);
await page.fill('#mr-topic', 'croup');
await page.click('#btn-mr-generate');
// What it was written from, said when it lands: ungrounded material
// presented as grounded is the failure worth preventing.
await expect(page.locator('#mr-status')).toContainText('7 library excerpts', { timeout: 30000 });
// And it is in the library without a click, which is the reload.
await expect(page.locator('#mr-list')).toContainText('Croup in children (new)');
});
test('a job that fails on the server says why, in the tab that asked', async ({ authedPage: _, page }) => {
const running = { id: 9, topic: 'croup', kind: 'presentation', status: 'running',
created_at: new Date().toISOString() };
const failed = Object.assign({}, running, { status: 'failed', error: 'The model returned nothing. Try again.' });
await stub(page, { jobs: n => [n === 0 ? running : failed] });
await openTab(page);
await page.fill('#mr-topic', 'croup');
await page.click('#btn-mr-generate');
await expect(page.locator('#mr-status')).toContainText('The model returned nothing', { timeout: 30000 });
await expect(page.locator('#btn-mr-generate')).toBeEnabled();
}); });
test('unticking the library is sent as false, not omitted', async ({ authedPage: _, page }) => { test('unticking the library is sent as false, not omitted', async ({ authedPage: _, page }) => {
@ -298,16 +378,23 @@ test.describe('My Resources', () => {
await expect(page.locator('#mr-modify-status')).toContainText('could not be applied'); await expect(page.locator('#mr-modify-status')).toContainText('could not be applied');
}); });
test('a search that ran is reported, including one that found nothing', async ({ authedPage: _, page }) => { test('a search that ran is reported when the job lands, including one that found nothing', async ({ authedPage: _, page }) => {
await stub(page, { searches: [ // Searching happens on the server, inside the job, so what was searched for
// arrives with the job landing rather than with the click.
const running = { id: 9, topic: 'croup', kind: 'presentation', status: 'running',
created_at: new Date().toISOString() };
const done = Object.assign({}, running, { status: 'done',
result: { resource: { id: 9, title: 'Croup in children' }, grounding: { used: true, count: 6 },
searches: [
{ tool: 'pubmed_search', query: 'croup', count: 6, reason: null }, { tool: 'pubmed_search', query: 'croup', count: 6, reason: null },
{ tool: 'web_search', query: 'croup', count: 0, reason: 'no results' }, { tool: 'web_search', query: 'croup', count: 0, reason: 'no results' },
]}); ], imageJobs: [], imageFailures: [] } });
await stub(page, { jobs: n => [n === 0 ? running : done] });
await openTab(page); await openTab(page);
await page.fill('#mr-topic', 'croup'); await page.fill('#mr-topic', 'croup');
await page.click('#btn-mr-generate'); await page.click('#btn-mr-generate');
// A query that left the network is worth showing plainly. // A query that left the network is worth showing plainly.
await expect(page.locator('body')).toContainText('Searched PubMed'); await expect(page.locator('body')).toContainText('Searched PubMed', { timeout: 30000 });
await expect(page.locator('body')).toContainText('Nothing found on the web'); await expect(page.locator('body')).toContainText('Nothing found on the web');
}); });
}); });

View file

@ -72,6 +72,20 @@ echo " testing revision ${RUNNING:-<unknown>}"
echo "==> Seeding e2e accounts" echo "==> Seeding e2e accounts"
"${COMPOSE[@]}" exec -T pediatric-scribe-e2e node e2e/seed.js "${COMPOSE[@]}" exec -T pediatric-scribe-e2e node e2e/seed.js
# ── Sessions for the browser ──────────────────────────────────────────
# Minted here, on the host, where the docker CLI exists: the browser runs in a
# container that has neither the CLI nor its socket, so a fixture that shells
# out to `docker` from in there cannot log anybody in and every spec dies at
# the auth fixture before it starts. The seed prints its connection banner
# first, so the token is the last line.
echo "==> Minting e2e sessions"
E2E_AUTH_TOKEN="$("${COMPOSE[@]}" exec -T pediatric-scribe-e2e node e2e/seed.js token "${E2E_TEST_EMAIL:-e2e-user@ped-ai.test}" | tail -n1)"
E2E_ADMIN_AUTH_TOKEN="$("${COMPOSE[@]}" exec -T pediatric-scribe-e2e node e2e/seed.js token "${E2E_ADMIN_EMAIL:-e2e-admin@ped-ai.test}" | tail -n1)"
if [ -z "$E2E_AUTH_TOKEN" ] || [ -z "$E2E_ADMIN_AUTH_TOKEN" ]; then
echo "FATAL: the e2e sessions could not be minted" >&2
exit 1
fi
# ── The browser ─────────────────────────────────────────────────────── # ── The browser ───────────────────────────────────────────────────────
# Host network and a loopback URL, because the browser only treats loopback as # Host network and a loopback URL, because the browser only treats loopback as
# a secure context over plain http, and the app cannot sign in without one. # a secure context over plain http, and the app cannot sign in without one.
@ -84,6 +98,8 @@ docker run --rm --ipc=host \
-e BASE_URL="$APP_URL" \ -e BASE_URL="$APP_URL" \
-e CI=true \ -e CI=true \
-e OPENAPI_UNDESCRIBED_BUDGET="${OPENAPI_UNDESCRIBED_BUDGET:-}" \ -e OPENAPI_UNDESCRIBED_BUDGET="${OPENAPI_UNDESCRIBED_BUDGET:-}" \
-e E2E_AUTH_TOKEN="$E2E_AUTH_TOKEN" \
-e E2E_ADMIN_AUTH_TOKEN="$E2E_ADMIN_AUTH_TOKEN" \
"$PLAYWRIGHT_IMAGE" \ "$PLAYWRIGHT_IMAGE" \
sh -c "npm install --no-audit --no-fund --silent && npx playwright test ${GREP:+--grep \"$GREP\"}" sh -c "npm install --no-audit --no-fund --silent && npx playwright test ${GREP:+--grep \"$GREP\"}"
STATUS=$? STATUS=$?