Release / Build and publish image (push) Failing after 9m58s
To ting, meldt ind fra en uge, der ikke svarede på det, man åbnede den for. **Dagene var ikke lige brede.** Gitteret stod på `repeat(7, 1fr)`, og `1fr` er `minmax(auto, 1fr)`: `auto` som minimum betyder, at en søjle ikke kan blive smallere end sit længste ord. Én opgave, der hed "Gårdbutikken på Møllerup Gods — opfølgning på mail", gjorde onsdag dobbelt så bred som mandag og klemte resten af ugen sammen om den. `minmax(0, 1fr)` lader søjlen blive så smal, den skal, og så klipper brikken sin tekst af — hvilket den allerede kunne. **Ugen var syv punktopstillinger.** Den svarede 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. Nu et gitter med en tidsrende og syv dage: timerne ned ad siden, begivenheder placeret efter klokkeslæt, og et bånd foroven til det, der ikke har et — en heldagsbegivenhed lagt klokken nul ville påstå, den holdt fra midnat til midnat. Overlappende ting deler bredden i sammenhængende klynger frem for parvist: overlapper A med B og B med C, skal alle tre dele, også når A og C ikke rører hinanden. Ellers ville A og C hver tro, de havde halvdelen, og lægge sig oven på hver sin halvdel af B. Hvilke timer der tegnes, kommer fra ugens egne begivenheder med arbejdsdagen som gulv. Et helt døgn er fireogtyve rækker, hvoraf de otte altid er tomme, og så er gitteret noget man ruller i frem for noget man ser — men en vagt klokken fem skal med, uden at alle andre uger betaler for den med tomme rækker. Måneden er ikke rørt ud over søjlebredden. En dag i en månedsrude er halvanden centimeter høj; et døgn tegnet i den er ikke en visning, det er en streg. Brikkerne er flyttet ud i to snippets, som begge visninger tegner med. De viser de samme to ting og skal vise dem ens; det er placeringen, der er forskellig, og så er det kun placeringen, der står to steder. `data-date` følger med over på ugens timesøjler — det er sådan noget uden for komponenten peger på en dag. Båndet får sit eget `data-allday`, så de to ikke bliver til det samme svar på ét spørgsmål. Co-Authored-By: Claude Opus 5 <[email protected]>
3213 lines
141 KiB
JavaScript
3213 lines
141 KiB
JavaScript
import { test, expect } from '@playwright/test';
|
|
import { USER } from './user.js';
|
|
|
|
/**
|
|
* The four flows a broken build must not survive: add a task, tick it off, reach
|
|
* every page in the sidebar, and open a task.
|
|
*
|
|
* Every test asserts on *rendered* content rather than on a URL. A route that no
|
|
* longer exists still answers 200 here — the Go binary serves the SPA shell for
|
|
* anything it does not recognise — so `expect(page).toHaveURL()` would pass on
|
|
* precisely the bug this suite exists to catch.
|
|
*/
|
|
|
|
/**
|
|
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
|
* or an API call that came back a failure.
|
|
*
|
|
* Failed responses are recorded with their URL and status. "A console error
|
|
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
|
* 500" is.
|
|
*/
|
|
/**
|
|
* Opens the project header's overflow menu and clicks one of its items.
|
|
*
|
|
* The header used to carry eight controls in one row — title, colour, show-done,
|
|
* three view buttons, share, history, delete — and a project name is not a short
|
|
* word, so the heading was what gave way. Everything but the view switcher moved
|
|
* behind one button; this is how a test reaches them.
|
|
*/
|
|
async function projectAction(page, name) {
|
|
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
|
// `exact` only means anything for a string. The share item reads "Delt · 2" once
|
|
// somebody is in the project, so callers pass a regex for that one.
|
|
const item =
|
|
name instanceof RegExp
|
|
? page.getByRole('menuitem', { name })
|
|
: page.getByRole('menuitem', { name, exact: true });
|
|
await item.click();
|
|
}
|
|
|
|
function watchForTrouble(page) {
|
|
const trouble = [];
|
|
page.on('console', (message) => {
|
|
// The browser logs its own line for every failed fetch. It carries no URL,
|
|
// so it is noise next to the response listener below.
|
|
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
|
trouble.push(message.text());
|
|
}
|
|
});
|
|
page.on('pageerror', (error) => trouble.push(String(error)));
|
|
page.on('response', (response) => {
|
|
const url = response.url();
|
|
if (!url.includes('/api/') || response.ok()) return;
|
|
|
|
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
|
// which is the correct answer on the sign-in page and after signing out.
|
|
// Every other non-ok response is worth failing over — that is what this
|
|
// watcher is for.
|
|
const path = new URL(url).pathname;
|
|
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
|
|
|
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
|
});
|
|
return trouble;
|
|
}
|
|
|
|
test('hurtig tilføjelse opretter en opgave, og den kan lukkes', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const box = page.getByLabel('Ny opgave');
|
|
// "i dag" rather than "i morgen": this is the Today view, and a task parsed
|
|
// onto tomorrow is correctly absent from it — which would look like the quick
|
|
// add had failed.
|
|
await box.fill('køb kaffe i dag p1');
|
|
await box.press('Enter');
|
|
|
|
// The server parses the line, so this asserts the whole round trip: "i dag"
|
|
// and "p1" are gone from the title because they became a date and a priority.
|
|
const task = page.getByText('køb kaffe', { exact: true });
|
|
await expect(task).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Markér som færdig' }).first().click();
|
|
await expect(task).toBeHidden();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
test('hvert link i sidebjælken fører et sted hen', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
|
|
// Wait for one link before counting them. `all()` is a snapshot with no
|
|
// auto-waiting — it returns whatever matches at that instant — so counting
|
|
// straight after goto() measures how fast the machine is. It passed on a
|
|
// laptop and found zero links on a CI runner.
|
|
await expect(sidebar.getByRole('link').first()).toBeVisible();
|
|
|
|
const hrefs = await sidebar
|
|
.getByRole('link')
|
|
.evaluateAll((links) => links.map((a) => a.getAttribute('href')));
|
|
expect(hrefs.length).toBeGreaterThanOrEqual(4); // I dag, Kommende, Indbakke, Indstillinger
|
|
|
|
// Re-resolved by href on each pass rather than held as handles: navigating
|
|
// re-renders the sidebar, and a handle from before the click is a handle to
|
|
// an element that no longer exists.
|
|
for (const href of hrefs) {
|
|
await sidebar.locator(`a[href="${href}"]`).first().click();
|
|
|
|
// Wait for the navigation before looking at the page. Without this the
|
|
// assertion can be satisfied by the heading of the page you just left —
|
|
// which is not a hypothetical: a change that removed a project page's
|
|
// heading entirely passed here on a fast machine and failed on CI.
|
|
await page.waitForURL(`**${href}`);
|
|
|
|
// Something with a heading rendered. A route written outside the routes
|
|
// tree lands on the SPA shell and renders nothing — which is exactly how
|
|
// the label route broke, and it answered 200 the whole time.
|
|
await expect(
|
|
page.locator('main h1, main h2').first(),
|
|
`${href} renderede ingenting`
|
|
).toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
test('en opgave kan åbnes og redigeres', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const box = page.getByLabel('Ny opgave');
|
|
// Dated, so it lands in this view: an undated task goes to the Inbox and is
|
|
// correctly absent here.
|
|
await box.fill('skriv rapport i dag');
|
|
await box.press('Enter');
|
|
|
|
await page.getByText('skriv rapport', { exact: true }).click();
|
|
|
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
|
await expect(drawer).toBeVisible();
|
|
|
|
// A sub-task, a comment and a description at once — which is also the shape
|
|
// that used to fail: three concurrent writes met SQLite's deferred BEGIN and
|
|
// one of them came back a 500.
|
|
await drawer.getByLabel('Beskrivelse').fill('Inden fredag.');
|
|
await drawer.getByLabel('Ny undertask').fill('lav dispositionen');
|
|
await drawer.getByLabel('Ny undertask').press('Enter');
|
|
await drawer.getByLabel('Ny kommentar').fill('Anders har materialet.');
|
|
await drawer.getByRole('button', { name: 'Skriv' }).click();
|
|
|
|
await expect(drawer.getByText('lav dispositionen')).toBeVisible();
|
|
await expect(drawer.getByText('Anders har materialet.')).toBeVisible();
|
|
|
|
// Nothing rolled back: a toast here means the server refused a write.
|
|
await expect(page.locator('.toast')).toHaveCount(0);
|
|
|
|
await page.keyboard.press('Escape');
|
|
await expect(drawer).toBeHidden();
|
|
|
|
// The description reached the row behind the drawer, so it was really saved.
|
|
await expect(page.getByText('Inden fredag.')).toBeVisible();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
test('hver fane under indstillinger renderer', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/indstillinger');
|
|
|
|
const tabs = page.getByRole('navigation', { name: 'Indstillinger' });
|
|
await expect(tabs).toBeVisible();
|
|
|
|
for (const label of [
|
|
'Konto',
|
|
'Notifikationer',
|
|
'Integrationer',
|
|
'AI',
|
|
'API-tokens',
|
|
// Only an administrator sees these two, and the account these tests run as
|
|
// is the first account, which is one.
|
|
'Brugere',
|
|
'Historik',
|
|
'Fejl',
|
|
'Data og skabeloner'
|
|
]) {
|
|
await tabs.getByRole('link', { name: label, exact: true }).click();
|
|
await expect(
|
|
page.locator('section.panel').first(),
|
|
`fanen ${label} renderede ingen sektion`
|
|
).toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The PWA's own files. Both of the bugs found last in this project were of this
|
|
* kind — a manifest promising icons that were never generated — and they are
|
|
* invisible to every other test in the repository.
|
|
*/
|
|
test('manifestet og service workeren findes, og ikonerne findes også', async ({ request }) => {
|
|
const manifestResponse = await request.get('/manifest.webmanifest');
|
|
expect(manifestResponse.ok()).toBeTruthy();
|
|
|
|
const manifest = await manifestResponse.json();
|
|
for (const icon of manifest.icons ?? []) {
|
|
const iconResponse = await request.get(icon.src);
|
|
expect(iconResponse.ok(), `${icon.src} findes ikke`).toBeTruthy();
|
|
}
|
|
|
|
// Safari never reads the manifest's icons; it looks for this by name, and
|
|
// without it an installed PWA on iOS gets a screenshot of the page as its
|
|
// home-screen icon.
|
|
const appleIcon = await request.get('/apple-touch-icon.png');
|
|
expect(appleIcon.ok(), 'apple-touch-icon.png findes ikke').toBeTruthy();
|
|
|
|
// The service worker is what Web Push needs; without it a subscription cannot
|
|
// be created at all.
|
|
const worker = await request.get('/sw.js');
|
|
expect(worker.ok()).toBeTruthy();
|
|
expect(await worker.text()).toContain("addEventListener('push'");
|
|
});
|
|
|
|
/**
|
|
* A project can be renamed, deleted and brought back.
|
|
*
|
|
* The round trip matters more than the three steps: deleting used to be a
|
|
* one-way door in the interface — the API could restore, but only from an id
|
|
* that had already disappeared from the screen.
|
|
*/
|
|
test('et projekt kan omdøbes, slettes og hentes tilbage', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
await sidebar.getByLabel('Projektnavn').fill('Ferie');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
await expect(page.getByRole('heading', { name: 'Ferie' })).toBeVisible();
|
|
|
|
// The heading is the rename affordance: click it, and it becomes a field.
|
|
await page.getByRole('button', { name: 'Ferie' }).click();
|
|
const title = page.getByLabel('Projektets navn');
|
|
await title.fill('Sommerferie');
|
|
await title.press('Enter');
|
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
|
|
|
page.once('dialog', (dialog) => dialog.accept());
|
|
await projectAction(page, 'Slet projektet');
|
|
|
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeHidden();
|
|
|
|
// And it is reachable again, which is the half that did not exist.
|
|
await page.goto('/indstillinger/data');
|
|
const trash = page.locator('section.panel').filter({ hasText: 'Papirkurv' });
|
|
await expect(trash.getByText('Sommerferie')).toBeVisible();
|
|
|
|
await trash.getByRole('button', { name: 'Hent tilbage' }).click();
|
|
await expect(sidebar.getByRole('link', { name: 'Sommerferie' })).toBeVisible();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A group can be made, folded, filled and deleted without taking its projects
|
|
* with it.
|
|
*
|
|
* The fold is the part worth driving in a browser: it is stored on the account
|
|
* rather than in localStorage, so it is a round trip, and "the arrow turned" is
|
|
* not the same claim as "it came back folded after a reload".
|
|
*/
|
|
test('en projektgruppe kan foldes, fyldes og slettes uden at tage projekterne med', async ({
|
|
page
|
|
}) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
await sidebar.getByLabel('Projektnavn').fill('Regnskab');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
await expect(sidebar.getByRole('link', { name: 'Regnskab' })).toBeVisible();
|
|
|
|
await sidebar.getByRole('button', { name: 'Ny gruppe' }).click();
|
|
await sidebar.getByLabel('Gruppens navn').fill('Arbejde');
|
|
await sidebar.getByLabel('Gruppens navn').press('Enter');
|
|
|
|
// The name is a link to the group's own page; the chevron beside it is a
|
|
// separate button that folds. Two intentions, two targets — the single one they
|
|
// used to share could only ever serve the smaller.
|
|
//
|
|
// `exact` on the link, because a group's name is a user-chosen word that can
|
|
// turn up inside another control's name, and Playwright matches an accessible
|
|
// name by substring unless told otherwise.
|
|
const heading = sidebar.getByRole('link', { name: 'Arbejde', exact: true });
|
|
const fold = sidebar.getByRole('button', { name: /^Fold Arbejde/ });
|
|
await expect(heading).toBeVisible();
|
|
await expect(sidebar.getByText('Træk et projekt herop')).toBeVisible();
|
|
|
|
// A group's name must be readable in full, and must start where a project's
|
|
// name starts.
|
|
//
|
|
// Both halves are here because both were once broken at the same time and for
|
|
// different reasons. "Omdøb" and "Slet" sat in the flow reserving their width
|
|
// even while invisible, so ARBEJDE was drawn as "ARBE"; and the fold chevron
|
|
// sat before the name, pushing a group 22px further in than the projects it
|
|
// contains — so a group read as if it were filed under them. Every assertion
|
|
// in this file passed through both, because both buttons still worked.
|
|
const shape = await sidebar.locator('.folder-head').evaluate((el) => {
|
|
const link = el.querySelector('h2 a');
|
|
const name = { ...link.querySelector('.dot').getBoundingClientRect().toJSON() };
|
|
// The last child of the link is the name itself; the dot is a span before it.
|
|
return {
|
|
clipped: link.scrollWidth > link.clientWidth + 1,
|
|
left: name.left,
|
|
right: link.getBoundingClientRect().right,
|
|
count: el.querySelector('.count')?.getBoundingClientRect().left ?? Infinity
|
|
};
|
|
});
|
|
|
|
expect(shape.clipped, 'gruppens navn bliver klippet').toBe(false);
|
|
expect(shape.count, 'navnet og tallet overlapper').toBeGreaterThanOrEqual(shape.right);
|
|
|
|
// Measured dot to dot, not box to box: both rows begin with the same coloured
|
|
// mark, and it is the only thing in them that means the same on both sides.
|
|
// Comparing the group's inner link with the project's outer box reported the
|
|
// row's own padding as a misalignment.
|
|
const projectLeft = await sidebar
|
|
.getByRole('link', { name: 'Regnskab' })
|
|
.evaluate((el) => el.querySelector('.dot').getBoundingClientRect().left);
|
|
expect(
|
|
Math.abs(shape.left - projectLeft),
|
|
`gruppen starter ${Math.round(shape.left - projectLeft)}px fra projekterne`
|
|
).toBeLessThanOrEqual(2);
|
|
|
|
// 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'
|
|
);
|
|
|
|
// En foldet gruppe står med sin chevron fremme permanent, og den lå oven i
|
|
// prikken med et felt i --surface bag sig — en lys firkant ud for navnet på
|
|
// den sænkede bjælke, hele tiden. Nu tones prikken ud i stedet, så der er
|
|
// ingenting at male henover den med.
|
|
const chevron = await sidebar
|
|
.locator('.folder-head .chevron-button')
|
|
.evaluate((el) => ({
|
|
background: getComputedStyle(el).backgroundColor,
|
|
dot: getComputedStyle(el.closest('.folder-head').querySelector('.group-dot')).opacity
|
|
}));
|
|
expect(chevron.background, 'chevronen har en firkant bag sig').toBe('rgba(0, 0, 0, 0)');
|
|
expect(chevron.dot, 'prikken ligger stadig under chevronen').toBe('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');
|
|
|
|
const painted = await sidebar
|
|
.locator('.folder-head .group-dot')
|
|
.evaluate((el) => getComputedStyle(el).backgroundColor);
|
|
expect(painted, 'gruppens prik fik ikke sin farve').not.toBe('rgba(0, 0, 0, 0)');
|
|
|
|
// The heading itself is *not* indented — only what is filed under it. A heading
|
|
// that starts further in than the rows above it says the heading is inside
|
|
// something, which it is not.
|
|
const headLeft = await sidebar
|
|
.locator('.folder-head')
|
|
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
|
const looseLeft = await sidebar
|
|
.locator('.views a')
|
|
.first()
|
|
.evaluate((el) => el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft));
|
|
expect(headLeft, 'gruppehovedet er rykket ind').toBeLessThanOrEqual(looseLeft);
|
|
|
|
// A project inside a group starts further in than a row outside one. Measured
|
|
// rather than asserted on a class, because indentation is a fact about where
|
|
// the row's contents land — and measured at the *content* edge, not the box:
|
|
// the indent is padding, so the element's own left edge does not move.
|
|
await sidebar.getByRole('link', { name: 'Regnskab' }).dragTo(heading);
|
|
const contentLeft = (el) =>
|
|
el.getBoundingClientRect().left + parseFloat(getComputedStyle(el).paddingLeft);
|
|
const [inside, outside] = await Promise.all([
|
|
sidebar.locator('.folder > a').first().evaluate(contentLeft),
|
|
sidebar.locator('.views a').first().evaluate(contentLeft)
|
|
]);
|
|
expect(inside, 'et projekt i en gruppe er ikke rykket ind').toBeGreaterThan(outside);
|
|
|
|
await sidebar
|
|
.getByRole('link', { name: 'Regnskab' })
|
|
.dragTo(sidebar.getByRole('heading', { name: 'Projekter' }));
|
|
|
|
// Renaming writes one row rather than one per project, which is the reason a
|
|
// group is a table and not a string repeated on every project.
|
|
await sidebar.getByRole('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 says so, and offers to try again.
|
|
await page.route('**/api/v1/projects/*', (r) => r.abort());
|
|
await page.reload();
|
|
await expect(page.getByText('Serveren svarede ikke')).toBeVisible();
|
|
await expect(page.getByRole('button', { name: 'Prøv igen' })).toBeVisible();
|
|
await page.unroute('**/api/v1/projects/*');
|
|
await page.getByRole('button', { name: 'Prøv igen' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
|
|
|
expect(trouble.filter((t) => !t.includes('projects'))).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A task can be dragged onto another day, and onto another project.
|
|
*
|
|
* Both are one gesture standing in for a form, and both cross a component
|
|
* boundary: the row is dragged out of Kommende and dropped on the sidebar, which
|
|
* is a different part of the tree entirely. What decides whether a target will
|
|
* take it is the drag's MIME type, and that is only readable while the drag is in
|
|
* the air — so this is the one thing here that cannot be checked any other way.
|
|
*/
|
|
test('en opgave kan trækkes til en anden dag og videre til et projekt', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const box = page.getByLabel('Ny opgave');
|
|
await box.fill('hent pakken i dag');
|
|
await box.press('Enter');
|
|
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
await sidebar.getByLabel('Projektnavn').fill('Ærinder');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
// Waited for before navigating away, or the create can lose its own redirect.
|
|
await expect(sidebar.getByRole('link', { name: 'Ærinder' })).toBeVisible();
|
|
|
|
await page.goto('/upcoming');
|
|
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
|
|
|
const day = (name) =>
|
|
page.locator('section').filter({ has: page.getByRole('heading', { name, exact: true }) });
|
|
const row = page.locator('[draggable="true"]').filter({ hasText: 'hent pakken' });
|
|
|
|
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
|
|
await row.dragTo(day('I morgen'));
|
|
await expect(day('I morgen').getByText('hent pakken')).toBeVisible();
|
|
await expect(day('I dag').getByText('hent pakken')).toBeHidden();
|
|
|
|
// Onto "I dag" in the sidebar: the most-made rescheduling there is, and the
|
|
// sidebar is where the pointer already is. "Kommende" deliberately does not
|
|
// take one — it is a range, not a day, so a drop would have to invent a date
|
|
// its label does not name.
|
|
await row.dragTo(sidebar.getByRole('link', { name: 'I dag' }));
|
|
await expect(day('I dag').getByText('hent pakken')).toBeVisible();
|
|
await row.dragTo(day('I morgen'));
|
|
|
|
// And onto a project, which is a move rather than a field: the task has to
|
|
// find a place among that project's tasks, and leave its old section behind.
|
|
await row.dragTo(sidebar.getByRole('link', { name: 'Ærinder' }));
|
|
await sidebar.getByRole('link', { name: 'Ærinder' }).click();
|
|
await expect(page.getByRole('heading', { name: 'Ærinder' })).toBeVisible();
|
|
await expect(page.getByText('hent pakken', { exact: true })).toBeVisible();
|
|
|
|
// And the same task in the month grid, where a chip is dragged from one cell
|
|
// to another. The date is read off the grid rather than worked out here: the
|
|
// browser is pinned to Europe/Copenhagen and this process is not, and for two
|
|
// hours of every day they disagree about what today is.
|
|
await page.goto('/upcoming');
|
|
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
|
|
|
const chip = page.locator('.chip').filter({ hasText: 'hent pakken' });
|
|
await expect(chip).toBeVisible();
|
|
|
|
const from = await page.locator('.day').filter({ has: chip }).getAttribute('data-date');
|
|
const to = new Date(new Date(`${from}T12:00:00Z`).getTime() + 86400000)
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
|
|
await chip.dragTo(page.locator(`[data-date="${to}"]`));
|
|
await expect(page.locator(`[data-date="${to}"]`).getByText('hent pakken')).toBeVisible();
|
|
await expect(page.locator(`[data-date="${from}"]`).getByText('hent pakken')).toBeHidden();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* An invite link creates the account it was sent to.
|
|
*
|
|
* The server has emailed this URL since invites were built and nothing rendered
|
|
* for it: the SPA fallback served the shell, the shell found no session, and the
|
|
* recipient got a plain sign-in form for an account that did not exist yet. A 200
|
|
* all the way down — the same shape as `/mcp` and `/.well-known/`, and invisible
|
|
* to every test that does not open the link.
|
|
*
|
|
* A second browser context, because the invitee is a different person: inheriting
|
|
* the signed-in session would test the one case that never happens.
|
|
*/
|
|
test('et invitationslink opretter kontoen og giver adgang til projektet', async ({
|
|
browser,
|
|
page
|
|
}) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
await sidebar.getByLabel('Projektnavn').fill('Fælleshuset');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
await expect(page.getByRole('heading', { name: 'Fælleshuset' })).toBeVisible();
|
|
|
|
await projectAction(page, 'Del');
|
|
await page.getByLabel('Inviter via e-mail').fill('[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]');
|
|
await page.getByLabel('Rolle').selectOption('viewer');
|
|
await page.getByRole('button', { name: 'Inviter' }).click();
|
|
const link = await page.locator('.link-out code').textContent();
|
|
|
|
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
|
const andreas = await context.newPage();
|
|
await andreas.goto(link);
|
|
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
|
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
|
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
|
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
|
|
await andreas
|
|
.getByRole('navigation', { name: 'Hovedmenu' })
|
|
.getByRole('link', { name: /Fælles/ })
|
|
.click();
|
|
// A viewer sees the project and is told, plainly, that it is read-only.
|
|
await expect(andreas.getByText('Du kan se dette projekt, men ikke ændre det')).toBeVisible();
|
|
await expect(andreas.getByLabel('Ny opgave')).toHaveCount(0);
|
|
|
|
// The owner promotes them from the member list. The owner's own row is text,
|
|
// not a dropdown: ownership is transferred, not granted.
|
|
await page.reload();
|
|
await projectAction(page, /Delt/);
|
|
await expect(page.getByText('Ejer')).toBeVisible();
|
|
await page.getByLabel('Rolle for andreas').selectOption('editor');
|
|
|
|
await andreas.reload();
|
|
await expect(andreas.getByLabel('Ny opgave')).toBeVisible();
|
|
await expect(andreas.getByText('Du kan se dette projekt')).toHaveCount(0);
|
|
|
|
// Now that they can edit, hand them something — and the row says whose it is.
|
|
// Only when it is somebody else's: your own list would otherwise carry your own
|
|
// face on every line, which is a column of one initial telling you nothing.
|
|
await page.getByLabel('Ny opgave').fill('brænde kaffe');
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await page.getByText('brænde kaffe', { exact: true }).click();
|
|
const drawer = page.getByRole('complementary', { name: 'Opgave' });
|
|
await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' });
|
|
await page.keyboard.press('Escape');
|
|
|
|
const row = page.locator('.row').filter({ hasText: 'brænde kaffe' });
|
|
await expect(row.getByTitle('Ansvarlig: andreas')).toBeVisible();
|
|
// And not on a task that is nobody's.
|
|
await page.getByLabel('Ny opgave').fill('min egen');
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await expect(
|
|
page.locator('.row').filter({ hasText: 'min egen' }).locator('.assignee')
|
|
).toHaveCount(0);
|
|
|
|
// The log has recorded all of this since the beginning and nothing showed it.
|
|
await projectAction(page, 'Historik');
|
|
await expect(page.getByText('oprettede projektet')).toBeVisible();
|
|
await expect(page.getByText('ændrede rollen for')).toBeVisible();
|
|
|
|
await context.close();
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A task dragged across a month boundary.
|
|
*
|
|
* The month grid could not do this. It is anchored to a month, so the two days on
|
|
* either side of its edge sit in different grids, and getting from one to the other
|
|
* means paging — which cannot be done mid-drag, because a drag in flight swallows
|
|
* the click that would page it. The week view exists for exactly this: a week that
|
|
* straddles the 31st and the 1st has both days in the same row.
|
|
*
|
|
* The dates are worked out in the browser, not here. Playwright pins the page to
|
|
* Europe/Copenhagen and this process is not pinned to anything, so for two hours of
|
|
* every day the two disagree about what today is.
|
|
*/
|
|
test('en opgave kan trækkes hen over et månedsskifte i uge-visningen', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
// The first day, starting tomorrow, whose next day is in another month *and* in
|
|
// the same Monday-to-Sunday week. Such a pair exists at every month boundary
|
|
// except one that falls between a Sunday and a Monday, so searching forward
|
|
// finds one within about two months whatever today is.
|
|
const { from, to } = await page.evaluate(() => {
|
|
const iso = (d) =>
|
|
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
|
|
d.getDate()
|
|
).padStart(2, '0')}`;
|
|
const monday = (d) => {
|
|
const m = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
m.setDate(m.getDate() - ((m.getDay() + 6) % 7));
|
|
return iso(m);
|
|
};
|
|
const day = new Date();
|
|
for (let i = 0; i < 90; i++) {
|
|
day.setDate(day.getDate() + 1);
|
|
const next = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
|
|
if (day.getMonth() !== next.getMonth() && monday(day) === monday(next)) {
|
|
return { from: iso(day), to: iso(next) };
|
|
}
|
|
}
|
|
throw new Error('no month boundary inside a week in the next 90 days');
|
|
});
|
|
|
|
const box = page.getByLabel('Ny opgave');
|
|
await box.fill(`skifte dæk ${from}`);
|
|
await box.press('Enter');
|
|
// Not asserted here: this is the Today view and the task is dated weeks out, so
|
|
// it is correctly absent. The week grid below is where it has to turn up.
|
|
await expect(box).toHaveValue('');
|
|
|
|
await page.goto('/upcoming');
|
|
await page.getByRole('button', { name: 'Uge', exact: true }).click();
|
|
|
|
// Forward a week at a time until the row is the one holding that date. Bounded,
|
|
// because a loop that pages forever on a broken button is a timeout with no
|
|
// explanation in it.
|
|
const cell = page.locator(`[data-date="${from}"]`);
|
|
const next = page.getByRole('button', { name: 'Næste uge' });
|
|
for (let i = 0; i < 15 && (await cell.count()) === 0; i++) {
|
|
await next.click();
|
|
}
|
|
await expect(cell, `uge-visningen nåede aldrig frem til ${from}`).toBeVisible();
|
|
|
|
// The claim the view is for: both sides of the month boundary, in one row.
|
|
await expect(page.locator(`[data-date="${to}"]`)).toBeVisible();
|
|
expect(from.slice(0, 7), 'de to dage skulle ligge i hver sin måned').not.toEqual(to.slice(0, 7));
|
|
|
|
const chip = page.locator('.chip').filter({ hasText: 'skifte dæk' });
|
|
await expect(chip).toBeVisible();
|
|
// 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 } });
|
|
|
|
// Sluppet på timesøjlen, landet i heldagsbåndet — og det er det rigtige sted.
|
|
// Et træk hen på en dag flytter datoen og sætter ikke et klokkeslæt, så opgaven
|
|
// har ingen tid at stå ved. `data-allday` er den anden halvdel af dagen: siden
|
|
// ugen blev et døgn, er en dag to kasser, og en prøve skal sige hvilken.
|
|
await expect(page.locator(`[data-allday="${to}"]`).getByText('skifte dæk')).toBeVisible();
|
|
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 bånd: et træk hen på en dag flytter datoen og sætter ikke
|
|
// et klokkeslæt, så opgaven har ingen tid at stå ved.
|
|
await expect(page.locator(`[data-allday="${next}"]`).getByText('vande blomster')).toBeVisible();
|
|
await expect(band.getByText('vande blomster')).toBeHidden();
|
|
// The events stayed where they were; a rescheduled task moves nothing else.
|
|
await expect(cell.locator('.tevent')).toHaveCount(1);
|
|
await expect(band.locator('.event')).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('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.
|
|
await sidebar
|
|
.getByRole('link', { name: 'Drejebænk' })
|
|
.dragTo(sidebar.getByRole('link', { name: 'Værksted' }));
|
|
await sidebar.getByRole('link', { name: 'Værksted' }).click();
|
|
|
|
await expect(page.getByRole('heading', { name: 'Værksted', level: 1 })).toBeVisible();
|
|
const row = page.locator('.projects a').filter({ hasText: 'Drejebænk' });
|
|
await expect(row).toBeVisible();
|
|
await expect(row.getByText('1', { exact: true }), 'antallet af åbne opgaver').toBeVisible();
|
|
|
|
// The description, which is the part a heading cannot carry. Click to write,
|
|
// blur to save — the same shape a task's description has.
|
|
await page.getByRole('button', { name: /Skriv hvad det her er/ }).click();
|
|
const about = page.getByLabel('Om gruppen');
|
|
await expect(about).toBeVisible();
|
|
await about.fill('Alt der larmer og støver.');
|
|
await about.blur();
|
|
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
|
|
|
// And it survives a reload, which is the difference between the page keeping up
|
|
// and the server taking it.
|
|
await page.reload();
|
|
await expect(page.getByText('Alt der larmer og støver.')).toBeVisible();
|
|
await expect(page.locator('.projects a').filter({ hasText: 'Drejebænk' })).toBeVisible();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The account's language changes the interface, not only the parser.
|
|
*
|
|
* `locale` has been on the account since the beginning and only chose which
|
|
* grammar quick add read a line with — so picking English changed nothing anybody
|
|
* could see, and the field said so honestly: "Sprog i hurtig tilføjelse".
|
|
*
|
|
* Asserted on both directions and on a date, because the date formatter is the
|
|
* half that is easy to leave behind: `toLocaleDateString('da-DK', …)` was written
|
|
* into a dozen components, and an English interface still saying "24. aug." is the
|
|
* shape of a half-finished translation.
|
|
*
|
|
* It puts Danish back at the end. Everything after this reads Danish labels, and a
|
|
* test that leaves the account in English fails the ones that follow it rather
|
|
* than itself — which is the worst way for a suite to report anything.
|
|
*/
|
|
test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/indstillinger');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeVisible();
|
|
|
|
await page.getByLabel('Sprog').selectOption('en');
|
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
|
|
|
// The sidebar redraws without a reload: i18n.locale is $state, which is the
|
|
// whole reason that module is .svelte.js.
|
|
const english = page.getByRole('navigation', { name: 'Main menu' });
|
|
await expect(english.getByRole('link', { name: 'Today' })).toBeVisible();
|
|
await expect(english.getByRole('link', { name: 'Upcoming' })).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
|
|
|
|
// And it survives a reload, which is the difference between the page keeping up
|
|
// and the account actually holding it.
|
|
await page.reload();
|
|
await expect(
|
|
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Today' })
|
|
).toBeVisible();
|
|
|
|
// A date, in the half that is easy to leave in Danish. The month grid's heading
|
|
// is `toLocaleDateString`, which was `'da-DK'` in a dozen components before
|
|
// `tag()` existed — an English interface still saying "december" is exactly the
|
|
// shape of a half-finished translation.
|
|
await page.goto('/upcoming');
|
|
await page.getByRole('button', { name: 'Month', exact: true }).click();
|
|
await expect(page.locator('.calendar h2')).not.toContainText(
|
|
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
|
);
|
|
|
|
// The Inbox is named by the server at account creation, before anybody has seen
|
|
// a settings page — so it is the one project name nobody chose, and it follows
|
|
// the language like a label. Rename it and that becomes your name for it in
|
|
// every language, the same rule as any other project.
|
|
await expect(
|
|
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Inbox' })
|
|
).toBeVisible();
|
|
|
|
// Danish again, for everything that runs after this.
|
|
await page.goto('/indstillinger');
|
|
await page.getByLabel('Language').selectOption('da');
|
|
await page.getByRole('button', { name: 'Save' }).first().click();
|
|
await expect(
|
|
page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'I dag' })
|
|
).toBeVisible();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* Finished tasks can be looked at, and are out of the way by default.
|
|
*
|
|
* A task list is what is left to do, so closing something has to remove it from
|
|
* the plan — but "what did I get done" is a real question and the answer was
|
|
* nowhere. They come back in one list at the bottom rather than back among the
|
|
* open ones in their sections: a closed task has stopped being work and started
|
|
* being a record, and putting the record back in the middle makes the plan longer
|
|
* without making it say more.
|
|
*
|
|
* The setting is in localStorage, so this also checks it survives a reload — the
|
|
* half that a toggle held only in component state would fail.
|
|
*/
|
|
test('færdige opgaver kan vises og skjules igen', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
await sidebar.getByLabel('Projektnavn').fill('Loftet');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
await expect(page.getByRole('heading', { name: 'Loftet' })).toBeVisible();
|
|
|
|
for (const what of ['rydde op', 'male gavlen']) {
|
|
await page.getByLabel('Ny opgave').fill(what);
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await expect(page.getByText(what, { exact: true })).toBeVisible();
|
|
}
|
|
|
|
// Close one. It leaves the list, which is the behaviour being protected here.
|
|
await page
|
|
.locator('.row')
|
|
.filter({ hasText: 'rydde op' })
|
|
.getByRole('button', { name: 'Markér som færdig' })
|
|
.click();
|
|
await expect(page.getByText('rydde op', { exact: true })).toBeHidden();
|
|
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
|
|
|
// And it can be looked at again, under its own heading at the bottom.
|
|
await projectAction(page, 'Vis færdige');
|
|
const done = page.locator('section.done');
|
|
await expect(done.getByRole('heading', { name: 'Færdige' })).toBeVisible();
|
|
await expect(done.getByText('rydde op', { exact: true })).toBeVisible();
|
|
// Still exactly where it was in the plan — reopened tasks are the only ones
|
|
// that come back up.
|
|
await expect(done.getByText('male gavlen', { exact: true })).toHaveCount(0);
|
|
|
|
// The choice is in localStorage, so it holds across a reload.
|
|
await page.reload();
|
|
await expect(page.locator('section.done').getByText('rydde op', { exact: true })).toBeVisible();
|
|
|
|
await projectAction(page, 'Skjul færdige');
|
|
await expect(page.locator('section.done')).toHaveCount(0);
|
|
await expect(page.getByText('male gavlen', { exact: true })).toBeVisible();
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A section holds more than one task, and stops being highlighted afterwards.
|
|
*
|
|
* Both reported from use, and both the same root cause. TaskList read the dragged
|
|
* task from its *own* `draggingId`, which is only set when the drag started in
|
|
* that list — so a task dragged in from the unsectioned rows arrived as null and
|
|
* the handler returned early. It had already called `stopPropagation`, so the
|
|
* section around it never got the drop either.
|
|
*
|
|
* The result was a section you could only drop into while it was empty, because
|
|
* an empty one has no rows to aim at. Put one task in, and there was no way to add
|
|
* a second: it looked exactly like a section that holds one task.
|
|
*
|
|
* The same `stopPropagation` left the section's frame lit, since the handler that
|
|
* clears it never ran.
|
|
*/
|
|
test('en sektion kan rumme flere opgaver, og rammen forsvinder bagefter @forms', async ({
|
|
page
|
|
}) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
await sidebar.getByLabel('Projektnavn').fill('Badeværelset');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
await expect(page.getByRole('heading', { name: 'Badeværelset' })).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
|
await page.getByLabel('Ny sektion').fill('Håndværker');
|
|
await page.getByLabel('Ny sektion').press('Enter');
|
|
await expect(page.getByRole('heading', { name: 'Håndværker' })).toBeVisible();
|
|
|
|
for (const what of ['knager op', 'fuge om vinduet']) {
|
|
await page.getByLabel('Ny opgave').fill(what);
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await expect(page.getByText(what, { exact: true })).toBeVisible();
|
|
}
|
|
|
|
const section = page.locator('section').filter({
|
|
has: page.getByRole('heading', { name: 'Håndværker' })
|
|
});
|
|
const row = (what) => page.locator('.row').filter({ hasText: what });
|
|
|
|
// The first goes in while the section is empty — the only drop that used to
|
|
// work, because there was no row in the way to swallow it.
|
|
await row('knager op').dragTo(section);
|
|
await expect(section.getByText('knager op', { exact: true })).toBeVisible();
|
|
|
|
// And the second, aimed at the row that is now in there. This is the one that
|
|
// did nothing at all.
|
|
await row('fuge om vinduet').dragTo(section.getByText('knager op', { exact: true }));
|
|
await expect(section.getByText('fuge om vinduet', { exact: true })).toBeVisible();
|
|
await expect(section.locator('.row')).toHaveCount(2);
|
|
|
|
// The frame is a drop target being offered, not a state. Once the drag is over
|
|
// it has nothing left to say.
|
|
await expect(section).not.toHaveClass(/\bover\b/);
|
|
|
|
// It survives a reload, so this is the server's answer and not the page's.
|
|
await page.reload();
|
|
const after = page.locator('section').filter({
|
|
has: page.getByRole('heading', { name: 'Håndværker' })
|
|
});
|
|
await expect(after.locator('.row')).toHaveCount(2);
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The project header keeps its name readable.
|
|
*
|
|
* It was a flex row of eight controls and a heading, and the heading was the part
|
|
* that gave way: "GarageRisteriet" rendered as "GarageRist / eriet", broken
|
|
* mid-word, because `overflow-wrap: anywhere` breaks at the first opportunity
|
|
* rather than the last necessary one. A name split mid-syllable reads as a
|
|
* rendering fault, not as a wrap.
|
|
*
|
|
* Everything but the view switcher lives behind one button now. This measures the
|
|
* heading rather than counting controls, because the thing being protected is that
|
|
* the name is legible — not any particular arrangement of the row.
|
|
*/
|
|
test('projektets navn brydes ikke midt i et ord', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
await sidebar.getByLabel('Projektnavn').fill('GarageRisteriet');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
|
|
const heading = page.getByRole('heading', { name: 'GarageRisteriet', level: 1 });
|
|
await expect(heading).toBeVisible();
|
|
|
|
// One line. The name is fifteen characters and the row now has room for it,
|
|
// which is the whole point of moving the rest behind a button.
|
|
const box = await heading.boundingBox();
|
|
const lineHeight = await heading.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
|
|
expect(box.height, 'overskriften er brudt over flere linjer').toBeLessThan(lineHeight * 2);
|
|
|
|
// And the actions are still reachable, just one click further in.
|
|
await page.getByRole('button', { name: 'Flere handlinger' }).click();
|
|
for (const item of ['Vis færdige', 'Del', 'Historik', 'Farve på projektet', 'Slet projektet']) {
|
|
await expect(page.getByRole('menuitem', { name: item, exact: true })).toBeVisible();
|
|
}
|
|
|
|
// Escape closes it — a menu you can only leave by pressing its own button again
|
|
// is one you click twice to get out of.
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.getByRole('menuitem', { name: 'Historik', exact: true })).toHaveCount(0);
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A task can be written straight into a section, two ways.
|
|
*
|
|
* The box at the top of the page puts things in the project with no section, which
|
|
* is right for capturing — but a project has sections because the work belongs
|
|
* somewhere, and dragging every new task down into the right one is a second
|
|
* gesture for something you already knew when you typed it.
|
|
*
|
|
* The field at the foot of a section passes the section it sits in. The sigil is
|
|
* for the box at the top, and for Today and Upcoming, where you are not standing
|
|
* in a section at all.
|
|
*/
|
|
test('en opgave kan skrives direkte i en sektion @forms', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
|
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
|
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
|
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
|
|
|
// A section whose name contains the sigil, because that is the one that would
|
|
// break: the bare form has to be greedy enough to swallow the whole name.
|
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
|
await page.getByLabel('Ny sektion').fill('Kunder/Ordrer');
|
|
await page.getByLabel('Ny sektion').press('Enter');
|
|
await expect(page.getByRole('heading', { name: 'Kunder/Ordrer' })).toBeVisible();
|
|
|
|
const section = page.locator('section').filter({
|
|
has: page.getByRole('heading', { name: 'Kunder/Ordrer' })
|
|
});
|
|
|
|
// The field at the foot of the section. No sigil — standing there is enough.
|
|
await section.getByRole('button', { name: '+ Tilføj opgave' }).click();
|
|
const field = page.getByLabel('Ny opgave i Kunder/Ordrer');
|
|
await field.fill('Karpenhøj bestilling i morgen p1');
|
|
await field.press('Enter');
|
|
|
|
const landed = section.locator('.row').filter({ hasText: 'Karpenhøj bestilling' });
|
|
await expect(landed).toBeVisible();
|
|
// It went through quick add, so the date and the priority were read as well.
|
|
await expect(landed.getByText('I morgen')).toBeVisible();
|
|
|
|
// And the sigil, from the box at the top, which is not standing anywhere.
|
|
await page.getByLabel('Ny opgave').fill('nye poser /Kunder/Ordrer');
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
await expect(section.locator('.row').filter({ hasText: 'nye poser' })).toBeVisible();
|
|
// The sigil is consumed, not left in the title.
|
|
await expect(section.getByText('/Kunder/Ordrer')).toHaveCount(0);
|
|
|
|
await expect(section.locator('.row')).toHaveCount(2);
|
|
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A task closed by mistake can be got back, two ways.
|
|
*
|
|
* Reported from use: "I just closed a task by accident, and there is no undo and no
|
|
* list of closed tasks." Both halves were true. Completing is one click on a small
|
|
* circle beside a row you were only reading, and it takes the row off the screen —
|
|
* so the mistake and the evidence of it leave together.
|
|
*
|
|
* The toast covers the mistake you notice at once. Færdige covers the one you
|
|
* notice tomorrow, and it is a place rather than a toggle because somebody hunting
|
|
* for a task they closed does not know which view they closed it in.
|
|
*/
|
|
test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/');
|
|
|
|
await page.getByLabel('Ny opgave').fill('rens tagrenden i dag');
|
|
await page.getByLabel('Ny opgave').press('Enter');
|
|
const row = page.locator('.row').filter({ hasText: 'rens tagrenden' });
|
|
await expect(row).toBeVisible();
|
|
|
|
// Closed by mistake.
|
|
await row.getByRole('button', { name: 'Markér som færdig' }).click();
|
|
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
|
|
|
// The toast names it, so you can tell which one you hit.
|
|
const toast = page.locator('.toast').filter({ hasText: 'rens tagrenden' });
|
|
await expect(toast).toBeVisible();
|
|
await toast.getByRole('button', { name: 'Fortryd' }).click();
|
|
await expect(page.locator('.row').filter({ hasText: 'rens tagrenden' })).toBeVisible();
|
|
|
|
// Now the other half: close it again and walk away from the toast.
|
|
await page
|
|
.locator('.row')
|
|
.filter({ hasText: 'rens tagrenden' })
|
|
.getByRole('button', { name: 'Markér som færdig' })
|
|
.click();
|
|
await expect(page.getByText('rens tagrenden', { exact: true })).toBeHidden();
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
// 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.
|
|
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
|
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
|
// Playwright matches an accessible name loosely unless told otherwise.
|
|
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
|
|
|
await page.getByRole('button', { name: 'Log ind med en passkey' }).click();
|
|
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
|
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
|
|
|
// And the key records that it was used, which is what makes the list worth
|
|
// reading when somebody wonders whether a device is still in play.
|
|
await page.goto(`${origin}/indstillinger`);
|
|
await expect(
|
|
page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/)
|
|
).toBeVisible();
|
|
|
|
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
|
await context.close();
|
|
expect(trouble).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The sidebar never scrolls sideways.
|
|
*
|
|
* It grew a horizontal scrollbar on an ordinary desktop, under the whole menu.
|
|
* The cause was one flex item: the account name had `overflow: hidden` and no
|
|
* `min-width: 0`, so it refused to shrink below its content and pushed the row
|
|
* past the column — and `overflow-y: auto` on the sidebar resolves to `auto` on
|
|
* the other axis too, which turned "a row that is too wide" into a scrollbar.
|
|
*
|
|
* Asserted with a long name, because the name is the part that varies and a short
|
|
* one hides the whole class of bug. The mobile test measures the *document*; this
|
|
* measures the sidebar itself, which is a different box and was never checked.
|
|
*/
|
|
test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
|
const trouble = watchForTrouble(page);
|
|
await page.goto('/indstillinger');
|
|
|
|
// A name long enough to overflow the column at its default width.
|
|
// `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an
|
|
// ambiguous locator waits for one of them to go away rather than failing.
|
|
await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard');
|
|
await page.getByRole('button', { name: 'Gem' }).first().click();
|
|
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
|
|
|
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
|
const sideways = await sidebar.evaluate((el) => el.scrollWidth - el.clientWidth);
|
|
expect(sideways, 'sidebjælken kan scrolles vandret').toBeLessThanOrEqual(1);
|
|
|
|
// 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.
|
|
await page_.blur();
|
|
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();
|
|
|
|
// Shared by being filed in a project. Not an access list of its own.
|
|
await page.getByLabel('Del i').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.getByLabel('Del i').selectOption({ label: 'Kun mig' });
|
|
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('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('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\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 ).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: "Del i" står på projektet, uden at
|
|
// nogen har valgt det bagefter.
|
|
await expect(page.getByLabel('Del i')).toHaveValue(/.+/);
|
|
const shared = await page.getByLabel('Del i').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('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();
|
|
|
|
await page.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([]);
|
|
});
|