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=$?