2026-08-17 10:40:34 +02:00
|
|
|
import { test, expect } from '@playwright/test';
|
2026-08-17 19:43:49 +02:00
|
|
|
import { USER } from './user.js';
|
2026-08-17 10:40:34 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-08-18 11:00:32 +02:00
|
|
|
/**
|
|
|
|
|
* Opens the project header's overflow menu and clicks one of its items.
|
|
|
|
|
*
|
|
|
|
|
* The header used to carry eight controls in one row — title, colour, show-done,
|
|
|
|
|
* three view buttons, share, history, delete — and a project name is not a short
|
|
|
|
|
* word, so the heading was what gave way. Everything but the view switcher moved
|
|
|
|
|
* behind one button; this is how a test reaches them.
|
|
|
|
|
*/
|
|
|
|
|
async function projectAction(page, name) {
|
|
|
|
|
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
|
|
|
|
// `exact` only means anything for a string. The share item reads "Delt · 2" once
|
|
|
|
|
// somebody is in the project, so callers pass a regex for that one.
|
|
|
|
|
const item =
|
|
|
|
|
name instanceof RegExp
|
|
|
|
|
? page.getByRole('menuitem', { name })
|
|
|
|
|
: page.getByRole('menuitem', { name, exact: true });
|
|
|
|
|
await item.click();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:40:34 +02:00
|
|
|
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();
|
2026-08-18 11:56:54 +02:00
|
|
|
if (!url.includes('/api/') || response.ok()) return;
|
|
|
|
|
|
|
|
|
|
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
|
|
|
|
// which is the correct answer on the sign-in page and after signing out.
|
|
|
|
|
// Every other non-ok response is worth failing over — that is what this
|
|
|
|
|
// watcher is for.
|
|
|
|
|
const path = new URL(url).pathname;
|
|
|
|
|
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
|
|
|
|
|
|
|
|
|
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
2026-08-17 10:40:34 +02:00
|
|
|
});
|
|
|
|
|
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' });
|
|
|
|
|
|
2026-08-17 10:48:38 +02:00
|
|
|
// 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();
|
2026-08-17 10:40:34 +02:00
|
|
|
|
2026-08-17 15:07:15 +02:00
|
|
|
// 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}`);
|
|
|
|
|
|
2026-08-17 10:40:34 +02:00
|
|
|
// 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',
|
2026-08-17 22:58:02 +02:00
|
|
|
// Only an administrator sees these two, and the account these tests run as
|
2026-08-17 21:10:46 +02:00
|
|
|
// is the first account, which is one.
|
|
|
|
|
'Brugere',
|
2026-08-18 09:05:02 +02:00
|
|
|
'Historik',
|
2026-08-17 22:58:02 +02:00
|
|
|
'Fejl',
|
2026-08-17 10:40:34 +02:00
|
|
|
'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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 22:37:52 +02:00
|
|
|
// 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();
|
|
|
|
|
|
2026-08-17 10:40:34 +02:00
|
|
|
// 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'");
|
|
|
|
|
});
|
2026-08-17 14:00:39 +02:00
|
|
|
|
2026-08-17 14:59:51 +02:00
|
|
|
/**
|
|
|
|
|
* 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');
|
|
|
|
|
|
2026-08-17 15:07:15 +02:00
|
|
|
await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible();
|
2026-08-17 14:59:51 +02:00
|
|
|
|
2026-08-17 15:07:15 +02:00
|
|
|
// 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');
|
2026-08-17 14:59:51 +02:00
|
|
|
await title.fill('Sommerferie');
|
|
|
|
|
await title.press('Enter');
|
|
|
|
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
page.once('dialog', (dialog) => dialog.accept());
|
2026-08-18 11:00:32 +02:00
|
|
|
await projectAction(page, 'Slet projektet');
|
2026-08-17 14:59:51 +02:00
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/**
|
|
|
|
|
* 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');
|
|
|
|
|
|
2026-08-18 09:20:03 +02:00
|
|
|
// The name is a link to the group's own page; the chevron beside it is a
|
|
|
|
|
// separate button that folds. Two intentions, two targets — the single one they
|
|
|
|
|
// used to share could only ever serve the smaller.
|
|
|
|
|
//
|
|
|
|
|
// `exact` on the link, because a group's name is a user-chosen word that can
|
|
|
|
|
// turn up inside another control's name, and Playwright matches an accessible
|
|
|
|
|
// name by substring unless told otherwise.
|
|
|
|
|
const heading = sidebar.getByRole('link', { name: 'Arbejde', exact: true });
|
|
|
|
|
const fold = sidebar.getByRole('button', { name: /^Fold Arbejde/ });
|
2026-08-17 19:25:10 +02:00
|
|
|
await expect(heading).toBeVisible();
|
|
|
|
|
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
|
|
|
|
|
2026-08-18 18:10:34 +02:00
|
|
|
// A group's name must be readable in full, and must start where a project's
|
|
|
|
|
// name starts.
|
2026-08-17 20:53:14 +02:00
|
|
|
//
|
2026-08-18 18:10:34 +02:00
|
|
|
// Both halves are here because both were once broken at the same time and for
|
|
|
|
|
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
|
|
|
|
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
|
|
|
|
// sat before the name, pushing a group 22px further in than the projects it
|
|
|
|
|
// contains — so a group read as if it were filed under them. Every assertion
|
|
|
|
|
// in this file passed through both, because both buttons still worked.
|
|
|
|
|
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
|
|
|
|
const link = el.querySelector('h2 a');
|
|
|
|
|
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
|
|
|
|
// The last child of the link is the name itself; the dot is a span before it.
|
|
|
|
|
return {
|
|
|
|
|
clipped: link.scrollWidth > link.clientWidth + 1,
|
|
|
|
|
left: name.left,
|
|
|
|
|
right: link.getBoundingClientRect().right,
|
|
|
|
|
count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
|
|
|
|
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
|
|
|
|
|
|
|
|
|
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
|
|
|
|
// mark, and it is the only thing in them that means the same on both sides.
|
|
|
|
|
// Comparing the group's inner link with the project's outer box reported the
|
|
|
|
|
// row's own padding as a misalignment.
|
|
|
|
|
const projectLeft = await sidebar
|
|
|
|
|
.getByRole('link', { name: 'Regnskab' })
|
|
|
|
|
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
|
|
|
|
expect(
|
|
|
|
|
Math.abs(shape.left - projectLeft),
|
|
|
|
|
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
|
|
|
|
).toBeLessThanOrEqual(2);
|
2026-08-17 20:53:14 +02:00
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
// 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.
|
2026-08-18 09:20:03 +02:00
|
|
|
await fold.click();
|
|
|
|
|
await expect(fold).toHaveAttribute('aria-expanded', 'false');
|
2026-08-17 19:25:10 +02:00
|
|
|
await page.reload();
|
2026-08-18 09:20:03 +02:00
|
|
|
await expect(sidebar.getByRole('button', { name: /^Fold Arbejde/ })).toHaveAttribute(
|
2026-08-17 19:25:10 +02:00
|
|
|
'aria-expanded',
|
|
|
|
|
'false'
|
|
|
|
|
);
|
2026-08-18 09:20:03 +02:00
|
|
|
await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click();
|
2026-08-17 21:50:20 +02:00
|
|
|
|
2026-08-18 09:05:02 +02:00
|
|
|
// Colour, which is chosen while renaming rather than from the row. Both are
|
|
|
|
|
// edits to the same thing, and a heading carrying four controls crowded the
|
|
|
|
|
// name it exists to show.
|
2026-08-18 20:08:24 +02:00
|
|
|
// Double-click on the row opens it; the two words that used to sit on top of
|
|
|
|
|
// the name are gone from the sidebar entirely.
|
|
|
|
|
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
2026-08-17 21:50:20 +02:00
|
|
|
await sidebar.getByRole('button', { name: 'Petrol' }).click();
|
2026-08-18 09:05:02 +02:00
|
|
|
// Still open: picking a colour must not close the form under the pointer, which
|
|
|
|
|
// is what a plain blur-to-cancel does in the two browsers that do not focus a
|
|
|
|
|
// button on click.
|
|
|
|
|
await expect(sidebar.getByLabel('Gruppens navn')).toBeVisible();
|
|
|
|
|
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
2026-08-17 21:50:20 +02:00
|
|
|
|
2026-08-18 09:05:02 +02:00
|
|
|
const painted = await sidebar
|
|
|
|
|
.locator('.folder-head .group-dot')
|
|
|
|
|
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
2026-08-17 21:50:20 +02:00
|
|
|
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
|
|
|
|
|
|
2026-08-18 09:05:02 +02:00
|
|
|
// The heading itself is *not* indented — only what is filed under it. A heading
|
|
|
|
|
// that starts further in than the rows above it says the heading is inside
|
|
|
|
|
// something, which it is not.
|
|
|
|
|
const headLeft = await sidebar
|
|
|
|
|
.locator('.folder-head')
|
|
|
|
|
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
|
|
|
|
const looseLeft = await sidebar
|
|
|
|
|
.locator('.views a')
|
|
|
|
|
.first()
|
|
|
|
|
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
|
|
|
|
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
|
|
|
|
|
2026-08-17 21:50:20 +02:00
|
|
|
// 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' }));
|
2026-08-17 19:25:10 +02:00
|
|
|
|
|
|
|
|
// 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.
|
2026-08-18 20:08:24 +02:00
|
|
|
await sidebar.getByRole('link', { name: 'Arbejde', exact: true }).dblclick();
|
2026-08-17 19:25:10 +02:00
|
|
|
const name = sidebar.getByLabel('Gruppens navn');
|
|
|
|
|
await name.fill('Kontoret');
|
|
|
|
|
await name.press('Enter');
|
2026-08-18 09:20:03 +02:00
|
|
|
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeVisible();
|
2026-08-17 19:25:10 +02:00
|
|
|
|
|
|
|
|
// 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".
|
2026-08-18 20:08:24 +02:00
|
|
|
// Deleting is on the group's own page now, where the projects it will not take
|
|
|
|
|
// with it are on the screen above the button.
|
|
|
|
|
await sidebar.getByRole('link', { name: 'Kontoret', exact: true }).click();
|
2026-08-17 19:25:10 +02:00
|
|
|
page.once('dialog', (dialog) => dialog.accept());
|
2026-08-18 20:08:24 +02:00
|
|
|
await page.getByRole('button', { name: 'Slet' }).click();
|
2026-08-18 09:20:03 +02:00
|
|
|
await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden();
|
2026-08-17 19:25:10 +02:00
|
|
|
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 22:04:55 +02:00
|
|
|
/**
|
|
|
|
|
* 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');
|
2026-08-18 09:20:03 +02:00
|
|
|
// Waited for. Creating a project navigates, and `goto` is a promise nobody here
|
|
|
|
|
// is holding — quick add fired before it landed puts the task in the Inbox with
|
|
|
|
|
// no date, where the Today view is right not to show it. Intermittent, and it
|
|
|
|
|
// reads as the quick add having failed.
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
2026-08-17 22:04:55 +02:00
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
/**
|
|
|
|
|
* 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');
|
2026-08-18 09:20:03 +02:00
|
|
|
// Waited for before navigating away, or the create can lose its own redirect.
|
|
|
|
|
await expect(sidebar.getByRole('link', { name: 'Ærinder' })).toBeVisible();
|
2026-08-17 19:25:10 +02:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
2026-08-17 22:35:40 +02:00
|
|
|
// 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'));
|
|
|
|
|
|
2026-08-17 19:25:10 +02:00
|
|
|
// 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');
|
2026-08-18 09:05:02 +02:00
|
|
|
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
2026-08-17 19:25:10 +02:00
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:30:21 +02:00
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
|
2026-08-18 11:00:32 +02:00
|
|
|
await projectAction(page, 'Del');
|
2026-08-17 19:30:21 +02:00
|
|
|
await page.getByLabel('Inviter via e-mail').fill('nabo@example.dk');
|
|
|
|
|
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();
|
2026-08-18 11:56:54 +02:00
|
|
|
await invitee.getByLabel('Navn', { exact: true }).fill('Nabo');
|
2026-08-17 19:30:21 +02:00
|
|
|
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();
|
2026-08-17 19:36:37 +02:00
|
|
|
|
2026-08-17 21:54:26 +02:00
|
|
|
// 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('nabo@example.dk');
|
|
|
|
|
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();
|
|
|
|
|
|
2026-08-17 19:36:37 +02:00
|
|
|
// 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();
|
2026-08-17 22:31:43 +02:00
|
|
|
// 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();
|
2026-08-17 19:36:37 +02:00
|
|
|
|
2026-08-17 19:30:21 +02:00
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 19:43:49 +02:00
|
|
|
/**
|
|
|
|
|
* 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([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 21:10:46 +02:00
|
|
|
/**
|
|
|
|
|
* 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('anders@example.dk');
|
|
|
|
|
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('anders@example.dk')).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);
|
2026-08-18 11:56:54 +02:00
|
|
|
await invitee.getByLabel('Navn', { exact: true }).fill('Anders');
|
2026-08-17 21:10:46 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 22:14:33 +02:00
|
|
|
/**
|
|
|
|
|
* 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');
|
2026-08-18 09:20:03 +02:00
|
|
|
// Waited for, like everywhere else here: creating a project navigates, and the
|
|
|
|
|
// controls below only exist once it has.
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Fælles' })).toBeVisible();
|
2026-08-17 22:14:33 +02:00
|
|
|
|
2026-08-18 11:00:32 +02:00
|
|
|
await projectAction(page, 'Del');
|
2026-08-17 22:14:33 +02:00
|
|
|
await page.getByLabel('Inviter via e-mail').fill('andreas@example.dk');
|
|
|
|
|
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);
|
2026-08-18 11:56:54 +02:00
|
|
|
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
2026-08-17 22:14:33 +02:00
|
|
|
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();
|
2026-08-18 11:00:32 +02:00
|
|
|
await projectAction(page, /Delt/);
|
2026-08-17 22:14:33 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-08-17 22:31:43 +02:00
|
|
|
// 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.
|
2026-08-18 11:00:32 +02:00
|
|
|
await projectAction(page, 'Historik');
|
2026-08-17 22:31:43 +02:00
|
|
|
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
|
|
|
|
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await context.close();
|
2026-08-17 22:14:33 +02:00
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 09:05:02 +02:00
|
|
|
/**
|
|
|
|
|
* A task dragged across a month boundary.
|
|
|
|
|
*
|
|
|
|
|
* The month grid could not do this. It is anchored to a month, so the two days on
|
|
|
|
|
* either side of its edge sit in different grids, and getting from one to the other
|
|
|
|
|
* means paging — which cannot be done mid-drag, because a drag in flight swallows
|
|
|
|
|
* the click that would page it. The week view exists for exactly this: a week that
|
|
|
|
|
* straddles the 31st and the 1st has both days in the same row.
|
|
|
|
|
*
|
|
|
|
|
* The dates are worked out in the browser, not here. Playwright pins the page to
|
|
|
|
|
* Europe/Copenhagen and this process is not pinned to anything, so for two hours of
|
|
|
|
|
* every day the two disagree about what today is.
|
|
|
|
|
*/
|
|
|
|
|
test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ({ page }) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
// The first day, starting tomorrow, whose next day is in another month *and* in
|
|
|
|
|
// the same Monday-to-Sunday week. Such a pair exists at every month boundary
|
|
|
|
|
// except one that falls between a Sunday and a Monday, so searching forward
|
|
|
|
|
// finds one within about two months whatever today is.
|
|
|
|
|
const { from, to } = await page.evaluate(() => {
|
|
|
|
|
const iso = (d) =>
|
|
|
|
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
|
|
|
|
d.getDate()
|
|
|
|
|
).padStart(2, '0')}`;
|
|
|
|
|
const monday = (d) => {
|
|
|
|
|
const m = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
|
|
|
m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
|
|
|
|
|
return iso(m);
|
|
|
|
|
};
|
|
|
|
|
const day = new Date();
|
|
|
|
|
for (let i = 0; i < 90; i++) {
|
|
|
|
|
day.setDate(day.getDate() + 1);
|
|
|
|
|
const next = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
|
|
|
|
|
if (day.getMonth() !== next.getMonth() && monday(day) === monday(next)) {
|
|
|
|
|
return { from: iso(day), to: iso(next) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw new Error('no month boundary inside a week in the next 90 days');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const box = page.getByLabel('Ny opgave');
|
|
|
|
|
await box.fill(`skifte dæk ${from}`);
|
|
|
|
|
await box.press('Enter');
|
|
|
|
|
// Not asserted here: this is the Today view and the task is dated weeks out, so
|
|
|
|
|
// it is correctly absent. The week grid below is where it has to turn up.
|
|
|
|
|
await expect(box).toHaveValue('');
|
|
|
|
|
|
|
|
|
|
await page.goto('/upcoming');
|
|
|
|
|
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
|
|
|
|
|
|
|
|
|
// Forward a week at a time until the row is the one holding that date. Bounded,
|
|
|
|
|
// because a loop that pages forever on a broken button is a timeout with no
|
|
|
|
|
// explanation in it.
|
|
|
|
|
const cell = page.locator(`[data-date="${from}"]`);
|
|
|
|
|
const next = page.getByRole('button', { name: 'Næste uge' });
|
|
|
|
|
for (let i = 0; i < 15 && (await cell.count()) === 0; i++) {
|
|
|
|
|
await next.click();
|
|
|
|
|
}
|
|
|
|
|
await expect(cell, `uge-visningen nåede aldrig frem til ${from}`).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// The claim the view is for: both sides of the month boundary, in one row.
|
|
|
|
|
await expect(page.locator(`[data-date="${to}"]`)).toBeVisible();
|
|
|
|
|
expect(from.slice(0, 7), 'de to dage skulle ligge i hver sin måned').not.toEqual(to.slice(0, 7));
|
|
|
|
|
|
|
|
|
|
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
|
|
|
|
await expect(chip).toBeVisible();
|
|
|
|
|
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
|
|
|
|
|
|
|
|
|
await expect(page.locator(`[data-date="${to}"]`).getByText('skifte dæk')).toBeVisible();
|
|
|
|
|
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The instance-wide history.
|
|
|
|
|
*
|
|
|
|
|
* The per-project panel has shown the same rows for a while; this is the question
|
|
|
|
|
* it cannot answer, which is what happened in a project the administrator is not a
|
|
|
|
|
* member of. The test uses a second person's own project for exactly that reason —
|
|
|
|
|
* asserting against a project the admin can already see would pass with the old
|
|
|
|
|
* per-project endpoint behind it.
|
|
|
|
|
*/
|
|
|
|
|
test('historik-siden viser hændelser fra et projekt, administratoren ikke er med i', async ({
|
|
|
|
|
browser,
|
|
|
|
|
page
|
|
|
|
|
}) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
|
|
|
|
|
// A second account, with a project of its own. storageState is spelled out:
|
|
|
|
|
// a "fresh" context inherits the signed-in one otherwise, which is the single
|
|
|
|
|
// case this test is not about.
|
|
|
|
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
|
|
|
const theirs = await context.newPage();
|
|
|
|
|
const email = `historik-${Date.now()}@example.dk`;
|
|
|
|
|
|
|
|
|
|
await page.goto('/indstillinger/brugere');
|
|
|
|
|
await page.getByLabel('E-mailadresse').fill(email);
|
|
|
|
|
await page.getByRole('button', { name: 'Send invitation' }).click();
|
|
|
|
|
const link = await page.locator('.link-out').textContent();
|
|
|
|
|
expect(link).toContain('/invite?token=');
|
|
|
|
|
|
|
|
|
|
await theirs.goto(link);
|
2026-08-18 11:56:54 +02:00
|
|
|
await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid');
|
2026-08-18 09:05:02 +02:00
|
|
|
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
|
|
|
|
await theirs.getByRole('button', { name: 'Opret konto' }).click();
|
|
|
|
|
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const sidebar = theirs.getByRole('navigation', { name: 'Hovedmenu' });
|
|
|
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').fill('Sigrids eget');
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
|
await expect(sidebar.getByRole('link', { name: 'Sigrids eget' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// The administrator cannot open that project, and can still see that it happened.
|
|
|
|
|
await page.goto('/indstillinger/historik');
|
|
|
|
|
const rows = page.locator('.rows li').filter({ hasText: 'Sigrids eget' });
|
|
|
|
|
await expect(rows.first()).toBeVisible();
|
|
|
|
|
await expect(rows.first().getByText('oprettede projektet')).toBeVisible();
|
|
|
|
|
// `exact`, or this matches the project name "Sigrids eget" in the same row.
|
|
|
|
|
await expect(rows.first().getByText('Sigrid', { exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And the filters narrow it rather than emptying it.
|
|
|
|
|
await page.getByLabel('Hændelse').selectOption('project.created');
|
|
|
|
|
await expect(page.locator('.rows li').filter({ hasText: 'Sigrids eget' }).first()).toBeVisible();
|
|
|
|
|
await page.getByRole('button', { name: 'Ryd' }).click();
|
|
|
|
|
|
|
|
|
|
await context.close();
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 23:09:08 +02:00
|
|
|
/**
|
|
|
|
|
* The project page on a phone.
|
|
|
|
|
*
|
|
|
|
|
* Every other test here runs at desktop width, and this is what that misses: the
|
|
|
|
|
* header is a flex row of a title and four controls, and 390px does not have room
|
|
|
|
|
* for them. Without wrapping, the heading was squeezed to its min-content — which
|
|
|
|
|
* with `overflow-wrap: anywhere` is one character — so a project called
|
|
|
|
|
* "Skovvænget" rendered as a vertical column of letters while the buttons ran off
|
|
|
|
|
* the right edge. It looked like a broken font.
|
|
|
|
|
*/
|
|
|
|
|
test('projektsiden er brugbar på en telefon', async ({ page }) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
// The sidebar is a drawer at this width, so the project is reached through it.
|
|
|
|
|
await page.getByRole('button', { name: 'Vis menu' }).click();
|
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
|
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').fill('Skovvænget');
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
|
|
|
|
|
|
const heading = page.getByRole('heading', { name: 'Skovvænget' });
|
|
|
|
|
await expect(heading).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const box = await heading.boundingBox();
|
|
|
|
|
// Wide enough to hold the word, and short enough not to be a column of
|
|
|
|
|
// letters. One line of "Skovvænget" at --text-2xl is nowhere near 200px tall.
|
|
|
|
|
expect(box.width, 'overskriften er klemt sammen').toBeGreaterThan(120);
|
|
|
|
|
expect(box.height, 'overskriften er brudt op i én bogstavkolonne').toBeLessThan(120);
|
|
|
|
|
|
|
|
|
|
// And nothing sticks out past the right edge of the page.
|
2026-08-17 23:24:49 +02:00
|
|
|
const sideways = () =>
|
|
|
|
|
page.evaluate(
|
|
|
|
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
|
|
|
|
);
|
|
|
|
|
expect(await sideways(), 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
|
|
|
|
|
|
|
|
|
// The task drawer, too. It was `width: 100vw`, which includes the scrollbar
|
|
|
|
|
// gutter where a percentage does not, so the drawer came out wider than the
|
|
|
|
|
// page and the whole document could be pushed sideways.
|
|
|
|
|
await page.getByLabel('Ny opgave').fill('mal væggen');
|
|
|
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
|
|
|
await page.getByText('mal væggen', { exact: true }).click();
|
|
|
|
|
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
|
|
|
|
expect(await sideways(), 'opgaveruden skubber siden sidelæns').toBeLessThanOrEqual(1);
|
2026-08-17 23:09:08 +02:00
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 09:20:03 +02:00
|
|
|
/**
|
|
|
|
|
* A group is somewhere you can go.
|
|
|
|
|
*
|
|
|
|
|
* It was a heading over some rows: a name, a colour, and whether it was folded.
|
|
|
|
|
* Enough to tidy a list and not enough to be what people mean by it — "Arbejde" is
|
|
|
|
|
* a body of work with context of its own, and the documents that belong to all of
|
|
|
|
|
* it rather than to any one project inside it.
|
|
|
|
|
*/
|
|
|
|
|
test('en gruppe er en side med sine projekter, en beskrivelse og filer', async ({ page }) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
|
|
|
await sidebar.getByRole('button', { name: 'Ny gruppe' }).click();
|
|
|
|
|
await sidebar.getByLabel('Gruppens navn').fill('Værksted');
|
|
|
|
|
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
|
|
|
|
await expect(sidebar.getByRole('link', { name: 'Værksted' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').fill('Drejebænk');
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Drejebænk' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.getByLabel('Ny opgave').fill('bestil stål');
|
|
|
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
|
|
|
await expect(page.getByText('bestil stål', { exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Into the group, then onto its page — which is what the heading is a link to
|
|
|
|
|
// now. The chevron beside it still folds; they are two intentions and the old
|
|
|
|
|
// single target could only serve the smaller one.
|
|
|
|
|
await sidebar
|
|
|
|
|
.getByRole('link', { name: 'Drejebænk' })
|
|
|
|
|
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
|
|
|
|
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
|
|
|
|
const row = page.locator('.projects a').filter({ hasText: 'Drejebænk' });
|
|
|
|
|
await expect(row).toBeVisible();
|
|
|
|
|
await expect(row.getByText('1', { exact: true }), 'antallet af åbne opgaver').toBeVisible();
|
|
|
|
|
|
|
|
|
|
// The description, which is the part a heading cannot carry. Click to write,
|
|
|
|
|
// blur to save — the same shape a task's description has.
|
|
|
|
|
await page.getByRole('button', { name: /Skriv hvad det her er/ }).click();
|
|
|
|
|
const about = page.getByLabel('Om gruppen');
|
|
|
|
|
await expect(about).toBeVisible();
|
|
|
|
|
await about.fill('Alt der larmer og støver.');
|
|
|
|
|
await about.blur();
|
|
|
|
|
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And it survives a reload, which is the difference between the page keeping up
|
|
|
|
|
// and the server taking it.
|
|
|
|
|
await page.reload();
|
|
|
|
|
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
|
|
|
|
await expect(page.locator('.projects a').filter({ hasText: 'Drejebænk' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 09:46:08 +02:00
|
|
|
/**
|
|
|
|
|
* The account's language changes the interface, not only the parser.
|
|
|
|
|
*
|
|
|
|
|
* `locale` has been on the account since the beginning and only chose which
|
|
|
|
|
* grammar quick add read a line with — so picking English changed nothing anybody
|
|
|
|
|
* could see, and the field said so honestly: "Sprog i hurtig tilføjelse".
|
|
|
|
|
*
|
|
|
|
|
* Asserted on both directions and on a date, because the date formatter is the
|
|
|
|
|
* half that is easy to leave behind: `toLocaleDateString('da-DK', …)` was written
|
|
|
|
|
* into a dozen components, and an English interface still saying "24. aug." is the
|
|
|
|
|
* shape of a half-finished translation.
|
|
|
|
|
*
|
|
|
|
|
* It puts Danish back at the end. Everything after this reads Danish labels, and a
|
|
|
|
|
* test that leaves the account in English fails the ones that follow it rather
|
|
|
|
|
* than itself — which is the worst way for a suite to report anything.
|
|
|
|
|
*/
|
|
|
|
|
test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
await page.goto('/indstillinger');
|
|
|
|
|
|
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
|
|
|
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.getByLabel('Sprog').selectOption('en');
|
|
|
|
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
|
|
|
|
|
|
|
|
|
// The sidebar redraws without a reload: i18n.locale is $state, which is the
|
|
|
|
|
// whole reason that module is .svelte.js.
|
|
|
|
|
const english = page.getByRole('navigation', { name: 'Main menu' });
|
|
|
|
|
await expect(english.getByRole('link', { name: 'Today' })).toBeVisible();
|
|
|
|
|
await expect(english.getByRole('link', { name: 'Upcoming' })).toBeVisible();
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And it survives a reload, which is the difference between the page keeping up
|
|
|
|
|
// and the account actually holding it.
|
|
|
|
|
await page.reload();
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Today' })
|
|
|
|
|
).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// A date, in the half that is easy to leave in Danish. The month grid's heading
|
|
|
|
|
// is `toLocaleDateString`, which was `'da-DK'` in a dozen components before
|
|
|
|
|
// `tag()` existed — an English interface still saying "december" is exactly the
|
|
|
|
|
// shape of a half-finished translation.
|
|
|
|
|
await page.goto('/upcoming');
|
|
|
|
|
await page.getByRole('button', { name: 'Month', exact: true }).click();
|
|
|
|
|
await expect(page.locator('.calendar h2')).not.toContainText(
|
|
|
|
|
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-18 12:02:56 +02:00
|
|
|
// The Inbox is named by the server at account creation, before anybody has seen
|
|
|
|
|
// a settings page — so it is the one project name nobody chose, and it follows
|
|
|
|
|
// the language like a label. Rename it and that becomes your name for it in
|
|
|
|
|
// every language, the same rule as any other project.
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Inbox' })
|
|
|
|
|
).toBeVisible();
|
2026-08-18 09:46:08 +02:00
|
|
|
|
|
|
|
|
// Danish again, for everything that runs after this.
|
|
|
|
|
await page.goto('/indstillinger');
|
|
|
|
|
await page.getByLabel('Language').selectOption('da');
|
|
|
|
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'I dag' })
|
|
|
|
|
).toBeVisible();
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 09:49:58 +02:00
|
|
|
/**
|
|
|
|
|
* Finished tasks can be looked at, and are out of the way by default.
|
|
|
|
|
*
|
|
|
|
|
* A task list is what is left to do, so closing something has to remove it from
|
|
|
|
|
* the plan — but "what did I get done" is a real question and the answer was
|
|
|
|
|
* nowhere. They come back in one list at the bottom rather than back among the
|
|
|
|
|
* open ones in their sections: a closed task has stopped being work and started
|
|
|
|
|
* being a record, and putting the record back in the middle makes the plan longer
|
|
|
|
|
* without making it say more.
|
|
|
|
|
*
|
|
|
|
|
* The setting is in localStorage, so this also checks it survives a reload — the
|
|
|
|
|
* half that a toggle held only in component state would fail.
|
|
|
|
|
*/
|
|
|
|
|
test('færdige opgaver kan vises og skjules igen', 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('Loftet');
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Loftet' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
for (const what of ['rydde op', 'male gavlen']) {
|
|
|
|
|
await page.getByLabel('Ny opgave').fill(what);
|
|
|
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
|
|
|
await expect(page.getByText(what, { exact: true })).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Close one. It leaves the list, which is the behaviour being protected here.
|
|
|
|
|
await page
|
|
|
|
|
.locator('.row')
|
|
|
|
|
.filter({ hasText: 'rydde op' })
|
|
|
|
|
.getByRole('button', { name: 'Markér som færdig' })
|
|
|
|
|
.click();
|
|
|
|
|
await expect(page.getByText('rydde op', { exact: true })).toBeHidden();
|
|
|
|
|
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And it can be looked at again, under its own heading at the bottom.
|
2026-08-18 11:00:32 +02:00
|
|
|
await projectAction(page, 'Vis færdige');
|
2026-08-18 09:49:58 +02:00
|
|
|
const done = page.locator('section.done');
|
|
|
|
|
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
|
|
|
|
|
await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
|
|
|
|
|
// Still exactly where it was in the plan — reopened tasks are the only ones
|
|
|
|
|
// that come back up.
|
|
|
|
|
await expect(done.getByText('male gavlen', { exact: true })).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
// The choice is in localStorage, so it holds across a reload.
|
|
|
|
|
await page.reload();
|
|
|
|
|
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
|
|
|
|
|
|
2026-08-18 11:00:32 +02:00
|
|
|
await projectAction(page, 'Skjul færdige');
|
2026-08-18 09:49:58 +02:00
|
|
|
await expect(page.locator('section.done')).toHaveCount(0);
|
|
|
|
|
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 10:35:06 +02:00
|
|
|
/**
|
|
|
|
|
* A section holds more than one task, and stops being highlighted afterwards.
|
|
|
|
|
*
|
|
|
|
|
* Both reported from use, and both the same root cause. TaskList read the dragged
|
|
|
|
|
* task from its *own* `draggingId`, which is only set when the drag started in
|
|
|
|
|
* that list — so a task dragged in from the unsectioned rows arrived as null and
|
|
|
|
|
* the handler returned early. It had already called `stopPropagation`, so the
|
|
|
|
|
* section around it never got the drop either.
|
|
|
|
|
*
|
|
|
|
|
* The result was a section you could only drop into while it was empty, because
|
|
|
|
|
* an empty one has no rows to aim at. Put one task in, and there was no way to add
|
|
|
|
|
* a second: it looked exactly like a section that holds one task.
|
|
|
|
|
*
|
|
|
|
|
* The same `stopPropagation` left the section's frame lit, since the handler that
|
|
|
|
|
* clears it never ran.
|
|
|
|
|
*/
|
|
|
|
|
test('en sektion kan rumme flere opgaver, og rammen forsvinder bagefter @forms', 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('Badeværelset');
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Badeværelset' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
|
|
|
|
await page.getByLabel('Ny sektion').fill('Håndværker');
|
|
|
|
|
await page.getByLabel('Ny sektion').press('Enter');
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Håndværker' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
for (const what of ['knager op', 'fuge om vinduet']) {
|
|
|
|
|
await page.getByLabel('Ny opgave').fill(what);
|
|
|
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
|
|
|
await expect(page.getByText(what, { exact: true })).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const section = page.locator('section').filter({
|
|
|
|
|
has: page.getByRole('heading', { name: 'Håndværker' })
|
|
|
|
|
});
|
|
|
|
|
const row = (what) => page.locator('.row').filter({ hasText: what });
|
|
|
|
|
|
|
|
|
|
// The first goes in while the section is empty — the only drop that used to
|
|
|
|
|
// work, because there was no row in the way to swallow it.
|
|
|
|
|
await row('knager op').dragTo(section);
|
|
|
|
|
await expect(section.getByText('knager op', { exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And the second, aimed at the row that is now in there. This is the one that
|
|
|
|
|
// did nothing at all.
|
|
|
|
|
await row('fuge om vinduet').dragTo(section.getByText('knager op', { exact: true }));
|
|
|
|
|
await expect(section.getByText('fuge om vinduet', { exact: true })).toBeVisible();
|
|
|
|
|
await expect(section.locator('.row')).toHaveCount(2);
|
|
|
|
|
|
|
|
|
|
// The frame is a drop target being offered, not a state. Once the drag is over
|
|
|
|
|
// it has nothing left to say.
|
|
|
|
|
await expect(section).not.toHaveClass(/\bover\b/);
|
|
|
|
|
|
|
|
|
|
// It survives a reload, so this is the server's answer and not the page's.
|
|
|
|
|
await page.reload();
|
|
|
|
|
const after = page.locator('section').filter({
|
|
|
|
|
has: page.getByRole('heading', { name: 'Håndværker' })
|
|
|
|
|
});
|
|
|
|
|
await expect(after.locator('.row')).toHaveCount(2);
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 11:00:32 +02:00
|
|
|
/**
|
|
|
|
|
* The project header keeps its name readable.
|
|
|
|
|
*
|
|
|
|
|
* It was a flex row of eight controls and a heading, and the heading was the part
|
|
|
|
|
* that gave way: "GarageRisteriet" rendered as "GarageRist / eriet", broken
|
|
|
|
|
* mid-word, because `overflow-wrap: anywhere` breaks at the first opportunity
|
|
|
|
|
* rather than the last necessary one. A name split mid-syllable reads as a
|
|
|
|
|
* rendering fault, not as a wrap.
|
|
|
|
|
*
|
|
|
|
|
* Everything but the view switcher lives behind one button now. This measures the
|
|
|
|
|
* heading rather than counting controls, because the thing being protected is that
|
|
|
|
|
* the name is legible — not any particular arrangement of the row.
|
|
|
|
|
*/
|
|
|
|
|
test('projektets navn brydes ikke midt i et ord', 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('GarageRisteriet');
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
|
|
|
|
|
|
const heading = page.getByRole('heading', { name: 'GarageRisteriet', level: 1 });
|
|
|
|
|
await expect(heading).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// One line. The name is fifteen characters and the row now has room for it,
|
|
|
|
|
// which is the whole point of moving the rest behind a button.
|
|
|
|
|
const box = await heading.boundingBox();
|
|
|
|
|
const lineHeight = await heading.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
|
|
|
|
expect(box.height, 'overskriften er brudt over flere linjer').toBeLessThan(lineHeight * 2);
|
|
|
|
|
|
|
|
|
|
// And the actions are still reachable, just one click further in.
|
|
|
|
|
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
|
|
|
|
for (const item of ['Vis færdige', 'Del', 'Historik', 'Farve på projektet', 'Slet projektet']) {
|
|
|
|
|
await expect(page.getByRole('menuitem', { name: item, exact: true })).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Escape closes it — a menu you can only leave by pressing its own button again
|
|
|
|
|
// is one you click twice to get out of.
|
|
|
|
|
await page.keyboard.press('Escape');
|
|
|
|
|
await expect(page.getByRole('menuitem', { name: 'Historik', exact: true })).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 11:13:10 +02:00
|
|
|
/**
|
|
|
|
|
* A task can be written straight into a section, two ways.
|
|
|
|
|
*
|
|
|
|
|
* The box at the top of the page puts things in the project with no section, which
|
|
|
|
|
* is right for capturing — but a project has sections because the work belongs
|
|
|
|
|
* somewhere, and dragging every new task down into the right one is a second
|
|
|
|
|
* gesture for something you already knew when you typed it.
|
|
|
|
|
*
|
|
|
|
|
* The field at the foot of a section passes the section it sits in. The sigil is
|
|
|
|
|
* for the box at the top, and for Today and Upcoming, where you are not standing
|
|
|
|
|
* in a section at all.
|
|
|
|
|
*/
|
|
|
|
|
test('en opgave kan skrives direkte i en sektion @forms', 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 expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// A section whose name contains the sigil, because that is the one that would
|
|
|
|
|
// break: the bare form has to be greedy enough to swallow the whole name.
|
|
|
|
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
|
|
|
|
await page.getByLabel('Ny sektion').fill('Kunder/Ordrer');
|
|
|
|
|
await page.getByLabel('Ny sektion').press('Enter');
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Kunder/Ordrer' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const section = page.locator('section').filter({
|
|
|
|
|
has: page.getByRole('heading', { name: 'Kunder/Ordrer' })
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The field at the foot of the section. No sigil — standing there is enough.
|
|
|
|
|
await section.getByRole('button', { name: '+ Tilføj opgave' }).click();
|
|
|
|
|
const field = page.getByLabel('Ny opgave i Kunder/Ordrer');
|
|
|
|
|
await field.fill('Karpenhøj bestilling i morgen p1');
|
|
|
|
|
await field.press('Enter');
|
|
|
|
|
|
|
|
|
|
const landed = section.locator('.row').filter({ hasText: 'Karpenhøj bestilling' });
|
|
|
|
|
await expect(landed).toBeVisible();
|
|
|
|
|
// It went through quick add, so the date and the priority were read as well.
|
|
|
|
|
await expect(landed.getByText('I morgen')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And the sigil, from the box at the top, which is not standing anywhere.
|
|
|
|
|
await page.getByLabel('Ny opgave').fill('nye poser /Kunder/Ordrer');
|
|
|
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
|
|
|
await expect(section.locator('.row').filter({ hasText: 'nye poser' })).toBeVisible();
|
|
|
|
|
// The sigil is consumed, not left in the title.
|
|
|
|
|
await expect(section.getByText('/Kunder/Ordrer')).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
await expect(section.locator('.row')).toHaveCount(2);
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 11:18:01 +02:00
|
|
|
/**
|
|
|
|
|
* A task closed by mistake can be got back, two ways.
|
|
|
|
|
*
|
|
|
|
|
* Reported from use: "I just closed a task by accident, and there is no undo and no
|
|
|
|
|
* list of closed tasks." Both halves were true. Completing is one click on a small
|
|
|
|
|
* circle beside a row you were only reading, and it takes the row off the screen —
|
|
|
|
|
* so the mistake and the evidence of it leave together.
|
|
|
|
|
*
|
|
|
|
|
* The toast covers the mistake you notice at once. Færdige covers the one you
|
|
|
|
|
* notice tomorrow, and it is a place rather than a toggle because somebody hunting
|
|
|
|
|
* for a task they closed does not know which view they closed it in.
|
|
|
|
|
*/
|
|
|
|
|
test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
await page.getByLabel('Ny opgave').fill('rens tagrenden i dag');
|
|
|
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
|
|
|
const row = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
|
|
|
|
await expect(row).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Closed by mistake.
|
|
|
|
|
await row.getByRole('button', { name: 'Markér som færdig' }).click();
|
|
|
|
|
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
|
|
|
|
|
|
|
|
|
// The toast names it, so you can tell which one you hit.
|
|
|
|
|
const toast = page.locator('.toast').filter({ hasText: 'rens tagrenden' });
|
|
|
|
|
await expect(toast).toBeVisible();
|
|
|
|
|
await toast.getByRole('button', { name: 'Fortryd' }).click();
|
|
|
|
|
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Now the other half: close it again and walk away from the toast.
|
|
|
|
|
await page
|
|
|
|
|
.locator('.row')
|
|
|
|
|
.filter({ hasText: 'rens tagrenden' })
|
|
|
|
|
.getByRole('button', { name: 'Markér som færdig' })
|
|
|
|
|
.click();
|
|
|
|
|
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
|
|
|
|
|
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
|
|
|
await sidebar.getByRole('link', { name: 'Færdige' }).click();
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Færdige', level: 1 })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const closed = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
|
|
|
|
await expect(closed).toBeVisible();
|
|
|
|
|
// Grouped by the day it was closed, which is how anybody would describe it.
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'I dag', level: 2 })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And it reopens from here, leaving the list at once.
|
|
|
|
|
await closed.getByRole('button', { name: 'Genåbn opgave' }).click();
|
|
|
|
|
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toHaveCount(0);
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 11:56:54 +02:00
|
|
|
/**
|
|
|
|
|
* A passkey, registered and then signed in with, end to end.
|
|
|
|
|
*
|
|
|
|
|
* The Go tests cover who may reach these endpoints and that a challenge is spent
|
|
|
|
|
* when it is used. What they cannot do is produce a signature — that needs an
|
|
|
|
|
* authenticator. Chrome has a virtual one behind CDP, and it is the only way to
|
|
|
|
|
* find out whether the encoding is right: every boundary here is a base64url
|
|
|
|
|
* conversion, and one wrong byte produces a signature that will not verify,
|
|
|
|
|
* reported as "that key was not accepted" — which reads as a broken key rather
|
|
|
|
|
* than a broken encoder.
|
|
|
|
|
*
|
|
|
|
|
* Chromium only, and deliberately so: this is testing our ceremony, not WebKit's
|
|
|
|
|
* authenticator.
|
|
|
|
|
*/
|
|
|
|
|
test('en passkey kan registreres og logges ind med', async ({ browser, browserName }) => {
|
|
|
|
|
test.skip(browserName !== 'chromium', 'den virtuelle authenticator findes kun i Chromium');
|
|
|
|
|
|
|
|
|
|
// Its own context, at `localhost` rather than the loopback address the rest of
|
|
|
|
|
// the suite uses. WebAuthn refuses an IP as a relying party id — the browser
|
|
|
|
|
// throws `SecurityError: This is an invalid domain` — and localhost is the
|
|
|
|
|
// exception every browser makes. It is not the shared baseURL because
|
|
|
|
|
// resolving `localhost` is slow enough here to cost the whole suite minutes.
|
|
|
|
|
//
|
|
|
|
|
// Cookies are bound to an origin, so the signed-in state from the setup project
|
|
|
|
|
// does not carry across: this signs in with a password first, which is also the
|
|
|
|
|
// honest order — you register a key on an account you have already proved you
|
|
|
|
|
// own.
|
|
|
|
|
const origin = 'http://localhost:8097';
|
|
|
|
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
|
|
|
|
|
await page.goto(`${origin}/`);
|
|
|
|
|
await page.getByLabel(/E-mail/).fill(USER.email);
|
|
|
|
|
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
|
|
|
|
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
|
|
|
|
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// A virtual authenticator that verifies its owner, so the key counts as both
|
|
|
|
|
// factors — which is what lets the login complete without a password.
|
|
|
|
|
const client = await page.context().newCDPSession(page);
|
|
|
|
|
await client.send('WebAuthn.enable');
|
|
|
|
|
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
|
|
|
|
|
options: {
|
|
|
|
|
protocol: 'ctap2',
|
|
|
|
|
transport: 'internal',
|
|
|
|
|
hasResidentKey: true,
|
|
|
|
|
hasUserVerification: true,
|
|
|
|
|
isUserVerified: true,
|
|
|
|
|
automaticPresenceSimulation: true
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await page.goto(`${origin}/indstillinger`);
|
|
|
|
|
await page.getByLabel('Navn på nøglen').fill('Testnøglen');
|
|
|
|
|
await page.getByRole('button', { name: 'Tilføj en nøgle' }).click();
|
|
|
|
|
|
|
|
|
|
// It is in the list, and it counts as both factors — the virtual authenticator
|
|
|
|
|
// verifies, so the login below needs no password.
|
|
|
|
|
const key = page.locator('li').filter({ hasText: 'Testnøglen' });
|
|
|
|
|
await expect(key).toBeVisible();
|
|
|
|
|
await expect(key.getByText('begge faktorer')).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// It survives a reload, so this is the server's row and not the page's state.
|
|
|
|
|
await page.reload();
|
|
|
|
|
await expect(page.locator('li').filter({ hasText: 'Testnøglen' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// Now sign out and back in with it. No email typed: the device knows which
|
|
|
|
|
// account its key belongs to, which is also why this page cannot be used to
|
|
|
|
|
// find out who has an account here.
|
|
|
|
|
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
|
|
|
|
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
|
|
|
|
// Playwright matches an accessible name loosely unless told otherwise.
|
|
|
|
|
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.getByRole('button', { name: 'Log ind med en passkey' }).click();
|
|
|
|
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And the key records that it was used, which is what makes the list worth
|
|
|
|
|
// reading when somebody wonders whether a device is still in play.
|
|
|
|
|
await page.goto(`${origin}/indstillinger`);
|
|
|
|
|
await expect(
|
|
|
|
|
page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/)
|
|
|
|
|
).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
|
|
|
|
await context.close();
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-18 09:05:02 +02:00
|
|
|
/**
|
|
|
|
|
* The sidebar never scrolls sideways.
|
|
|
|
|
*
|
|
|
|
|
* It grew a horizontal scrollbar on an ordinary desktop, under the whole menu.
|
|
|
|
|
* The cause was one flex item: the account name had `overflow: hidden` and no
|
|
|
|
|
* `min-width: 0`, so it refused to shrink below its content and pushed the row
|
|
|
|
|
* past the column — and `overflow-y: auto` on the sidebar resolves to `auto` on
|
|
|
|
|
* the other axis too, which turned "a row that is too wide" into a scrollbar.
|
|
|
|
|
*
|
|
|
|
|
* Asserted with a long name, because the name is the part that varies and a short
|
|
|
|
|
* one hides the whole class of bug. The mobile test measures the *document*; this
|
|
|
|
|
* measures the sidebar itself, which is a different box and was never checked.
|
|
|
|
|
*/
|
|
|
|
|
test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
await page.goto('/indstillinger');
|
|
|
|
|
|
|
|
|
|
// A name long enough to overflow the column at its default width.
|
2026-08-18 11:56:54 +02:00
|
|
|
// `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an
|
|
|
|
|
// ambiguous locator waits for one of them to go away rather than failing.
|
|
|
|
|
await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard');
|
2026-08-18 09:05:02 +02:00
|
|
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
|
|
|
|
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
|
|
|
|
|
|
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
|
|
|
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
|
|
|
|
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
|
|
|
|
|
|
|
|
|
// And the whole page, for the same reason at a different scale.
|
|
|
|
|
const page_sideways = await page.evaluate(
|
|
|
|
|
() => document.documentElement.scrollWidth - document.documentElement.clientWidth
|
|
|
|
|
);
|
|
|
|
|
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
|
|
|
|
|
|
|
|
|
// Put the name back, so the tests after this one see the account they expect.
|
2026-08-18 11:56:54 +02:00
|
|
|
await page.getByLabel('Navn', { exact: true }).fill(USER.name);
|
2026-08-18 09:05:02 +02:00
|
|
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A project can have more than one section.
|
|
|
|
|
*
|
|
|
|
|
* Reported from use: sections "have no function, because you cannot create more
|
|
|
|
|
* than one". The API makes as many as you ask for — there is a Go test that makes
|
|
|
|
|
* three — so whatever this is, it is in the page. Every other test here makes
|
|
|
|
|
* exactly one section, which is the shape of test that cannot see it.
|
|
|
|
|
*/
|
|
|
|
|
test('et projekt kan have flere sektioner @forms', 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('Ombygning');
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Ombygning' })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
|
|
|
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
|
|
|
|
const field = page.getByLabel('Ny sektion');
|
|
|
|
|
await expect(field, `feltet kom ikke frem for "${name}"`).toBeVisible();
|
|
|
|
|
await field.fill(name);
|
|
|
|
|
await field.press('Enter');
|
|
|
|
|
await expect(
|
|
|
|
|
page.getByRole('heading', { name, exact: true }),
|
|
|
|
|
`sektionen "${name}" kom ikke på siden`
|
|
|
|
|
).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// All three at once, still there — an each-block that threw would have stopped
|
|
|
|
|
// rendering at the one that broke it.
|
|
|
|
|
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
|
|
|
|
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// And they survive a reload, which is the difference between "the page kept up"
|
|
|
|
|
// and "the server took it".
|
|
|
|
|
await page.reload();
|
|
|
|
|
for (const name of ['Planlægning', 'I gang', 'Til gennemsyn']) {
|
|
|
|
|
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The board is where somebody actually looks for a section, because a column on
|
|
|
|
|
// a board *is* one — and it had no way to make one at all. Reported as
|
|
|
|
|
// "sections have no function: there is no way to create them", which from
|
|
|
|
|
// inside a board was exactly true.
|
|
|
|
|
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
|
2026-08-18 11:56:54 +02:00
|
|
|
|
|
|
|
|
// A board gets the width a reading column withholds. This was `.view:has(.board)`
|
|
|
|
|
// for as long as the board has existed and never once matched: `.board` is inside
|
|
|
|
|
// a child component, and Svelte scopes a selector to the component that wrote it.
|
|
|
|
|
// The rule read correctly, compiled, and could not fire.
|
|
|
|
|
const wide = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
|
|
|
|
|
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
|
|
|
|
const narrow = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
|
|
|
|
|
expect(wide, 'board-visningen er ikke bredere end listen').toBeGreaterThan(narrow);
|
|
|
|
|
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
2026-08-18 09:05:02 +02:00
|
|
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
|
|
|
|
const field = page.getByLabel('Ny sektion');
|
|
|
|
|
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
|
|
|
|
|
await field.fill('Afsluttet');
|
|
|
|
|
await field.press('Enter');
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.reload();
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Afsluttet', exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Form fields on a touch device are at least 16px.
|
|
|
|
|
*
|
|
|
|
|
* Not a matter of taste. Below 16px, iOS Safari zooms the page in when a field
|
|
|
|
|
* takes focus and does not zoom back out when it is blurred — so the person is
|
|
|
|
|
* left with the whole interface too large and has to pinch it back by hand, once
|
|
|
|
|
* per field they tap. The type scale tops out at 15px, so every input in the app
|
|
|
|
|
* did it; the task drawer worst, being four fields deep.
|
|
|
|
|
*
|
|
|
|
|
* Chromium cannot reproduce the zoom, but it can be asked the question that causes
|
|
|
|
|
* it, which is what this checks. A touch context rather than a narrow viewport:
|
|
|
|
|
* the rule is keyed on `(hover: none) and (pointer: coarse)`, because an iPad does
|
|
|
|
|
* the same thing at a width no phone breakpoint would catch.
|
|
|
|
|
*/
|
|
|
|
|
test('felter er mindst 16px, så iOS ikke zoomer ind og bliver der', async ({ browser }) => {
|
|
|
|
|
const phone = { viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true };
|
|
|
|
|
|
|
|
|
|
const tooSmall = async (page, where) => {
|
|
|
|
|
const bad = await page.evaluate(() =>
|
|
|
|
|
[...document.querySelectorAll('input, textarea, select')]
|
|
|
|
|
.filter((el) => el.getClientRects().length)
|
|
|
|
|
.map((el) => ({
|
|
|
|
|
what: el.tagName.toLowerCase() + (el.type ? `[type=${el.type}]` : ''),
|
|
|
|
|
size: parseFloat(getComputedStyle(el).fontSize)
|
|
|
|
|
}))
|
|
|
|
|
.filter((f) => f.size < 16)
|
|
|
|
|
);
|
|
|
|
|
expect(bad, `for små felter på ${where}`).toEqual([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// The sign-in form, in a context with the session spelled out as empty —
|
|
|
|
|
// newContext inherits the project's storageState, so a "fresh" browser is
|
|
|
|
|
// otherwise already signed in and the form never renders.
|
|
|
|
|
const signedOut = await browser.newContext({
|
|
|
|
|
...phone,
|
|
|
|
|
storageState: { cookies: [], origins: [] }
|
|
|
|
|
});
|
|
|
|
|
const anon = await signedOut.newPage();
|
|
|
|
|
await anon.goto('/');
|
|
|
|
|
await expect(anon.getByRole('button', { name: 'Log ind' })).toBeVisible();
|
|
|
|
|
await tooSmall(anon, 'login-siden');
|
|
|
|
|
await signedOut.close();
|
|
|
|
|
|
|
|
|
|
const context = await browser.newContext(phone);
|
|
|
|
|
const page = await context.newPage();
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
await expect(page.getByLabel('Ny opgave')).toBeVisible();
|
|
|
|
|
await tooSmall(page, 'I dag');
|
|
|
|
|
|
|
|
|
|
// The task drawer, which is where it was worst: four fields deep. Dated today,
|
|
|
|
|
// or it goes to the Inbox and is correctly absent from the view being looked at.
|
|
|
|
|
await page.getByLabel('Ny opgave').fill('slibe gulvet i dag');
|
|
|
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
|
|
|
await page.getByText('slibe gulvet', { exact: true }).click();
|
|
|
|
|
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
|
|
|
|
|
await tooSmall(page, 'opgavedetaljer');
|
|
|
|
|
|
|
|
|
|
// And the settings pages, which are almost entirely fields.
|
|
|
|
|
await page.goto('/indstillinger');
|
|
|
|
|
await expect(page.locator('section.panel').first()).toBeVisible();
|
|
|
|
|
await tooSmall(page, 'indstillinger');
|
|
|
|
|
|
|
|
|
|
await context.close();
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 14:00:39 +02:00
|
|
|
/**
|
|
|
|
|
* 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');
|
|
|
|
|
});
|
2026-08-18 21:45:31 +02:00
|
|
|
|
|
|
|
|
test('man kan skrive hvor som helst, og feltet fortæller hvad det forstår', async ({ page }) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
|
|
|
|
|
// Nothing focused, nothing clicked: just type. The thought does not have to
|
|
|
|
|
// survive a journey to find somewhere to put it.
|
|
|
|
|
await page.locator('body').click({ position: { x: 5, y: 5 } });
|
|
|
|
|
// "i dag" because this is the Today view: a task parsed onto no date at all is
|
|
|
|
|
// correctly absent from it, which would read as the capture having failed.
|
|
|
|
|
await page.keyboard.type('ring til Anders i dag');
|
|
|
|
|
|
|
|
|
|
const field = page.locator('[data-quickadd]');
|
|
|
|
|
await expect(field).toBeFocused();
|
|
|
|
|
// Every letter arrives, including the first — which is the one a naive
|
|
|
|
|
// implementation drops while it is busy moving focus.
|
|
|
|
|
await expect(field).toHaveValue('ring til Anders i dag');
|
|
|
|
|
|
|
|
|
|
// And the legend appears with the field, saying what the parser can read. It
|
|
|
|
|
// used to live in the placeholder, which vanishes at the first keystroke —
|
|
|
|
|
// exactly when the help starts being useful.
|
|
|
|
|
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
|
|
|
|
|
|
|
|
|
|
await page.keyboard.press('Enter');
|
|
|
|
|
await expect(page.getByText('ring til Anders', { exact: true })).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// In English too. The shortcut used to find the field by its Danish label, so
|
|
|
|
|
// it silently did nothing for anybody running the interface in English.
|
|
|
|
|
await page.goto('/indstillinger');
|
|
|
|
|
await page.getByLabel('Sprog').selectOption('en');
|
|
|
|
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
|
|
|
|
await page.goto('/');
|
|
|
|
|
await page.locator('body').click({ position: { x: 5, y: 5 } });
|
|
|
|
|
await page.keyboard.type('call Anders');
|
|
|
|
|
await expect(page.locator('[data-quickadd]')).toHaveValue('call Anders');
|
|
|
|
|
|
|
|
|
|
// Back to Danish. The account is shared with every test after this one, and
|
|
|
|
|
// leaving it in English made three of them hunt for buttons by names that no
|
|
|
|
|
// longer existed — a failure that looks like the feature under test and is not.
|
|
|
|
|
await page.goto('/indstillinger');
|
|
|
|
|
await page.getByLabel('Language').selectOption('da');
|
|
|
|
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
|
|
|
|
await expect(page.getByRole('button', { name: 'Gem' }).first()).toBeVisible();
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|
2026-08-18 22:22:31 +02:00
|
|
|
|
|
|
|
|
test('en postkasse kan tilføjes, og værtens afvisning når frem til skærmen', async ({ page }) => {
|
|
|
|
|
const trouble = watchForTrouble(page);
|
|
|
|
|
await page.goto('/indstillinger/integrationer');
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole('heading', { name: 'Postkasser' })).toBeVisible();
|
|
|
|
|
await page.getByRole('button', { name: 'Tilføj en postkasse' }).click();
|
|
|
|
|
|
|
|
|
|
// Nothing is listening there, so the dial fails at once. The point is not the
|
|
|
|
|
// failure but where it is reported: the server tests the connection before it
|
|
|
|
|
// saves, so this is refused at the door rather than accepted and then silently
|
|
|
|
|
// failing every ten minutes.
|
|
|
|
|
await page.getByLabel('IMAP-server').fill('127.0.0.1:1');
|
|
|
|
|
await page.getByLabel('Brugernavn').fill('kw');
|
|
|
|
|
await page.getByLabel('App-kodeord').fill('hemmelig');
|
|
|
|
|
// `exact`, because "Forbind" is a prefix of "Forbind Gmail" one panel up and
|
|
|
|
|
// Playwright matches an accessible name by substring unless told otherwise.
|
|
|
|
|
await page.getByRole('button', { name: 'Forbind', exact: true }).click();
|
|
|
|
|
|
|
|
|
|
// The host's own words, not "noget gik galt". This is the whole reason
|
|
|
|
|
// upstream refusals are answered as 422: a proxy may replace the body of a 5xx,
|
|
|
|
|
// and then the person is told nothing.
|
|
|
|
|
await expect(page.getByText(/Postkassen svarede/)).toBeVisible();
|
|
|
|
|
|
|
|
|
|
// And nothing was saved on the way past.
|
|
|
|
|
await page.reload();
|
|
|
|
|
await expect(page.getByText('127.0.0.1:1')).toBeHidden();
|
|
|
|
|
|
|
|
|
|
// The 422 above is the point of the test, not a symptom. Everything else the
|
|
|
|
|
// watcher caught still has to be empty.
|
|
|
|
|
expect(trouble.filter((t) => !t.includes('/mailboxes → 422'))).toEqual([]);
|
|
|
|
|
});
|
2026-08-19 12:28:43 +02:00
|
|
|
|
|
|
|
|
test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastaturet', 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('Tallene');
|
|
|
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
|
|
|
|
|
|
const row = sidebar.getByRole('link', { name: /^Tallene/ });
|
|
|
|
|
await expect(row).toBeVisible();
|
|
|
|
|
// Nothing in it yet, so no number at all: a nought beside every empty project
|
|
|
|
|
// is a column of noughts.
|
|
|
|
|
await expect(row).toHaveText('Tallene');
|
|
|
|
|
|
|
|
|
|
await row.click();
|
|
|
|
|
const box = page.getByLabel('Ny opgave');
|
|
|
|
|
await box.fill('første i dag');
|
|
|
|
|
await box.press('Enter');
|
|
|
|
|
|
|
|
|
|
// One task, one in the sidebar — live, without a reload.
|
|
|
|
|
await expect(sidebar.getByRole('link', { name: /^Tallene/ })).toContainText('1', {
|
|
|
|
|
timeout: 10000
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// And it counts what is left, not what has been: finishing it takes the number
|
|
|
|
|
// away entirely, which is the whole reason it is not member_count any more —
|
|
|
|
|
// that one said 2 on an empty project and meant two people.
|
|
|
|
|
await page.getByRole('button', { name: 'Markér som færdig' }).first().click();
|
|
|
|
|
await expect(sidebar.getByRole('link', { name: /^Tallene/ })).toHaveText('Tallene', {
|
|
|
|
|
timeout: 10000
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The fold has a button, but a rectangle with a line down it is not a word.
|
|
|
|
|
const width = () => sidebar.evaluate((el) => el.getBoundingClientRect().width);
|
|
|
|
|
expect(await width()).toBeGreaterThan(100);
|
|
|
|
|
await page.keyboard.press('ControlOrMeta+b');
|
|
|
|
|
await expect.poll(width).toBeLessThan(5);
|
|
|
|
|
await page.keyboard.press('ControlOrMeta+b');
|
|
|
|
|
await expect.poll(width).toBeGreaterThan(100);
|
|
|
|
|
|
|
|
|
|
expect(trouble).toEqual([]);
|
|
|
|
|
});
|