test(e2e): drive My Resources through the browser, and assert what it sends
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

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, and nothing
exercised the browser at all — so a mismatch between what the form sends and
what the route reads 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 assert the request bodies, not only the rendering: that Generate sends
topic, kind, slideCount, refinement, model and all four options as the strings
the route compares against; that unticking the library sends 'false' rather than
omitting the field, which the route would read as on; and that Modify posts to
the right resource with every source option. Plus the screen's own behaviour —
availability gating on both cards, the illustration hint switching on and
staying off once overruled, the bounded searchable library, the two different
empty states, an article never being offered as slides, a local refusal that
spends no round trip, and a refused modification surfacing its reason. Fourteen
tests, both viewports.

The API is stubbed. This is the contract between the screen and the route, and
stubbing keeps it fast, free and deterministic.

Proven to catch regressions rather than merely pass: renaming useCorpus in the
form failed two tests, breaking the availability gating failed one, and
truncating the modify picker failed another.

Two flakes of my own were fixed rather than retried. openTab slept 400ms for the
library and picker instead of waiting for them, which made Modify report
"nothing to modify yet" under load. And the console-error guard failed on
net::ERR_ABORTED and net::ERR_NETWORK_CHANGED — a request in flight when the
context closes, and the host network reconfiguring under a browser that runs on
it. Both are the harness, not the page: anything genuinely failing carries a
status code and is still caught. Five consecutive clean full runs after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 01:51:15 +02:00
parent 3ec65a91f6
commit 9b2cb339a1
2 changed files with 318 additions and 0 deletions

View file

@ -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
];

View file

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