diff --git a/e2e/fixtures.js b/e2e/fixtures.js index 2bd2a368..4789ebbe 100644 --- a/e2e/fixtures.js +++ b/e2e/fixtures.js @@ -36,6 +36,15 @@ const CONSOLE_ERROR_ALLOWLIST = [ /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 ]; diff --git a/e2e/tests/my-resources.spec.js b/e2e/tests/my-resources.spec.js new file mode 100644 index 00000000..6da274c8 --- /dev/null +++ b/e2e/tests/my-resources.spec.js @@ -0,0 +1,309 @@ +// ============================================================ +// MY RESOURCES — the UI, and the requests it actually sends +// ============================================================ +// 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. Nothing +// exercised the browser, so a mismatch between what the form sends and what the +// route reads would have 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 tests assert the request bodies, not only the rendering. The API is +// stubbed: this is about the contract between the screen and the route, and +// stubbing keeps it fast, free and deterministic. + +const { test, expect, E2E_BASE } = require('../fixtures'); + +const OPTIONS = { + success: true, + models: ['model-a', 'model-b'], + defaultModel: 'model-a', + imagesAvailable: true, + webSearchAvailable: true, + pubmedAvailable: true, +}; + +const LIBRARY = { + success: true, + resources: [ + { id: 1, title: 'Croup in children', kind: 'presentation', topic: 'croup', + grounded_count: 12, created_at: '2026-09-01T10:00:00Z' }, + { id: 2, title: 'Neonatal jaundice', kind: 'article', topic: 'jaundice', + grounded_count: 0, created_at: '2026-09-02T10:00:00Z' }, + { id: 3, title: 'Bronchiolitis basics', kind: 'presentation', topic: 'bronchiolitis', + grounded_count: 30, created_at: '2026-09-03T10:00:00Z' }, + ], +}; + +/** Stub the feature's endpoints and record every request body sent to them. */ +async function stub(page, overrides = {}) { + const sent = []; + const json = (route, body, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + + await page.route(/\/api\/my-resources\/options/, r => json(r, overrides.options || OPTIONS)); + + await page.route(/\/api\/my-resources\/\d+\/refine/, async route => { + sent.push({ url: route.request().url(), body: route.request().postDataJSON() }); + if (overrides.refineStatus) return json(route, overrides.refineBody || { error: 'refused' }, overrides.refineStatus); + return json(route, { success: true, resource: { id: 1, title: 'Croup in children' }, + markdown: '# Croup\n\n- revised', grounding: { used: true, count: 4 }, + searches: [], imageJobs: [], imageFailures: [] }); + }); + + 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: [] }); + }); + + // The bare listing, and nothing longer — /options and /generate are matched above. + await page.route(/\/api\/my-resources$/, r => json(r, overrides.library || LIBRARY)); + return sent; +} + +async function openTab(page) { + await page.goto(E2E_BASE + '/'); + await page.waitForSelector('button.tab-btn', { timeout: 20000 }); + const vp = page.viewportSize(); + if (vp && vp.width <= 768) await page.click('#btn-menu-toggle').catch(() => {}); + await page.click('button.tab-btn[data-tab="myresources"]'); + await page.waitForSelector('#mr-topic', { timeout: 20000 }); + // Wait for the answers, not for a guess at how long they take. A fixed sleep + // here made two tests fail only in a full run: the library had not landed, so + // Modify reported "nothing to modify yet" instead of the refusal under test. + await page.waitForFunction(() => { + const list = document.getElementById('mr-list'); + const picker = document.getElementById('mr-modify-target'); + const listed = list && (list.querySelector('.saved-enc-item') || /Nothing yet/.test(list.textContent)); + return Boolean(listed && picker && picker.options.length); + }, { timeout: 20000 }); +} + +const visible = (page, id) => page.evaluate(i => { + const el = document.getElementById(i); + return el ? el.offsetParent !== null : 'absent'; +}, id); + +test.describe('My Resources', () => { + + test('the screen says what it is for, and offers every enabled source', async ({ authedPage: _, page }) => { + await stub(page); + await openTab(page); + + // "Private to you" said who could see it, not what it did. + await expect(page.locator('#myresources-tab')).toContainText('Build a teaching deck or handout'); + await expect(page.locator('#myresources-tab')).toContainText('Only you can see these'); + + for (const id of ['mr-use-corpus', 'mr-pubmed', 'mr-web-search', 'mr-with-images']) { + expect(await visible(page, id), id).toBe(true); + } + // The library is the default, because most resources should be grounded. + await expect(page.locator('#mr-use-corpus')).toBeChecked(); + for (const id of ['mr-pubmed', 'mr-web-search', 'mr-with-images']) { + await expect(page.locator('#' + id), id + ' is opt-in').not.toBeChecked(); + } + // Two approved models means a choice worth offering; one would not be. + expect(await visible(page, 'mr-model-row')).toBe(true); + }); + + test('an option an administrator has not enabled is hidden, not shown and refused', async ({ authedPage: _, page }) => { + await stub(page, { options: { ...OPTIONS, webSearchAvailable: false, pubmedAvailable: false, + imagesAvailable: false, models: ['only-one'] } }); + await openTab(page); + for (const id of ['mr-web-row', 'mr-pubmed-row', 'mr-images-row']) { + expect(await visible(page, id), id).toBe(false); + } + // And the same rule on the Modify card, from the same answer. + for (const id of ['mr-modify-web-row', 'mr-modify-pubmed-row', 'mr-modify-images-row']) { + expect(await visible(page, id), id).toBe(false); + } + // One model is not a decision anyone should be asked to take. + expect(await visible(page, 'mr-model-row')).toBe(false); + }); + + test('Generate sends exactly what the route reads', async ({ authedPage: _, page }) => { + const sent = await stub(page); + await openTab(page); + + await page.fill('#mr-topic', 'croup in children'); + await page.selectOption('#mr-kind', 'presentation'); + await page.fill('#mr-slide-count', '9'); + await page.check('#mr-pubmed'); + await page.check('#mr-web-search'); + await page.fill('#mr-refinement', 'for FY1s'); + await page.click('#btn-mr-generate'); + await expect.poll(() => sent.length, { timeout: 15000 }).toBeGreaterThan(0); + + const body = sent[0].body; + expect(body.topic).toBe('croup in children'); + expect(body.kind).toBe('presentation'); + expect(body.slideCount).toBe('9'); + expect(body.refinement).toBe('for FY1s'); + // Strings, because the route compares against 'true' / 'false'. + expect(body.useCorpus).toBe('true'); + expect(body.withPubmed).toBe('true'); + expect(body.withWebSearch).toBe('true'); + 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'); + }); + + test('unticking the library is sent as false, not omitted', async ({ authedPage: _, page }) => { + const sent = await stub(page); + await openTab(page); + await page.fill('#mr-topic', 'anything'); + await page.uncheck('#mr-use-corpus'); + await page.click('#btn-mr-generate'); + await expect.poll(() => sent.length, { timeout: 15000 }).toBeGreaterThan(0); + // The route reads `!== 'false'`, so an omitted field would silently mean on. + expect(sent[0].body.useCorpus).toBe('false'); + }); + + test('asking for a figure in the instructions switches illustrations on', async ({ authedPage: _, page }) => { + await stub(page); + await openTab(page); + await expect(page.locator('#mr-with-images')).not.toBeChecked(); + + await page.fill('#mr-refinement', 'case-based, and include a diagram of the airway'); + await expect(page.locator('#mr-with-images')).toBeChecked(); + await expect(page.locator('#mr-image-hint')).toContainText('Illustration switched on'); + + // Switching it off by hand sticks: it must not fight the person using it. + await page.uncheck('#mr-with-images'); + await page.fill('#mr-refinement', 'case-based, and include a diagram of the airway please'); + await expect(page.locator('#mr-with-images')).not.toBeChecked(); + await expect(page.locator('#mr-image-hint')).toContainText('Tick the illustration option'); + }); + + test('with no image model, an instruction asking for one says so', async ({ authedPage: _, page }) => { + await stub(page, { options: { ...OPTIONS, imagesAvailable: false } }); + await openTab(page); + await page.fill('#mr-refinement', 'include a diagram'); + await expect(page.locator('#mr-image-hint')).toContainText('no image model is configured'); + }); + + test('a failed generation says what went wrong and keeps the form', async ({ authedPage: _, page }) => { + await stub(page, { generateStatus: 500, generateBody: { error: 'Generation failed' } }); + await openTab(page); + await page.fill('#mr-topic', 'anything'); + await page.click('#btn-mr-generate'); + await expect(page.locator('#mr-status')).toContainText('Generation failed'); + // The topic is still there to try again with. + await expect(page.locator('#mr-topic')).toHaveValue('anything'); + await expect(page.locator('#btn-mr-generate')).toBeEnabled(); + }); + + test('the library is bounded, searchable, and says which empty it is', async ({ authedPage: _, page }) => { + await stub(page); + await openTab(page); + await expect(page.locator('#mr-list .saved-enc-item')).toHaveCount(3); + + const box = await page.evaluate(() => { + const el = document.getElementById('mr-list'); + const cs = getComputedStyle(el); + return { maxHeight: cs.maxHeight, overflowY: cs.overflowY }; + }); + expect(box.maxHeight).toBe('360px'); + expect(box.overflowY).toBe('auto'); + + await page.fill('#mr-search', 'croup'); + await expect(page.locator('#mr-list .saved-enc-item')).toHaveCount(1); + // Topic is searched as well as title. + await page.fill('#mr-search', 'jaundice'); + await expect(page.locator('#mr-list .saved-enc-item')).toHaveCount(1); + // Telling someone whose search missed that they have never generated + // anything would be wrong. + await page.fill('#mr-search', 'zzzz-nothing'); + await expect(page.locator('#mr-list')).toContainText('Nothing matches'); + await page.fill('#mr-search', ''); + await expect(page.locator('#mr-list .saved-enc-item')).toHaveCount(3); + }); + + test('an empty library says so differently', async ({ authedPage: _, page }) => { + await stub(page, { library: { success: true, resources: [] } }); + await openTab(page); + await expect(page.locator('#mr-list')).toContainText('Nothing yet'); + // Nothing to modify, and the picker says that rather than sitting empty. + await expect(page.locator('#mr-modify-target')).toBeDisabled(); + await expect(page.locator('#mr-modify-target')).toContainText('Nothing to modify yet'); + }); + + test('an article is never offered as slides', async ({ authedPage: _, page }) => { + await stub(page); + await openTab(page); + const row = id => page.locator('#mr-list .saved-enc-item').filter({ hasText: id }); + // A deck of paragraphs is not a presentation. + await expect(row('Neonatal jaundice').locator('[data-format="pptx"]')).toHaveCount(0); + await expect(row('Neonatal jaundice').locator('[data-format="docx"]')).toHaveCount(1); + await expect(row('Neonatal jaundice').locator('[data-format="pdf"]')).toHaveCount(1); + // A presentation as Word is fine — prose absorbs slides without overflowing. + await expect(row('Croup in children').locator('[data-format="pptx"]')).toHaveCount(1); + }); + + test('Modify sends the instruction and every source option', async ({ authedPage: _, page }) => { + const sent = await stub(page); + await openTab(page); + + // The picker is the library, so it cannot drift from it. + await expect(page.locator('#mr-modify-target option')).toHaveCount(3); + await expect(page.locator('#mr-modify-target')).toContainText('Croup in children — presentation'); + + await page.selectOption('#mr-modify-target', '3'); + await page.fill('#mr-modify-instructions', 'split slide four'); + await page.check('#mr-modify-pubmed'); + await page.check('#mr-modify-images'); + await page.click('#btn-mr-modify'); + await expect.poll(() => sent.length, { timeout: 15000 }).toBeGreaterThan(0); + + const call = sent[0]; + expect(call.url).toContain('/my-resources/3/refine'); + expect(call.body.instructions).toBe('split slide four'); + expect(call.body.useCorpus).toBe('true'); + expect(call.body.withPubmed).toBe('true'); + expect(call.body.withWebSearch).toBe('false'); + expect(call.body.withImages).toBe('true'); + await expect(page.locator('#mr-modify-status')).toContainText('Applied'); + }); + + test('Modify refuses locally rather than spending a round trip', async ({ authedPage: _, page }) => { + const sent = await stub(page); + await openTab(page); + await page.fill('#mr-modify-instructions', ''); + await page.click('#btn-mr-modify'); + await expect(page.locator('#mr-modify-status')).toContainText('Say what to change'); + expect(sent.length, 'nothing was sent').toBe(0); + }); + + test('a refused modification surfaces the reason', async ({ authedPage: _, page }) => { + await stub(page, { refineStatus: 502, + refineBody: { error: 'That change could not be applied. Try wording it differently.' } }); + await openTab(page); + await page.selectOption('#mr-modify-target', '1'); + await page.fill('#mr-modify-instructions', 'do something impossible'); + await page.click('#btn-mr-modify'); + // Saying "applied" here is how a modification that changed nothing hides. + 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' }, + ]}); + 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('Nothing found on the web'); + }); +});