import { test, expect } from '@playwright/test'; import { USER } from './user.js'; /** * The four flows a broken build must not survive: add a task, tick it off, reach * every page in the sidebar, and open a task. * * Every test asserts on *rendered* content rather than on a URL. A route that no * longer exists still answers 200 here — the Go binary serves the SPA shell for * anything it does not recognise — so `expect(page).toHaveURL()` would pass on * precisely the bug this suite exists to catch. */ /** * Collects anything that went wrong quietly: a thrown exception, a console error, * or an API call that came back a failure. * * Failed responses are recorded with their URL and status. "A console error * happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned * 500" is. */ /** * 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(); } function watchForTrouble(page) { const trouble = []; page.on('console', (message) => { // The browser logs its own line for every failed fetch. It carries no URL, // so it is noise next to the response listener below. if (message.type() === 'error' && !message.text().includes('Failed to load resource')) { trouble.push(message.text()); } }); page.on('pageerror', (error) => trouble.push(String(error))); page.on('response', (response) => { const url = response.url(); if (!url.includes('/api/') || response.ok()) 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()}`); }); return trouble; } test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const box = page.getByLabel('Ny opgave'); // "i dag" rather than "i morgen": this is the Today view, and a task parsed // onto tomorrow is correctly absent from it — which would look like the quick // add had failed. await box.fill('køb kaffe i dag p1'); await box.press('Enter'); // The server parses the line, so this asserts the whole round trip: "i dag" // and "p1" are gone from the title because they became a date and a priority. const task = page.getByText('køb kaffe', { exact: true }); await expect(task).toBeVisible(); await page.getByRole('button', { name: 'Markér som færdig' }).first().click(); await expect(task).toBeHidden(); expect(trouble).toEqual([]); }); test('hvert link i sidebjælken fører et sted hen', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); // Wait for one link before counting them. `all()` is a snapshot with no // auto-waiting — it returns whatever matches at that instant — so counting // straight after goto() measures how fast the machine is. It passed on a // laptop and found zero links on a CI runner. await expect(sidebar.getByRole('link').first()).toBeVisible(); const hrefs = await sidebar .getByRole('link') .evaluateAll((links) => links.map((a) => a.getAttribute('href'))); expect(hrefs.length).toBeGreaterThanOrEqual(4); // I dag, Kommende, Indbakke, Indstillinger // Re-resolved by href on each pass rather than held as handles: navigating // re-renders the sidebar, and a handle from before the click is a handle to // an element that no longer exists. for (const href of hrefs) { await sidebar.locator(`a[href="${href}"]`).first().click(); // Wait for the navigation before looking at the page. Without this the // assertion can be satisfied by the heading of the page you just left — // which is not a hypothetical: a change that removed a project page's // heading entirely passed here on a fast machine and failed on CI. await page.waitForURL(`**${href}`); // Something with a heading rendered. A route written outside the routes // tree lands on the SPA shell and renders nothing — which is exactly how // the label route broke, and it answered 200 the whole time. await expect( page.locator('main h1, main h2').first(), `${href} renderede ingenting` ).toBeVisible({ timeout: 5000 }); } expect(trouble).toEqual([]); }); test('en opgave kan åbnes og redigeres', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const box = page.getByLabel('Ny opgave'); // Dated, so it lands in this view: an undated task goes to the Inbox and is // correctly absent here. await box.fill('skriv rapport i dag'); await box.press('Enter'); await page.getByText('skriv rapport', { exact: true }).click(); const drawer = page.getByRole('complementary', { name: 'Opgave' }); await expect(drawer).toBeVisible(); // A sub-task, a comment and a description at once — which is also the shape // that used to fail: three concurrent writes met SQLite's deferred BEGIN and // one of them came back a 500. await drawer.getByLabel('Beskrivelse').fill('Inden fredag.'); await drawer.getByLabel('Ny undertask').fill('lav dispositionen'); await drawer.getByLabel('Ny undertask').press('Enter'); await drawer.getByLabel('Ny kommentar').fill('Anders har materialet.'); await drawer.getByRole('button', { name: 'Skriv' }).click(); await expect(drawer.getByText('lav dispositionen')).toBeVisible(); await expect(drawer.getByText('Anders har materialet.')).toBeVisible(); // Nothing rolled back: a toast here means the server refused a write. await expect(page.locator('.toast')).toHaveCount(0); await page.keyboard.press('Escape'); await expect(drawer).toBeHidden(); // The description reached the row behind the drawer, so it was really saved. await expect(page.getByText('Inden fredag.')).toBeVisible(); expect(trouble).toEqual([]); }); test('hver fane under indstillinger renderer', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/indstillinger'); const tabs = page.getByRole('navigation', { name: 'Indstillinger' }); await expect(tabs).toBeVisible(); for (const label of [ 'Konto', 'Notifikationer', 'Integrationer', 'AI', 'API-tokens', // Only an administrator sees these two, and the account these tests run as // is the first account, which is one. 'Brugere', 'Historik', 'Fejl', 'Data og skabeloner' ]) { await tabs.getByRole('link', { name: label, exact: true }).click(); await expect( page.locator('section.panel').first(), `fanen ${label} renderede ingen sektion` ).toBeVisible({ timeout: 5000 }); } expect(trouble).toEqual([]); }); /** * The PWA's own files. Both of the bugs found last in this project were of this * kind — a manifest promising icons that were never generated — and they are * invisible to every other test in the repository. */ test('manifestet og service workeren findes, og ikonerne findes også', async ({ request }) => { const manifestResponse = await request.get('/manifest.webmanifest'); expect(manifestResponse.ok()).toBeTruthy(); const manifest = await manifestResponse.json(); for (const icon of manifest.icons ?? []) { const iconResponse = await request.get(icon.src); expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy(); } // Safari never reads the manifest's icons; it looks for this by name, and // without it an installed PWA on iOS gets a screenshot of the page as its // home-screen icon. const appleIcon = await request.get('/apple-touch-icon.png'); expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy(); // The service worker is what Web Push needs; without it a subscription cannot // be created at all. const worker = await request.get('/sw.js'); expect(worker.ok()).toBeTruthy(); expect(await worker.text()).toContain("addEventListener('push'"); }); /** * A project can be renamed, deleted and brought back. * * The round trip matters more than the three steps: deleting used to be a * one-way door in the interface — the API could restore, but only from an id * that had already disappeared from the screen. */ test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); await sidebar.getByLabel('Projektnavn').fill('Ferie'); await sidebar.getByLabel('Projektnavn').press('Enter'); await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible(); // The heading is the rename affordance: click it, and it becomes a field. await page.getByRole('button', { name: 'Ferie' }).click(); const title = page.getByLabel('Projektets navn'); await title.fill('Sommerferie'); await title.press('Enter'); await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible(); page.once('dialog', (dialog) => dialog.accept()); await projectAction(page, 'Slet projektet'); await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden(); // And it is reachable again, which is the half that did not exist. await page.goto('/indstillinger/data'); const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' }); await expect(trash.getByText('Sommerferie')).toBeVisible(); await trash.getByRole('button', { name: 'Hent tilbage' }).click(); await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible(); expect(trouble).toEqual([]); }); /** * A group can be made, folded, filled and deleted without taking its projects * with it. * * The fold is the part worth driving in a browser: it is stored on the account * rather than in localStorage, so it is a round trip, and "the arrow turned" is * not the same claim as "it came back folded after a reload". */ test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne med', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); await sidebar.getByLabel('Projektnavn').fill('Regnskab'); await sidebar.getByLabel('Projektnavn').press('Enter'); await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible(); await sidebar.getByRole('button', { name: 'Ny gruppe' }).click(); await sidebar.getByLabel('Gruppens navn').fill('Arbejde'); await sidebar.getByLabel('Gruppens navn').press('Enter'); // 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/ }); await expect(heading).toBeVisible(); await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible(); // The words in the heading must not sit on top of each other. // // Everything else here clicks by accessible name, which is exactly why this // needs saying out loud: "Omdøb" and "Slet" once got the 20px square meant for // the "+" icon beside "Projekter", overflowed it, and were drawn overlapping. // Both buttons still worked, so every assertion in this file passed. const boxes = await sidebar.locator('.folder-head').evaluate((el) => // Links as well as buttons: the group's name is a link to its page now, and // a measurement that skipped it would miss the widest thing in the row. [...el.querySelectorAll('button, a')].map((b) => { const r = b.getBoundingClientRect(); return { text: b.textContent.trim(), left: r.left, right: r.right }; }) ); for (let i = 1; i < boxes.length; i++) { expect( boxes[i].left, `"${boxes[i - 1].text}" og "${boxes[i].text}" overlapper i gruppehovedet` ).toBeGreaterThanOrEqual(boxes[i - 1].right); } // Dragged in, then dragged back out onto "Projekter" — which is the reason // that heading is a drop target at all: with every project filed away there // would otherwise be no loose row left to aim at. await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading); await expect(sidebar.getByText('Træk et projekt herop')).toBeHidden(); await sidebar .getByRole('link', { name: 'Regnskab' }) .dragTo(sidebar.getByRole('heading', { name: 'Projekter' })); await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible(); // Folded, reloaded, still folded. localStorage would have passed the first // half of that and failed the second on another machine. await fold.click(); await expect(fold).toHaveAttribute('aria-expanded', 'false'); await page.reload(); await expect(sidebar.getByRole('button', { name: /^Fold Arbejde/ })).toHaveAttribute( 'aria-expanded', 'false' ); await sidebar.getByRole('button', { name: /^Fold Arbejde/ }).click(); // 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. await sidebar.getByRole('button', { name: 'Omdøb' }).click(); await sidebar.getByRole('button', { name: 'Petrol' }).click(); // 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'); const painted = await sidebar .locator('.folder-head .group-dot') .evaluate((el) => getComputedStyle(el).backgroundColor); expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)'); // 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); // A project inside a group starts further in than a row outside one. Measured // rather than asserted on a class, because indentation is a fact about where // the row's contents land — and measured at the *content* edge, not the box: // the indent is padding, so the element's own left edge does not move. await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading); const contentLeft = (el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft); const [inside, outside] = await Promise.all([ sidebar.locator('.folder > a').first().evaluate(contentLeft), sidebar.locator('.views a').first().evaluate(contentLeft) ]); expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside); await sidebar .getByRole('link', { name: 'Regnskab' }) .dragTo(sidebar.getByRole('heading', { name: 'Projekter' })); // Renaming writes one row rather than one per project, which is the reason a // group is a table and not a string repeated on every project. await sidebar.getByRole('button', { name: 'Omdøb' }).click(); const name = sidebar.getByLabel('Gruppens navn'); await name.fill('Kontoret'); await name.press('Enter'); await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeVisible(); // Deleting the heading must not take the project filed under it. It is // ungrouped here, which is the case that would look identical either way if // the assertion were only "the group is gone". page.once('dialog', (dialog) => dialog.accept()); await sidebar.getByRole('button', { name: 'Slet' }).click(); await expect(sidebar.getByRole('link', { name: 'Kontoret', exact: true })).toBeHidden(); await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible(); expect(trouble).toEqual([]); }); /** * A task can be dropped into a section, including one with nothing in it. * * Only the task *rows* took a drop, so a section with no rows had nothing to aim * at — and a section you have just made is always empty, which is exactly when * you want to fill it. The board has always taken a drop on the column; the list * had no equivalent. * * The second half is the other thing that made this hard to report: the page used * to say "Projektet findes ikke, eller du har ikke adgang til det" whenever the * load failed *or* had not finished, so a dropped connection and a project you * cannot see were one sentence, and it never asked again. */ test('en opgave kan trækkes ned i en tom sektion, og en fejlet indlæsning siger det', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); await sidebar.getByLabel('Projektnavn').fill('Risteriet'); await sidebar.getByLabel('Projektnavn').press('Enter'); // 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(); await page.getByLabel('Ny opgave').fill('brænde kaffe'); await page.getByLabel('Ny opgave').press('Enter'); await expect(page.getByText('brænde kaffe')).toBeVisible(); await page.getByRole('button', { name: '+ Tilføj sektion' }).click(); await page.getByLabel('Ny sektion').fill('Uge 34'); await page.getByLabel('Ny sektion').press('Enter'); const section = page .locator('section') .filter({ has: page.getByRole('heading', { name: 'Uge 34' }) }); await expect(section.getByText('Tom')).toBeVisible(); await page.locator('.sortable').filter({ hasText: 'brænde kaffe' }).dragTo(section); await expect(section.getByText('brænde kaffe')).toBeVisible(); await expect(page.locator('.toast')).toHaveCount(0); // A load that fails says so, and offers to try again. await page.route('**/api/v1/projects/*', (r) => r.abort()); await page.reload(); await expect(page.getByText('Serveren svarede ikke')).toBeVisible(); await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible(); await page.unroute('**/api/v1/projects/*'); await page.getByRole('button', { name: 'Prøv igen' }).click(); await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible(); expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]); }); /** * A task can be dragged onto another day, and onto another project. * * Both are one gesture standing in for a form, and both cross a component * boundary: the row is dragged out of Kommende and dropped on the sidebar, which * is a different part of the tree entirely. What decides whether a target will * take it is the drag's MIME type, and that is only readable while the drag is in * the air — so this is the one thing here that cannot be checked any other way. */ test('en opgave kan trækkes til en anden dag og videre til et projekt', async ({ page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const box = page.getByLabel('Ny opgave'); await box.fill('hent pakken i dag'); await box.press('Enter'); await expect(page.getByText('hent pakken', { exact: true })).toBeVisible(); const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); await sidebar.getByLabel('Projektnavn').fill('Ærinder'); await sidebar.getByLabel('Projektnavn').press('Enter'); // Waited for before navigating away, or the create can lose its own redirect. await expect(sidebar.getByRole('link', { name: 'Ærinder' })).toBeVisible(); await page.goto('/upcoming'); await page.getByRole('button', { name: 'Liste', exact: true }).click(); const day = (name) => page.locator('section').filter({ has: page.getByRole('heading', { name, exact: true }) }); const row = page.locator('[draggable="true"]').filter({ hasText: 'hent pakken' }); await expect(day('I dag').getByText('hent pakken')).toBeVisible(); await row.dragTo(day('I morgen')); await expect(day('I morgen').getByText('hent pakken')).toBeVisible(); await expect(day('I dag').getByText('hent pakken')).toBeHidden(); // Onto "I dag" in the sidebar: the most-made rescheduling there is, and the // sidebar is where the pointer already is. "Kommende" deliberately does not // take one — it is a range, not a day, so a drop would have to invent a date // its label does not name. await row.dragTo(sidebar.getByRole('link', { name: 'I dag' })); await expect(day('I dag').getByText('hent pakken')).toBeVisible(); await row.dragTo(day('I morgen')); // And onto a project, which is a move rather than a field: the task has to // find a place among that project's tasks, and leave its old section behind. await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' })); await sidebar.getByRole('link', { name: 'Ærinder' }).click(); await expect(page.getByRole('heading', { name: 'Ærinder' })).toBeVisible(); await expect(page.getByText('hent pakken', { exact: true })).toBeVisible(); // And the same task in the month grid, where a chip is dragged from one cell // to another. The date is read off the grid rather than worked out here: the // browser is pinned to Europe/Copenhagen and this process is not, and for two // hours of every day they disagree about what today is. await page.goto('/upcoming'); await page.getByRole('button', { name: 'Måned', exact: true }).click(); const chip = page.locator('.chip').filter({ hasText: 'hent pakken' }); await expect(chip).toBeVisible(); const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date'); const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000) .toISOString() .slice(0, 10); await chip.dragTo(page.locator(`[data-date="${to}"]`)); await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible(); await expect(page.locator(`[data-date="${from}"]`).getByText('hent pakken')).toBeHidden(); expect(trouble).toEqual([]); }); /** * An invite link creates the account it was sent to. * * The server has emailed this URL since invites were built and nothing rendered * for it: the SPA fallback served the shell, the shell found no session, and the * recipient got a plain sign-in form for an account that did not exist yet. A 200 * all the way down — the same shape as `/mcp` and `/.well-known/`, and invisible * to every test that does not open the link. * * A second browser context, because the invitee is a different person: inheriting * the signed-in session would test the one case that never happens. */ test('et invitationslink opretter kontoen og giver adgang til projektet', async ({ browser, page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); await sidebar.getByLabel('Projektnavn').fill('Fælleshuset'); await sidebar.getByLabel('Projektnavn').press('Enter'); await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible(); await projectAction(page, 'Del'); 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(); await invitee.getByLabel('Navn', { exact: true }).fill('Nabo'); await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); await invitee.getByRole('button', { name: 'Opret konto' }).click(); // Signed in, with the project they were invited to. await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); await expect( invitee.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Fælleshuset/ }) ).toBeVisible(); // One failure here is not trouble: the page loads before the account exists, so // the shell's first `me()` is answered 401. That 401 is how the signed-out // screen appears at all. expect(inviteeTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]); await context.close(); // The invitee opens the project. Nothing has ever driven this: the assertion // above only proved the project reaches their *sidebar*, and a member's view // of a project page is a different code path from an owner's — different role, // different guards, and the page shows the same "findes ikke" message while it // is still loading as it does when the load failed. const inviteePage = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const member = await inviteePage.newPage(); const memberTrouble = watchForTrouble(member); await member.goto('/'); await member.getByLabel('E-mail').fill('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(); // Now that there is somebody to delegate to, the inviter can — and the view // for it appears. It is hidden on an instance with one person, where it could // never have anything in it. await page.reload(); await expect(sidebar.getByRole('link', { name: 'Venter på andre' })).toBeVisible(); // A project the inviter owns stays under "Projekter" after sharing it. It used // to move to "Delt med mig" the moment somebody accepted, and lose its place in // the order on the way. await expect( page.locator('.group').filter({ hasText: 'Projekter' }).getByText('Fælleshuset') ).toBeVisible(); await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset'); await page.getByLabel('Ny opgave').press('Enter'); await page.getByText('males inden fredag', { exact: true }).click(); const drawer = page.getByRole('complementary', { name: 'Opgave' }); await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' }); await page.keyboard.press('Escape'); await sidebar.getByRole('link', { name: 'Venter på andre' }).click(); await expect(page.getByRole('heading', { name: 'Venter på andre' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Nabo' })).toBeVisible(); // The task's own text, not the whole row's: quick add read "fredag" as the due // date, so the title is "males inden" and the rest of that line is metadata — // which now has the assignee in it too. await expect(page.getByText('males inden', { exact: true })).toBeVisible(); expect(trouble).toEqual([]); }); /** * The reset link renders its own form. * * The token is nonsense, so this stops at the form — which is the part that did * not exist. "Glemt adgangskode?" has always sent a link to this address and the * API has always accepted the token; what the link opened was the sign-in screen, * asking for the password the person had just said they had forgotten. */ test('nulstillingslinket viser et felt til den nye adgangskode', async ({ browser }) => { const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const page = await context.newPage(); await page.goto('/reset?token=ugyldigt'); await expect(page.getByText('Vælg en ny adgangskode')).toBeVisible(); await page.getByLabel(/adgangskode/i).fill('et langt kodeord til test'); await page.getByRole('button', { name: 'Gem adgangskoden' }).click(); // And it says so rather than pretending: the token is not a real one. await expect(page.getByRole('alert')).toBeVisible(); await context.close(); }); /** * The device list, and ending one of them. * * `last_seen_at` has been written on every request since sessions existed — once * a minute, deliberately, so this list could say "lige nu" — and until now * nothing read it. A session you cannot see is a session you cannot end, which * matters most in the one situation it exists for: somebody else has your cookie. */ test('enhedslisten viser denne enhed, og en anden kan logges ud', async ({ browser, page }) => { const trouble = watchForTrouble(page); // A second sign-in as the same person: a real second session, not a second tab // sharing this one's cookie. const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const elsewhere = await context.newPage(); await elsewhere.goto('/'); await elsewhere.getByLabel('E-mail').fill(USER.email); await elsewhere.getByLabel('Adgangskode').fill(USER.password); await elsewhere.getByRole('button', { name: 'Log ind' }).click(); await expect(elsewhere.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); await page.goto('/indstillinger'); const panel = page.locator('section.panel').filter({ hasText: 'Enheder' }); await expect(panel.getByText('denne enhed')).toBeVisible(); await expect(panel.locator('li')).toHaveCount(2); // End the one that is not this browser — the row without the badge. await panel.locator('li').filter({ hasNot: page.getByText('denne enhed') }) .getByRole('button', { name: 'Log ud' }) .click(); await expect(panel.locator('li')).toHaveCount(1); // That session is really gone, and this one is untouched. await elsewhere.reload(); await expect(elsewhere.getByText('Log ind for at fortsætte')).toBeVisible(); await context.close(); await expect(panel.getByText('denne enhed')).toBeVisible(); expect(trouble).toEqual([]); }); /** * An administrator can put somebody on the instance without sharing a project * with them first. * * This is the answer to "how do I create a user": there is no open registration * and no account with a password somebody else chose, so adding a person means * issuing an invite that carries no project. The whole loop is worth driving — * the link, a second browser, the signup, and the new account showing up in the * list as somebody who is *not* an administrator. */ test('en administrator kan invitere en bruger til instansen', async ({ browser, page }) => { const trouble = watchForTrouble(page); await page.goto('/indstillinger/brugere'); await page.getByLabel('E-mailadresse').fill('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); await invitee.getByLabel('Navn', { exact: true }).fill('Anders'); await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); await invitee.getByRole('button', { name: 'Opret konto' }).click(); // They are in — with an Inbox of their own, and no project shared with them, // which is the difference between this and a project invite. await expect(invitee.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); await expect(invitee.getByRole('heading', { name: 'I dag' })).toBeVisible(); await context.close(); await page.reload(); const konti = page.locator('section.panel').filter({ hasText: 'Konti' }); const row = konti.locator('li').filter({ hasText: 'Anders' }); await expect(row).toBeVisible(); // Invited, not promoted. Asserted through the button that offers to promote // them — which only reads this way for somebody who is not one. Matching the // word "administrator" in the row would match that very button. await expect(row.getByRole('button', { name: 'Gør til administrator' })).toBeVisible(); // And the invite is spent rather than left live. await expect(page.getByText('Afventer svar')).toBeHidden(); expect(trouble).toEqual([]); }); /** * The two refusals that keep an instance administrable. Driven here as well as in * the API tests because the interface has to *say* why, and a 409 nobody explains * is a button that appears broken. */ test('den sidste administrator kan hverken fjernes eller slette sig selv', async ({ page }) => { await page.goto('/indstillinger/brugere'); const konti = page.locator('section.panel').filter({ hasText: 'Konti' }); const mine = konti.locator('li').filter({ hasText: 'dig' }); await expect(mine).toBeVisible(); // No buttons on your own row: the server refuses both, and a button whose only // purpose is to be refused wastes a click to teach a rule. await expect(mine.getByRole('button')).toHaveCount(0); }); /** * A member's role can be corrected without removing them. * * This is what a viewer who cannot add tasks looks like from the other side. The * rule itself is right — a viewer may not write — but until now the only way to * fix an invite sent with the wrong role was to remove the person and invite them * again, which unassigns every task they were responsible for on the way past. * * Driven end to end because the interesting part is that the *other* browser's * permissions actually change: the server decides, and the page has to agree. */ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => { const trouble = watchForTrouble(page); await page.goto('/'); const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); await sidebar.getByRole('button', { name: 'Nyt projekt' }).click(); await sidebar.getByLabel('Projektnavn').fill('Fælles'); await sidebar.getByLabel('Projektnavn').press('Enter'); // 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(); await projectAction(page, 'Del'); 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); await andreas.getByLabel('Navn', { exact: true }).fill('andreas'); await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); await andreas.getByRole('button', { name: 'Opret konto' }).click(); await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); await andreas .getByRole('navigation', { name: 'Hovedmenu' }) .getByRole('link', { name: /Fælles/ }) .click(); // A viewer sees the project and is told, plainly, that it is read-only. await expect(andreas.getByText('Du kan se dette projekt, men ikke ændre det')).toBeVisible(); await expect(andreas.getByLabel('Ny opgave')).toHaveCount(0); // The owner promotes them from the member list. The owner's own row is text, // not a dropdown: ownership is transferred, not granted. await page.reload(); await projectAction(page, /Delt/); await expect(page.getByText('Ejer')).toBeVisible(); await page.getByLabel('Rolle for andreas').selectOption('editor'); await andreas.reload(); await expect(andreas.getByLabel('Ny opgave')).toBeVisible(); await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0); // Now that they can edit, hand them something — and the row says whose it is. // Only when it is somebody else's: your own list would otherwise carry your own // face on every line, which is a column of one initial telling you nothing. await page.getByLabel('Ny opgave').fill('brænde kaffe'); await page.getByLabel('Ny opgave').press('Enter'); await page.getByText('brænde kaffe', { exact: true }).click(); const drawer = page.getByRole('complementary', { name: 'Opgave' }); await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' }); await page.keyboard.press('Escape'); const row = page.locator('.row').filter({ hasText: 'brænde kaffe' }); await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible(); // And not on a task that is nobody's. await page.getByLabel('Ny opgave').fill('min egen'); await page.getByLabel('Ny opgave').press('Enter'); await expect( page.locator('.row').filter({ hasText: 'min egen' }).locator('.assignee') ).toHaveCount(0); // The log has recorded all of this since the beginning and nothing showed it. await projectAction(page, 'Historik'); await expect(page.getByText('oprettede projektet')).toBeVisible(); await expect(page.getByText('ændrede rollen for')).toBeVisible(); await context.close(); expect(trouble).toEqual([]); }); /** * 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); await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid'); 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([]); }); /** * 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. 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); expect(trouble).toEqual([]); }); /** * 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([]); }); /** * 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 ); // 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(); // 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([]); }); /** * 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. await projectAction(page, 'Vis færdige'); 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(); await projectAction(page, 'Skjul færdige'); await expect(page.locator('section.done')).toHaveCount(0); await expect(page.getByText('male gavlen', { exact: true })).toBeVisible(); expect(trouble).toEqual([]); }); /** * 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([]); }); /** * 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([]); }); /** * 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([]); }); /** * 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([]); }); /** * 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([]); }); /** * 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. // `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'); 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. await page.getByLabel('Navn', { exact: true }).fill(USER.name); 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(); // 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(); 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([]); }); /** * 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'); });