From f0cc537df135da525e6f246d22c826e01fc98fde Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 16 Sep 2026 04:02:10 +0200 Subject: [PATCH] test(e2e): the browser suite follows generation into its job, and can log in again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- e2e/fixtures.js | 15 ++++- e2e/tests/my-resources.spec.js | 115 +++++++++++++++++++++++++++++---- scripts/e2e.sh | 16 +++++ 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/e2e/fixtures.js b/e2e/fixtures.js index e80c7a70..33aee5a3 100644 --- a/e2e/fixtures.js +++ b/e2e/fixtures.js @@ -58,15 +58,28 @@ function isAllowedConsoleNoise(text) { // 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(); + { 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. diff --git a/e2e/tests/my-resources.spec.js b/e2e/tests/my-resources.spec.js index f03c5ece..b2cf06c1 100644 --- a/e2e/tests/my-resources.spec.js +++ b/e2e/tests/my-resources.spec.js @@ -54,16 +54,35 @@ async function stub(page, overrides = {}) { 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 => { sent.push({ url: route.request().url(), body: route.request().postDataJSON() }); 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' }, - markdown: '# New\n\n- one', grounding: { used: true, count: 7 }, - searches: overrides.searches || [], imageJobs: overrides.imageJobs || [], imageFailures: [] }); + return json(route, { success: true, job: { id: 9, topic: 'New resource', kind: 'presentation', status: 'queued' } }, 202); }); - // The bare listing, and nothing longer — /options and /generate are matched above. - await page.route(/\/api\/my-resources$/, r => json(r, overrides.library || LIBRARY)); + // The job list the page polls while anything is in flight. `overrides.jobs` + // 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; } @@ -156,9 +175,70 @@ test.describe('My Resources', () => { expect(body.withImages).toBe('false'); expect(body.model).toBe('model-a'); - // What it was written from is said plainly; ungrounded material presented as - // grounded is the failure worth preventing. - await expect(page.locator('#mr-status')).toContainText('7 library excerpts'); + // The click is answered now, not when the deck exists: the browser is no + // longer the thing doing the waiting, and the button is usable again at once. + 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 }) => { @@ -298,16 +378,23 @@ test.describe('My Resources', () => { 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 }) => { - await stub(page, { searches: [ - { tool: 'pubmed_search', query: 'croup', count: 6, reason: null }, - { tool: 'web_search', query: 'croup', count: 0, reason: 'no results' }, - ]}); + test('a search that ran is reported when the job lands, including one that found nothing', async ({ authedPage: _, page }) => { + // 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: 'web_search', query: 'croup', count: 0, reason: 'no results' }, + ], imageJobs: [], imageFailures: [] } }); + await stub(page, { jobs: n => [n === 0 ? running : done] }); await openTab(page); await page.fill('#mr-topic', 'croup'); await page.click('#btn-mr-generate'); // 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'); }); }); diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 06367892..0adb7bea 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -72,6 +72,20 @@ echo " testing revision ${RUNNING:-}" echo "==> Seeding e2e accounts" "${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 ─────────────────────────────────────────────────────── # 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. @@ -84,6 +98,8 @@ docker run --rm --ipc=host \ -e BASE_URL="$APP_URL" \ -e CI=true \ -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" \ sh -c "npm install --no-audit --no-fund --silent && npx playwright test ${GREP:+--grep \"$GREP\"}" STATUS=$?