Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at 11:49, twice from another at 10:45 — and there is no way to find out what any of them were. verdande logs the real cause of every 500, but on a Rune the log belongs to the container and the container is replaced on every restart. So an operator is told that something broke and given nothing to act on, which is the worst of both: alarming and useless. Every 500 now writes a row: what the handler was attempting in its own words, the error it hit, the method and path, who was asking, and the request id that ties it back to the log line for whoever still has it. The "what" is the part a status code cannot give — "list projects" and "move task" are the difference between a fault you can find and one you can only count. Written best-effort and never checked. This is the path that is already reporting a failure, and a diagnostic that can itself fail the request would turn one broken screen into two. Threading the request through `internal()` and `storeError()` touched 108 call sites across 19 files, which is the price of not having had it there from the start. It is mechanical and the compiler checked all of it. Kept for thirty days by the nightly sweep: long enough to answer "what happened last week", short enough that a fault loop cannot fill the volume the tasks live on. Read at Indstillinger → Fejl, administrators only — it carries paths, account names and the server's own error strings. The empty state says "Ingen fejl registreret. Det er den rigtige tilstand", because a blank panel should not read as a broken one. The test drops a table and asks for the endpoint that reads it: the same code path a disk error takes, and the row that comes back has to name the operation, the account and the request. Co-Authored-By: Claude Opus 5 <[email protected]>
824 lines
36 KiB
JavaScript
824 lines
36 KiB
JavaScript
import { test, expect } from '@playwright/test';
|
|
import { USER } from './user.js';
|
|
|
|
/**
|
|
* 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',
|
|
// Only an administrator sees these two, and the account these tests run as
|
|
// is the first account, which is one.
|
|
'Brugere',
|
|
'Fejl',
|
|
'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();
|
|
}
|
|
|
|
// Safari never reads the manifest's icons; it looks for this by name, and
|
|
// without it an installed PWA on iOS gets a screenshot of the page as its
|
|
// home-screen icon.
|
|
const appleIcon = await request.get('/apple-touch-icon.png');
|
|
expect(appleIcon.ok(), 'apple-touch-icon.png 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');
|
|
|
|
// `exact`, because the heading's colour dot is a button too and its label is
|
|
// "Farve på Arbejde" — and Playwright matches an accessible name by substring
|
|
// unless told otherwise.
|
|
const heading = sidebar.getByRole('button', { name: 'Arbejde', exact: true });
|
|
await expect(heading).toBeVisible();
|
|
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
|
|
|
// The words in the heading must not sit on top of each other.
|
|
//
|
|
// Everything else here clicks by accessible name, which is exactly why this
|
|
// needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for
|
|
// the "+" icon beside "Projekter", overflowed it, and were drawn overlapping.
|
|
// Both buttons still worked, so every assertion in this file passed.
|
|
const boxes = await sidebar.locator('.folder-head').evaluate((el) =>
|
|
[...el.querySelectorAll('button')].map((b) => {
|
|
const r = b.getBoundingClientRect();
|
|
return { text: b.textContent.trim(), left: r.left, right: r.right };
|
|
})
|
|
);
|
|
for (let i = 1; i < boxes.length; i++) {
|
|
expect(
|
|
boxes[i].left,
|
|
`"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet`
|
|
).toBeGreaterThanOrEqual(boxes[i - 1].right);
|
|
}
|
|
|
|
// 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', exact: true })).toHaveAttribute(
|
|
'aria-expanded',
|
|
'false'
|
|
);
|
|
await sidebar.getByRole('button', { name: 'Arbejde', exact: true }).click();
|
|
|
|
// Colour, and indentation. The dot is the control as well as the preview, so
|
|
// the thing you press is the thing you are about to change.
|
|
await sidebar.getByRole('button', { name: 'Farve på Arbejde' }).click();
|
|
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
|
|
|
const painted = await sidebar.locator('.folder-head .group-dot').evaluate((el) =>
|
|
getComputedStyle(el).backgroundColor
|
|
);
|
|
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
|
|
|
|
// A project inside a group starts further in than a row outside one. Measured
|
|
// rather than asserted on a class, because indentation is a fact about where
|
|
// the row's contents land — and measured at the *content* edge, not the box:
|
|
// the indent is padding, so the element's own left edge does not move.
|
|
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
|
|
const contentLeft = (el) =>
|
|
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
|
|
const [inside, outside] = await Promise.all([
|
|
sidebar.locator('.folder > a').first().evaluate(contentLeft),
|
|
sidebar.locator('.views a').first().evaluate(contentLeft)
|
|
]);
|
|
expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside);
|
|
|
|
await sidebar
|
|
.getByRole('link', { name: 'Regnskab' })
|
|
.dragTo(sidebar.getByRole('heading', { name: 'Projekter' }));
|
|
|
|
// 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', exact: true })).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', exact: true })).toBeHidden();
|
|
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A task can be dropped into a section, including one with nothing in it.
|
|
*
|
|
* Only the task *rows* took a drop, so a section with no rows had nothing to aim
|
|
* at — and a section you have just made is always empty, which is exactly when
|
|
* you want to fill it. The board has always taken a drop on the column; the list
|
|
* had no equivalent.
|
|
*
|
|
* The second half is the other thing that made this hard to report: the page used
|
|
* to say "Projektet findes ikke, eller du har ikke adgang til det" whenever the
|
|
* load failed *or* had not finished, so a dropped connection and a project you
|
|
* cannot see were one sentence, and it never asked again.
|
|
*/
|
|
test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning siger det', 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('Risteriet');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
await page.getByLabel('Ny opgave').fill('brænde kaffe');
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await expect(page.getByText('brænde kaffe')).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
|
await page.getByLabel('Ny sektion').fill('Uge 34');
|
|
await page.getByLabel('Ny sektion').press('Enter');
|
|
|
|
const section = page
|
|
.locator('section')
|
|
.filter({ has: page.getByRole('heading', { name: 'Uge 34' }) });
|
|
await expect(section.getByText('Tom')).toBeVisible();
|
|
|
|
await page.locator('.sortable').filter({ hasText: 'brænde kaffe' }).dragTo(section);
|
|
await expect(section.getByText('brænde kaffe')).toBeVisible();
|
|
await expect(page.locator('.toast')).toHaveCount(0);
|
|
|
|
// A load that fails says so, and offers to try again.
|
|
await page.route('**/api/v1/projects/*', (r) => r.abort());
|
|
await page.reload();
|
|
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
|
await page.unroute('**/api/v1/projects/*');
|
|
await page.getByRole('button', { name: 'Prøv igen' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
|
|
|
expect(trouble.filter((t) => !t.includes('projects'))).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();
|
|
|
|
// Onto "I dag" in the sidebar: the most-made rescheduling there is, and the
|
|
// sidebar is where the pointer already is. "Kommende" deliberately does not
|
|
// take one — it is a range, not a day, so a drop would have to invent a date
|
|
// its label does not name.
|
|
await row.dragTo(sidebar.getByRole('link', { name: 'I dag' }));
|
|
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
|
|
await row.dragTo(day('I morgen'));
|
|
|
|
// 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([]);
|
|
});
|
|
|
|
/**
|
|
* An invite link creates the account it was sent to.
|
|
*
|
|
* The server has emailed this URL since invites were built and nothing rendered
|
|
* for it: the SPA fallback served the shell, the shell found no session, and the
|
|
* recipient got a plain sign-in form for an account that did not exist yet. A 200
|
|
* all the way down — the same shape as `/mcp` and `/.well-known/`, and invisible
|
|
* to every test that does not open the link.
|
|
*
|
|
* A second browser context, because the invitee is a different person: inheriting
|
|
* the signed-in session would test the one case that never happens.
|
|
*/
|
|
test('et invitationslink opretter kontoen og giver adgang til projektet', async ({
|
|
browser,
|
|
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('Fælleshuset');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Del' }).click();
|
|
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
|
|
await page.getByRole('button', { name: 'Inviter' }).click();
|
|
|
|
// With no mail server the link is shown rather than swallowed, which is also
|
|
// the only way this test can get hold of it.
|
|
const link = await page.locator('.link-out code').textContent();
|
|
expect(link).toContain('/invite?token=');
|
|
|
|
// Explicitly empty, because a context made through the `browser` fixture picks
|
|
// up the project's storageState — and an invitee who is already signed in as
|
|
// the person who sent the invite is the one case that never happens.
|
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
const invitee = await context.newPage();
|
|
const inviteeTrouble = watchForTrouble(invitee);
|
|
await invitee.goto(link);
|
|
|
|
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
|
|
await invitee.getByLabel('Navn').fill('Nabo');
|
|
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
|
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
|
|
|
// Signed in, with the project they were invited to.
|
|
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
await expect(
|
|
invitee.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Fælleshuset/ })
|
|
).toBeVisible();
|
|
|
|
// One failure here is not trouble: the page loads before the account exists, so
|
|
// the shell's first `me()` is answered 401. That 401 is how the signed-out
|
|
// screen appears at all.
|
|
expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
|
await context.close();
|
|
|
|
// The invitee opens the project. Nothing has ever driven this: the assertion
|
|
// above only proved the project reaches their *sidebar*, and a member's view
|
|
// of a project page is a different code path from an owner's — different role,
|
|
// different guards, and the page shows the same "findes ikke" message while it
|
|
// is still loading as it does when the load failed.
|
|
const inviteePage = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
const member = await inviteePage.newPage();
|
|
const memberTrouble = watchForTrouble(member);
|
|
await member.goto('/');
|
|
await member.getByLabel('E-mail').fill('[email protected]');
|
|
await member.getByLabel('Adgangskode').fill('et langt kodeord til test');
|
|
await member.getByRole('button', { name: 'Log ind' }).click();
|
|
|
|
const theirs = member.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await theirs.getByRole('link', { name: /Fælleshuset/ }).click();
|
|
await expect(member.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
|
await expect(member.getByText('findes ikke')).toHaveCount(0);
|
|
expect(memberTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
|
|
await inviteePage.close();
|
|
|
|
// Now that there is somebody to delegate to, the inviter can — and the view
|
|
// for it appears. It is hidden on an instance with one person, where it could
|
|
// never have anything in it.
|
|
await page.reload();
|
|
await expect(sidebar.getByRole('link', { name: 'Venter på andre' })).toBeVisible();
|
|
|
|
// A project the inviter owns stays under "Projekter" after sharing it. It used
|
|
// to move to "Delt med mig" the moment somebody accepted, and lose its place in
|
|
// the order on the way.
|
|
await expect(
|
|
page.locator('.group').filter({ hasText: 'Projekter' }).getByText('Fælleshuset')
|
|
).toBeVisible();
|
|
|
|
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await page.getByText('males inden fredag', { exact: true }).click();
|
|
|
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
|
|
await page.keyboard.press('Escape');
|
|
|
|
await sidebar.getByRole('link', { name: 'Venter på andre' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Venter på andre' })).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Nabo' })).toBeVisible();
|
|
// The task's own text, not the whole row's: quick add read "fredag" as the due
|
|
// date, so the title is "males inden" and the rest of that line is metadata —
|
|
// which now has the assignee in it too.
|
|
await expect(page.getByText('males inden', { exact: true })).toBeVisible();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The reset link renders its own form.
|
|
*
|
|
* The token is nonsense, so this stops at the form — which is the part that did
|
|
* not exist. "Glemt adgangskode?" has always sent a link to this address and the
|
|
* API has always accepted the token; what the link opened was the sign-in screen,
|
|
* asking for the password the person had just said they had forgotten.
|
|
*/
|
|
test('nulstillingslinket viser et felt til den nye adgangskode', async ({ browser }) => {
|
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
const page = await context.newPage();
|
|
|
|
await page.goto('/reset?token=ugyldigt');
|
|
await expect(page.getByText('Vælg en ny adgangskode')).toBeVisible();
|
|
|
|
await page.getByLabel(/adgangskode/i).fill('et langt kodeord til test');
|
|
await page.getByRole('button', { name: 'Gem adgangskoden' }).click();
|
|
|
|
// And it says so rather than pretending: the token is not a real one.
|
|
await expect(page.getByRole('alert')).toBeVisible();
|
|
await context.close();
|
|
});
|
|
|
|
/**
|
|
* The device list, and ending one of them.
|
|
*
|
|
* `last_seen_at` has been written on every request since sessions existed — once
|
|
* a minute, deliberately, so this list could say "lige nu" — and until now
|
|
* nothing read it. A session you cannot see is a session you cannot end, which
|
|
* matters most in the one situation it exists for: somebody else has your cookie.
|
|
*/
|
|
test('enhedslisten viser denne enhed, og en anden kan logges ud', async ({ browser, page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
|
|
// A second sign-in as the same person: a real second session, not a second tab
|
|
// sharing this one's cookie.
|
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
const elsewhere = await context.newPage();
|
|
await elsewhere.goto('/');
|
|
await elsewhere.getByLabel('E-mail').fill(USER.email);
|
|
await elsewhere.getByLabel('Adgangskode').fill(USER.password);
|
|
await elsewhere.getByRole('button', { name: 'Log ind' }).click();
|
|
await expect(elsewhere.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
|
|
await page.goto('/indstillinger');
|
|
const panel = page.locator('section.panel').filter({ hasText: 'Enheder' });
|
|
await expect(panel.getByText('denne enhed')).toBeVisible();
|
|
await expect(panel.locator('li')).toHaveCount(2);
|
|
|
|
// End the one that is not this browser — the row without the badge.
|
|
await panel.locator('li').filter({ hasNot: page.getByText('denne enhed') })
|
|
.getByRole('button', { name: 'Log ud' })
|
|
.click();
|
|
await expect(panel.locator('li')).toHaveCount(1);
|
|
|
|
// That session is really gone, and this one is untouched.
|
|
await elsewhere.reload();
|
|
await expect(elsewhere.getByText('Log ind for at fortsætte')).toBeVisible();
|
|
await context.close();
|
|
|
|
await expect(panel.getByText('denne enhed')).toBeVisible();
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* An administrator can put somebody on the instance without sharing a project
|
|
* with them first.
|
|
*
|
|
* This is the answer to "how do I create a user": there is no open registration
|
|
* and no account with a password somebody else chose, so adding a person means
|
|
* issuing an invite that carries no project. The whole loop is worth driving —
|
|
* the link, a second browser, the signup, and the new account showing up in the
|
|
* list as somebody who is *not* an administrator.
|
|
*/
|
|
test('en administrator kan invitere en bruger til instansen', async ({ browser, page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/indstillinger/brugere');
|
|
|
|
await page.getByLabel('E-mailadresse').fill('[email protected]');
|
|
await page.getByRole('button', { name: 'Send invitation' }).click();
|
|
|
|
const link = await page.locator('.link-out').textContent();
|
|
expect(link).toContain('/invite?token=');
|
|
await expect(page.getByText('[email protected]')).toBeVisible();
|
|
await expect(page.getByText('til instansen')).toBeVisible();
|
|
|
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
const invitee = await context.newPage();
|
|
await invitee.goto(link);
|
|
await invitee.getByLabel('Navn').fill('Anders');
|
|
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
|
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
|
|
|
// They are in — with an Inbox of their own, and no project shared with them,
|
|
// which is the difference between this and a project invite.
|
|
await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
await expect(invitee.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
|
await context.close();
|
|
|
|
await page.reload();
|
|
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
|
|
const row = konti.locator('li').filter({ hasText: 'Anders' });
|
|
await expect(row).toBeVisible();
|
|
// Invited, not promoted. Asserted through the button that offers to promote
|
|
// them — which only reads this way for somebody who is not one. Matching the
|
|
// word "administrator" in the row would match that very button.
|
|
await expect(row.getByRole('button', { name: 'Gør til administrator' })).toBeVisible();
|
|
// And the invite is spent rather than left live.
|
|
await expect(page.getByText('Afventer svar')).toBeHidden();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The two refusals that keep an instance administrable. Driven here as well as in
|
|
* the API tests because the interface has to *say* why, and a 409 nobody explains
|
|
* is a button that appears broken.
|
|
*/
|
|
test('den sidste administrator kan hverken fjernes eller slette sig selv', async ({ page }) => {
|
|
await page.goto('/indstillinger/brugere');
|
|
|
|
const konti = page.locator('section.panel').filter({ hasText: 'Konti' });
|
|
const mine = konti.locator('li').filter({ hasText: 'dig' });
|
|
await expect(mine).toBeVisible();
|
|
|
|
// No buttons on your own row: the server refuses both, and a button whose only
|
|
// purpose is to be refused wastes a click to teach a rule.
|
|
await expect(mine.getByRole('button')).toHaveCount(0);
|
|
});
|
|
|
|
/**
|
|
* A member's role can be corrected without removing them.
|
|
*
|
|
* This is what a viewer who cannot add tasks looks like from the other side. The
|
|
* rule itself is right — a viewer may not write — but until now the only way to
|
|
* fix an invite sent with the wrong role was to remove the person and invite them
|
|
* again, which unassigns every task they were responsible for on the way past.
|
|
*
|
|
* Driven end to end because the interesting part is that the *other* browser's
|
|
* permissions actually change: the server decides, and the page has to agree.
|
|
*/
|
|
test('en rolle kan rettes uden at fjerne personen', async ({ browser, 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('Fælles');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
await page.getByRole('button', { name: 'Del' }).click();
|
|
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
|
|
await page.getByLabel('Rolle').selectOption('viewer');
|
|
await page.getByRole('button', { name: 'Inviter' }).click();
|
|
const link = await page.locator('.link-out code').textContent();
|
|
|
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
const andreas = await context.newPage();
|
|
await andreas.goto(link);
|
|
await andreas.getByLabel('Navn').fill('andreas');
|
|
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
|
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
|
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
|
|
await andreas
|
|
.getByRole('navigation', { name: 'Hovedmenu' })
|
|
.getByRole('link', { name: /Fælles/ })
|
|
.click();
|
|
// A viewer sees the project and is told, plainly, that it is read-only.
|
|
await expect(andreas.getByText('Du kan se dette projekt, men ikke ændre det')).toBeVisible();
|
|
await expect(andreas.getByLabel('Ny opgave')).toHaveCount(0);
|
|
|
|
// The owner promotes them from the member list. The owner's own row is text,
|
|
// not a dropdown: ownership is transferred, not granted.
|
|
await page.reload();
|
|
await page.getByRole('button', { name: /Delt/ }).click();
|
|
await expect(page.getByText('Ejer')).toBeVisible();
|
|
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
|
|
|
await andreas.reload();
|
|
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
|
await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0);
|
|
|
|
// Now that they can edit, hand them something — and the row says whose it is.
|
|
// Only when it is somebody else's: your own list would otherwise carry your own
|
|
// face on every line, which is a column of one initial telling you nothing.
|
|
await page.getByLabel('Ny opgave').fill('brænde kaffe');
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await page.getByText('brænde kaffe', { exact: true }).click();
|
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
|
await page.keyboard.press('Escape');
|
|
|
|
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
|
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
|
|
// And not on a task that is nobody's.
|
|
await page.getByLabel('Ny opgave').fill('min egen');
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await expect(
|
|
page.locator('.row').filter({ hasText: 'min egen' }).locator('.assignee')
|
|
).toHaveCount(0);
|
|
|
|
// The log has recorded all of this since the beginning and nothing showed it.
|
|
await page.getByRole('button', { name: 'Historik' }).click();
|
|
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
|
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
|
|
|
await context.close();
|
|
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');
|
|
});
|