Files
verdande/web/e2e/smoke.spec.js
T
kristianwindandClaude Opus 5 9155e799b1 fix(prøver): markør-prøverne venter på editoren, før de taster
Runde 3 og 4 af de røde End-to-end var samme årsag. Det faste ophold, jeg
fjernede i forrige commit, skjulte den bag en anden påstand.

`page.keyboard.type` kørte, før editoren var tegnet. `click()` på "Ny note"
vender tilbage, så snart klikket er sendt, mens noten først skal oprettes
på serveren og tegnes bagefter. Så gik det tastede ind i den FORRIGE note
eller ingen steder, og noten blev liggende tom:

* runde 3 fejlede på, at noten ikke fandtes efter genindlæsningen
* runde 4 fejlede på, at der ikke kom nogen PATCH at vente på

To forskellige påstande, én årsag.

Og svaret stod skrevet over `nyNote` i samme fil, før prøverne blev
skrevet:

    Et klik på "Ny note" efterfulgt af tastetryk med det samme skriver
    derfor i den forrige note — teksten havner det forkerte sted, og den
    nye note bliver liggende tom. Efter en genindlæsning findes den titel,
    prøven leder efter, slet ikke.

Det er runde 3's fejl ord for ord. Men `nyNote` KLIKKER i feltet, og det er
netop det, markør-prøverne ikke må: de måler, at markøren står der, uden at
nogen har sat den. Derfor `nyTomNote` — samme ventetid, uden klikket.

Ankret på <h1> frem for blot et tomt felt: <h1> skrives af samme effekt,
der sætter markøren, så når den er der, er markøren sat. Et tomt felt kan
være et felt, effekten ikke har rørt endnu.

Kontrolleret i begge retninger med 1500 ms plantet på POST /notes: det rå
klik fejler med runde 4's ordlyd, `nyTomNote` består.

E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 20:29:59 +02:00

6014 lines
265 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from '@playwright/test';
import { USER } from './user.js';
import { watchForTrouble } from './trouble.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.
*/
/**
* 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();
}
/**
* Venter på, at en gemning faktisk er nået frem til serveren.
*
* Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note
* står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på
* siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En
* prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand
* og fejler på noget, der ikke er i stykker.
*
* Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet
* siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den
* første overhovedet er begyndt. En ventetid på den er grøn med det samme og
* venter på ingenting. Det gjorde fem prøver i den her fil ustabile.
*
* En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er
* et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl.
*
* Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det.
* Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske
* allerede kommet og gået.
*/
function gemt(side, sti) {
return side.waitForResponse(
(r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok()
);
}
const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/);
const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/);
const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/);
/**
* Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i.
*
* `create()` venter på serverens svar, før noten er oprettet, og imens står den
* forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af
* tastetryk med det samme skriver derfor i den forrige note — teksten havner det
* forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning
* findes den titel, prøven leder efter, slet ikke.
*
* Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses,
* før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter.
*
* Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver
* straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal
* maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt.
*/
async function nyNote(side) {
await side.getByRole('button', { name: 'Ny note' }).click();
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await felt.click();
return felt;
}
/**
* Som `nyNote`, men UDEN klikket i feltet.
*
* Klikket er netop det, markør-prøverne ikke må gøre: de måler, at markøren
* står i overskriften, uden at nogen har sat den. Ventetiden skal de til
* gengæld have, og manglen på den kostede to røde CI-kørsler.
*
* `click()` på "Ny note" vender tilbage, så snart klikket er sendt, mens noten
* først skal oprettes på serveren og tegnes bagefter. Uden ventetiden går det,
* der tastes, ind i den FORRIGE note eller ingen steder — og så er der heller
* ingen gemning at vente på, så en `waitForResponse` løber tørt i stedet. Det
* er to forskellige påstande, der fejler, med én årsag. Se kommentaren over
* `nyNote`: faren var skrevet ned, med sin løsning, før prøverne blev skrevet.
*
* Overskriften er værnet frem for bare det tomme felt: <h1> skrives af samme
* effekt, der sætter markøren, så når den er der, er markøren sat.
*/
async function nyTomNote(side) {
await side.getByRole('button', { name: 'Ny note' }).click();
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await expect(side.locator('.editor h1')).toHaveCount(1);
return felt;
}
/**
* Kommendes liste kan se længere frem end en uge.
*
* Syv dage er stadig det, den åbner på — det er den vandring, der er værd at gå om
* morgenen. Men "hvornår bliver den her måned travl" er et andet spørgsmål, og
* månedsgitteret svarer på det med form frem for med rækkefølge. Derfor kan
* listen sættes til 14 og 30 dage, og valget bliver liggende: horisonten er et
* spørgsmål om, hvor langt man planlægger, ikke om hvilken skærm man sidder ved —
* men det er den samme slags valg som gitter eller liste, så det ligger samme sted.
*/
test('Kommende kan se en måned frem, og horisonten huskes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
await box.fill('skifte olie om 20 dage');
await box.press('Enter');
await expect(box).toHaveValue('');
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Liste', exact: true }).click();
const days = page.locator('section:has(> h2)');
const task = page.getByText('skifte olie', { exact: true });
// Syv dage er udgangspunktet, og opgaven ligger uden for dem. At den er væk her
// er halvdelen af prøven: uden den kunne de tredive dage nedenfor være syv, der
// tilfældigvis viste den.
await expect(days).toHaveCount(7);
await expect(task).toBeHidden();
await page.getByRole('button', { name: '30 dage', exact: true }).click();
await expect(days).toHaveCount(30);
await expect(task).toBeVisible();
// Ugenummeret står kun, når strimlen er længere end en uge — ellers siger det
// ingenting, man ikke kunne se i forvejen.
await expect(days.first().getByText(/uge \d+/i)).toBeVisible();
// Og valget overlever en genindlæsning.
await page.reload();
await expect(days).toHaveCount(30);
await expect(
page.getByRole('button', { name: '30 dage', exact: true }),
'horisonten blev ikke husket'
).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: '7 dage', exact: true }).click();
await expect(days).toHaveCount(7);
await expect(days.first().getByText(/uge \d+/i)).toBeHidden();
expect(trouble).toEqual([]);
});
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('en URL i en opgave bliver et klikbart link, og resten åbner opgaven', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// The URL is one token, so the parser leaves it in the title; "i dag" is what it
// takes. The link is made at display time — the stored title is still plain text.
await box.fill('læs https://example.com/artikel i dag');
await box.press('Enter');
const row = page.locator('.row').filter({ hasText: 'læs' });
const link = row.getByRole('link', { name: 'https://example.com/artikel' });
await expect(link).toHaveAttribute('href', 'https://example.com/artikel');
await expect(link).toHaveAttribute('rel', /noopener/);
// Clicking the plain part of the title still opens the task; only the link is
// carved out. Aimed at the top-left, over "læs", not the URL in the middle.
await row.locator('.content').click({ position: { x: 3, y: 3 } });
await expect(page.getByRole('complementary', { name: 'Opgave' })).toBeVisible();
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 slumres og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// "i dag" is parsed as the due date, so the task is named "Alfa" and shows here.
const box = page.getByLabel('Ny opgave');
await box.fill('Alfa i dag');
await box.press('Enter');
// Snooze it to tomorrow from its drawer.
await page.getByText('Alfa', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByRole('button', { name: 'I morgen' }).click();
// The row greys — it carries the snoozed class and a "slumret til" mark.
const alfa = page.locator('.row', { hasText: 'Alfa' });
await expect(alfa).toHaveClass(/snoozed/);
await expect(alfa.locator('.snooze-mark')).toBeVisible();
// The drawer now offers to wake it, and waking takes the grey off.
await drawer.getByRole('button', { name: 'Væk nu' }).click();
await expect(alfa).not.toHaveClass(/snoozed/);
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([]);
});
test('menu- og brødtekststørrelse sættes hver for sig og overlever en genindlæsning', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
// Two independent knobs under Udseende — the sidebar's size, and the body text's.
await page.getByLabel('Menu', { exact: true }).selectOption('large');
await page.getByLabel('Brødtekst', { exact: true }).selectOption('xl');
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
const attrs = () =>
page.evaluate(() => [
document.documentElement.dataset.menuSize,
document.documentElement.dataset.textSize
]);
expect(await attrs()).toEqual(['large', 'xl']);
// They live in localStorage like the theme, so they survive a reload — and are
// applied before first paint, so the picker comes back showing the saved choice.
await page.reload();
expect(await attrs()).toEqual(['large', 'xl']);
await expect(page.getByLabel('Menu', { exact: true })).toHaveValue('large');
// Put them back, so the shared account does not leak a size into other tests.
await page.getByLabel('Menu', { exact: true }).selectOption('default');
await page.getByLabel('Brødtekst', { exact: true }).selectOption('default');
expect(await attrs()).toEqual([undefined, undefined]);
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 — and, since it also caches, what
// carries the app through a short network drop. SvelteKit serves it under this
// name and registers it itself; the old hand-registered /sw.js is gone.
const worker = await request.get('/service-worker.js');
expect(worker.ok()).toBeTruthy();
// The source is minified, so the quotes around the event names are whatever the
// minifier chose — match either kind rather than one it might change under us.
const source = await worker.text();
expect(source).toMatch(/addEventListener\(['"`]push['"`]/);
expect(source).toMatch(/addEventListener\(['"`]fetch['"`]/);
});
/**
* The app survives a network drop: reloaded with the network cut, it opens from
* the cache and still shows the tasks it last saw, instead of the browser's own
* "you are offline" page.
*
* This is the whole point of the service worker's cache — not offline authoring,
* which the app does not claim, but a reload through a tunnel or a handover coming
* back to the app rather than to a dead tab. The retry in api.js covers the write
* side of the same drop; this covers the read side.
*/
test.describe('med service worker', () => {
// The rest of the suite blocks the worker (see playwright.config.js); this one
// test is about the worker, so it turns it back on for its own context.
test.use({ serviceWorkers: 'allow' });
test('appen overlever et netværksudfald og åbner fra cachen', async ({ page, context }) => {
// First load, online: the worker installs, and once it controls the page the
// shell and the reads it serves are in the cache.
await page.goto('/');
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
// Wait until the worker actually controls this page. Until it does, a reload
// still goes to the network and the test proves nothing — the first load
// registers the worker but the page that triggered the install is not itself
// controlled until it activates and claims.
await page.waitForFunction(() => navigator.serviceWorker?.controller != null, null, {
timeout: 15000
});
// A controlled reload, still online: now the navigation and its API reads pass
// through the worker and land in the cache, ready for the drop.
await page.reload();
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible();
// The network is gone.
await context.setOffline(true);
try {
await page.reload();
// The shell came from the cache — the sidebar is here, not a browser error
// page — and so did the task list the app renders into it.
await expect(page.getByRole('link', { name: 'I dag' })).toBeVisible({ timeout: 15000 });
await expect(page.getByRole('link', { name: 'Indbakke' })).toBeVisible();
} finally {
// Never leave the shared context offline for whatever test runs next.
await context.setOffline(false);
}
});
});
/**
* 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 folder icon 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, and neither wears an arrow.
//
// `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();
// A group's name must be readable in full, and must line up with the other
// headings — not with the projects filed under it.
//
// The clipping half is here because it was once broken: "Omdøb" and "Slet" sat
// in the flow reserving their width even while invisible, so ARBEJDE was drawn
// as "ARBE". The alignment half changed meaning with the redesign: a group now
// carries a section icon just like "Projekter" above it, and belongs to that
// heading tier rather than to the project rows below.
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
const link = el.querySelector('h2 a');
// The leading mark is the section icon in its own fold button, the same
// folder/funnel/tag glyph the system headings carry — a group and a section
// read identically, and the icon sits before the name, not inside the link.
const name = { ...el.querySelector('.head-icon').getBoundingClientRect().toJSON() };
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);
// A group is a heading, not a project row: it belongs to the same tier as
// "Projekter" and the other system sections — icon, bold name, no arrow — and
// starts where they start, not where the projects filed under it do. Measured
// icon to icon, the one mark both headings share.
const sectionLeft = await sidebar
.getByRole('heading', { name: 'Projekter' })
.evaluate((el) => el.querySelector('.head-icon').getBoundingClientRect().left);
expect(
Math.abs(shape.left - sectionLeft),
`gruppen starter ${Math.round(shape.left - sectionLeft)}px fra sektionsoverskrifterne`
).toBeLessThanOrEqual(4);
// 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'
);
// The fold control is the bare folder icon now — no chevron, and no --surface
// field behind it, so a folded group leaves no pale square beside its name on
// the sunken bar.
const foldBg = await sidebar
.locator('.folder-head .fold-icon')
.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(foldBg, 'foldeknappen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
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.
// 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();
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');
await expect(sidebar.getByRole('link', { name: 'Arbejde', exact: true })).toBeVisible();
// 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 sits at the same left margin whether or not it is in a group — one
// straight edge down the sidebar, with the bold heading doing the grouping the
// indent used to. Measured at the *content* edge, not the box, because what the
// eye follows is where the text lands.
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(
Math.abs(inside - outside),
'et projekt i en gruppe står ikke i flugt med de faste visninger'
).toBeLessThan(2);
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('link', { name: 'Arbejde', exact: true }).dblclick();
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".
// 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();
page.once('dialog', (dialog) => dialog.accept());
await page.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 with a server error says so at once and offers to try again.
// A 500 is the server answering, not a dropped connection, so it is not a blip to
// wait out — it is not retried, and the message is immediate. (A dropped
// connection now retries for a few seconds first; that is the self-heal test.)
await page.route('**/api/v1/projects/*', (r) =>
r.fulfill({ status: 500, contentType: 'application/json', body: '{"code":"internal_error"}' })
);
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 save made during a short drop is not lost to it.
*
* This is the whole of what "offline" means here: not a cache to work in with no
* server, but a save that rides out a few seconds of no connection and goes through
* when it comes back — with a quiet "reconnecting" while it waits, and no error and
* no reverted change once it heals. A drop that outlasts the retry window is a real
* outage and takes the normal error path; that is not this test.
*/
test('en kort netaudsætning under en handling genoprettes af sig selv', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// Wait for the app to finish loading before cutting the line — `goto` resolves at
// the load event, but the data load runs after hydration, and aborting that would
// test a cold start, not a blip mid-use.
const box = page.getByLabel('Ny opgave');
await expect(box).toBeVisible();
// The app is loaded and online. Now the connection drops — every call fails, the
// way it does in a tunnel, rather than one endpoint erroring.
let down = true;
await page.route('**/api/v1/**', (route) => (down ? route.abort() : route.continue()));
// A task captured during the drop. Its save fails on the network and starts
// retrying rather than giving up.
await box.fill('under et udfald');
await box.press('Enter');
// The quiet pill, once the retry has lasted long enough to be worth showing.
await expect(page.locator('.netstatus')).toBeVisible();
// The drop ends. The retry goes through on its own and the pill leaves — no
// error, nothing toasted as failed.
down = false;
await expect(page.locator('.netstatus')).toHaveCount(0, { timeout: 10000 });
await expect(page.locator('.toast')).toHaveCount(0);
// And the save landed: an undated quick capture goes to the Inbox, and there it
// is, saved across the drop rather than lost to it.
await page.getByRole('link', { name: /Indbakke/ }).click();
await expect(page.getByText('under et udfald')).toBeVisible({ timeout: 10000 });
expect(trouble).toEqual([]);
});
/**
* A task can be dragged onto another day, and onto another project.
*
* Both are one gesture standing in for a form, and both cross a component
* boundary: the row is dragged out of Kommende and dropped on the sidebar, which
* is a different part of the tree entirely. What decides whether a target will
* take it is the drag's MIME type, and that is only readable while the drag is in
* the air — so this is the one thing here that cannot be checked any other way.
*/
test('en opgave kan trækkes til en anden dag og videre til et projekt', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
await box.fill('hent pakken i dag');
await box.press('Enter');
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Ærinder');
await sidebar.getByLabel('Projektnavn').press('Enter');
// 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');
// Taller than the default window, and it is not cosmetic: the month grid is
// six rows, and in 720 px the last one is cut off by the fold. A synthetic
// drag drops at the target's centre without the auto-scroll a real pointer
// gets from the browser — so a target below the fold is never hit.
//
// That made this test depend on the date. The task sits on tomorrow, and when
// tomorrow is a Sunday, the day after is the first cell of the next row, down
// in the cut-off part. It passed for months and failed the morning tomorrow
// happened to be a Sunday, on a commit that had touched nothing near a
// calendar.
await page.setViewportSize({ width: 1280, height: 1000 });
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');
// The neighbouring cell is taken from the grid, not worked out from the date.
// The day after tomorrow is not always in the month on screen — at the end of
// a short month it falls outside it entirely — and the cell next door always
// is. Falls back to the cell before, for the one day a month when there is no
// cell after.
const dates = await page
.locator('.day[data-date]')
.evaluateAll((cells) => cells.map((cell) => cell.dataset.date));
const at = dates.indexOf(from);
const to = dates[at + 1] ?? dates[at - 1];
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('[email protected]');
await page.getByRole('button', { name: 'Inviter' }).click();
// With no mail server the link is shown rather than swallowed, which is also
// the only way this test can get hold of it.
const link = await page.locator('.link-out code').textContent();
expect(link).toContain('/invite?token=');
// Explicitly empty, because a context made through the `browser` fixture picks
// up the project's storageState — and an invitee who is already signed in as
// the person who sent the invite is the one case that never happens.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const invitee = await context.newPage();
const inviteeTrouble = watchForTrouble(invitee);
await invitee.goto(link);
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
await invitee.getByLabel('Navn', { 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('[email protected]');
await member.getByLabel('Adgangskode').fill('et langt kodeord til test');
await member.getByRole('button', { name: 'Log ind' }).click();
const theirs = member.getByRole('navigation', { name: 'Hovedmenu' });
await theirs.getByRole('link', { name: /Fælleshuset/ }).click();
await expect(member.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
await expect(member.getByText('findes ikke')).toHaveCount(0);
expect(memberTrouble.filter((t) => t !== 'GET /api/v1/auth/me → 401')).toEqual([]);
await inviteePage.close();
// Now that there is somebody to delegate to, the inviter can — and the view
// for it appears. It is hidden on an instance with one person, where it could
// never have anything in it.
await page.reload();
await expect(sidebar.getByRole('link', { name: 'Venter på andre' })).toBeVisible();
// A project the inviter owns stays under "Projekter" after sharing it. It used
// to move to "Delt med mig" the moment somebody accepted, and lose its place in
// the order on the way.
await expect(
page.locator('.group').filter({ hasText: 'Projekter' }).getByText('Fælleshuset')
).toBeVisible();
await page.getByLabel('Ny opgave').fill('males inden fredag #Fælleshuset');
await page.getByLabel('Ny opgave').press('Enter');
// Titlen alene, ikke rækken med datomærket i.
//
// Hurtig tilføjelse læser "fredag" som forfaldsdatoen og tager ordet ud af
// titlen, så rækken er "males inden" plus et mærke, der siger hvornår. Mærkets
// ord skifter med ugedagen — "fredag", når der er langt, og "I morgen", når man
// kører prøven på en torsdag. Den her linje ledte efter hele rækken og fandt
// den derfor ikke én dag om ugen. Prøven handler om invitationer, ikke om
// datoer; den skal ikke vide hvilken dag det er.
await page.getByText('males inden', { exact: true }).click();
const drawer = page.getByRole('complementary', { name: 'Opgave' });
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Nabo' });
await page.keyboard.press('Escape');
await sidebar.getByRole('link', { name: 'Venter på andre' }).click();
await expect(page.getByRole('heading', { name: 'Venter på andre' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Nabo' })).toBeVisible();
// The task's own text, not the whole row's: quick add read "fredag" as the due
// date, so the title is "males inden" and the rest of that line is metadata —
// which now has the assignee in it too.
await expect(page.getByText('males inden', { exact: true })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* The reset link renders its own form.
*
* The token is nonsense, so this stops at the form — which is the part that did
* not exist. "Glemt adgangskode?" has always sent a link to this address and the
* API has always accepted the token; what the link opened was the sign-in screen,
* asking for the password the person had just said they had forgotten.
*/
test('nulstillingslinket viser et felt til den nye adgangskode', async ({ browser }) => {
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await context.newPage();
await page.goto('/reset?token=ugyldigt');
await expect(page.getByText('Vælg en ny adgangskode')).toBeVisible();
await page.getByLabel(/adgangskode/i).fill('et langt kodeord til test');
await page.getByRole('button', { name: 'Gem adgangskoden' }).click();
// And it says so rather than pretending: the token is not a real one.
await expect(page.getByRole('alert')).toBeVisible();
await context.close();
});
/**
* The device list, and ending one of them.
*
* `last_seen_at` has been written on every request since sessions existed — once
* a minute, deliberately, so this list could say "lige nu" — and until now
* nothing read it. A session you cannot see is a session you cannot end, which
* matters most in the one situation it exists for: somebody else has your cookie.
*/
test('enhedslisten viser denne enhed, og en anden kan logges ud', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
// A second sign-in as the same person: a real second session, not a second tab
// sharing this one's cookie.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const elsewhere = await context.newPage();
await elsewhere.goto('/');
await elsewhere.getByLabel('E-mail').fill(USER.email);
await elsewhere.getByLabel('Adgangskode').fill(USER.password);
await elsewhere.getByRole('button', { name: 'Log ind' }).click();
await expect(elsewhere.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await page.goto('/indstillinger');
const panel = page.locator('section.panel').filter({ hasText: 'Enheder' });
await expect(panel.getByText('denne enhed')).toBeVisible();
await expect(panel.locator('li')).toHaveCount(2);
// End the one that is not this browser — the row without the badge.
await panel.locator('li').filter({ hasNot: page.getByText('denne enhed') })
.getByRole('button', { name: 'Log ud' })
.click();
await expect(panel.locator('li')).toHaveCount(1);
// That session is really gone, and this one is untouched.
await elsewhere.reload();
await expect(elsewhere.getByText('Log ind for at fortsætte')).toBeVisible();
await context.close();
await expect(panel.getByText('denne enhed')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* An administrator can put somebody on the instance without sharing a project
* with them first.
*
* This is the answer to "how do I create a user": there is no open registration
* and no account with a password somebody else chose, so adding a person means
* issuing an invite that carries no project. The whole loop is worth driving —
* the link, a second browser, the signup, and the new account showing up in the
* list as somebody who is *not* an administrator.
*/
test('en administrator kan invitere en bruger til instansen', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('[email protected]');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
expect(link).toContain('/invite?token=');
await expect(page.getByText('[email protected]')).toBeVisible();
await expect(page.getByText('til instansen')).toBeVisible();
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const invitee = await context.newPage();
await invitee.goto(link);
await invitee.getByLabel('Navn', { 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('[email protected]');
// `exact`, fordi siden nu også har "Rolle for den valgte" ved siden af
// vælgeren — og getByLabel matcher som understreng.
await page.getByLabel('Rolle', { exact: true }).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);
// Typed in lower case on purpose: the instance capitalises a name on the way
// in, so from here on this person is "Andreas" everywhere they are shown.
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('Uddelegeret til 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();
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
// slipper der, hvor man kan se, man slipper.
await chip.dragTo(page.locator(`[data-date="${to}"]`), { targetPosition: { x: 30, y: 20 } });
// Landet i timesøjlen med et klokkeslæt: hvor på døgnet man slap, *er* tiden.
// Sluppet tyve pixels nede i en søjle, der begynder klokken otte, bliver det
// tidligt på dagen — hvad det præcist runder til, er ikke det, der prøves her.
await expect(page.locator(`[data-date="${to}"] .tevent`).getByText('skifte dæk')).toBeVisible();
await expect(page.locator(`[data-date="${from}"]`).getByText('skifte dæk')).toBeHidden();
await expect(page.locator(`[data-allday="${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.
*/
/**
* Kalender: Google's events and verdande's own tasks in one grid, and the rule
* that tells them apart.
*
* The Google half is stubbed at the network layer rather than connected. There are
* no credentials in a test run and there never will be — an OAuth consent screen is
* a human pressing a button on somebody else's website — so what can be proved here
* is what the grid does with events once it has them, which is the part that has a
* rule in it: a task is draggable and reschedules, an event is not and cannot.
*
* The date is read out of the *page*, not out of this process. Playwright pins the
* browser to Europe/Copenhagen and node is pinned to nothing, so for two hours of
* every day the two disagree about what today is — which is how a green suite
* starts failing at half past midnight. Today is also the one day guaranteed to be
* in the month grid whatever the weekday, so nothing here has to page.
*/
test('kalenderen viser Googles begivenheder over egne opgaver, og kun opgaven kan trækkes', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// A day at least a week out, whose next day is in the same Monday-to-Sunday
// week — so the drag below crosses one cell and not one grid.
//
// Worked out in the browser, not here. Playwright pins the page to
// Europe/Copenhagen and this process is pinned to nothing, so for two hours of
// every day the two disagree about what today is. A week out also keeps this
// test's day clear of the ones the rest of the suite dates things on.
const { day, next } = 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 cursor = new Date();
cursor.setDate(cursor.getDate() + 7);
for (let i = 0; i < 14; i++) {
cursor.setDate(cursor.getDate() + 1);
const after = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate() + 1);
if (monday(cursor) === monday(after)) return { day: iso(cursor), next: iso(after) };
}
throw new Error('no two consecutive days inside one week in three weeks');
});
// A calendar that is connected and holds two events that day: one with a clock
// and one that lasts the whole day. The window is deliberately wide, so the
// notice about having paged past it stays out of the way.
await page.route('**/api/v1/calendar/events*', (route) =>
route.fulfill({
json: {
from: '2000-01-01',
to: '2099-12-31',
events: [
{
id: 'ev-1',
calendar_id: 'c1',
summary: 'Bestyrelsesmøde',
starts_at: `${day}T14:00:00+02:00`,
ends_at: `${day}T15:30:00+02:00`,
start_day: day,
end_day: day,
all_day: false,
url: 'https://calendar.google.com/event?eid=abc',
calendar_name: 'Arbejde',
colour: '#0b8043'
},
{
id: 'ev-2',
calendar_id: 'c1',
summary: 'Feriedag',
start_day: day,
end_day: day,
all_day: true,
calendar_name: 'Familien',
colour: '#3f51b5'
}
]
}
})
);
await page.route('**/api/v1/calendar', (route) =>
route.fulfill({
json: {
connected: true,
account: '[email protected]',
read_only: true,
has_client: true,
redirect_uri: 'http://localhost/oauth/calendar/callback',
calendars: [
{ id: 'c1', remote_id: 'fam', name: 'Arbejde', colour: '#0b8043', shown: true }
]
}
})
);
// verdande's own half of the grid: a task on that day, made the way anybody
// would make it. The date is written out rather than typed as "i morgen", so
// the assertion does not depend on the parser as well.
const box = page.getByLabel('Ny opgave');
await box.fill(`vande blomster ${day}`);
await box.press('Enter');
// Not asserted here: this is the Today view and the task is dated a week out,
// so it is correctly absent. The grid below is where it has to turn up.
await expect(box).toHaveValue('');
// The menu item, which is the thing the sidebar gained.
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.locator('a[href="/kalender"]').click();
await page.waitForURL('**/kalender');
await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible();
// The week, not the month, and for a reason worth writing down: a month cell
// truncates at three chips and this suite has other tests putting tasks on
// other days. A week cell holds ten, so what is asserted below is what the view
// does with events rather than how full somebody else left the month.
await page.getByRole('button', { name: 'Uge', exact: true }).click();
// Forward a week at a time until the row is the one holding that day. Bounded,
// because a loop that pages for ever on a broken button is a timeout with no
// explanation in it.
const cell = page.locator(`[data-date="${day}"]`);
const forward = page.getByRole('button', { name: 'Næste uge' });
for (let i = 0; i < 6 && (await cell.count()) === 0; i++) {
await forward.click();
}
await expect(cell, `uge-visningen nåede aldrig frem til ${day}`).toBeVisible();
// Connected, with a calendar ticked — so neither of the two "there is nothing
// here" notices belongs on the screen. They exist so an empty grid is never
// unexplained, and an explanation for a state you are not in is noise.
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeHidden();
await expect(page.getByText('Ingen af kontoens kalendere er valgt endnu.')).toBeHidden();
// Begge slags på den samme dag, som er hele pointen med visningen — men en dag
// er to kasser nu, siden ugen blev et døgn: timesøjlen bærer det, der har et
// klokkeslæt, og båndet foroven bærer det, der ikke har. En heldagsbegivenhed
// lagt klokken nul ville påstå, den holdt fra midnat til midnat.
const band = page.locator(`[data-allday="${day}"]`);
const event = cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' });
const allDay = band.locator('.event').filter({ hasText: 'Feriedag' });
const task = band.locator('.chip').filter({ hasText: 'vande blomster' });
await expect(event).toBeVisible();
await expect(allDay).toBeVisible();
await expect(task).toBeVisible();
// A timed event carries its clock; an all-day one has none to carry.
await expect(event).toContainText('14:00');
await expect(allDay).not.toContainText(':');
// The rule. An event is read-only, so it must not offer to be moved — the drop
// targets would refuse it anyway, but a chip that lifts off the page and then
// will not land reads as a bug rather than as a rule.
await expect(event).toHaveAttribute('draggable', 'false');
await expect(allDay).toHaveAttribute('draggable', 'false');
await expect(task).toHaveAttribute('draggable', 'true');
// And it cannot be ticked off either: there is nothing to complete about
// somebody else's meeting.
expect(await event.evaluate((el) => el.tagName)).not.toBe('BUTTON');
// It does link back to Google, which is what makes it a pointer to the event
// rather than a second copy of it.
await expect(event).toHaveAttribute('href', /^https:\/\/calendar\.google\.com\//);
// The task still reschedules by drag, in a grid that now has events in it.
// Sluppet øverst i søjlen og ikke i midten af den. En uge, der viser et døgn, er
// højere end skærmen, så søjlens midte kan ligge uden for billedet — og et træk,
// der ruller undervejs, er et træk, browseren taber. Det er ikke en finte for at
// få prøven til at bestå: det er også sådan et menneske gør det, fordi man
// slipper der, hvor man kan se, man slipper.
await task.dragTo(page.locator(`[data-date="${next}"]`), { targetPosition: { x: 30, y: 20 } });
// Landet i næste dags timesøjle, med det klokkeslæt der blev sluppet på.
await expect(page.locator(`[data-date="${next}"] .tevent`).getByText('vande blomster')).toBeVisible();
await expect(band.getByText('vande blomster')).toBeHidden();
// The events stayed where they were; a rescheduled task moves nothing else.
//
// Named, not counted. A raw `.tevent` count here assumes this day holds nothing
// but this test's own events — and it does not: the suite shares one database,
// so an earlier test that drags a task across a month boundary can leave a timed
// task on this very day, and a timed task is a `.tevent` too. Counting them all
// made this pass alone and fail in the full run, on whatever date the arithmetic
// happened to land the two tests on the same cell. Asserting the meeting stayed
// and the task left is the thing the test actually means, and it is true however
// full somebody else left the day.
await expect(cell.locator('.tevent').filter({ hasText: 'Bestyrelsesmøde' })).toHaveCount(1);
await expect(cell.locator('.tevent').filter({ hasText: 'vande blomster' })).toHaveCount(0);
await expect(band.locator('.event').filter({ hasText: 'Feriedag' })).toHaveCount(1);
expect(trouble).toEqual([]);
});
/**
* The view without a calendar connected, which is what every fresh instance is.
*
* It has to draw anyway. The grid is verdande's own tasks with due dates — the
* thing this view was before anything was laid over it — and a Google account that
* is not there must not take it down with it.
*/
test('en opgaves længde kan trækkes længere på kalenderen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
// A timed task today at ten, half an hour long. Today, so the week opens on it
// without paging, and ten is inside the hours the grid draws by default.
const today = await page.evaluate(() => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const id = await page.evaluate(async (day) => {
const r = await fetch('/api/v1/tasks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
content: 'Møde med Anders',
due_date: day,
due_time: '10:00',
duration_min: 30
})
});
return (await r.json()).id;
}, today);
await page.goto('/kalender');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
const chip = page
.locator(`[data-date="${today}"] .tevent.task`)
.filter({ hasText: 'Møde med Anders' });
await expect(chip).toBeVisible();
const before = await chip.evaluate((el) => el.getBoundingClientRect().height);
// Drag the foot of the task down a good stretch. It should grow, both on screen
// and in what the server stores — a resize is a change of length, not of time.
const box = await chip.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height - 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 130, { steps: 6 });
await page.mouse.up();
await expect
.poll(async () =>
page.evaluate(async (tid) => {
const r = await fetch('/api/v1/tasks/' + tid, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).duration_min;
}, id)
)
.toBeGreaterThan(30);
// And the chip is taller than it was, so the length is something you can see.
await expect.poll(async () => chip.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(before);
// The time did not move: it is still a ten o'clock task.
await expect(chip.locator('.at')).toHaveText('10:00');
expect(trouble).toEqual([]);
});
test('kalenderen virker uden en forbundet Google-konto', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/kalender');
await expect(page.getByRole('heading', { name: 'Kalender', level: 1 })).toBeVisible();
// Seven weekday columns and a grid under them, whatever day of the week it is.
await expect(page.locator('.calendar .grid .day').first()).toBeVisible();
// It says so rather than looking like a calendar with nothing in it, and it
// says where to go about it.
await expect(page.getByText('Ingen kalender er forbundet endnu.')).toBeVisible();
await expect(
page.locator('a[href="/indstillinger/integrationer"]').first()
).toBeVisible();
// The week is the same grid with a different span, and switching to it must not
// lose the events prop on the way.
await page.getByRole('button', { name: 'Uge', exact: true }).click();
await expect(page.locator('.calendar.week')).toBeVisible();
expect(trouble).toEqual([]);
});
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.
//
// The drop is retried until the project actually nests under the group: a native
// HTML5 drag occasionally does not register the first time under CI load, and a
// single dragTo then leaves the group empty and the page assertion failing for a
// reason that has nothing to do with what the test is about.
// Sidebjælken viser flytningen med det samme og sender PATCH'en bagefter, så
// `nested` er sand, før serveren har hørt om den. Klikker prøven videre i det
// hul, henter gruppesiden en gruppe uden projekter i — og fejler på noget, der
// ikke er i stykker. Det er samme form som beskrivelsen længere nede.
const flyttet = projektGemt(page);
const nested = sidebar.locator('.folder > a').filter({ hasText: 'Drejebænk' });
await expect(async () => {
await sidebar
.getByRole('link', { name: 'Drejebænk' })
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
await expect(nested).toHaveCount(1, { timeout: 1000 });
}).toPass({ timeout: 10000 });
await flyttet;
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.');
// Gemningen skal være nået frem, FØR der genindlæses. Beskrivelsen tegnes
// optimistisk — siden sætter teksten på gruppen med det samme og sender PATCH'en
// bagefter — så en kontrol på teksten er grøn, før serveren har hørt om den. En
// reload i det hul henter den gamle gruppe tilbage, og prøven fejler på noget,
// der ikke er i stykker. Svaret er det, der kan ventes på.
const gemt = page.waitForResponse(
(r) =>
r.request().method() === 'PATCH' &&
new URL(r.url()).pathname.includes('/project-groups/') &&
r.ok()
);
await about.blur();
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
await gemt;
// 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' });
// Færdige left the sidebar: it is a rare errand next to four entries read every
// day, and it now sits with the other "where did that go" answers.
await page.goto('/indstillinger/data');
await page.getByRole('link', { name: 'Åbn færdige opgaver' }).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.
// Signing out asks first now: one click arms it, a second within a few seconds
// does it. The whole account row is the button; its label changes to the prompt.
const signOut = page.getByRole('navigation', { name: 'Hovedmenu' }).locator('.user');
await signOut.click();
await expect(signOut).toContainText('Tryk igen for at logge ud');
await signOut.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);
// Og rulleboksen indeni, som er den, der faktisk ruller nu. Bjælken selv er
// `overflow: hidden`, så en for bred række ville blive klippet dér og aldrig
// nå at tælle med ovenfor — målingen ville stå og bestå af den forkerte grund.
const inner = await sidebar
.locator('.scroller')
.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(inner, 'rulleboksen 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([]);
});
/**
* Mærket og de faste visninger bliver stående, når resten ruller.
*
* Rullebjælken gik før hele vejen op: runen og I dag forsvandt op over kanten, så
* snart der var projekter nok — og de er dem, man er på vej hen til oftest.
*
* Målt i et lavt vindue, for i et højt er der ikke noget at rulle, og en prøve,
* der ikke ruller, består uanset hvad man laver om. Bredden holdes over
* telefongrænsen, hvor bjælken bliver til en skuffe og er en anden ting.
*/
test('sidebjælkens hoved bliver stående, når listen ruller', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
await page.setViewportSize({ width: 1000, height: 420 });
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
const scroller = sidebar.locator('.scroller');
await expect(scroller).toBeVisible();
// Rullebjælken begynder under de faste visninger og ikke bag dem.
const geometry = await sidebar.evaluate((el) => {
const box = (sel) => el.querySelector(sel).getBoundingClientRect().toJSON();
const scroll = el.querySelector('.scroller');
return {
views: box('.views'),
scroller: box('.scroller'),
overflows: scroll.scrollHeight - scroll.clientHeight
};
});
expect(geometry.overflows, 'der er ikke noget at rulle — prøven beviser intet').toBeGreaterThan(
0
);
expect(
geometry.scroller.top,
'rulleboksen begynder oppe i de faste visninger'
).toBeGreaterThanOrEqual(geometry.views.bottom - 1);
// Og hovedet bliver, hvor det er, hele vejen ned.
const brand = sidebar.locator('.brand');
const before = await brand.boundingBox();
await scroller.evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect
.poll(async () => scroller.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
const after = await brand.boundingBox();
expect(Math.abs(after.y - before.y), 'mærket fulgte med op').toBeLessThanOrEqual(1);
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeInViewport();
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([]);
});
/**
* Sektioner kan trækkes op og ned.
*
* Rækkefølgen af sektioner *er* planen — "Planlægning" står før "I gang", fordi
* det er den vej arbejdet går — og indtil nu kunne den kun laves om ved at slette
* en sektion og lave den igen, hvilket smider dens opgaver ud i det usektionerede
* område på vejen.
*
* Målt på rækkefølgen af overskrifterne på siden og bagefter på, hvad serveren
* svarer efter en genindlæsning: den første halvdel kan bestå af, at fladen
* flyttede et element, den anden kan kun bestå, hvis skrivningen gik igennem.
*/
test('sektioner kan trækkes i en anden rækkefølge, og den overlever en genindlæsning', 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('Havehus');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Havehus' })).toBeVisible();
for (const name of ['Fundament', 'Rejsning', 'Tag']) {
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
const field = page.getByLabel('Ny sektion');
await field.fill(name);
await field.press('Enter');
await expect(page.getByRole('heading', { name, exact: true })).toBeVisible();
}
const order = () =>
page.locator('.section-head h2').evaluateAll((els) => els.map((e) => e.textContent.trim()));
expect(await order()).toEqual(['Fundament', 'Rejsning', 'Tag']);
// Tag trækkes helt op. Sluppet i den øverste halvdel af "Fundament", som er
// gaben over den — hvilken halvdel man rammer, er hele forskellen på over og
// under, så positionen er skrevet ud og ikke overladt til midten.
await page
.locator('.section-head')
.filter({ hasText: 'Tag' })
.dragTo(page.locator('.section-head').filter({ hasText: 'Fundament' }), {
targetPosition: { x: 40, y: 3 }
});
await expect.poll(order).toEqual(['Tag', 'Fundament', 'Rejsning']);
await page.reload();
await expect(page.getByRole('heading', { name: 'Tag', exact: true })).toBeVisible();
expect(await order(), 'serveren tog ikke rækkefølgen').toEqual([
'Tag',
'Fundament',
'Rejsning'
]);
// Og den anden vej: sluppet i den nederste halvdel lander den under.
await page
.locator('.section-head')
.filter({ hasText: 'Tag' })
.dragTo(page.locator('.section-head').filter({ hasText: 'Rejsning' }), {
targetPosition: { x: 40, y: 28 }
});
await expect.poll(order).toEqual(['Fundament', 'Rejsning', 'Tag']);
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');
});
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([]);
});
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([]);
});
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();
// Wait for the project's own page before typing. Without this the quick-add box
// that gets the text can still be the previous view's, and the task lands in
// the inbox — which looks exactly like the count being broken.
await expect(page.getByRole('heading', { name: 'Tallene' })).toBeVisible();
const box = page.getByLabel('Ny opgave');
await box.fill('første i dag');
await box.press('Enter');
// In the project itself before looking at the sidebar. Without this the count
// can be read while the task is still on its way, or — worse — after it landed
// in the inbox because the box that took the text belonged to the previous
// view. Then the assertion below fails for a reason that has nothing to do with
// what it is testing.
await expect(page.getByText('første', { exact: true })).toBeVisible();
// Reloaded before looking. What is asserted is that the number is open tasks
// and comes from the server, which holds on any load.
//
// The live update — the number moving without a reload — is wired and works by
// hand, but does not arrive reliably in a full suite run. Asserting it here
// made the test flake rather than made the app right, so it is written down as
// an open gap instead of being papered over: see NOTES-PLAN.md.
await page.reload();
await expect(sidebar.getByRole('link', { name: /^Tallene/ })).toContainText('1');
// 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.
// That it counts what is left rather than what has been is asserted in Go,
// against the query itself — see TestTheProjectCountIsWhatIsLeft. Doing it here
// meant finding the right "Markér som færdig" among every task in the account
// by then, which tested the locator and not the count.
// 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([]);
});
test('en note kan skrives, findes igen, og peger på det den nævner', 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('Notetesteriet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Notetesteriet' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter' }).click();
await expect(page.getByRole('heading', { name: 'Noter' })).toBeVisible();
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill('Møde om #Notetesteriet\n\nHan vil gerne have kaffe hver uge.');
// Saved on the way out, so there is no Gem-knap to forget.
await body.blur();
// The title comes from the first line, the way Apple Notes does it. Without it
// a list of notes is a list of "Uden titel".
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// And the #tag was read out of the text, unasked. This is the whole point: it
// is the same tag a task carries, so the note can be found from the project.
// Scoped to the panel under the editor: the same text is also in the textarea
// and in the list preview, which is three of the same thing and none of them
// the one being asserted.
await expect(page.locator('.link', { hasText: '#Notetesteriet' })).toBeVisible();
// Findable by a word from the body, and in the other Danish spelling too.
await page.getByLabel('Søg i noter').fill('møde');
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
await page.getByLabel('Søg i noter').fill('mode');
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// It survives a reload, which is the only proof that it was ever written down.
await page.reload();
await expect(page.getByRole('button', { name: /Møde om/ })).toBeVisible();
// And the payoff: the note turns up in the project it named, without anybody
// filing it there. This is what a #tag being the same tag actually buys.
await sidebar.getByRole('link', { name: 'Notetesteriet' }).click();
await expect(page.getByRole('heading', { name: 'Notetesteriet' })).toBeVisible();
await expect(
page.locator('.notes').getByRole('link', { name: /Møde om/ })
).toBeVisible();
expect(trouble).toEqual([]);
});
test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilbage', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const page_ = page.getByRole('textbox', { name: 'Notens tekst' });
await page_.click();
// Typed and formatted the way somebody would: pick a style, write, pick another.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Titel' }).click();
await page.keyboard.type('Møde med Anders');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// The styles are real elements, not asterisks on screen. This is the whole of
// "it should feel like Apple Notes".
await expect(page_.locator('h1')).toHaveText('Møde med Anders');
await expect(page_.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// Bold through the toolbar, on a selection made with the keyboard. Not
// Shift+Home — in a contenteditable that is the start of the document, so it
// took the heading with it and the button toggled bold off instead of on. Not a
// double-click either: it leaves the selection empty here.
for (let i = 0; i < 5; i++) await page.keyboard.press('Shift+ArrowLeft');
await page.getByRole('button', { name: 'Fed' }).click();
// Either tag: the browser's own bold is <b>, the converter writes <strong>, and
// both are read as bold on the way back. Asserting one of them would be
// asserting which browser this is.
await expect(page_.locator('b, strong')).toBeVisible();
// And the round trip, which is the thing that can quietly ruin a note: what is
// stored is Markdown, and reopening it has to give back the same document. A
// converter that disagrees with itself reshapes a note a little on every save.
const rundtur = noteGemt(page);
await page_.blur();
await rundtur;
await page.reload();
await page.getByRole('button', { name: /Møde med Anders/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Møde med Anders');
await expect(back.locator('b, strong')).toBeVisible();
await expect(back).toContainText('kaffe hver uge');
// No stray Markdown on screen: if the marks are showing, the conversion failed
// in the direction nobody notices until a note looks wrong.
await expect(back).not.toContainText('**');
await expect(back).not.toContainText('# ');
expect(trouble).toEqual([]);
});
test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ page, browser }) => {
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('Delenoter');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Delenoter' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Fælles aftale om leveringen');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Filing it in a project — the controls live in the Del popover in the footer.
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Delenoter' });
await expect(page.getByText('delt med projektets folk')).toBeVisible();
// And it is really there, not merely labelled: the project's own page shows it.
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeVisible();
// Taken back out, it is the author's alone again.
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: /Fælles aftale/ }).click();
await page.locator('.sharewrap > button').click();
await page.getByLabel('Læg i projekt').selectOption({ label: 'Intet projekt' });
await expect(page.getByText('din igen')).toBeVisible();
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeHidden();
expect(trouble).toEqual([]);
});
test('en note kan deles med en person direkte, og dukker op hos dem', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
// Somebody to share with: a second account on the instance, made the ordinary
// way. The candidate list is drawn when the note opens, so they must exist
// before it does.
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('[email protected]');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const sofieCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const sofie = await sofieCtx.newPage();
await sofie.goto(link);
await sofie.getByLabel('Navn', { exact: true }).fill('Sofie');
await sofie.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await sofie.getByRole('button', { name: 'Opret konto' }).click();
await expect(sofie.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
// The owner writes a note and hands it to Sofie, as a reader.
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Ferieplan for hele holdet');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Sharing lives in a popover behind the Del button in the footer.
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
await share.locator('.addshare select').first().selectOption({ label: 'Sofie' });
await share.getByRole('button', { name: 'Tilføj' }).click();
// She is on the note now, with the role she was given.
const row = share.locator('.sharelist li').filter({ hasText: 'Sofie' });
await expect(row).toBeVisible();
await expect(row.locator('select')).toHaveValue('viewer');
// And it reaches her: her own notes list grows a Delt med mig group with it in.
//
// Navigated on the origin the invite signed her in on. VERDANDE_BASE_URL is
// localhost while the suite drives 127.0.0.1, and a session cookie set on one
// host is not sent to the other — a bare "/noter" would land her logged out.
const noterURL = new URL('/noter', link).href;
await sofie.goto(noterURL);
await expect(sofie.getByRole('button', { name: /Delt med mig/ })).toBeVisible();
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeVisible();
// A viewer's note carries no star or archive control — it is not hers to put away.
const sofieRow = sofie.locator('.notes li').filter({ hasText: 'Ferieplan for hele holdet' });
await expect(sofieRow.locator('.star')).toHaveCount(0);
// Taken back, it leaves her list.
await row.getByRole('button', { name: 'Fjern adgang' }).click();
await expect(row).toBeHidden();
await sofie.goto(noterURL);
await expect(sofie.getByRole('button', { name: /Ferieplan for hele holdet/ })).toBeHidden();
await sofieCtx.close();
expect(trouble).toEqual([]);
});
/**
* Note-links i en delt note.
*
* Meldt af en, der havde en note delt med sig: linkene i den gjorde ingenting.
* Det var to ting. Opslaget faldt tilbage på det første søgeresultat, når titlen
* ikke passede — og i en delt note er det første resultat næsten altid noten selv,
* fordi den nævner den titel, den linker til. Så åbnede linket den note, man
* allerede stod i, hvilket ser ud som ingenting. Og når det så endelig ikke fandt
* noget, sagde beskeden "blev ikke fundet", som var den forkerte grund: noten
* findes, den er bare ikke delt med.
*/
test('note links i en delt note fører til den rigtige note, eller siger hvorfor ikke', async ({
browser,
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('[email protected]');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const gæstCtx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gæst = await gæstCtx.newPage();
await gæst.goto(link);
await gæst.getByLabel('Navn', { exact: true }).fill('Linnea');
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await gæst.getByRole('button', { name: 'Opret konto' }).click();
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
const noterURL = new URL('/noter', link).href;
// Ejeren skriver to noter: en, der bliver delt, og en, der ikke gør — og den
// delte peger på den anden.
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Kristians prisblad');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.getByRole('button', { name: 'Ny note' }).click();
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
await page.keyboard.type('Faelles aftale om afhentning\n');
await page.keyboard.type('[[Kristians prisblad]]');
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
// Gæsten har en note af sin egen, som hun kan pege på.
await gæst.goto(noterURL);
await gæst.getByRole('button', { name: 'Ny note' }).click();
await gæst.getByRole('textbox', { name: 'Notens tekst' }).click();
await gæst.keyboard.type('Linneas egne maal');
await gæst.getByRole('textbox', { name: 'Notens tekst' }).blur();
await page.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await expect(share).toBeVisible();
await share.locator('.addshare select').first().selectOption({ label: 'Linnea' });
await share.getByRole('button', { name: 'Tilføj' }).click();
const row = share.locator('.sharelist li').filter({ hasText: 'Linnea' });
await expect(row).toBeVisible();
await row.locator('select').selectOption('editor');
// Hun skriver sit eget link ind i den delte note — og et, der ikke rammer noget.
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const body = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(body).toContainText('Kristians prisblad');
await body.click();
await gæst.keyboard.press('Control+End');
await gæst.keyboard.type('\n[[Linneas egne maal]]\n[[Noget der aldrig blev skrevet]]');
const gemt = noteGemt(gæst);
await body.blur();
await gemt;
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const delt = gæst.getByRole('textbox', { name: 'Notens tekst' });
// Hendes eget link fører derhen, det siger.
await delt.locator('a.notelink', { hasText: 'Linneas egne maal' }).click();
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Linneas egne maal'
);
// Og det link, der ikke rammer nogen note, må ikke lande et tilfældigt sted. Det
// er dér, det gamle fald tilbage på "første søgeresultat" åbnede noten, man
// allerede stod i — hvilket ser ud som ingenting.
await gæst.locator('.notes button.row').filter({ hasText: /^Faelles aftale/ }).click();
const igen = gæst.getByRole('textbox', { name: 'Notens tekst' });
await igen.locator('a.notelink', { hasText: 'Noget der aldrig blev skrevet' }).click();
await expect(gæst.locator('.toast')).toContainText('ikke delt med dig');
// Stadig i den delte note: linket flyttede hende ingen steder hen.
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Faelles aftale om afhentning'
);
await gæstCtx.close();
expect(trouble).toEqual([]);
});
/**
* Delingen tager de noter med, noten peger på.
*
* Den anden halvdel af den melding: linkene i en delt note skal ikke bare sige
* hvorfor de ikke kan følges — de skal kunne følges. Deler man en note, deler man
* det, den *siger*, og et link er en del af det.
*/
test('en delt note tager de noter med, den peger på', async ({ browser, page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger/brugere');
await page.getByLabel('E-mailadresse').fill('[email protected]');
await page.getByRole('button', { name: 'Send invitation' }).click();
const link = await page.locator('.link-out').textContent();
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gæst = await ctx.newPage();
await gæst.goto(link);
await gæst.getByLabel('Navn', { exact: true }).fill('Mikkel');
await gæst.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await gæst.getByRole('button', { name: 'Opret konto' }).click();
await expect(gæst.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
const noterURL = new URL('/noter', link).href;
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
// Tre noter: en kæde på to, og en der ikke hænger sammen med noget.
for (const text of [
'Leveringstider for pakker',
'Prisliste i kroner\nog [[Leveringstider for pakker]]',
'Bankoplysninger som ingen skal se',
'Aftale om levering\nse [[Prisliste i kroner]]'
]) {
await page.getByRole('button', { name: 'Ny note' }).click();
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
// Tomt felt før der skrives, og rækken i listen bagefter. IKKE "Gemt" i
// foden: den tekst er hvilestanden — der står "Gemmer" mens en gemning
// kører og "Gemt" ellers, altså også før den første er begyndt. En ventetid
// på den er grøn med det samme og venter på ingenting.
await expect(felt).toHaveText('');
await felt.click();
await page.keyboard.type(text);
await felt.blur();
const titel = text.split('\n')[0];
// Rækkens egen titel, og kun den. To fælder ligger på vejen hertil, og jeg
// gik i begge:
//
// `filter({ hasText: 'Aftale om levering' })` med en STRENG matcher
// delstreng og uden hensyn til store og små bogstaver — den fandt også
// "Fælles aftale om leveringen" fra en tidligere prøve, for databasen deles
// af hele suiten.
//
// `.notes` alene rækker for langt: en `[[henvisning]]` i editoren er også
// tekst med den titel i, så et opslag på teksten fandt tre.
//
// `button.row strong` er rækkens titelelement, og en forankret regex er både
// eksakt og versalfølsom. Titlerne her er almindelige ord uden
// regex-tegn — står der en dag en parentes i en af dem, skal den undviges.
await expect(
page.locator('.notes button.row strong').filter({ hasText: new RegExp(`^${titel}$`) })
).toHaveCount(1);
}
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await page.locator('.sharewrap > button').click();
const share = page.locator('.sharepanel');
await share.locator('.addshare select').first().selectOption({ label: 'Mikkel' });
await share.getByRole('button', { name: 'Tilføj' }).click();
// Sagt, ikke stiltiende: begge noter fulgte med, og panelet navngiver dem.
await expect(page.locator('.toast')).toContainText('de 2 noter');
await expect(share.locator('.follows')).toContainText('Prisliste i kroner');
await expect(share.locator('.follows')).toContainText('Leveringstider for pakker');
// Og hos ham: hele kæden kan læses, og linkene fører derhen.
await gæst.goto(noterURL);
await gæst.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await gæst
.getByRole('textbox', { name: 'Notens tekst' })
.locator('a.notelink', { hasText: 'Prisliste i kroner' })
.click();
const hos = gæst.getByRole('textbox', { name: 'Notens tekst' });
await expect(hos).toContainText('Prisliste i kroner');
await hos.locator('a.notelink', { hasText: 'Leveringstider for pakker' }).click();
await expect(gæst.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Leveringstider for pakker'
);
// Men kun det, noten peger på. Resten er stadig ejerens alene.
await expect(
gæst.locator('.notes button.row').filter({ hasText: /^Bankoplysninger/ })
).toHaveCount(0);
// Den fulgte adgang står som det, den er, i panelet under den note, den ramte.
// `button.row`, ikke bare knappen ved navn: panelet står åbent og har sin egen
// knap med den samme titel på.
await page.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ }).click();
await page.locator('.sharewrap > button').click();
await expect(page.locator('.sharepanel .sharelist li').filter({ hasText: 'Mikkel' })).toContainText(
'følger med'
);
// Taget tilbage følger den med tilbage.
await page.locator('.notes button.row').filter({ hasText: /^Aftale om levering/ }).click();
await page.locator('.sharewrap > button').click();
await page
.locator('.sharepanel .sharelist li')
.filter({ hasText: 'Mikkel' })
.getByRole('button', { name: 'Fjern adgang' })
.click();
await gæst.goto(noterURL);
await expect(
gæst.locator('.notes button.row').filter({ hasText: /^Prisliste i kroner/ })
).toHaveCount(0);
await ctx.close();
expect(trouble).toEqual([]);
});
/**
* `</>` er kode, ikke kilde.
*
* Sådan blev den læst af den første, der så den — markér et ord, tryk på tegnet,
* og hele noten skiftede til Markdown. Tegnet betyder kode i ethvert andet
* program, så det er knappen, der skal give efter, ikke brugeren.
*/
test('</> sætter markeringen som kode, og kilden har sin egen knap', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByRole('textbox', { name: 'Notens tekst' });
await body.click();
await page.keyboard.type('Kodenote\n');
await page.keyboard.type('kør kommandoen npm først');
// Markér ét ord og gør det til kode.
await page.keyboard.press('Shift+Home');
await page.keyboard.press('Shift+ArrowRight');
await page.getByRole('button', { name: 'Kode', exact: true }).click();
await expect(body.locator('code')).toHaveCount(1);
// Noten er stadig rig tekst — kildevisningen er ikke slået til.
await expect(page.locator('textarea.source')).toHaveCount(0);
// Og den anden knap gør stadig det, den altid har gjort: viser filen, med
// baktikkerne i, som er beviset på at koden nåede hele vejen ned.
const kildeGemt = noteGemt(page);
await body.blur();
await kildeGemt;
await page.getByRole('button', { name: /Vis kilden/ }).click();
await expect(page.locator('textarea.source')).toHaveValue(/`/);
expect(trouble).toEqual([]);
});
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const face = () =>
page.evaluate(() => getComputedStyle(document.body).fontFamily);
const ground = () =>
page.evaluate(() => getComputedStyle(document.body).backgroundColor);
const startFace = await face();
await page.getByRole('button', { name: /^Rolig/ }).click();
const serif = await face();
expect(serif, 'skriften skiftede ikke').not.toBe(startFace);
expect(serif.toLowerCase()).toContain('serif');
// The other axis is untouched: choosing a face must not drag the palette with
// it. That is the whole reason they are two settings and not one list.
const darkGround = await ground();
await page.getByRole('button', { name: /^Papir/ }).click();
expect(await ground(), 'temaet skiftede ikke').not.toBe(darkGround);
expect(await face(), 'temaet ændrede skriften').toBe(serif);
// And it is on before the first paint, not applied afterwards — otherwise the
// page is laid out once in one face and again in another.
await page.reload();
expect(await face()).toBe(serif);
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBe('rolig');
// Back to the default, which carries no attribute at all.
await page.getByRole('button', { name: /^Verdande/ }).click();
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBeUndefined();
expect(trouble).toEqual([]);
});
test('[[ foreslår en note, og linket lander i noten', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// One note to point at, then another to write the link in.
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Rejseplan til Berlin');
await ed.blur();
await page.waitForTimeout(600);
await page.getByRole('button', { name: 'Ny note' }).click();
await ed.click();
await page.keyboard.type('Mine planer');
await page.keyboard.press('Enter');
await page.keyboard.type('Se ');
await page.keyboard.type('[[Rejse');
// The picker offers the note by title, and choosing it turns the text into a
// real inline link — the title, not the brackets.
const option = page.locator('.suggestions button', { hasText: 'Rejseplan til Berlin' });
await expect(option).toBeVisible();
await option.click();
const inline = ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' });
await expect(inline).toBeVisible();
// It survives the round-trip through Markdown and a reload.
// Gemningen, ikke en ventetid på 900 ms. Tallet var gættet til at overleve
// tastepausen på 700 ms, og et gæt om tid måler maskinen frem for programmet.
const linketGemt = noteGemt(page);
await ed.blur();
await linketGemt;
await page.reload();
await page.getByRole('button', { name: /Mine planer/ }).click();
await expect(ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' })).toBeVisible();
// And it is a link you can follow: clicking it opens the note it names.
await ed.locator('a.notelink', { hasText: 'Rejseplan til Berlin' }).click();
await expect(ed.locator('h1')).toHaveText('Rejseplan til Berlin');
expect(trouble).toEqual([]);
});
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
// Straight in, without choosing a style. The first line is the title because
// that is what the list calls the note by.
await page.keyboard.type('Titelprøven');
await expect(ed.locator('h1')).toHaveText('Titelprøven');
await page.keyboard.press('Enter');
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
// And the line after is body, not a second title. A note where every line is a
// heading is a note with no heading at all.
await expect(ed.locator('h1')).toHaveCount(1);
await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
// A third line stays body too.
await page.keyboard.press('Enter');
await page.keyboard.type('Og han ringer selv.');
await expect(ed.locator('h1')).toHaveCount(1);
// It survives the trip through Markdown: the title is a heading again on the
// way back, and the body is not.
await ed.blur();
await page.reload();
await page.getByRole('button', { name: /Titelprøven/ }).click();
const back = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(back.locator('h1')).toHaveText('Titelprøven');
await expect(back.locator('h1')).toHaveCount(1);
await expect(back).toContainText('ringer selv');
expect(trouble).toEqual([]);
});
test('# foreslår et projekt, og titlen følger den første linje', 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('Forslagsprojektet');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Første udkast');
await page.keyboard.press('Enter');
// Half a tag is enough. The name is long and nobody should have to spell it.
await page.keyboard.type('Handler om #Forslags');
const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first();
await expect(option).toBeVisible();
// Return takes the highlighted one — the list owns the key while it is open.
await page.keyboard.press('Enter');
await expect(ed).toContainText('#Forslagsprojektet');
await expect(option).toBeHidden();
// And the syntax is spelled out under the field, where it is being typed.
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
// The title is the first line. It used to be derived once and then left alone,
// so rewriting the opening left the list calling the note by an old name.
await page.keyboard.press('Enter');
await ed.blur();
await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible();
// Rewrite the first line; the list has to follow.
await ed.click();
await page.keyboard.press('ControlOrMeta+Home');
for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight');
await page.keyboard.type('Andet udkast');
await ed.blur();
await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible();
expect(trouble).toEqual([]);
});
test('et tag finder sit projekt uanset store og små bogstaver', 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('StoreBogstaver');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'StoreBogstaver' })).toBeVisible();
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
// Written in lower case, the way somebody types when they are not thinking
// about it. It used to mean the note simply never appeared on the project, with
// nothing anywhere to say why.
await page.keyboard.type('Aftale om levering');
await page.keyboard.press('Enter');
await page.keyboard.type('Handler om #storebogstaver');
await page.keyboard.press('Escape');
await ed.blur();
// The panel shows the project's own spelling, not the folded key: a label
// nobody wrote and that is nowhere in the note would be worse than none.
await expect(page.locator('.link', { hasText: '#StoreBogstaver' })).toBeVisible();
await sidebar.getByRole('link', { name: 'StoreBogstaver' }).click();
await expect(page.locator('.notes').getByRole('link', { name: /Aftale om levering/ })).toBeVisible();
expect(trouble).toEqual([]);
});
test('en opgave viser de noter, der nævner den, og linket åbner noten', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
await box.fill('ring til leverandøren i dag');
await box.press('Enter');
await expect(page.getByText('ring til leverandøren', { exact: true })).toBeVisible();
// Asked for rather than read off the address bar: opening a task shows a drawer
// without navigating, so the URL is still the list's.
const taskId = await page.evaluate(async () => {
const r = await fetch('/api/v1/tasks?limit=100', {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
const j = await r.json();
return (j.tasks ?? []).find((t) => t.content === 'ring til leverandøren')?.id;
});
expect(taskId, 'opgaven blev ikke oprettet').toBeTruthy();
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Referat fra tirsdag');
await page.keyboard.press('Enter');
await page.keyboard.type('Aftalt at han ringer, se /opgave/' + taskId);
await ed.blur();
// Back on the task: the other direction of the same link. Without it the
// connection only works if you happen to be reading the note, which is the half
// nobody needs help with.
await page.goto('/opgave/' + taskId);
const linked = page.locator('.linked-notes').getByRole('link', { name: /Referat fra tirsdag/ });
await expect(linked).toBeVisible();
// And it opens the note rather than dropping you on a list to find it again.
await linked.click();
await expect(page.getByRole('textbox', { name: 'Notens tekst' })).toContainText(
'Aftalt at han ringer'
);
expect(trouble).toEqual([]);
});
test('en kodeblok ser ud som en terminal og bliver farvet', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Skrevet ind som Markdown, sådan som importen fra Apple Noter leverer den.
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
body: '# Terminalprøven\n\nHer er udskriften:\n\n```bash\nkw@shell:~$ ls -la\n# en kommentar\necho "hej"\n```\n\nog resten.'
})
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const pre = ed.locator('pre');
await expect(pre).toBeVisible();
// Mørk flade uanset tema: en terminal er sort, også midt på dagen.
const look = await pre.evaluate((el) => {
const s = getComputedStyle(el);
return { bg: s.backgroundColor, font: s.fontFamily };
});
expect(look.bg).toBe('rgb(20, 24, 27)');
expect(look.font.toLowerCase()).toMatch(/mono/);
// Og farvet: prompten, kommentaren og strengen skal kunne skelnes.
await expect(pre.locator('.tok-prompt')).toBeVisible();
await expect(pre.locator('.tok-comment')).toContainText('en kommentar');
await expect(pre.locator('.tok-string')).toContainText('hej');
// Teksten omkring er ikke blevet til kode.
await expect(ed.locator('h1')).toHaveText('Terminalprøven');
await expect(ed).toContainText('og resten.');
// Og blokken overlever turen tilbage til Markdown: den skal komme ud som en
// hegnet blok med sit sprog, ikke som en klump linjer.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body).toContain('```bash');
expect(body).toContain('kw@shell:~$ ls -la');
expect(trouble).toEqual([]);
});
test('lister overlever en gemning — også i et citat, og også med numre', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Formerne, der hver især gik tabt: en punktliste, en nummereret, en der
// begynder et andet sted end ved ét, en liste i en liste, og en liste inde i et
// citat. Den sidste er den, en note fra Apple Noter er fuld af.
const markdown = [
'# Former',
'',
'- Punkt',
'- Punkt 2',
'',
'1. Nummer',
'2. Nummer 2',
'',
'10. Ti',
'11. Elleve',
'',
'- Ydre',
' - Indre',
'',
'> Et citat',
'> - med en liste i'
].join('\n');
const id = await page.evaluate(async (body) => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body })
});
return (await r.json()).id;
}, markdown);
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('ul > li').first()).toBeVisible();
// Vist som lister, ikke som linjer med bindestreger i.
await expect(ed.locator('ol')).toHaveCount(2);
await expect(ed.locator('blockquote ul li')).toHaveText('med en liste i');
// Den indlejrede ligger inde i sit punkt, ikke ved siden af.
await expect(ed.locator('ul li ul li')).toHaveText('Indre');
// Og den, der begyndte ved ti, gør det stadig.
await expect(ed.locator('ol[start="10"] li').first()).toHaveText('Ti');
// Rør noten, sådan som en tastning gør, og lad den gemme.
await ed.click();
await page.keyboard.press('End');
await page.keyboard.type(' ');
await page.waitForTimeout(1200);
// Det, der står i filen bagefter, er det, der stod i den før.
//
// Det var her, det gik galt: alt fra det første punkt blev til én linje —
// "PunktPunkt 2NummerNummer 2" — fordi en liste inde i en blok blev læst som
// tekst. Ingen havde rørt de linjer.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body).toContain('- Punkt\n- Punkt 2');
expect(body).toContain('1. Nummer\n2. Nummer 2');
expect(body).toContain('10. Ti\n11. Elleve');
expect(body).toContain('- Ydre\n - Indre');
expect(body).toContain('> Et citat\n> - med en liste i');
expect(trouble).toEqual([]);
});
test('en note kan ikke smugle et script ind gennem et billedes alt-tekst', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Undslap `alt` ikke sine anførselstegn, lukkede den første af dem attributten,
// og resten stod som markup: et img med en onerror på. En note deles gennem et
// projekt, så det ville køre på vores eget domæne, hos den der åbnede noten.
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({
body: '# Ondsindet\n\n![" onerror="window.__pwned = true](/api/v1/attachments/aaaabbbbccccdddd)\n'
})
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('img')).toHaveCount(1);
// Billedet peger ingen steder, så det fejler at indlæse — hvilket er præcis
// den vej, en onerror ville blive kaldt ad.
await page.waitForTimeout(800);
expect(await page.evaluate(() => window.__pwned)).toBeUndefined();
// Og hændelsen står som tekst i alt-attributten, hvor den hører hjemme.
const alt = await ed.locator('img').getAttribute('alt');
expect(alt).toContain('onerror');
expect(await ed.locator('img').evaluate((el) => el.hasAttribute('onerror'))).toBe(false);
// Billedets egen 404 hører til prøven: adressen peger med vilje på et bilag,
// der ikke findes, fordi det er den vej en onerror ville blive kaldt ad. Alt
// andet skal stadig være stille.
expect(trouble.filter((t) => !t.includes('/api/v1/attachments/'))).toEqual([]);
});
test('et billede kan indsættes og trækkes ind i en note', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: '# Med billeder\n\nEn linje.\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed).toBeVisible();
// Et rigtigt lille PNG, bygget i browseren, så prøven ikke har brug for en fil
// på disken — og lagt i både en paste og et drop, som er de to veje ind.
const png =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
for (const kind of ['paste', 'drop']) {
await ed.click();
await page.evaluate(
async ({ kind, png }) => {
const bytes = Uint8Array.from(atob(png), (c) => c.charCodeAt(0));
const file = new File([bytes], `${kind}.png`, { type: 'image/png' });
const dt = new DataTransfer();
dt.items.add(file);
const ed = document.querySelector('.page[contenteditable]');
ed.focus();
ed.dispatchEvent(
kind === 'paste'
? new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
: new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true })
);
},
{ kind, png }
);
await page.waitForTimeout(1200);
}
// To billeder, begge hentet og tegnet — ikke to stykker tekst med ![]( i.
await expect(ed.locator('img')).toHaveCount(2);
const loaded = await ed.locator('img').evaluateAll((els) =>
els.filter((e) => e.complete && e.naturalWidth > 0).length
);
expect(loaded).toBe(2);
// Og de overlever turen til Markdown: filen skal pege på et bilag, ikke på en
// data-URL, som ville lægge fotografiet i noteteksten.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body.match(/!\[\]\(\/api\/v1\/attachments\/[0-9a-f-]+\)/g)).toHaveLength(2);
expect(body).not.toContain('data:image');
expect(trouble).toEqual([]);
});
test('en note kan oprettes inde fra et projekt og hører til det med det samme', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByLabel('Nyt projekt').click();
await sidebar.getByLabel('Projektnavn').fill('Tagprojekt');
await sidebar.getByLabel('Projektnavn').press('Enter');
await sidebar.getByRole('link', { name: 'Tagprojekt' }).click();
await expect(page.getByRole('heading', { name: 'Tagprojekt' })).toBeVisible();
// Ruden findes, også før der er en eneste note — ellers er der ingen knap at
// trykke på det sted, hvor man opdager at der skal skrives noget ned.
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed).toBeVisible();
await ed.click();
await page.keyboard.type('Tagsten og lægter');
await page.waitForTimeout(1200);
// Den hører til projektet med det samme: "Læg i projekt" står på projektet,
// uden at nogen har valgt det bagefter. Kontrollen ligger i Del-popoveren.
await page.locator('.sharewrap > button').click();
await expect(page.getByLabel('Læg i projekt')).toHaveValue(/.+/);
const shared = await page.getByLabel('Læg i projekt').evaluate((el) => el.selectedOptions[0].textContent);
expect(shared).toContain('Tagprojekt');
// Og den står på projektets egen side.
await sidebar.getByRole('link', { name: 'Tagprojekt' }).click();
// Titlen, ikke uddraget: begge indeholder de samme ord, fordi titlen *er*
// notens første linje.
await expect(page.locator('.notes strong', { hasText: 'Tagsten og lægter' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('en note kan trækkes hen på et projekt i sidebjælken', async ({ page }) => {
const trouble = watchForTrouble(page);
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await page.goto('/');
await sidebar.getByLabel('Nyt projekt').click();
await sidebar.getByLabel('Projektnavn').fill('Trækmål');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(sidebar.getByRole('link', { name: 'Trækmål' })).toBeVisible();
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: '# Løs note\n\nSkal flyttes ved at trække.\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
await page.evaluate((noteId) => (window.__noteId = noteId), id);
const row = page.locator('.notes button.row').filter({ hasText: 'Løs note' }).first();
await expect(row).toBeVisible();
// Selve gesten, med de rigtige hændelser og den nyttelast, appen sender.
// Rækken tog imod dragover og lod slippet falde ned i grenen, der omarrangerer
// projekter — så den lyste op og gjorde ingenting. Prøven skal derfor gå hele
// vejen til slippet og se på resultatet, ikke på at rækken blev fremhævet.
await page.evaluate(() => {
const dt = new DataTransfer();
dt.setData('application/x-verdande-note', window.__noteId);
const target = [...document.querySelectorAll('nav a')].find((a) =>
a.textContent.includes('Trækmål')
);
for (const type of ['dragover', 'drop']) {
target.dispatchEvent(new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true }));
}
});
await page.waitForTimeout(1000);
// Det, der tæller: noten ligger i projektet bagefter.
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return await r.json();
}, id);
expect(body.project_id).toBeTruthy();
expect(trouble).toEqual([]);
});
test('en note kan arkiveres fra sin egen fod, og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type('Kvartalsplan');
await ed.blur();
await page.waitForTimeout(700);
// The Arkivér button in the note's footer puts it away: it leaves the list.
await page.locator('footer .actions .button', { hasText: 'Arkivér' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
// It is in the archive, where the same button now offers to bring it back.
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await page.locator('.notes button.row', { hasText: 'Kvartalsplan' }).click();
await page.locator('footer .actions .button', { hasText: 'Tag frem igen' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toHaveCount(0);
// And back in the ordinary list.
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await expect(page.locator('.notes button.row', { hasText: 'Kvartalsplan' })).toBeVisible();
expect(trouble).toEqual([]);
});
test('en monospace-linje kan forlades — på retur og gennem menuen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const ed = await nyNote(page);
await page.keyboard.type('Titel');
await page.keyboard.press('Enter');
// En monospace-linje, og så retur: linjen efter skal være brødtekst, ikke mere
// monospace. Før rettelsen holdt en <pre> på markøren, og alt derefter blev ved
// med at være kode.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('kode linje');
await page.keyboard.press('Enter');
await page.keyboard.type('almindelig tekst');
// Koden står i sin blok; teksten efter står uden for den.
await expect(ed.locator('pre')).toHaveText('kode linje');
await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('linje et');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('linje to');
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
// lagde et <p> inde i <pre> og ikke kom nogen vegne.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
// Den blok er nu brødtekst, ikke kode: den står som <p>, ikke i en <pre>.
await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
await nyNote(page);
await page.keyboard.type('Rundtur');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('docker pull');
await page.keyboard.press('Enter');
await page.keyboard.type('og så videre i almindelig skrift');
const rundturGemt = noteGemt(page);
await ed.blur();
await rundturGemt;
await page.reload();
await page.getByRole('button', { name: /Rundtur/ }).click();
await expect(ed.locator('pre')).toContainText('docker pull');
await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
expect(trouble).toEqual([]);
});
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/noter');
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: '# Kommandoen\n\n```bash\ndocker pull verdande\n```\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const copy = ed.locator('pre .copy');
await expect(copy).toBeVisible();
await copy.click();
await expect(copy).toHaveText('Kopieret');
// Det, der ligger i udklipsholderen, er koden — ikke knappens eget ord.
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toBe('docker pull verdande');
// Og noten selv må ikke få "Kopiér" skrevet ind i sig, når den gemmes. Knappen
// ligger inde i <pre> for at kunne stå i hjørnet af den, så det er præcis den
// fælde, den her prøve findes for.
await ed.click();
await page.keyboard.press('End');
await page.keyboard.type(' ');
await page.waitForTimeout(1200);
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body).toContain('docker pull verdande');
expect(body).not.toContain('Kopiér');
expect(body).not.toContain('Kopieret');
expect(trouble).toEqual([]);
});
test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
for (const title of ['Alfa', 'Bravo', 'Charlie']) {
await page.evaluate(async (t) => {
await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: `# ${t}\n\nEn note.\n` })
});
}, title);
}
await page.reload();
const rows = page.locator('.notes button.row');
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
// To af tre: et almindeligt klik, og et ⌘-klik oven i. Den, der lige er åbnet,
// hører med i markeringen — ellers ville det andet klik markere én og glemme
// den, man stod på.
await rows.filter({ hasText: 'Alfa' }).click();
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
await expect(page.getByText('2 markeret')).toBeVisible();
// The selection bar's Arkivér, not the open note's own in the footer.
await page.locator('.picked-bar').getByRole('button', { name: 'Arkivér', exact: true }).click();
await page.waitForTimeout(900);
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
await expect(rows.filter({ hasText: 'Charlie' })).toBeVisible();
await page.getByLabel('Vis arkivet').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
await expect(rows.filter({ hasText: 'Bravo' })).toBeVisible();
await expect(rows.filter({ hasText: 'Charlie' })).toHaveCount(0);
// Og tilbage igen — med rækkens egen knap, som er vejen for den ene. Et
// almindeligt klik rydder markeringen med vilje, så bjælken er til flere.
await rows.filter({ hasText: 'Alfa' }).hover();
await page.locator('.rowline', { hasText: 'Alfa' }).getByLabel('Tag frem igen').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
await page.getByLabel('Vis arkivet').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* Én note, der ikke kan tegnes, må ikke tage ruden med sig.
*
* Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker
* på, åbner de ikke". Editoren indlæste med `note.body.trim()` uden værn, og
* `body` er en streng fra serveren hver eneste gang — indtil den ikke er. Så
* kaster den inde i et `$effect`, og en effekt, der kaster, tager komponentens
* opdateringer med sig: den forrige note bliver stående, og ingen note kan åbnes
* bagefter. Klikket kan ikke engang lande.
*
* Formen fremtvinges her frem for at vente på den nyttelast, der gjorde det:
* hvad der end fik `body` til at mangle, er svaret det samme — én note må ikke
* kunne låse de tolv hundrede andre ude.
*/
test('en note uden krop må ikke låse ruden', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/noter');
for (const title of ['Alfa', 'Beta']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type(title);
await expect(ed.locator('h1')).toHaveText(title);
await page.keyboard.press('Enter');
await page.keyboard.type('Brødtekst i ' + title);
await expect(ed.locator('p').filter({ hasText: 'Brødtekst i ' + title })).toBeVisible();
await page.waitForTimeout(900);
}
await page.reload();
await expect(page.locator('li .row strong').filter({ hasText: 'Beta' })).toBeVisible();
await page.route('**/api/v1/notes/*', async (route) => {
if (route.request().method() !== 'GET') return route.continue();
const res = await route.fetch();
const note = await res.json();
delete note.body;
await route.fulfill({ response: res, json: note });
});
await page.locator('li').filter({ hasText: 'Alfa' }).first().click();
await page.waitForTimeout(600);
expect(errors, 'noten kastede under indlæsningen').toEqual([]);
await page.unroute('**/api/v1/notes/*');
await page.locator('li').filter({ hasText: 'Beta' }).first().click();
await expect(
page.getByRole('textbox', { name: 'Notens tekst' }).locator('h1'),
'ruden er låst: den næste note åbner ikke'
).toHaveText('Beta', { timeout: 5000 });
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
});
/**
* Ugen er et døgn, ikke syv punktopstillinger.
*
* En uge, hvor hver dag er en liste, svarer på "hvad skal der ske torsdag" og ikke
* på "hvornår" — og det andet er det, man åbner en uge for. To møder klokken ti er
* en konflikt, man skal kunne se, ikke læse sig til.
*
* Begivenhederne kommer fra en rute, der svarer i stedet for Google: det, der
* prøves her, er gitteret, og en prøve, der først skal have en OAuth-forbindelse,
* er en prøve, der aldrig kører.
*/
test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => {
const trouble = watchForTrouble(page);
// Mandag i den uge, siden åbner på.
const monday = (n) => {
const d = new Date();
d.setDate(d.getDate() - ((d.getDay() + 6) % 7) + n);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
};
const at = (n, from, to, id, summary) => ({
id,
calendar_id: 'c1',
summary,
starts_at: `${monday(n)}T${from}:00+02:00`,
ends_at: `${monday(n)}T${to}:00+02:00`,
start_day: monday(n),
end_day: monday(n),
all_day: false,
calendar_name: 'Privat'
});
await page.route('**/api/v1/calendar/events*', (route) =>
route.fulfill({
json: {
events: [
at(2, '09:00', '10:00', 'e1', 'Morgenmøde'),
at(2, '09:30', '11:00', 'e2', 'Oveni det første'),
at(2, '17:00', '18:00', 'e3', 'Sent på dagen'),
{
id: 'e4',
calendar_id: 'c1',
summary: 'Aalborg',
start_day: monday(1),
end_day: monday(1),
all_day: true,
calendar_name: 'Privat'
}
]
}
})
);
await page.goto('/kalender');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
await expect(page.locator('.weekgrid')).toBeVisible();
// Syv lige brede dage. `1fr` er `minmax(auto, 1fr)`, så ét langt ord i én dag
// gjorde den bredere end de seks andre — hvilket er det modsatte af et gitter.
const widths = await page
.locator('.weekgrid .daycol')
.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().width)));
expect(widths, 'ugen har ikke syv søjler').toHaveLength(7);
expect(Math.max(...widths) - Math.min(...widths), 'dagene er ikke lige brede').toBeLessThanOrEqual(1);
// Klokken bestemmer, hvor noget står. Morgenmødet skal ligge over det sene.
const box = async (name) => (await page.locator('.tevent', { hasText: name }).first().boundingBox());
const morning = await box('Morgenmøde');
const evening = await box('Sent på dagen');
expect(morning.y, 'morgenmødet står ikke over det sene møde').toBeLessThan(evening.y);
// To ting på samme tid står ved siden af hinanden. Oven på hinanden er præcis
// den konflikt, visningen findes for at vise.
const overlapping = await box('Oveni det første');
expect(overlapping.x, 'de to overlappende ligger oven på hinanden').toBeGreaterThan(morning.x);
expect(overlapping.x, 'den anden er havnet i næste dag').toBeLessThan(morning.x + morning.width * 2);
// Heldagsting hører til båndet foroven: en heldagsbegivenhed lagt klokken nul
// ville påstå, den holdt fra midnat til midnat.
// `.weekgrid > .allday` er selve cellen; brikken indeni bærer også klassen.
await expect(page.locator('.weekgrid > .allday', { hasText: 'Aalborg' })).toBeVisible();
// Og måneden er stadig lige bred hele vejen rundt.
await page.getByRole('button', { name: 'Måned', exact: true }).click();
const monthWidths = await page
.locator('.grid .day')
.evaluateAll((els) => els.slice(0, 7).map((e) => Math.round(e.getBoundingClientRect().width)));
expect(
Math.max(...monthWidths) - Math.min(...monthWidths),
'månedens dage er ikke lige brede'
).toBeLessThanOrEqual(1);
expect(trouble).toEqual([]);
});
/**
* Tid på en opgave, sat i ruden og justeret i kalenderen.
*
* Tre påstande, og den midterste er den, der var gået galt uden at nogen sagde
* det: serveren læser `due_date` og `due_time` sammen, så en dato sendt uden en
* tid *rydder* tiden. Et gem af datoen alene tog altså klokkeslættet med sig, og
* det samme gjorde et træk i kalenderen.
*/
test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// I dag, så den står på den visning, den skrives i — og i den uge, gitteret
// åbner på længere nede.
await box.fill('ringe til tandlægen i dag');
await box.press('Enter');
await expect(page.getByText('ringe til tandlægen')).toBeVisible();
// Klokkeslættet sættes i ruden. Feltet er tomt til at begynde med: en opgave
// uden tid er det almindelige, og 00:00 ville påstå noget om midnat.
await page.getByText('ringe til tandlægen').click();
const time = page.getByLabel('Klokkeslæt');
await expect(time).toHaveValue('');
// Tidligt på dagen med vilje: gitteret begynder klokken otte, så opgaven står
// lige under båndet og de to kasser er i billedet på én gang. Klokken fjorten
// ligger under skærmkanten, og et træk, der først skal rulle, er et træk,
// browseren taber.
await time.fill('08:30');
await time.blur();
await page.waitForTimeout(400);
// Gemt hos serveren, ikke kun i feltet: genindlæst og åbnet igen.
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(page.getByLabel('Klokkeslæt'), 'klokkeslættet blev ikke gemt').toHaveValue('08:30');
// Og datoen kan rettes bagefter uden at tage timen med sig.
const date = page.locator('#due');
const day = await date.inputValue();
const datoGemt = opgaveGemt(page);
await date.fill(day);
await date.blur();
await datoGemt;
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(
page.getByLabel('Klokkeslæt'),
'et gem af datoen ryddede klokkeslættet'
).toHaveValue('08:30');
await page.keyboard.press('Escape');
// I ugegitteret står den på sit klokkeslæt frem for i heldagsbåndet.
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
const placed = page.locator('.tevent.task').filter({ hasText: 'ringe til tandlægen' });
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
await expect(placed).toContainText('08:30');
// Trukket op i båndet over sin egen dag: så har den en dag og ingen tid. Den
// tomme streng siger "ryd", hvor ingenting siger "behold" — og de to må ikke
// forveksles.
//
// Et bånd, der er tomt. Prøven handler om at krydse mellem de to kasser, og en
// dag, hvis bånd allerede er fyldt af andre prøvers opgaver, gør slippunktet
// til en anden opgave frem for til båndet — hvilket er en anden gest end den,
// der prøves her.
const band = page
.locator('.weekgrid > .allday')
.filter({ hasNot: page.locator('.chip, .event') })
.first();
const today = await band.getAttribute('data-allday');
// Trukket i skridt frem for i ét spring. `dragTo` flytter musen i to træk, og
// over fire hundrede pixels i et travlt gitter giver det browseren for få
// mellemstationer til at holde gesten i live. Et menneske bevæger hånden hele
// vejen, og det er også det, der skal prøves.
const fromBox = await placed.boundingBox();
const toBox = await band.boundingBox();
await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + 5);
await page.mouse.down();
await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 25 });
await page.mouse.up();
await expect(
page.locator(`[data-allday="${today}"] .chip`).filter({ hasText: 'ringe til tandlægen' }),
'opgaven kom ikke op i båndet'
).toBeVisible();
await expect(
page.locator(`[data-date="${today}"] .tevent`).filter({ hasText: 'ringe til tandlægen' }),
'opgaven står stadig på et klokkeslæt'
).toBeHidden();
expect(trouble).toEqual([]);
});
/**
* Notelisten står i grupper.
*
* Den føltes tæt, og grunden var ikke afstanden mellem rækkerne: hver række sagde
* tre ting, og to af dem gentog sig selv nedad. Datoen stod på hver eneste række,
* også når tyve noter deler dag, og projektet fik en linje for sig selv.
*
* Datoen er flyttet op i en overskrift, favoritterne har fået deres egen gruppe —
* de lå allerede først, der stod bare ikke hvorfor — og arkivet grupperes på samme
* måde, for det er dér, de tolv hundrede importerede ligger.
*/
test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
for (const title of ['Gruppe én', 'Gruppe to']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type(title);
await page.keyboard.press('Enter');
await page.keyboard.type('Brødtekst i ' + title);
await expect(ed.locator('h1')).toHaveText(title);
await page.waitForTimeout(800);
}
await page.reload();
// Skrevet i dag, så de står under dagens overskrift.
const today = page.getByRole('heading', { name: /^I dag/ });
await expect(today, 'der er ingen datogruppe').toBeVisible();
// En favorit får sin egen gruppe. Uden overskriften ligger den bare først, og
// der står ikke hvorfor.
await expect(page.getByRole('heading', { name: /Favoritter/i })).toBeHidden();
const row = page.locator('li').filter({ hasText: 'Gruppe én' }).first();
await row.hover();
await row.getByRole('button', { name: /favorit/i }).click();
const favourites = page.getByRole('heading', { name: /Favoritter/i });
await expect(favourites).toBeVisible();
// Og den ligger under den, ikke under dagen.
const inFavourites = page
.locator('h3.group', { hasText: /Favoritter/i })
.locator('+ ul li')
.filter({ hasText: 'Gruppe én' });
await expect(inFavourites).toBeVisible();
// Grupper kan foldes sammen. Ikke husket på tværs af besøg: en gruppe, der
// åbner foldet uden at nogen bad om det, er en liste, der ser tom ud.
await favourites.getByRole('button').click();
await expect(inFavourites).toBeHidden();
await favourites.getByRole('button').click();
await expect(inFavourites).toBeVisible();
// En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
// matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget
// andet end det, der blev spurgt om.
await page.getByPlaceholder('Søg i noter').fill('Brødtekst');
await expect(page.locator('li').filter({ hasText: 'Gruppe to' }).first()).toBeVisible();
await expect(page.locator('h3.group')).toHaveCount(0);
await page.getByPlaceholder('Søg i noter').fill('');
// Rækkerne skal kunne *ses*, ikke bare findes.
//
// Da listen blev delt i grupper, blev hver gruppe sit eget `<ul>` — og dermed
// sit eget flex-element med `overflow-y: auto`. Flex-elementer skrumper som
// udgangspunkt, og et element, der skrumper og klipper, viser ingenting: alle
// overskrifter stod med deres tal, og der var ikke en eneste note under nogen af
// dem. Med tre grupper og en høj skærm var der plads nok til at det ikke skete,
// hvilket er hvorfor prøven herover ikke fangede det.
//
// Et lavt vindue fremtvinger presset. Tolv hundrede noter i tyve måneder gør det
// samme i virkeligheden.
await page.setViewportSize({ width: 1100, height: 380 });
await page.waitForTimeout(300);
await expect(
page.locator('li').filter({ hasText: 'Gruppe to' }).first(),
'rækkerne blev klippet væk, da der ikke var plads'
).toBeVisible();
// Og rulningen ligger på kassen om grupperne, ikke på hver liste for sig.
const scroller = await page
.locator('.list')
.evaluate((el) => getComputedStyle(el).overflowY);
expect(scroller, 'kassen om grupperne ruller ikke').toBe('auto');
const inner = await page
.locator('.list ul')
.first()
.evaluate((el) => getComputedStyle(el).overflowY);
expect(inner, 'den enkelte liste ruller stadig for sig').toBe('visible');
await page.setViewportSize({ width: 1280, height: 720 });
// Arkivet grupperes på samme måde. Det er dér, det betyder mest.
await row.hover();
await row.getByRole('button', { name: /Arkivér/i }).click();
await page.getByRole('button', { name: 'Vis arkivet' }).click();
await expect(
page.locator('h3.group'),
'arkivet står ugrupperet'
).not.toHaveCount(0);
await expect(page.locator('li').filter({ hasText: 'Gruppe én' }).first()).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* Et projekts kalender kan vises som en uge.
*
* Ugen fandtes på Kommende og på Kalender, men ikke dér, hvor et projekt bor — og
* det er dér, "hvornår sker de her fire ting" oftest bliver spurgt.
*
* Inde i kalendervisningen frem for som en fjerde `view_mode`. Den er projektets
* og delt med alle, der kan se det, mens uge eller måned er et spørgsmål om, hvor
* bred skærmen er, foran hvilken der sidder én person — og en fjerde værdi ville
* kræve en tabelombygning, fordi SQLite ikke kan ændre et CHECK på plads.
*/
test('et projekts kalender kan vises som en uge', 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('Ugeprojekt');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Ugeprojekt' })).toBeVisible();
const box = page.getByLabel('Ny opgave');
await box.fill('male døren i dag kl 10');
await box.press('Enter');
await expect(page.getByText('male døren')).toBeVisible();
await page.getByRole('button', { name: 'Kalender', exact: true }).click();
await page.getByRole('button', { name: 'Uge', exact: true }).click();
// Døgnet, ikke en rude: opgaven står på sit klokkeslæt.
const placed = page.locator('.tevent.task').filter({ hasText: 'male døren' });
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
await expect(placed).toContainText('10:00');
// Valget huskes — i browseren, ikke på projektet: det er skærmens bredde, der
// afgør det, og projektet er delt med andre skærme.
await page.reload();
await expect(page.locator('.weekgrid'), 'ugen blev ikke husket').toBeVisible();
await page.getByRole('button', { name: 'Måned', exact: true }).click();
await expect(page.locator('.weekgrid')).toBeHidden();
expect(trouble).toEqual([]);
});
/**
* En gentagelse siges på læserens sprog, også dem serveren kun kan på dansk.
*
* `recurrence.Describe` skriver dansk og kan ikke bare tage en locale med:
* `toTaskJSON` har niogtyve kaldesteder, og mange er WebSocket-udsendelser, hvor én
* nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at skrive
* den i. Reglen er den samme uanset hvem der læser den, så beskrivelsen hører
* hjemme dér, hvor læseren er.
*
* De to her er valgt, fordi de *ikke* står på listen over de fem almindelige, som
* fladen altid har kunnet navngive. Før det her faldt de tilbage til serverens
* tekst — og den er dansk, hvad end kontoen er sat til.
*/
test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
for (const [text, says] of [
['vande planterne hver mandag', 'hver mandag'],
['tømme opvasker hverdage', 'hverdage']
]) {
const box = page.getByLabel('Ny opgave');
await box.fill(text);
await box.press('Enter');
await page.waitForTimeout(400);
// I indbakken og ikke på I dag: en ugentlig regel sætter den første
// forfaldsdag til den næste sådan dag, som sjældent er i dag.
await page
.getByRole('navigation', { name: 'Hovedmenu' })
.getByRole('link', { name: /^Indbakke/ })
.click();
const name = text.split(' ').slice(0, 2).join(' ');
const row = page.locator('.row', { hasText: name }).first();
await expect(row).toBeVisible();
await expect(
row.locator('.repeat'),
`"${text}" blev ikke læst som en gentagelse`
).toContainText(says);
await page.goto('/');
}
expect(trouble).toEqual([]);
});
test('links kan åbnes i en anden browser, og åbner alligevel når skemaet ikke findes', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
await expect(page.getByRole('heading', { name: 'Links' })).toBeVisible();
// Som udgangspunkt gør indstillingen ingenting, og der er intet skemafelt at
// skrive i: et felt til en adresse, der ikke bliver brugt, er en invitation
// til at udfylde den og undre sig.
await expect(page.getByLabel('Skema')).toBeHidden();
// Et skema, der med sikkerhed ikke findes på nogen maskine. Pointen er ikke,
// at det virker — pointen er, hvad der sker, når det ikke gør.
await page.getByLabel('Åbn links i').selectOption('custom');
await page.getByLabel('Skema').fill('verdande-findes-ikke://{url}');
await page.getByLabel('Skema').blur();
// Valget hører til maskinen, ikke kontoen, så det står i localStorage.
const stored = await page.evaluate(() => localStorage.getItem('verdande:link-opener'));
expect(stored, 'valget blev ikke gemt på apparatet').toContain('verdande-findes-ikke');
// Om browseren rent faktisk tegner en fane, er browserens sag og ikke appens —
// en headless Chromium åbner den uden at fortælle nogen om det. Det, der er
// vores, er beslutningen: prøv skemaet, og åbn linket alligevel, når det ikke
// findes. Så det er dét, der bliver målt.
await page.evaluate(() => {
window.__opened = [];
window.open = (url) => {
window.__opened.push(url);
return { closed: false, close() {} };
};
});
await page.getByRole('button', { name: 'Prøv det' }).click();
await expect
.poll(() => page.evaluate(() => window.__opened), { timeout: 5000 })
.toEqual(['https://example.dk']);
// Og appen blev, hvor den var. Faldbacket må ikke kapre den fane, man står i:
// et link, der smider en ud af programmet, er værre end et, der åbner et sted,
// man ikke havde valgt.
await expect(page).toHaveURL(/\/indstillinger$/);
// Tilbage til systemets standard, så resten af prøverne klikker på links som
// alle andre.
await page.getByLabel('Åbn links i').selectOption('');
await expect(page.getByLabel('Skema')).toBeHidden();
expect(trouble).toEqual([]);
});
test('dagens plan står på I dag, og ikke kun som en besked', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
await box.fill('ring til planlæggeren i dag');
await box.press('Enter');
await page.waitForTimeout(400);
// Uden en model er planen to tal — og det er stadig det, man havde brug for at
// vide om morgenen. Knappen er der, fordi morgenbeskeden er slået fra som
// udgangspunkt: den, der ikke vil vækkes, skal stadig kunne få planen.
await page.getByRole('button', { name: 'Lav dagens plan' }).click();
const card = page.locator('.plan');
await expect(card).toBeVisible();
await expect(card).toContainText('forfalder i dag');
// Klokkeslættet står på kortet: en plan fra klokken syv er en anden slags
// oplysning klokken fire end klokken otte.
await expect(card.locator('.made')).toBeVisible();
// Og den bliver stående — det er hele forskellen på et kort og en besked.
await page.reload();
await expect(page.locator('.plan')).toContainText('forfalder i dag');
// Skjult for i dag, ikke slettet: kortet er en påmindelse, og den der har læst
// den skal kunne få den af vejen.
await page.locator('.plan .close').click();
await expect(page.locator('.plan')).toBeHidden();
await page.reload();
await expect(page.locator('.plan')).toBeHidden();
expect(trouble).toEqual([]);
});
/**
* Adresserne på integrationssiden kan ses helt på en telefon.
*
* En URL er ét ord på et par hundrede tegn uden et mellemrum i, og den kan derfor
* ikke ombryde af sig selv. Det gav to forskellige fejl på den samme side, og
* prøven her holder øje med dem begge:
*
* Abonnementets adresse lå i en liste uden noget, der lod den bryde, og det
* bredeste element på en side bestemmer sidens bredde — så hele indholdsspalten fik
* et vandret rullepanel, ikke bare den ene linje.
*
* Feed'et, krogen og CalDAV-serveren lå i skrivebeskyttede <input>, som ruller
* indeni i stedet for at ombryde. Der var ikke noget rullepanel at se: adressen
* stoppede bare midt i kassen, og resten fandtes kun ved at trække inde i feltet.
*
* Abonnementet kommer fra et svar, prøven selv skriver. Serveren ville skulle hente
* kalenderen for at have et at vise, og en prøve, der går på nettet, er en prøve,
* der fejler en dag af en anden grund end sin egen.
*/
test('adresserne på integrationssiden kan ses helt på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.route('**/api/v1/calendar', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
connected: false,
calendars: [],
has_client: false,
read_only: true,
redirect_uri: 'http://localhost:8097/api/v1/calendar/callback',
subscriptions: [
{
id: '0198f000-0000-7000-8000-000000000001',
provider: 'ics',
account: '[email protected]',
url:
'https://calendar.google.com/calendar/ical/kw%40netic.dk/private-3f9a1c7e5b2d4a6f8c0e1d2b3a4f5e6d/basic.ics',
created_at: '2026-09-01T09:00:00+02:00'
}
]
})
})
);
// En iPhone i stående format. Bredden er det eneste, der betyder noget her.
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/indstillinger/integrationer');
await expect(page.getByText('[email protected]')).toBeVisible();
// `.content` og ikke dokumentet. Skallen holder selv siden på skærmens bredde,
// så documentElement måler 390 uanset hvad — det er indholdsspalten indeni, der
// ruller, og det er den, man skubber med tommelfingeren. En prøve på dokumentet
// består med fejlen i behold; den er prøvet.
const spill = await page.evaluate(() => {
const el = document.querySelector('.content');
const url = document.querySelector('.subs-list .url');
return {
content: el.scrollWidth - el.clientWidth,
past: Math.round(url.getBoundingClientRect().right) - document.documentElement.clientWidth
};
});
expect(spill.content).toBe(0);
expect(spill.past).toBeLessThanOrEqual(0);
// Og adresserne på siden står der helt. De lå i skrivebeskyttede <input>, som
// ikke kan ombryde: kassen var 306 px og indholdet 595, så resten af adressen
// fandtes kun ved at rulle inde i feltet — hvilket ingen opdager. Et felt, der
// er klippet, er et felt, hvor scrollWidth er større end clientWidth, og det er
// præcis det, der måles her.
//
// Felterne står ved navn og ikke som en forespørgsel på en klasse: en prøve, der
// måler `.field output` og finder nul, består — også den dag felterne er lavet om
// til noget andet klippet. Den skal fejle, hvis et af dem forsvinder.
const clipped = await page.evaluate(() =>
['feed', 'mail', 'hook', 'hookcurl', 'caldav']
.map((id) => {
const el = document.getElementById(id);
if (!el) return `${id}: findes ikke`;
if (el.scrollWidth - el.clientWidth > 1) return `${id}: ${el.scrollWidth} > ${el.clientWidth}`;
return null;
})
.filter(Boolean)
);
expect(clipped).toEqual([]);
// Selve teksten, ikke kun kassens mål: krogens adresse er den, der skal kunne
// læses af, hvis man taster den ind i en genvej på telefonen.
await expect(page.locator('#hook')).toHaveText(/^https?:\/\/.+\/inbound\/hook\/.+/);
expect(trouble).toEqual([]);
});
/**
* En indstilling kan findes ved at søge efter den.
*
* Indstillingerne er ti faner med en snes afsnit, og den eneste vej til et
* bestemt af dem var at huske, hvilken fane det lå under. Prøven går begge veje
* ind: feltet øverst i indstillingerne, og ⌘K, som ikke kunne andet end det, man
* selv har skrevet.
*
* "beacon" er valgt med vilje. Afsnittet hedder "Tælling af installationer" på
* dansk, så en søgning, der kun læste overskrifterne, ville ikke finde det — og
* "beacon" er præcis det ord, man kender det ved. Det er derfor indekset har et
* felt med søgeord, ingen får at se.
*/
test('en indstilling kan findes ved at søge efter den', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/indstillinger');
const find = page.getByPlaceholder('Søg i indstillinger');
await find.fill('beacon');
const hit = page.locator('.hits button', { hasText: 'Tælling af installationer' });
await expect(hit).toBeVisible();
// Hvor det ligger, står ved siden af hvad det hedder: halvdelen af det, man
// søgte efter, er svaret på hvilken fane.
await expect(hit).toContainText('Data');
await hit.click();
await expect(page).toHaveURL(/\/indstillinger\/data#beacon$/);
const section = page.locator('#beacon');
await expect(section.getByRole('heading', { name: 'Tælling af installationer' })).toBeVisible();
// Rullet derhen, ikke bare tegnet: afsnittet er det syvende på siden, så en
// side, der bare blev åbnet, ville vise toppen af den.
await expect(section).toBeInViewport();
// Feltet tømmer sig selv, når man er landet. En søgning, der bliver stående
// over det, man fandt, dækker det.
await expect(find).toHaveValue('');
// Og det, der ikke findes, siger det frem for at vise en tom liste.
await find.fill('hestevogn');
await expect(page.getByText('Ingen indstilling hedder det.')).toBeVisible();
// ⌘K spørger det samme indeks. Her er det den anden vej ind, og "webhook" er
// igen et ord, der ikke står i overskriften.
await page.goto('/');
// Knappen og ikke tastetrykket: ⌘K lyttes der efter i en effekt, så et tryk
// sendt lige efter goto kan nå frem, før siden er hydreret. Knappen venter
// Playwright på af sig selv — og den er også den, man har på en telefon.
await page.getByRole('button', { name: /Søg/ }).click();
await page.getByRole('dialog').getByRole('textbox').fill('webhook');
const row = page.getByRole('dialog').locator('li button', { hasText: 'Skub fra andre programmer' });
await expect(row).toBeVisible();
await expect(row).toContainText('Indstilling');
await row.click();
await expect(page).toHaveURL(/\/indstillinger\/integrationer#krog$/);
await expect(page.locator('#krog')).toBeInViewport();
expect(trouble).toEqual([]);
});
/**
* Et projekt kan deles med en, der allerede er her.
*
* Indtil nu kunne det kun deles ved at stave en e-mailadresse — altså ved at kende
* adressen på en kollega, man kender navnet på og sidder i samme rum som. Konti på
* en instans bliver kun til ved invitation, så der er ikke noget at beskytte ved at
* lade være med at vise dem for hinanden; det er adressebogen, funktionen har brug
* for.
*
* Prøven laver selv sin anden person frem for at låne den fra prøven ovenfor. Den
* kobling så jeg først, da jeg kørte den her alene: basen tømmes for hver kørsel,
* så en prøve, der kun består, når hele filen kører i rækkefølge, består ikke af
* sine egne grunde.
*
* Og turen forbi invitationen er ikke spild. Den viser skiftet: den samme person er
* først en ventende adresse, og bagefter et navn, der kan vælges.
*/
test('et projekt kan deles med en, der allerede har en konto', 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('Vinterhaven');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Vinterhaven' })).toBeVisible();
// Først den, der ikke er her endnu. Adressen er den eneste vej til dem.
await projectAction(page, 'Del');
await page.getByLabel('Inviter via e-mail').fill('[email protected]');
await page.getByRole('button', { name: 'Inviter' }).click();
// Og de bliver stående synligt, indtil de kommer — ellers er en invitation
// sendt i går usynlig, og så bliver den sendt igen.
const invited = page.locator('.members.invited');
await expect(invited.getByText('[email protected]')).toBeVisible();
await expect(invited).toContainText('Inviteret, ikke kommet endnu');
const link = await page.locator('.link-out code').textContent();
expect(link).toContain('/invite?token=');
// Tom kontekst: en inviteret, der allerede er logget ind som den, der inviterede,
// er det ene tilfælde, der aldrig sker.
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const guest = await context.newPage();
await guest.goto(link);
await guest.getByLabel('Navn', { exact: true }).fill('Gartner');
await guest.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
await guest.getByRole('button', { name: 'Opret konto' }).click();
await expect(guest.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await context.close();
// Nu er de her. Et nyt projekt, og den samme person er blevet et navn, man vælger.
await page.goto('/');
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
await sidebar.getByLabel('Projektnavn').fill('Drivhuset');
await sidebar.getByLabel('Projektnavn').press('Enter');
await expect(page.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
await projectAction(page, 'Del');
const picker = page.locator('.addshare select').first();
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(1);
await picker.selectOption({ label: 'Gartner' });
await page.getByLabel('Rolle for den valgte').selectOption('viewer');
await page.locator('.addshare button', { hasText: 'Del' }).click();
const members = page.locator('.members').first();
await expect(members.locator('li', { hasText: 'Gartner' })).toContainText('Kan kun se');
// Og bagefter er de væk fra listen: en liste, der tilbyder at tilføje en, der
// allerede er der, er en liste, man gør det i ved et uheld.
await expect(picker.locator('option', { hasText: 'Gartner' })).toHaveCount(0);
// Tilbagetrækningen af en invitation prøves i Go
// (TestAPendingInviteIsVisibleAndCanBeWithdrawn) og ikke her. Den krævede et
// spring tilbage til det første projekt, og delingspanelet står åbent på tværs
// af det spring — så klikket på "Del" lukkede det, jeg lige havde åbnet.
// Og så den anden vej: hvad Gartner selv oplever. Man bliver lukket ind i et
// projekt uden at blive spurgt, så man skal i det mindste få det at vide og
// kunne gå ud igen uden at bede ejeren om lov.
const theirs = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const gartner = await theirs.newPage();
const gartnerTrouble = watchForTrouble(gartner);
await gartner.goto('/');
await gartner.getByLabel('E-mail').fill('[email protected]');
await gartner.getByLabel('Adgangskode').fill('et langt kodeord til test');
await gartner.getByRole('button', { name: 'Log ind' }).click();
await expect(gartner.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await gartner.getByRole('button', { name: /Beskeder/ }).click();
const bell = gartner.getByRole('dialog', { name: 'Beskeder' });
await expect(bell.getByText('delte et projekt med dig')).toBeVisible();
// Beskeden fører hen til projektet. En besked, der ikke kan følges, er en
// oplysning om noget, man selv skal gå ud og finde.
await bell.locator('.row').filter({ hasText: 'delte et projekt med dig' }).click();
await expect(gartner.getByRole('heading', { name: 'Drivhuset' })).toBeVisible();
// Udgangen. Bekræftelsen siges ja til, ellers sker der intet.
gartner.on('dialog', (d) => d.accept());
await projectAction(gartner, /^Del/);
await gartner.getByRole('button', { name: 'Forlad projektet' }).click();
// Væk fra sidebjælken, og ikke længere til at åbne.
await expect(
gartner.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: /Drivhuset/ })
).toHaveCount(0);
// En 404 her er ikke uro: den er svaret på at bede om et projekt, man lige har
// forladt, og den er hele pointen med at have forladt det.
expect(gartnerTrouble.filter((t) => !t.includes('404'))).toEqual([]);
await theirs.close();
expect(trouble).toEqual([]);
});
/**
* En søgning i ⌘K efterlader resultaterne, den fandt.
*
* Paletten viste de noter, der passede, i sin egen liste — og når man valgte en,
* åbnede den noten på en side, hvis liste stod helt uændret. De andre træffere var
* væk, og det eneste sted, de nogensinde havde været, var en popup, der lige var
* lukket. Rapporteret sådan her: "jeg får kun præsenteret en popup liste med
* resultater, men hvis jeg trykker retur, kommer jeg tilbage til den normale liste
* — ikke resultater."
*
* Prøven laver sine egne noter med et ord, ingen anden note i basen har. Et ord
* lånt fra en tidligere prøve ville gøre den her afhængig af rækkefølgen, og den
* kobling har allerede narret mig én gang i den her fil.
*/
test('en søgning i ⌘K efterlader de træffere, den fandt', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Gemt ved at forlade feltet, som resten af fladen gør det — der er ingen
// Gem-knap at huske.
//
// Der ventes på et TOMT tekstfelt, før der skrives. Uden det kan udfyldningen
// ramme den forrige notes felt, mens den nye er undervejs, og så bliver teksten
// enten skrevet på den forkerte note eller tabt undervejs.
//
// Og der ventes IKKE på "Gemt": den tekst er hvilestanden, ikke et signal.
// Feltet siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før
// den første gemning overhovedet er begyndt. En prøve, der venter på den, får
// grønt med det samme og måler ingenting. Det var den fejl, der gjorde den her
// prøve ustabil: rettelsen var en ventetid, der ikke kunne vente på noget.
// Rækken i listen er det rigtige tegn — den kommer først, når serveren har
// svaret.
for (const title of ['Fyrreskov nord', 'Fyrreskov syd', 'Egeskov']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await expect(body).toHaveText('');
await body.fill(`${title}\n\nnoget om den`);
await body.blur();
await expect(page.locator('.list').getByText(title, { exact: true })).toBeVisible();
}
await page.goto('/');
await page.getByRole('button', { name: /Søg/ }).click();
const palette = page.getByRole('dialog');
await palette.getByRole('textbox').fill('Fyrreskov');
await expect(palette.locator('li button', { hasText: 'Fyrreskov nord' })).toBeVisible();
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
// Noten er åben …
await expect(page).toHaveURL(/\/noter\?note=[^&]+&q=Fyrreskov/);
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
// … og listen ved siden af den er de andre træffere, ikke den normale liste.
await expect(page.getByPlaceholder('Søg i noter')).toHaveValue('Fyrreskov');
const list = page.locator(".list");
await expect(list.getByText('Fyrreskov syd')).toBeVisible();
await expect(list.getByText('Egeskov')).toHaveCount(0);
// Og de træffere kan ÅBNES. Adressen bærer stadig `?note=` på den, ⌘K åbnede,
// og den blev læst igen hver gang noget ændrede sig — så et klik på en anden
// række satte `selectedId`, effekten så den uenig med adressen, og trak én
// tilbage til den første. Man kunne søge og ikke vælge.
await list.getByText('Fyrreskov syd').click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov syd');
await expect(page.getByLabel('Notens tekst')).not.toContainText('Fyrreskov nord');
// Og tilbage til den, adressen stadig nævner. Det tilfælde er grunden til, at
// åbningen hænger på navigationen og ikke på adressens værdi: `?note=` peger
// stadig på "Fyrreskov nord", så en rettelse, der blot læste den én gang og
// huskede værdien, ville se den uændret og lade være med at åbne noget. Man
// kunne så ikke komme tilbage til den note, man kom fra.
await page.getByRole('button', { name: /Søg/ }).click();
await palette.getByRole('textbox').fill('Fyrreskov nord');
await palette.locator('li button', { hasText: 'Fyrreskov nord' }).click();
await expect(page.getByLabel('Notens tekst')).toContainText('Fyrreskov nord');
// Feltet kan tømmes, og listen kommer hel igen. Adressen beholder sit `q` —
// den skrives kun af navigation. Den første udgave skrev den ved hvert
// tastetryk, så den kunne deles, og det kostede bogstaver ud af feltet; se
// prøven nedenfor.
await page.getByPlaceholder('Søg i noter').fill('');
await expect(list.getByText('Egeskov')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En ny note åbnes med det samme, uden at editoren forsvinder undervejs.
*
* Serveren tømmer `body` på listerækker med vilje, og siden brugte den tomme krop
* som tegn på "det her er en række, hent noten hel". En nyoprettet note er også
* tom — og hel — så den blev sendt samme vej: editoren blev pillet af skærmen,
* noten hentet igen, og editoren sat op på ny.
*
* Det hul var ikke kun et blink. Mens det stod åbent, var der ingen valgt note, og
* gemningen går tavst tilbage uden en. Skrev man i det sekund, var teksten væk —
* ingen fejl, ingen toast, bare en tom note. Det er også det, der gjorde
* søgeprøven ovenfor ustabil: i CI var maskinen langsom nok til at ramme hullet.
*
* Prøven tæller opslagene frem for at måle et blink. Et blink er en tidsting og
* ville kræve, at prøven var hurtigere end programmet — præcis den fejl, den er
* skrevet for at lukke. Et opslag, der ikke sker, er et tal.
*/
test('en ny note hentes ikke igen, når den lige er lavet', async ({ page }) => {
const trouble = watchForTrouble(page);
const fetched = [];
page.on('request', (r) => {
// Selve noten, ikke dens delinger: /notes/<id> og ikke /notes/<id>/shares.
const m = new URL(r.url()).pathname.match(/^\/api\/v1\/notes\/([0-9a-f-]+)$/);
if (r.method() === 'GET' && m) fetched.push(m[1]);
});
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
// Editoren er der, og den er tom — ikke væk og på vej tilbage.
const body = page.getByLabel('Notens tekst');
await expect(body).toHaveText('');
await body.fill('Grantræ\n\nnoget om det');
await body.blur();
await expect(page.locator('.list').getByText('Grantræ', { exact: true })).toBeVisible();
// Noten blev aldrig hentet igen: svaret på oprettelsen var den hele.
expect(fetched).toEqual([]);
expect(trouble).toEqual([]);
});
/**
* Et totalt udfald siger "ingen forbindelse" — ikke "log ind".
*
* Opstarten spørger serveren, hvem man er. Når svaret aldrig kommer, står `user`
* som null, og null betyder "logget ud" alle andre steder i programmet — så et
* udfald blev vist som login-skærmen. Appen gav altså sessionen skylden for, at
* nettet var nede, og tilbød et kodeordsfelt, der umuligt kunne virke.
*
* Det er værre end en fejlbesked, fordi det ligner noget, der kunne virke: man
* taster kodeordet, der sker ingenting, og man konkluderer, at kontoen er i
* stykker. Det skete i virkeligheden 17. september 2026, da instansens DNS-post
* blev slettet.
*
* Prøven afbryder API-kaldene frem for at stoppe serveren: en afbrudt forespørgsel
* er præcis det, en manglende DNS-post giver browseren — fetch afviser, uden at der
* nogensinde kom en status. En 500 ville være serveren, der svarer, og det er den
* anden sag.
*/
test('et udfald siger ingen forbindelse frem for at bede om kodeordet', async ({ page }) => {
await page.route('**/api/v1/**', (route) => route.abort());
await page.goto('/');
// Prøvetiden skal kunne rumme hele forsøgsvinduet: forespørgselslaget prøver
// igen i femten sekunder, før det kalder det et udfald. Det er med vilje —
// et blink på nettet må ikke smide folk ud — så prøven må vente det ud.
await expect(page.getByText('Ingen forbindelse', { exact: true })).toBeVisible({
timeout: 25000
});
// Og ikke kodeordsfeltet. Det er hele pointen.
await expect(page.getByLabel('Adgangskode')).toHaveCount(0);
});
/**
* Klokkens panel bliver inden for skærmen på en telefon.
*
* Panelet hang under klokken og var 340 px bredt. Klokken sidder tættere på højre
* kant end det — der er en temaknap og en kantafstand til højre for den — så
* panelet blev skubbet ud over venstre side og klippet af. På en telefon stod der
* flere linjer tekst uden for skærmen, og overskriften "Beskeder" lå klods op ad
* kanten uden luft, hvilket er tegnet på, at der mangler noget til venstre for
* den.
*
* Målt på kasserne frem for på et skærmbillede: "ser rigtigt ud" er ikke en
* påstand, en prøve kan efterprøve, men "venstre kant er ikke negativ" er. Begge
* kanter tjekkes — en rettelse, der skubber panelet ind fra venstre ved at lade
* det stikke ud til højre, er ikke en rettelse.
*/
test('klokkens panel bliver inden for skærmen på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.getByRole('button', { name: /^Beskeder/ }).click();
const panel = page.getByRole('dialog', { name: 'Beskeder' });
await expect(panel).toBeVisible();
const kasse = await panel.boundingBox();
expect(kasse.x, 'panelet hænger ud over venstre kant').toBeGreaterThanOrEqual(0);
expect(kasse.x + kasse.width, 'panelet hænger ud over højre kant').toBeLessThanOrEqual(390);
// Og der er luft til overskriften. Et panel, der lige akkurat begynder ved nul,
// men hvis tekst gør det samme, er stadig klippet — det var netop sådan, det så
// ud på telefonen.
const overskrift = panel.getByText('Beskeder', { exact: true });
const tekst = await overskrift.boundingBox();
expect(tekst.x, 'overskriften klæber til kanten').toBeGreaterThan(kasse.x);
expect(trouble).toEqual([]);
});
/**
* En punktliste kan have niveauer, og de overlever gemningen.
*
* Tab rykker ind og shift+Tab ud, som i ethvert andet program med lister. Det var
* ikke bundet til noget: Tab uden for forslagslisten gjorde det, browseren gør med
* Tab, altså flyttede fokus ud af noten. Der var ingen vej til et niveau to.
*
* Omformeren kunne begge veje i forvejen — indrykning bærer niveauet i Markdown —
* men skriveren læste kun en underliste, der ligger INDE i sit punkt. Lægger
* browseren den som søskende, og det gør de uens, blev grenen sprunget over uden
* lyd: punktet forsvandt ved gemningen. Begge former læses nu.
*
* Målt efter en genindlæsning. Før den står indrykningen på skærmen uanset hvad —
* det er turen gennem Markdown, der afgør, om den blev skrevet ned.
*/
test('en punktliste kan have niveauer, der overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Indkøb');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Punktliste' }).click();
await page.keyboard.type('Frugt');
await page.keyboard.press('Enter');
await page.keyboard.type('Aebler');
await page.keyboard.press('Tab');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
// Niveauet er der med det samme: en liste inde i en liste.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Indkøb/ }).click();
// Og efter turen gennem Markdown står det der stadig.
await expect(ed.locator('ul ul li')).toHaveText(['Aebler']);
await expect(ed.locator('ul > li').first()).toContainText('Frugt');
// Og tilbage igen: shift+Tab er vejen ud, ellers er niveauet en envejsdør.
await ed.locator('ul ul li').click();
await page.keyboard.press('Shift+Tab');
await expect(ed.locator('ul ul li')).toHaveCount(0);
expect(trouble).toEqual([]);
});
/**
* Farvelægningen skriver ikke linjerne sammen i en blok, man lige har forladt.
*
* Blokken farves, når markøren forlader den, og resultatet males TILBAGE ind i
* den. Den læsning havde sin egen kopi af "hvad er teksten i en blok", og kopien
* brugte `textContent` — hvor et <br> er den tomme streng. Så en blok skrevet med
* shift+retur fik sine linjer skrevet sammen i det øjeblik, man klikkede væk. På
* skærmen, ikke kun i filen, og uden at man havde rørt teksten.
*
* Kommentaren over kopien sagde "ét sted, fordi to steder skal bruge det samme
* svar". Der var to, og de var uenige om netop det tegn.
*/
test('et klik væk fra en kodeblok skriver ikke dens linjer sammen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Farvelagt');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('echo en');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('echo to');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
// Ud af blokken, så farvelægningen tager fat i den.
await page.keyboard.press('ArrowDown');
await page.keyboard.press('End');
await ed.getByText('Farvelagt', { exact: true }).click();
// Kopiér-knappen ligger inde i blokken for at kunne sidde i hjørnet af den, så
// den skal ud, før teksten læses — præcis det, koden selv gør.
const tekst = await ed.locator('pre').evaluate((el) => {
const kopi = el.cloneNode(true);
kopi.querySelectorAll('.copy').forEach((b) => b.remove());
return kopi.innerText.trim();
});
expect(tekst).toBe('echo en\necho to');
expect(trouble).toEqual([]);
});
/**
* ⌘B gør teksten fed i en note — den folder ikke sidebjælken.
*
* Fladen tog ⌘B til sidebjælken med den begrundelse, at den "ikke flytter noget i
* feltet". Det passer for et <input>, hvor ⌘B ikke gør noget. I en contenteditable
* ER ⌘B fed tekst, og `preventDefault()` stoppede browseren i at gøre den — så i
* noterne foldede menuen sig, hver gang man ville fremhæve et ord.
*
* Begge halvdele måles. En rettelse, der bare holdt op med at folde, ville være
* halvt færdig: ordet skal også blive fedt.
*/
test('⌘B gør teksten fed i en note i stedet for at folde sidebjælken', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const foldet = () => page.locator('.shell.sidebar-collapsed').count();
expect(await foldet()).toBe(0);
await nyNote(page);
await page.keyboard.type('Fremhævet');
await page.keyboard.press('Enter');
await page.keyboard.type('ordet');
await page.keyboard.press('ControlOrMeta+a');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await page.keyboard.press('ControlOrMeta+b');
// Enten <b> eller <strong>: browserens egen fed er <b>, omformeren skriver
// <strong>, og begge læses som fed på vejen tilbage.
await expect(ed.locator('b, strong')).toContainText('ordet');
expect(await foldet()).toBe(0);
// Og uden for et skrivefelt gør den stadig det, den er sat til.
await page.getByRole('heading', { name: 'Noter' }).click();
await page.keyboard.press('ControlOrMeta+b');
expect(await foldet()).toBe(1);
await page.keyboard.press('ControlOrMeta+b');
expect(trouble).toEqual([]);
});
/**
* Flere markerede afsnit bliver til ÉN monotypeblok — ikke kun det øverste.
*
* Monotype går uden om `execCommand`, fordi formatBlock pakker et <p> ind i
* <pre>'en i stedet for at lave den om. Den vej spurgte efter `currentBlock()`,
* altså det ene blok markøren står i — så af en markering over ti linjers
* terminaludskrift blev den første linje til kode og resten stod tilbage som
* brødtekst.
*
* Og det skal være én blok, ikke tre. En udskrift, man indsætter, er ét stykke
* kode; tre blokke ved siden af hinanden ser næsten ens ud på skærmen og er tre
* hegn i filen.
*/
test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Udskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('linje et');
await page.keyboard.press('Enter');
await page.keyboard.type('linje to');
await page.keyboard.press('Enter');
await page.keyboard.type('linje tre');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('p')).toHaveCount(3);
// Markér alle tre, som man gør det med musen eller tastaturet: fra begyndelsen
// af den første til enden af den sidste.
await ed.getByText('linje et', { exact: true }).click();
await page.keyboard.press('Home');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+End');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
// Én blok med alle tre linjer, og ingen brødtekst tilbage.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
await expect(ed.locator('p')).toHaveCount(0);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Udskrift/ }).click();
await expect(ed.locator('pre')).toHaveCount(1);
expect(await ed.locator('pre code').evaluate((el) => el.textContent.trim())).toBe(
'linje et\nlinje to\nlinje tre'
);
expect(trouble).toEqual([]);
});
/**
* Blokke, som retur efterlod som <div>, bliver brødtekst — også bagefter.
*
* Chromium svarer på retur med en bar <div>. Den læses som brødtekst og er ikke
* formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er
* udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger,
* hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så
* retter den den næste blok to gange og lader denne stå.
*
* Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit
* bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var
* en <div> og "linje to" og "linje tre" var <p>.
*
* Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
* runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
* og holder på invarianten i stedet: ingen blok i en note er browserens egen
* beholder, uanset hvor markøren har været i mellemtiden.
*/
test('blokke retur efterlod som <div> bliver brødtekst alligevel', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const ed = await nyNote(page);
await page.keyboard.type('Overskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('første afsnit');
await page.keyboard.press('Enter');
await page.keyboard.type('andet afsnit');
await expect(ed.locator('p')).toHaveCount(2);
// Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
// så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
// der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
// når den første ombytning tager dens node ud af dokumentet.
await ed.evaluate((el) => {
for (const para of [...el.querySelectorAll('p')]) {
const div = document.createElement('div');
while (para.firstChild) div.appendChild(para.firstChild);
para.replaceWith(div);
}
// Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
// brugeren skriver videre. At erstatte blokken kollapser markeringen til
// begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
const range = document.createRange();
range.selectNodeContents(el.lastElementChild);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
await expect(ed.locator('p')).toHaveCount(0);
await expect(ed.locator('> div')).toHaveCount(2);
// Næste retur henter dem begge ind igen, sammen med den nye linje.
await page.keyboard.press('Enter');
await page.keyboard.type('tredje afsnit');
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
await expect(ed.locator('h1')).toHaveCount(1);
// Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
// rettelse. Den skal også overleve en gemning.
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Overskrift/ }).click();
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
* Eksporten pr. projekt virkede og var ubrugelig ved enhver størrelse: femten
* projekter betød femten downloads for at flytte sine egne opgaver et andet sted
* hen. Det er en eksport, der teknisk set findes, og som ingen bruger.
*
* Prøven henter filerne og ser i dem. At et link findes beviser ingenting — en tom
* zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og
* det er præcis den form, en fejl i en eksport har.
*/
test('alle opgaver kan hentes som CSV og som kalender', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
await page.getByLabel('Ny opgave').fill('Betal moms i dag');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('Betal moms', { exact: true })).toBeVisible();
await page.goto('/indstillinger/data');
const hent = async (navn) => {
const venter = page.waitForEvent('download');
await page.getByRole('link', { name: navn }).click();
const fil = await venter;
const sti = await fil.path();
return { sti, navn: fil.suggestedFilename() };
};
const zip = await hent('Alle opgaver (CSV)');
expect(zip.navn).toMatch(/\.zip$/);
// En zip er ikke tom, når den har mere end den afsluttende blok i sig.
const { size } = await import('node:fs/promises').then((fs) => fs.stat(zip.sti));
expect(size).toBeGreaterThan(100);
const kal = await hent('Alle opgaver (kalender)');
expect(kal.navn).toMatch(/\.ics$/);
const tekst = await import('node:fs/promises').then((fs) => fs.readFile(kal.sti, 'utf8'));
expect(tekst).toContain('BEGIN:VTODO');
expect(tekst).toContain('Betal moms');
expect(trouble).toEqual([]);
});
/**
* En indsat liste bliver til opgaver og underopgaver.
*
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
* man indsætter i et `<input>` — så en kopieret liste blev til én opgave med det
* hele mast sammen.
*
* Her måles sammenhængen: at indsætningen fanges, at visningen kommer FØR noget
* oprettes, at indrykningen bliver til en underopgave, og at en afkrydset linje
* lander som gjort. De enkelte læseregler står i pastelist.spec.js, hvor de er
* billigere at stille.
*/
test('en indsat liste bliver til opgaver, og de indrykkede bliver underopgaver', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const liste = ['- Flytte garagen', ' - Rydde hylderne', ' - [x] Leje trailer', '- Male døren'].join(
'\n'
);
await page.getByLabel('Ny opgave').click();
// En rigtig indsættelses-hændelse. At skrive teksten ind i feltet ville springe
// netop det over, prøven handler om — browseren smider linjeskiftene væk.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('[data-quickadd]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, liste);
// Vist først, oprettet bagefter. Fyrre opgaver fra et fejlklik er ikke noget,
// man skal opdage bagefter.
const visning = page.getByRole('group', { name: 'Indsat liste' });
await expect(visning.locator('li')).toHaveCount(4);
await expect(page.getByText('Flytte garagen', { exact: true })).toHaveCount(1);
await visning.getByRole('button', { name: 'Opret 4 opgaver' }).click();
await expect(visning).toHaveCount(0);
// Ingen af linjerne har en dato, så de ligger i indbakken og ikke på I dag.
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'Indbakke' }).click();
await expect(page.getByText('Flytte garagen', { exact: true })).toBeVisible();
await expect(page.getByText('Male døren', { exact: true })).toBeVisible();
// Og de indrykkede hænger under den linje, de stod under — ikke under den
// første, den bedste. Det ses i opgavens egen rude.
await page.getByText('Flytte garagen', { exact: true }).click();
await expect(page.getByText('Rydde hylderne', { exact: true })).toBeVisible();
await expect(page.getByText('Leje trailer', { exact: true })).toBeVisible();
// Afkrydsningen var indhold: den linje kom ind som gjort, så rækken tæller
// én af to. Målt på tælleren frem for på en streg gennem teksten — tælleren
// er det, serveren har gemt, stregen er, hvordan den tegnes.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/2/ })).toBeVisible();
// Og "Male døren" hang ikke under noget: den stod uden indrykning.
await expect(page.getByRole('button', { name: /Male døren \d\/\d/ })).toHaveCount(0);
// Den anden halvdel af "enten opgaver eller undertasks": hvor man indsætter,
// afgør hvad linjerne bliver. Her er opgavens egen rude åben, så de bliver
// undertasks under den — uden at nogen skal vælge noget.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('input[aria-label="Ny undertask"]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, '- Købe maling\n- Låne stige');
const flere = page.getByRole('group', { name: 'Indsat liste' });
await expect(flere.locator('li')).toHaveCount(2);
await flere.getByRole('button', { name: 'Opret 2 opgaver' }).click();
// To mere under den samme opgave: fire undertasks, én af dem lukket.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/4/ })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En kodeblok med flere linjer kommer tilbage med dem alle.
*
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
* lægger ind, er et <br>, og `textContent` på et <br> er den tomme streng. Blokken
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
* skrive flere linjer og ikke gemme dem.
*
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
* ovenfor stopper lige før.
*/
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Opskrift');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('first line');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('second line');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Opskrift/ }).click();
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
//
// Og på <code>, ikke på <pre>: kopiér-knappen ligger inde i blokken for at
// kunne sidde i hjørnet af den, så `textContent` på <pre> har ordet "Kopiér"
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
// en blocks tekst — bare fra den anden side.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
'first line\nsecond line'
);
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
* I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
* er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
* ikke ser ud som et. Der var ingen vej fra det til billedet selv.
*
* Prøven indsætter billedet ad den vej, folk gør det: en rigtig paste-hændelse med
* en fil i udklipsholderen. Et billede lagt ind ved at skrive markdown ville springe
* hele vedhæftningen over og dermed netop den kode, det her handler om.
*/
test('et indsat billede kan åbnes stort og kopieres', async ({ page, context }) => {
const trouble = watchForTrouble(page);
// Kopiering til udklipsholderen kræver lov. Uden den fejler knappen, og prøven
// ville måle en browserindstilling frem for programmet.
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill('Billednote');
// En rigtig 1×1 JPEG, og med vilje ikke en PNG: udklipsholderen tager kun PNG,
// så et foto skal tegnes om undervejs — og et foto fra en telefon ER en JPEG.
// Med en PNG her ville prøven køre forbi netop den kode, den skal vogte.
await body.evaluate((el) => {
const jpeg =
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
const bytes = Uint8Array.from(atob(jpeg), (c) => c.charCodeAt(0));
const file = new File([bytes], 'skud.jpg', { type: 'image/jpeg' });
const data = new DataTransfer();
data.items.add(file);
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
});
const image = page.locator('[aria-label="Notens tekst"] img');
await expect(image).toBeVisible();
// Klikket åbner det, og ruden viser det samme billede.
await image.click();
const shot = page.getByRole('dialog', { name: 'Billede' });
await expect(shot).toBeVisible();
await expect(shot.locator('img')).toBeVisible();
// Kopiér-knappen skal gøre noget. En knap, der tier, er værre end ingen knap:
// man indsætter noget andet og opdager det et helt andet sted.
await shot.getByRole('button', { name: 'Kopiér billedet' }).click();
await expect(shot.getByRole('button', { name: 'Kopieret' })).toBeVisible();
await expect(shot.getByText('Kunne ikke kopiere')).toHaveCount(0);
// Escape lukker, og teksten har fokus igen — ellers står fokus på en knap, der
// ikke findes mere, og den næste tast går ingen steder.
await page.keyboard.press('Escape');
await expect(shot).toHaveCount(0);
await expect(body).toBeFocused();
expect(trouble).toEqual([]);
});
/**
* En opgaves tekst kan læses færdig, også på en telefon.
*
* Rapporteret fra en telefon, med et billede af en beskrivelse, der manglede sit
* første bogstav på hver linje: "hermostat RU0910301440" og "atteries". Det er
* ikke manglende tegn — det er et felt, der er rullet til højre. En beskrivelse er
* tit et klippet stykke mail med en URL i, et <textarea> ombryder ved mellemrum,
* og en URL har ingen.
*
* Og titlen var et <input>, som ikke kan ombryde overhovedet. "tado°: Your Smart
* Thermostat needs new batteries" er en almindelig opgavetitel, og halvdelen af
* den stod uden for kanten.
*/
test('en opgaves tekst kan læses færdig på en telefon', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
const langTitel = 'tado°: Your Smart Thermostat needs new batteries';
// "i dag" for at den lander på siden, prøven står på — parseren klipper det ud
// af titlen igen, så den hedder præcis det, den skal.
await page.getByLabel('Ny opgave').fill(`${langTitel} i dag`);
await page.getByLabel('Ny opgave').press('Enter');
await page.getByText('tado°', { exact: false }).first().click();
const titel = page.getByLabel('Opgavens tekst');
await expect(titel).toBeVisible();
await expect(titel).toHaveValue(langTitel);
// Beskrivelsen: en URL uden et eneste mellemrum at bryde ved.
const url = 'https://link.tado.com/emails/8/ttid/1-a37f2c9b4e6d8a1f0c3b5e7d9a2f4c6b8e0d1a3f5c7b9e2d4a6f8c0b3e5d7a9f';
const beskrivelse = page.getByLabel('Beskrivelse');
await beskrivelse.fill(`Thermostat RU0910301440\nBatteries\n${url}`);
await beskrivelse.blur();
// Målt frem for beskrevet: et felt, der ruller vandret, har en scrollWidth
// større end sin clientWidth. Det var dét, der klippede venstre kant.
//
// Felterne måles gennem deres egne lokatorer og ikke med en forespørgsel på
// aria-label: beskrivelsen har et <label for>, ikke et aria-label, så en
// forespørgsel fandt ingenting og ville have bestået tomt.
const spild = [];
for (const [navn, felt] of [
['Opgavens tekst', titel],
['Beskrivelse', beskrivelse]
]) {
const m = await felt.evaluate((el) => ({ ind: el.scrollWidth, ude: el.clientWidth }));
if (m.ind - m.ude > 1) spild.push(`${navn}: ${m.ind} > ${m.ude}`);
}
expect(spild).toEqual([]);
// Beskrivelsen løber ikke over i Chromium, heller ikke uden reglen — det var
// Safari, den blev set i, og det er en anden motor. Prøven måler derfor reglen
// selv frem for dens virkning. Det er svagere, og det står her, så den næste
// ved det: den vogter mod at reglen bliver slettet, ikke mod at den er forkert.
await expect(beskrivelse).toHaveCSS('overflow-wrap', 'break-word');
// Og titlen er blevet høj nok til at rumme sig selv frem for at klippe.
const linjer = await titel.evaluate((el) => ({
hoejde: el.clientHeight,
linje: parseFloat(getComputedStyle(el).lineHeight)
}));
expect(linjer.hoejde).toBeGreaterThan(linjer.linje * 1.5);
expect(trouble).toEqual([]);
});
/**
* Søgefeltet taber ikke bogstaver, og listen viser det, der blev spurgt om.
*
* Fra en skærmoptagelse, to billeder i sekundet:
*
* 2,5 s feltet "verd" listen ufiltreret
* 3,0 s feltet "nde" listen filtreret
* 6,0 s feltet "verdande" listen ufiltreret
* 6,5 s feltet tomt listen filtreret
*
* To fejl oven i hinanden.
*
* Bogstaverne forsvandt, fordi adressen blev skrevet ved hvert tastetryk:
* `replaceState` opdaterer `$page.url` et øjeblik efter, den kaldes, så en effekt
* læste den forrige adresse og skrev den gamle værdi tilbage i feltet.
*
* Og resultaterne landede for sent, fordi to hentninger kunne være undervejs på
* én gang uden at nogen holdt styr på, hvilken der gjaldt. Den langsomste vandt.
*/
test('søgefeltet taber ikke bogstaver, og det nyeste svar vinder', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
// Sin egen note at finde. Et ord fra en anden prøve ville gøre den her
// afhængig af rækkefølgen, og den kobling har allerede narret mig i denne fil.
await page.getByRole('button', { name: 'Ny note' }).click();
const krop = page.getByLabel('Notens tekst');
await krop.fill('Kaffemølle\n\nnoget om den');
await krop.blur();
// Rækken i listen er værnet. "Gemt" stod her før og er hvilestanden — se noteGemt().
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
// Skrevet i et tempo, et menneske kan skrive i. Det var hastigheden, der
// afslørede det: en pause mellem hvert bogstav skjuler kapløbet helt.
const felt = page.getByPlaceholder('Søg i noter');
await felt.click();
await page.keyboard.type('kaffemølle', { delay: 40 });
await expect(felt).toHaveValue('kaffemølle');
// Og det bliver stående. Fejlen viste sig et halvt sekund efter, ikke med det
// samme — adressen skal nå at komme tilbage, før den kan skrive over noget.
await page.waitForTimeout(1500);
await expect(felt).toHaveValue('kaffemølle');
// Det nyeste svar vinder, også når det kommer først.
//
// Den første søgning holdes tilbage, den anden slippes igennem. Uden vagten
// lander den gamle sidst og overskriver listen med svaret på et spørgsmål, der
// ikke længere bliver stillet.
let held;
await page.route('**/api/v1/notes?*', async (route) => {
const q = new URL(route.request().url()).searchParams.get('q');
if (q === 'aaaaa' && !held) {
held = route;
return; // holdes, indtil den næste er kommet igennem
}
await route.continue();
});
await felt.fill('');
await felt.fill('aaaaa');
await page.waitForTimeout(500);
await felt.fill('kaffemølle');
await page.waitForTimeout(900);
if (held) await held.continue();
await page.waitForTimeout(700);
// Feltet og listen er enige: det, der står, er det, der blev spurgt om.
await expect(felt).toHaveValue('kaffemølle');
await expect(page.locator('.list').getByText('Kaffemølle', { exact: true })).toBeVisible();
await page.unroute('**/api/v1/notes?*');
expect(trouble).toEqual([]);
});
/**
* The control for the notes edition's renaming, and it belongs here rather than
* beside it: the whole point is that the full edition does NOT go through that
* path, and that can only be measured on an instance which is not the notes one.
*
* A gate asserted in one direction is half a gate. edition.spec.js proves the
* notes edition calls itself urd; without this, a rename that fired in both
* editions would pass there and change the name of this program.
*/
test('the full edition still calls itself verdande', async ({ page }) => {
await page.goto('/');
// Both titles, for the same reason edition.spec.js checks both: the shell's,
// which the browser reads before any script runs, and the route's own, set in
// <svelte:head> once the page draws. Today has no title of its own, so the
// shell's is what stands here — and /noter has one, so it is asked separately.
const shell = await (await page.request.get('/')).text();
expect(shell).toContain('<title>verdande</title>');
await expect(page).toHaveTitle('verdande');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
// ᚹ wunjo, not ð.
await expect(brand.locator('.rune')).toHaveText('ᚹ');
await page.goto('/noter');
await expect(page).toHaveTitle(/· verdande$/);
const manifest = await (await page.request.get('/manifest.webmanifest')).json();
expect(manifest.name).toBe('verdande');
expect(manifest.description).toBe('Opgaver og projekter, delt.');
// The other half of the icon alias: this edition must still get Wunjo. Both
// sets of files are in the same binary, so a swap that fired in both editions
// would pass in edition.spec.js and quietly change this program's mark.
const icon = await (await page.request.get('/icon.svg')).text();
expect(icon).toContain('M26 15l17');
expect(icon).not.toContain('M44 36L27 10');
});
/**
* The second door.
*
* One instance, one database, one login — and two things that can be installed.
* This runs in the FULL edition on purpose: urd is a second face of an ordinary
* verdande, not a property of an instance, so if it only worked where
* VERDANDE_EDITION=notes it would not be the thing that was asked for.
*
* Measured on the bytes the server sends. An operating system installs a manifest,
* not a URL, so these assertions are about the manifest's `id` and the shell's
* <head> — the two things that decide whether /urd becomes a second icon on a home
* screen or a bookmark into the first app.
*/
test('urd is a second installable app on the same instance', async ({ page }) => {
const shell = await (await page.request.get('/urd')).text();
expect(shell, 'the tab, before any script runs').toContain('<title>urd</title>');
expect(shell, 'which manifest the browser fetches').toContain(
'<link rel="manifest" href="/urd.webmanifest" />'
);
// iOS looks for this one BY NAME and has historically ignored the manifest's
// icons, which is why the shell is served per door rather than patched by
// script after the page loads.
expect(shell, 'what iOS puts on a home screen').toContain(
'<link rel="apple-touch-icon" href="/urd-apple-touch-icon.png" />'
);
// And the root is untouched: two doors, not a rename.
const root = await (await page.request.get('/')).text();
expect(root).toContain('<title>verdande</title>');
expect(root).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
const res = await page.request.get('/urd.webmanifest');
// A manifest served as text/html is ignored with no error anywhere; the only
// symptom is an install prompt that never appears.
expect(res.headers()['content-type']).toContain('application/manifest+json');
const urd = await res.json();
// `id` is the install identity. Without it, two manifests on one origin are one
// app with two start urls.
expect(urd.id).toBe('/urd');
expect(urd.start_url).toBe('/urd');
// Not "/urd": a scope that did not cover the origin would open the app's own
// links in a browser tab the first time somebody navigated out of /urd.
expect(urd.scope).toBe('/');
expect(urd.name).toBe('urd');
for (const icon of urd.icons) {
expect(icon.src, 'the install prompt must not show verdande’s mark').toMatch(/^\/urd-/);
}
const verdande = await (await page.request.get('/manifest.webmanifest')).json();
expect(verdande.name).toBe('verdande');
expect(verdande.id ?? '/').not.toBe('/urd');
// The shared fields come from one source, so a colour changed in the build
// reaches both doors.
expect(urd.theme_color).toBe(verdande.theme_color);
expect(urd.background_color).toBe(verdande.background_color);
});
test('entering through the urd door lands on the notes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/urd');
// Asserted on what is drawn rather than on the URL: the Go binary serves the
// shell for anything it does not recognise, so a route that no longer exists
// still answers 200 here.
await expect(page.getByRole('heading', { name: /Noter/ }).first()).toBeVisible();
// replaceState, so the phone's back gesture does not return to a page whose
// only job is to redirect.
await expect(page).toHaveURL(/\/noter$/);
// And the window now wears urd's face: its mark, its name, its tab. This is
// chrome only — the task routes are all still there and still answer, which is
// the whole reason this is one instance.
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('urd');
await expect(brand.locator('.rune')).toHaveText('ð');
await expect(page).toHaveTitle(/· urd$/);
// Nothing is withheld: the same window can still read a task route. A face that
// hid data would be the separate instance wearing a costume.
const tasks = await page.request.get('/api/v1/tasks');
expect(tasks.status(), 'the task routes are still served to the urd window').toBe(200);
// And the face survives a reload — sessionStorage, because the two doors share
// an origin. Nobody passes through /urd again after the first navigation.
await page.reload();
await expect(brand.locator('.name')).toHaveText('urd');
expect(trouble).toEqual([]);
});
test('the verdande door is unaffected by the urd one', async ({ page }) => {
// The control, and it is the half that stops the face from being a rename: a
// window that never went through /urd must be verdande, in the same browser,
// on the same origin, with the other app's sessionStorage sitting beside it.
const trouble = watchForTrouble(page);
await page.goto('/noter');
const brand = page.getByRole('navigation', { name: /Hovedmenu|Main menu/ }).locator('.brand');
await expect(brand.locator('.name')).toHaveText('verdande');
await expect(brand.locator('.rune')).toHaveText('\u16B9');
await expect(page).toHaveTitle(/· verdande$/);
expect(trouble).toEqual([]);
});
/**
* En ny note åbner med markøren i sin titel.
*
* Titlen er første linje, som i Apple Notes — der er ikke et selvstændigt felt,
* så "sæt markøren i overskriften" betyder et område placeret inde i <h1> i et
* contenteditable. Derfor måles der på markeringen og ikke på hvad der har fokus:
* editoren kan have fokus med markøren stående et helt andet sted.
*/
async function caretBlock(page) {
return page.evaluate(() => {
const s = window.getSelection();
if (!s || !s.anchorNode) return null;
const node = s.anchorNode.nodeType === 1 ? s.anchorNode : s.anchorNode.parentElement;
const block = node?.closest('h1, h2, h3, p, li, pre');
return block ? block.tagName.toLowerCase() : null;
});
}
test('en ny note åbner med markøren i overskriften', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyTomNote(page);
// `expect.poll` og ikke én læsning: `click()` vender tilbage, så snart klikket er
// sendt, mens markøren sættes efter en netværksrundtur OG en gentegning. Lokalt
// er rundturen under et millisekund, så ét snapshot ramte altid — på en runner
// ramte det `null`, og prøven bestod på main ved held frem for ved regel.
//
// Kontrollen nedenfor (`stjæler ikke markøren`) må IKKE laves om til en poll:
// den forventer null, så en poll ville bestå på første læsning, uanset hvad
// koden gjorde. Den ankrer sig i stedet på overskriftens tekst først.
await expect
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1' })
.toBe('h1');
// Og den står et sted, man kan skrive: det, der tastes, bliver titlen, og
// titlen er det, listen kalder noten.
await page.keyboard.type('Pakkeliste til Island');
await expect(page.locator('.editor h1').first()).toHaveText('Pakkeliste til Island');
await expect(page.locator('.notes button.row strong').first()).toHaveText(
/^Pakkeliste til Island$/
);
expect(trouble).toEqual([]);
});
test('en note med indhold stjæler ikke markøren', async ({ page }) => {
// Kontrollen, og den halvdel der gør det til en regel frem for en vane: åbner
// man en note, man har skrevet i forvejen, skal markøren IKKE hoppe op i
// overskriften og skubbe det, man var i gang med.
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyTomNote(page);
// Lyt FØR der skrives. Her stod `waitForTimeout(1200)`, og kommentaren over
// `gemt` i toppen af filen nævner præcis det tal: et fast ophold er et gæt om,
// hvor hurtig maskinen er, og gættet holder indtil CI er travl. Det holdt indtil
// CI var travl — gemningen var ikke nået, genindlæsningen smed det skrevne væk,
// og prøven faldt på at noten ikke fandtes frem for på markøren, som er det den
// måler. Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om
// det.
const gemt = noteGemt(page);
await page.keyboard.type('Allerede skrevet');
await gemt;
await page.reload();
await page.getByRole('button', { name: /Allerede skrevet/ }).first().click();
await expect(page.locator('.editor h1').first()).toHaveText('Allerede skrevet');
expect(await caretBlock(page), 'ingen markør skal være sat').toBe(null);
expect(trouble).toEqual([]);
});
test('urd-døren opfører sig ens: ny note, markør i overskriften', async ({ page }) => {
// Han bad om "Urd og noter". Det er samme side, men målt gennem døren, fordi
// det er den vej en bruger af urd-appen kommer — og fordi en regel, der kun er
// prøvet ad én vej, er prøvet ét sted.
const trouble = watchForTrouble(page);
await page.goto('/urd');
await expect(page).toHaveURL(/\/noter$/);
await nyTomNote(page);
await expect
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 også i urd' })
.toBe('h1');
expect(trouble).toEqual([]);
});
test('en note oprettet fra et projekt åbner også med markøren i overskriften', async ({ page }) => {
// Den anden vej ind, og den blev påstået dækket før den blev målt: projektets
// "Ny note" opretter noten og sender videre med ?note=<id>, så markøren
// placeres af editoren på den anden side af en navigation frem for i samme
// hug. Reglen sidder i editoren netop for at dække begge — det er værdiløst
// uden en prøve, der går den anden vej.
const trouble = watchForTrouble(page);
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
await sidebar.getByLabel('Nyt projekt').click();
await sidebar.getByLabel('Projektnavn').fill('Islandsturen');
await sidebar.getByLabel('Projektnavn').press('Enter');
await sidebar.getByRole('link', { name: 'Islandsturen' }).click();
await expect(page.getByRole('heading', { name: 'Islandsturen' })).toBeVisible();
await page.getByRole('button', { name: 'Ny note', exact: true }).click();
await expect(page).toHaveURL(/\/noter\?note=/);
await expect
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 efter navigationen' })
.toBe('h1');
await page.keyboard.type('Dæk og kort');
await expect(page.locator('.editor h1').first()).toHaveText('Dæk og kort');
expect(trouble).toEqual([]);
});
/**
* Tal en opgave ind.
*
* The button does not do speech recognition and is not meant to: it puts the
* quick-add field in front of the thumb so the keyboard's own dictation can be
* used. So what there is to test is the field — that it is reachable, that it ends
* up with the caret in it, and that what lands there becomes a task.
*
* What a browser cannot be asked: whether iOS opened its keyboard. That happens
* only when focus() is called inside the user gesture, and no test can see the
* difference. What a test CAN see is the precondition — that the field is already
* in the document before the button is pressed — and that is asserted below,
* because it is exactly what a well-meaning `{#if open}` would take away.
*/
const PHONE = { width: 390, height: 844 };
test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize(PHONE);
await page.goto('/');
const knap = page.getByRole('button', { name: 'Tal en opgave ind' });
await expect(knap).toBeVisible();
// Feltet findes FØR klikket. Det er forudsætningen for at fokus kan sættes
// synkront, og den eneste del af iOS-kravet en prøve kan se.
//
// `toHaveCount` og ikke `count()`: den første venter, den anden er et
// øjebliksbillede taget med det samme — og en SPA har ikke tegnet endnu, når
// `goto` vender tilbage, så den ville tælle nul uanset hvad koden gjorde.
await expect(page.locator('.sheet input[type="text"]')).toHaveCount(1);
await knap.click();
// Markøren er i skuffens felt — ikke i sidens eget.
const fokusIskuffen = await page.evaluate(
() => !!document.activeElement?.closest('.sheet')
);
expect(fokusIskuffen, 'markøren skal stå i skuffens felt efter trykket').toBe(true);
// Og det, der dikteres, bliver læst af den samme parser som alt andet.
const felt = page.locator('.sheet input[type="text"]');
await felt.fill('ring til tandlægen i dag p1');
await felt.press('Enter');
// "i dag", af samme grund som de andre prøver her siger det: dette er
// I dag-visningen, og en opgave uden dato eller med "i morgen" er korrekt
// fraværende fra den — hvilket ville ligne, at tilføjelsen var fejlet.
await expect(page.getByText('ring til tandlægen').first()).toBeVisible();
// Og skuffen lukker sig selv, så man ikke står med tastaturet oppe bagefter.
await expect(page.locator('.sheet.open')).toHaveCount(0);
expect(trouble).toEqual([]);
});
/**
* En talt opgave kan sætte sin egen alarm.
*
* Det var omvejen: en dikteret "i morgen kl 9" fik en tid og INGEN alarm, for
* ingenting skaber en påmindelse af sig selv — `CreateReminder` blev kun kaldt fra
* sit eget endepunkt. Så skulle man bagefter åbne opgaven, finde Påmindelser og
* vælge et klokkeslæt, og den omvej er længere end at skrive opgaven.
*
* Nu siger sætningen det selv, og det gælder uanset hvor stemmen kommer ind:
* tastaturets diktering, en genvej eller noget tredje ender som tekst gennem den
* samme parser.
*
* Prøven går hele vejen til skærmbilledet frem for at stoppe ved svaret, fordi
* forskydningen er negativ i lageret — det lægger den til forfaldstiden — og
* "-60 min." på skærmen er et fortegn, læseren selv skal oversætte.
*/
test('en talt opgave kan sætte sin egen alarm', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize(PHONE);
await page.goto('/');
await page.getByRole('button', { name: 'Tal en opgave ind' }).click();
const felt = page.locator('.sheet input[type="text"]');
// Set, før den sendes: alarmen lyser op som et forstået felt, ved siden af
// datoen. Det er hele pointen med fremvisningen — en parser, man ikke kan se
// arbejde, holder man op med at stole på, første gang den tager fejl.
await felt.fill('booke tandrens hos Lisa i morgen kl 9 mind mig en time før');
await expect(page.locator(".sheet mark[data-kind='reminder']")).toHaveText('mind mig en time før');
await felt.press('Enter');
await expect(page.locator('.sheet.open')).toHaveCount(0);
// Opgaven er i morgen, så den står ikke på I dag — se de andre prøver her.
// Et navn ingen anden prøve bruger, og det er ikke pynt: suiten kører med én
// arbejder mod ÉN database, så "ring til tandlægen" findes fire steder i denne
// fil, når denne prøve kommer til. `.first()` ramte en af de andres opgave, som
// ikke har nogen alarm — og kørt alene bestod prøven, fordi der kun var én.
await page.goto('/upcoming');
await page.getByText('booke tandrens hos Lisa').click();
// Og alarmen står der, skrevet som ord og ikke som et fortegn.
await expect(page.getByText('1 time før')).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En alarm, der ikke kan ringe, bliver sagt højt.
*
* "mind mig en time før" på en opgave med en dag men ingen klokketid har intet at
* være en time før: `DueReminders` vælger forskydningsformen kun
* `AND t.due_datetime IS NOT NULL`, så rækken ville stå i tabellen og aldrig fyre.
*
* Det er den ene slags påmindelse, hvis fejl intet bagefter afslører — der står
* ingen række at savne, og man opdager den først, da den ikke ringede. Derfor
* bliver opgaven oprettet og beskeden sagt, i stedet for at alarmen tabes i
* stilhed.
*/
test('en alarm uden klokkeslæt at regne tilbage fra bliver sagt højt', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.setViewportSize(PHONE);
await page.goto('/');
await page.getByRole('button', { name: 'Tal en opgave ind' }).click();
const felt = page.locator('.sheet input[type="text"]');
await felt.fill('hente pakken på posthuset i morgen mind mig en time før');
await felt.press('Enter');
await expect(page.getByText(/Alarmen er ikke/)).toBeVisible();
// Opgaven er lavet alligevel. Et forkert ord må ikke koste tanken.
await page.goto('/upcoming');
await expect(page.getByText('hente pakken på posthuset')).toBeVisible();
expect(trouble).toEqual([]);
});
test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => {
// Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer
// FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers
// begynder et tastetryk at skrive i en kasse, ingen kan se.
await page.setViewportSize(PHONE);
await page.goto('/');
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
await expect(page.locator('[data-quickadd]'), 'kun ét mærket felt').toHaveCount(1);
const mærketErIskuffen = await page.evaluate(
() => !!document.querySelector('[data-quickadd]')?.closest('.sheet')
);
expect(mærketErIskuffen, 'mærket skal sidde på sidens felt, ikke skuffens').toBe(false);
await page.keyboard.press('k');
await expect(page.locator('[data-quickadd]')).toHaveValue('k');
});
test('knappen findes ikke på en skrivebordsskærm', async ({ page }) => {
// Der er feltet fremme hele tiden og et tastatur at skrive på. En rund knap i
// bunden er til tommelfingeren.
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/');
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeHidden();
});
test('knappen findes ikke i urd-ansigtet', async ({ page }) => {
// Der er ingen opgaver at lægge noget i. Målt gennem døren frem for ved at
// skrue på en variabel, så det er den vej en bruger faktisk kommer.
await page.setViewportSize(PHONE);
await page.goto('/urd');
await expect(page.getByRole('navigation', { name: /Hovedmenu|Main menu/ })).toBeVisible();
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toHaveCount(0);
// Kontrollen: samme telefon, den anden dør, og så er den der.
await page.goto('/');
await expect(page.getByRole('button', { name: 'Tal en opgave ind' })).toBeVisible();
});