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.
407 lines
21 KiB
JavaScript
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');
|
|
});
|
|
});
|