pediatric-ai-scribe-v3/e2e/tests/my-resources.spec.js
Daniel 87c69ce151
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 7s
revert: no new tests were asked for with the slide-cap change
The existing coverage moves with the constant: the review test asserts
against review.MAX_SLIDES + 1, and the browser suite already checks the slide
count the form sends. Removed so the change is exactly the four lines.
2026-09-16 17:43:30 +02:00

407 lines
21 KiB
JavaScript

// ============================================================
// 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');
});
});