Files
verdande/web/e2e/smoke.spec.js
T
Kristian WindandClaude Opus 5 c14559ed0c feat: project groups, a month on Kommende, and dragging things where they go
The three the handoff named as next. They are one commit because they are
one mechanism: all three end in something being dragged, and a drop target
cannot tell what is coming without the module they now share.

Groups are foldable headings over the projects in the sidebar. A table
rather than a `group_name` column on the project, because a group has to
be renamable in one write, has to survive being emptied, and has to
remember whether it is folded — none of which a string repeated on every
project row can do.

Folded state lives on the row, not in localStorage. The sidebar's *width*
is a property of the screen you are sitting at, which is why that one is
per browser; a folded group is a statement about the group — "this is the
part of my work I am not in right now" — and it is true on the laptop and
on the desktop. There is nobody else's answer to disagree with either: a
group belongs to exactly one person.

A shared project cannot be filed. `group_id` is a column on the project
row, so a member grouping somebody else's project would move it in the
owner's sidebar too — the same reason `sort_order` is owner-scoped. The
listing reports the column only to the owner, so a viewer is never told
where somebody else keeps it.

Deleting a group takes the heading and leaves the projects, and the
sidebar decides where a project renders by whether its group still
exists rather than by the id alone. A group deleted in another tab
arrives as one event and not one per project, so for a moment the
projects still name a heading that is no longer drawn — filing by the raw
id would drop them out of the sidebar entirely.

Kommende gains the month grid the project pages already had, behind a
Liste/Kalender toggle. The two views ask for different things — seven days
from today against six whole weeks around an arbitrary month — so each
loads its own, and the grid reports which dates it is showing rather than
having the page guess. Which view is remembered in localStorage: a month
of seven columns needs room, and the answer on a phone is not the answer
on a wide monitor.

And a task can now be dropped on a project in the sidebar, on a day in
Kommende, or on a cell in any month grid. Onto a project it is a *move*,
not a field: `sort_order` belongs to the project, so it has to find a
place among that project's tasks and leave its old section behind.

Each drag now carries its own MIME type. `getData` is deliberately
unreadable while a drag is in the air — the browser hands the payload over
on drop and not before, so a page cannot read what is being dragged across
it — and `dataTransfer.types` is all a target has to decide with. Deciding
is the point: calling preventDefault on a drag you cannot accept is what
turns "no" into a silent, wrong drop. The sidebar takes a project and a
task on the same row and has to tell them apart.

Two things fell out of building it. The month grid has always had one cell
narrower than the other six: `.today` styled the "I dag" button in the
header, and the day cell for today wears the same class, so `margin-left:
auto` was shrinking a grid item to its content. It is scoped to the header
now. And a project page listed whatever was in the store rather than what
belonged to that project, which did not matter until a task could leave a
project while you were looking at it.

Covered by eight new API tests and two new smoke tests, and the smoke tests
drive the drags for real — the type check they exercise exists only while
a drag is in flight, so nothing else can reach it.

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

381 lines
15 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([]);
});
/**
* A group can be made, folded, filled and deleted without taking its projects
* with it.
*
* The fold is the part worth driving in a browser: it is stored on the account
* rather than in localStorage, so it is a round trip, and "the arrow turned" is
* not the same claim as "it came back folded after a reload".
*/
test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne med', 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('Regnskab');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
await sidebar.getByRole('button', { name: 'Ny gruppe' }).click();
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
await sidebar.getByLabel('Gruppens navn').press('Enter');
const heading = sidebar.getByRole('button', { name: 'Arbejde' });
await expect(heading).toBeVisible();
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
// Dragged in, then dragged back out onto "Projekter" — which is the reason
// that heading is a drop target at all: with every project filed away there
// would otherwise be no loose row left to aim at.
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
await expect(sidebar.getByText('Træk et projekt herop')).toBeHidden();
await sidebar
.getByRole('link', { name: 'Regnskab' })
.dragTo(sidebar.getByRole('heading', { name: 'Projekter' }));
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
// Folded, reloaded, still folded. localStorage would have passed the first
// half of that and failed the second on another machine.
await heading.click();
await expect(heading).toHaveAttribute('aria-expanded', 'false');
await page.reload();
await expect(sidebar.getByRole('button', { name: 'Arbejde' })).toHaveAttribute(
'aria-expanded',
'false'
);
await sidebar.getByRole('button', { name: 'Arbejde' }).click();
// Renaming writes one row rather than one per project, which is the reason a
// group is a table and not a string repeated on every project.
await sidebar.getByRole('button', { name: 'Omdøb' }).click();
const name = sidebar.getByLabel('Gruppens navn');
await name.fill('Kontoret');
await name.press('Enter');
await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeVisible();
// Deleting the heading must not take the project filed under it. It is
// ungrouped here, which is the case that would look identical either way if
// the assertion were only "the group is gone".
page.once('dialog', (dialog) => dialog.accept());
await sidebar.getByRole('button', { name: 'Slet' }).click();
await expect(sidebar.getByRole('button', { name: 'Kontoret' })).toBeHidden();
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* A task can be dragged onto another day, and onto another project.
*
* Both are one gesture standing in for a form, and both cross a component
* boundary: the row is dragged out of Kommende and dropped on the sidebar, which
* is a different part of the tree entirely. What decides whether a target will
* take it is the drag's MIME type, and that is only readable while the drag is in
* the air — so this is the one thing here that cannot be checked any other way.
*/
test('en opgave kan trækkes til en anden dag og videre til et projekt', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
await box.fill('hent pakken i dag');
await box.press('Enter');
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ærinder');
await sidebar.getByLabel('Projektnavn').press('Enter');
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Liste', exact: true }).click();
const day = (name) =>
page.locator('section').filter({ has: page.getByRole('heading', { name, exact: true }) });
const row = page.locator('[draggable="true"]').filter({ hasText: 'hent pakken' });
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
await row.dragTo(day('I morgen'));
await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
await expect(day('I dag').getByText('hent pakken')).toBeHidden();
// And onto a project, which is a move rather than a field: the task has to
// find a place among that project's tasks, and leave its old section behind.
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
await sidebar.getByRole('link', { name: 'Ærinder' }).click();
await expect(page.getByRole('heading', { name: 'Ærinder' })).toBeVisible();
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
// And the same task in the month grid, where a chip is dragged from one cell
// to another. The date is read off the grid rather than worked out here: the
// browser is pinned to Europe/Copenhagen and this process is not, and for two
// hours of every day they disagree about what today is.
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Kalender' }).click();
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
await expect(chip).toBeVisible();
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000)
.toISOString()
.slice(0, 10);
await chip.dragTo(page.locator(`[data-date="${to}"]`));
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
await expect(page.locator(`[data-date="${from}"]`).getByText('hent pakken')).toBeHidden();
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');
});