// ============================================================ // 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: [] }); }); // 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, job: { id: 9, topic: 'New resource', kind: 'presentation', status: 'queued' } }, 202); }); // 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; } 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'); // Details is folded away until somebody has something long to paste. await page.click('#mr-details-wrap summary'); await page.fill('#mr-details', 'Cover: febrile seizure definition; red flags; when to LP; discharge advice.'); 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'); expect(body.details).toContain('when to LP'); // 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'); // 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: [] } }); // Running until Generate is pressed, done after it. The page polls the job // list as soon as it opens, so a job that lands on a poll *count* would // have landed before the click and there would be no transition to // announce — which is the behaviour under test, not a detail of the stub. const sent = await stub(page, { jobs: () => (sent.length ? [done] : [running]), 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.' }); // As above: running until the click, so the failure is a transition. const sent = await stub(page, { jobs: () => (sent.length ? [failed] : [running]) }); 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 }) => { 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 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: [] } }); // Running until the click, done after it — searching happens server-side, // inside the job, so what was searched for arrives with the landing. const sent = await stub(page, { jobs: () => (sent.length ? [done] : [running]) }); 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', { timeout: 30000 }); await expect(page.locator('body')).toContainText('Nothing found on the web'); }); });