Files
verdande/web/e2e/smoke.spec.js
T
Kristian WindandClaude Opus 5 7ab68b0b58 fix(projects): keep the heading, and make the sidebar test mean what it says
Renaming replaced the project page's h1 with an input, so a page an owner
opened had no heading at all. The sidebar smoke test caught it on CI — and
it was right to: every other view here has a heading, and it is what a
screen reader announces on arrival.

The heading stays and becomes a field when clicked. Same rename, one more
click, and the page keeps its outline.

The more interesting half is why that passed locally. The test clicked a
link and immediately asserted a heading was visible — and `toBeVisible`
was being satisfied by the heading of the page it had just left, before
the SPA swapped it out. On this laptop the old h1 lingered long enough to
answer; on a slower runner it did not. So the test did not check that the
new page rendered, it checked that some page had at some point.

waitForURL closes that. Verified by putting the broken heading back with
the strengthened test in place: it now fails locally with the same message
CI gave.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:10:00 +02:00

247 lines
9.4 KiB
JavaScript

import { test, expect } from '@playwright/test';
/**
* The four flows a broken build must not survive: add a task, tick it off, reach
* every page in the sidebar, and open a task.
*
* Every test asserts on *rendered* content rather than on a URL. A route that no
* longer exists still answers 200 here — the Go binary serves the SPA shell for
* anything it does not recognise — so `expect(page).toHaveURL()` would pass on
* precisely the bug this suite exists to catch.
*/
/**
* Collects anything that went wrong quietly: a thrown exception, a console error,
* or an API call that came back a failure.
*
* Failed responses are recorded with their URL and status. "A console error
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
* 500" is.
*/
function watchForTrouble(page) {
const trouble = [];
page.on('console', (message) => {
// The browser logs its own line for every failed fetch. It carries no URL,
// so it is noise next to the response listener below.
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
trouble.push(message.text());
}
});
page.on('pageerror', (error) => trouble.push(String(error)));
page.on('response', (response) => {
const url = response.url();
if (url.includes('/api/') && !response.ok()) {
trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`);
}
});
return trouble;
}
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// "i dag" rather than "i morgen": this is the Today view, and a task parsed
// onto tomorrow is correctly absent from it — which would look like the quick
// add had failed.
await box.fill('køb kaffe i dag p1');
await box.press('Enter');
// The server parses the line, so this asserts the whole round trip: "i dag"
// and "p1" are gone from the title because they became a date and a priority.
const task = page.getByText('køb kaffe', { exact: true });
await expect(task).toBeVisible();
await page.getByRole('button', { name: 'Markér som færdig' }).first().click();
await expect(task).toBeHidden();
expect(trouble).toEqual([]);
});
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
// Wait for one link before counting them. `all()` is a snapshot with no
// auto-waiting — it returns whatever matches at that instant — so counting
// straight after goto() measures how fast the machine is. It passed on a
// laptop and found zero links on a CI runner.
await expect(sidebar.getByRole('link').first()).toBeVisible();
const hrefs = await sidebar
.getByRole('link')
.evaluateAll((links) => links.map((a) => a.getAttribute('href')));
expect(hrefs.length).toBeGreaterThanOrEqual(4); // I dag, Kommende, Indbakke, Indstillinger
// Re-resolved by href on each pass rather than held as handles: navigating
// re-renders the sidebar, and a handle from before the click is a handle to
// an element that no longer exists.
for (const href of hrefs) {
await sidebar.locator(`a[href="${href}"]`).first().click();
// Wait for the navigation before looking at the page. Without this the
// assertion can be satisfied by the heading of the page you just left —
// which is not a hypothetical: a change that removed a project page's
// heading entirely passed here on a fast machine and failed on CI.
await page.waitForURL(`**${href}`);
// Something with a heading rendered. A route written outside the routes
// tree lands on the SPA shell and renders nothing — which is exactly how
// the label route broke, and it answered 200 the whole time.
await expect(
page.locator('main h1, main h2').first(),
`${href} renderede ingenting`
).toBeVisible({ timeout: 5000 });
}
expect(trouble).toEqual([]);
});
test('en opgave kan åbnes og redigeres', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// Dated, so it lands in this view: an undated task goes to the Inbox and is
// correctly absent here.
await box.fill('skriv rapport i dag');
await box.press('Enter');
await page.getByText('skriv rapport', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await expect(drawer).toBeVisible();
// A sub-task, a comment and a description at once — which is also the shape
// that used to fail: three concurrent writes met SQLite's deferred BEGIN and
// one of them came back a 500.
await drawer.getByLabel('Beskrivelse').fill('Inden fredag.');
await drawer.getByLabel('Ny undertask').fill('lav dispositionen');
await drawer.getByLabel('Ny undertask').press('Enter');
await drawer.getByLabel('Ny kommentar').fill('Anders har materialet.');
await drawer.getByRole('button', { name: 'Skriv' }).click();
await expect(drawer.getByText('lav dispositionen')).toBeVisible();
await expect(drawer.getByText('Anders har materialet.')).toBeVisible();
// Nothing rolled back: a toast here means the server refused a write.
await expect(page.locator('.toast')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(drawer).toBeHidden();
// The description reached the row behind the drawer, so it was really saved.
await expect(page.getByText('Inden fredag.')).toBeVisible();
expect(trouble).toEqual([]);
});
test('hver fane under indstillinger renderer', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const tabs = page.getByRole('navigation', { name: 'Indstillinger' });
await expect(tabs).toBeVisible();
for (const label of [
'Konto',
'Notifikationer',
'Integrationer',
'AI',
'API-tokens',
'Data og skabeloner'
]) {
await tabs.getByRole('link', { name: label, exact: true }).click();
await expect(
page.locator('section.panel').first(),
`fanen ${label} renderede ingen sektion`
).toBeVisible({ timeout: 5000 });
}
expect(trouble).toEqual([]);
});
/**
* The PWA's own files. Both of the bugs found last in this project were of this
* kind — a manifest promising icons that were never generated — and they are
* invisible to every other test in the repository.
*/
test('manifestet og service workeren findes, og ikonerne findes også', async ({ request }) => {
const manifestResponse = await request.get('/manifest.webmanifest');
expect(manifestResponse.ok()).toBeTruthy();
const manifest = await manifestResponse.json();
for (const icon of manifest.icons ?? []) {
const iconResponse = await request.get(icon.src);
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
}
// The service worker is what Web Push needs; without it a subscription cannot
// be created at all.
const worker = await request.get('/sw.js');
expect(worker.ok()).toBeTruthy();
expect(await worker.text()).toContain("addEventListener('push'");
});
/**
* A project can be renamed, deleted and brought back.
*
* The round trip matters more than the three steps: deleting used to be a
* one-way door in the interface — the API could restore, but only from an id
* that had already disappeared from the screen.
*/
test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ferie');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible();
// The heading is the rename affordance: click it, and it becomes a field.
await page.getByRole('button', { name: 'Ferie' }).click();
const title = page.getByLabel('Projektets navn');
await title.fill('Sommerferie');
await title.press('Enter');
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Slet projektet' }).click();
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
// And it is reachable again, which is the half that did not exist.
await page.goto('/indstillinger/data');
const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' });
await expect(trash.getByText('Sommerferie')).toBeVisible();
await trash.getByRole('button', { name: 'Hent tilbage' }).click();
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* The MCP connector address must not fall through to the app shell.
*
* It did: `/mcp` was not a route, so the SPA fallback answered 200 with a page
* of HTML. A connector pointed at it reported a successful connection and then
* failed to parse the response — which looks like a broken client rather than a
* missing route. A 401 in JSON is the honest answer.
*/
test('MCP-adressen svarer JSON, ikke app-skallen', async ({ request }) => {
const response = await request.post('/mcp', {
data: { jsonrpc: '2.0', id: 1, method: 'initialize' }
});
expect(response.status(), 'uden nøgle skal den afvise, ikke servere siden').toBe(401);
expect(response.headers()['content-type']).toContain('application/json');
expect((await response.json()).code).toBe('unauthorized');
});