import { test, expect } from '@playwright/test'; import { USER } from './user.js'; import { watchForTrouble } from './trouble.js'; /** * The four flows a broken build must not survive: add a task, tick it off, reach * every page in the sidebar, and open a task. * * Every test asserts on *rendered* content rather than on a URL. A route that no * longer exists still answers 200 here — the Go binary serves the SPA shell for * anything it does not recognise — so `expect(page).toHaveURL()` would pass on * precisely the bug this suite exists to catch. */ /** * Opens the project header's overflow menu and clicks one of its items. * * The header used to carry eight controls in one row — title, colour, show-done, * three view buttons, share, history, delete — and a project name is not a short * word, so the heading was what gave way. Everything but the view switcher moved * behind one button; this is how a test reaches them. */ async function projectAction(page, name) { await page.getByRole('button', { name: 'Flere handlinger' }).click(); // `exact` only means anything for a string. The share item reads "Delt · 2" once // somebody is in the project, so callers pass a regex for that one. const item = name instanceof RegExp ? page.getByRole('menuitem', { name }) : page.getByRole('menuitem', { name, exact: true }); await item.click(); } /** * Venter på, at en gemning faktisk er nået frem til serveren. * * Hele fladen tegner en ændring med det samme og sender kaldet bagefter — en note * står i listen, et projekt hopper ind under sin gruppe, en beskrivelse står på * siden — så alt, hvad man kan se, er sandt, før serveren har hørt om det. En * prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand * og fejler på noget, der ikke er i stykker. * * Og teksten "Gemt" i notefoden duer ikke som værn: den er hvilestanden. Feltet * siger "Gemmer" mens en gemning kører og "Gemt" ellers — altså også før den * første overhovedet er begyndt. En ventetid på den er grøn med det samme og * venter på ingenting. Det gjorde fem prøver i den her fil ustabile. * * En fast ventetid er den samme fejl med et tal foran: `waitForTimeout(1200)` er * et gæt om, hvor hurtig maskinen er, og det gæt holder indtil CI er travl. * * Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om det. * Skal kaldes FØR den handling, der udløser gemningen — ellers er svaret måske * allerede kommet og gået. */ function gemt(side, sti) { return side.waitForResponse( (r) => r.request().method() === 'PATCH' && sti.test(new URL(r.url()).pathname) && r.ok() ); } const noteGemt = (side) => gemt(side, /\/notes\/[^/]+$/); const opgaveGemt = (side) => gemt(side, /\/tasks\/[^/]+$/); const projektGemt = (side) => gemt(side, /\/projects\/[^/]+$/); /** * Laver en ny note og giver feltet tilbage, når det er DEN nye, der skrives i. * * `create()` venter på serverens svar, før noten er oprettet, og imens står den * forrige notes editor stadig på skærmen. Et klik på "Ny note" efterfulgt af * tastetryk med det samme skriver derfor i den forrige note — teksten havner det * forkerte sted, og den nye note bliver liggende tom. Efter en genindlæsning * findes den titel, prøven leder efter, slet ikke. * * Det tomme felt er værnet. Det er samme form som resten af filen: alt kan ses, * før serveren har hørt om det, så det, der kan ventes på, er tilstanden bagefter. * * Bemærk at hullet også findes for et menneske: klikker man Ny note og skriver * straks, lander de første anslag i den forrige note. Det er hurtigt på en lokal * maskine og sekunder på en dårlig forbindelse. Noteret som et selvstændigt punkt. */ async function nyNote(side) { await side.getByRole('button', { name: 'Ny note' }).click(); const felt = side.getByRole('textbox', { name: 'Notens tekst' }); await expect(felt).toHaveText(''); await felt.click(); return felt; } /** * Som `nyNote`, men UDEN klikket i feltet. * * Klikket er netop det, markør-prøverne ikke må gøre: de måler, at markøren * står i overskriften, uden at nogen har sat den. Ventetiden skal de til * gengæld have, og manglen på den kostede to røde CI-kørsler. * * `click()` på "Ny note" vender tilbage, så snart klikket er sendt, mens noten * først skal oprettes på serveren og tegnes bagefter. Uden ventetiden går det, * der tastes, ind i den FORRIGE note eller ingen steder — og så er der heller * ingen gemning at vente på, så en `waitForResponse` løber tørt i stedet. Det * er to forskellige påstande, der fejler, med én årsag. Se kommentaren over * `nyNote`: faren var skrevet ned, med sin løsning, før prøverne blev skrevet. * * Overskriften er værnet frem for bare det tomme felt:
på markøren, og alt derefter blev ved
// med at være kode.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('kode linje');
await page.keyboard.press('Enter');
await page.keyboard.type('almindelig tekst');
// Koden står i sin blok; teksten efter står uden for den.
await expect(ed.locator('pre')).toHaveText('kode linje');
await expect(ed.locator('p').filter({ hasText: 'almindelig tekst' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'almindelig tekst' })).toHaveCount(0);
// Shift+retur bliver derimod i blokken, så flerlinjet kode stadig kan skrives.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('linje et');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('linje to');
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toContainText('linje to');
// Og menuen kan føre en monospace-linje tilbage til brødtekst — dét, der før
// lagde et inde i
og ikke kom nogen vegne.
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
// Den blok er nu brødtekst, ikke kode: den står som , ikke i en
.
await expect(ed.locator('p').filter({ hasText: 'linje et' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'linje et' })).toHaveCount(0);
// Det overlever turen gennem Markdown. En ren note for sig, så prøven ikke
// afhænger af alt det ovenfor: kode, retur, brødtekst — gem, hent igen.
await nyNote(page);
await page.keyboard.type('Rundtur');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('docker pull');
await page.keyboard.press('Enter');
await page.keyboard.type('og så videre i almindelig skrift');
const rundturGemt = noteGemt(page);
await ed.blur();
await rundturGemt;
await page.reload();
await page.getByRole('button', { name: /Rundtur/ }).click();
await expect(ed.locator('pre')).toContainText('docker pull');
await expect(ed.locator('p').filter({ hasText: 'almindelig skrift' })).toBeVisible();
await expect(ed.locator('pre').filter({ hasText: 'almindelig skrift' })).toHaveCount(0);
expect(trouble).toEqual([]);
});
test('kodeblokken har en kopiér-knap, og knappen havner ikke i filen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/noter');
const id = await page.evaluate(async () => {
const r = await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: '# Kommandoen\n\n```bash\ndocker pull verdande\n```\n' })
});
return (await r.json()).id;
});
await page.goto('/noter?note=' + id);
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const copy = ed.locator('pre .copy');
await expect(copy).toBeVisible();
await copy.click();
await expect(copy).toHaveText('Kopieret');
// Det, der ligger i udklipsholderen, er koden — ikke knappens eget ord.
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toBe('docker pull verdande');
// Og noten selv må ikke få "Kopiér" skrevet ind i sig, når den gemmes. Knappen
// ligger inde i for at kunne stå i hjørnet af den, så det er præcis den
// fælde, den her prøve findes for.
await ed.click();
await page.keyboard.press('End');
await page.keyboard.type(' ');
await page.waitForTimeout(1200);
const body = await page.evaluate(async (noteId) => {
const r = await fetch('/api/v1/notes/' + noteId, {
credentials: 'include',
headers: { 'Sec-Fetch-Site': 'same-origin' }
});
return (await r.json()).body;
}, id);
expect(body).toContain('docker pull verdande');
expect(body).not.toContain('Kopiér');
expect(body).not.toContain('Kopieret');
expect(trouble).toEqual([]);
});
test('flere noter kan markeres, arkiveres og hentes frem igen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
for (const title of ['Alfa', 'Bravo', 'Charlie']) {
await page.evaluate(async (t) => {
await fetch('/api/v1/notes', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'same-origin' },
body: JSON.stringify({ body: `# ${t}\n\nEn note.\n` })
});
}, title);
}
await page.reload();
const rows = page.locator('.notes button.row');
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
// To af tre: et almindeligt klik, og et ⌘-klik oven i. Den, der lige er åbnet,
// hører med i markeringen — ellers ville det andet klik markere én og glemme
// den, man stod på.
await rows.filter({ hasText: 'Alfa' }).click();
await rows.filter({ hasText: 'Bravo' }).click({ modifiers: ['Meta'] });
await expect(page.getByText('2 markeret')).toBeVisible();
// The selection bar's Arkivér, not the open note's own in the footer.
await page.locator('.picked-bar').getByRole('button', { name: 'Arkivér', exact: true }).click();
await page.waitForTimeout(900);
// Væk fra listen, og ikke i papirkurven: arkivering er ikke sletning.
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
await expect(rows.filter({ hasText: 'Charlie' })).toBeVisible();
await page.getByLabel('Vis arkivet').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
await expect(rows.filter({ hasText: 'Bravo' })).toBeVisible();
await expect(rows.filter({ hasText: 'Charlie' })).toHaveCount(0);
// Og tilbage igen — med rækkens egen knap, som er vejen for den ene. Et
// almindeligt klik rydder markeringen med vilje, så bjælken er til flere.
await rows.filter({ hasText: 'Alfa' }).hover();
await page.locator('.rowline', { hasText: 'Alfa' }).getByLabel('Tag frem igen').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toHaveCount(0);
await page.getByLabel('Vis arkivet').click();
await page.waitForTimeout(900);
await expect(rows.filter({ hasText: 'Alfa' })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* Én note, der ikke kan tegnes, må ikke tage ruden med sig.
*
* Meldt ind som "jeg kan ikke skifte mellem noter — uanset hvilken jeg trykker
* på, åbner de ikke". Editoren indlæste med `note.body.trim()` uden værn, og
* `body` er en streng fra serveren hver eneste gang — indtil den ikke er. Så
* kaster den inde i et `$effect`, og en effekt, der kaster, tager komponentens
* opdateringer med sig: den forrige note bliver stående, og ingen note kan åbnes
* bagefter. Klikket kan ikke engang lande.
*
* Formen fremtvinges her frem for at vente på den nyttelast, der gjorde det:
* hvad der end fik `body` til at mangle, er svaret det samme — én note må ikke
* kunne låse de tolv hundrede andre ude.
*/
test('en note uden krop må ikke låse ruden', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/noter');
for (const title of ['Alfa', 'Beta']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type(title);
await expect(ed.locator('h1')).toHaveText(title);
await page.keyboard.press('Enter');
await page.keyboard.type('Brødtekst i ' + title);
await expect(ed.locator('p').filter({ hasText: 'Brødtekst i ' + title })).toBeVisible();
await page.waitForTimeout(900);
}
await page.reload();
await expect(page.locator('li .row strong').filter({ hasText: 'Beta' })).toBeVisible();
await page.route('**/api/v1/notes/*', async (route) => {
if (route.request().method() !== 'GET') return route.continue();
const res = await route.fetch();
const note = await res.json();
delete note.body;
await route.fulfill({ response: res, json: note });
});
await page.locator('li').filter({ hasText: 'Alfa' }).first().click();
await page.waitForTimeout(600);
expect(errors, 'noten kastede under indlæsningen').toEqual([]);
await page.unroute('**/api/v1/notes/*');
await page.locator('li').filter({ hasText: 'Beta' }).first().click();
await expect(
page.getByRole('textbox', { name: 'Notens tekst' }).locator('h1'),
'ruden er låst: den næste note åbner ikke'
).toHaveText('Beta', { timeout: 5000 });
expect(errors, 'ruden efterlod en fejl bag sig').toEqual([]);
});
/**
* Ugen er et døgn, ikke syv punktopstillinger.
*
* En uge, hvor hver dag er en liste, svarer på "hvad skal der ske torsdag" og ikke
* på "hvornår" — og det andet er det, man åbner en uge for. To møder klokken ti er
* en konflikt, man skal kunne se, ikke læse sig til.
*
* Begivenhederne kommer fra en rute, der svarer i stedet for Google: det, der
* prøves her, er gitteret, og en prøve, der først skal have en OAuth-forbindelse,
* er en prøve, der aldrig kører.
*/
test('ugen viser et døgn, og dagene er lige brede', async ({ page }) => {
const trouble = watchForTrouble(page);
// Mandag i den uge, siden åbner på.
const monday = (n) => {
const d = new Date();
d.setDate(d.getDate() - ((d.getDay() + 6) % 7) + n);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
d.getDate()
).padStart(2, '0')}`;
};
const at = (n, from, to, id, summary) => ({
id,
calendar_id: 'c1',
summary,
starts_at: `${monday(n)}T${from}:00+02:00`,
ends_at: `${monday(n)}T${to}:00+02:00`,
start_day: monday(n),
end_day: monday(n),
all_day: false,
calendar_name: 'Privat'
});
await page.route('**/api/v1/calendar/events*', (route) =>
route.fulfill({
json: {
events: [
at(2, '09:00', '10:00', 'e1', 'Morgenmøde'),
at(2, '09:30', '11:00', 'e2', 'Oveni det første'),
at(2, '17:00', '18:00', 'e3', 'Sent på dagen'),
{
id: 'e4',
calendar_id: 'c1',
summary: 'Aalborg',
start_day: monday(1),
end_day: monday(1),
all_day: true,
calendar_name: 'Privat'
}
]
}
})
);
await page.goto('/kalender');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
await expect(page.locator('.weekgrid')).toBeVisible();
// Syv lige brede dage. `1fr` er `minmax(auto, 1fr)`, så ét langt ord i én dag
// gjorde den bredere end de seks andre — hvilket er det modsatte af et gitter.
const widths = await page
.locator('.weekgrid .daycol')
.evaluateAll((els) => els.map((e) => Math.round(e.getBoundingClientRect().width)));
expect(widths, 'ugen har ikke syv søjler').toHaveLength(7);
expect(Math.max(...widths) - Math.min(...widths), 'dagene er ikke lige brede').toBeLessThanOrEqual(1);
// Klokken bestemmer, hvor noget står. Morgenmødet skal ligge over det sene.
const box = async (name) => (await page.locator('.tevent', { hasText: name }).first().boundingBox());
const morning = await box('Morgenmøde');
const evening = await box('Sent på dagen');
expect(morning.y, 'morgenmødet står ikke over det sene møde').toBeLessThan(evening.y);
// To ting på samme tid står ved siden af hinanden. Oven på hinanden er præcis
// den konflikt, visningen findes for at vise.
const overlapping = await box('Oveni det første');
expect(overlapping.x, 'de to overlappende ligger oven på hinanden').toBeGreaterThan(morning.x);
expect(overlapping.x, 'den anden er havnet i næste dag').toBeLessThan(morning.x + morning.width * 2);
// Heldagsting hører til båndet foroven: en heldagsbegivenhed lagt klokken nul
// ville påstå, den holdt fra midnat til midnat.
// `.weekgrid > .allday` er selve cellen; brikken indeni bærer også klassen.
await expect(page.locator('.weekgrid > .allday', { hasText: 'Aalborg' })).toBeVisible();
// Og måneden er stadig lige bred hele vejen rundt.
await page.getByRole('button', { name: 'Måned', exact: true }).click();
const monthWidths = await page
.locator('.grid .day')
.evaluateAll((els) => els.slice(0, 7).map((e) => Math.round(e.getBoundingClientRect().width)));
expect(
Math.max(...monthWidths) - Math.min(...monthWidths),
'månedens dage er ikke lige brede'
).toBeLessThanOrEqual(1);
expect(trouble).toEqual([]);
});
/**
* Tid på en opgave, sat i ruden og justeret i kalenderen.
*
* Tre påstande, og den midterste er den, der var gået galt uden at nogen sagde
* det: serveren læser `due_date` og `due_time` sammen, så en dato sendt uden en
* tid *rydder* tiden. Et gem af datoen alene tog altså klokkeslættet med sig, og
* det samme gjorde et træk i kalenderen.
*/
test('en opgave kan få et klokkeslæt, og beholder det når den flyttes', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const box = page.getByLabel('Ny opgave');
// I dag, så den står på den visning, den skrives i — og i den uge, gitteret
// åbner på længere nede.
await box.fill('ringe til tandlægen i dag');
await box.press('Enter');
await expect(page.getByText('ringe til tandlægen')).toBeVisible();
// Klokkeslættet sættes i ruden. Feltet er tomt til at begynde med: en opgave
// uden tid er det almindelige, og 00:00 ville påstå noget om midnat.
await page.getByText('ringe til tandlægen').click();
const time = page.getByLabel('Klokkeslæt');
await expect(time).toHaveValue('');
// Tidligt på dagen med vilje: gitteret begynder klokken otte, så opgaven står
// lige under båndet og de to kasser er i billedet på én gang. Klokken fjorten
// ligger under skærmkanten, og et træk, der først skal rulle, er et træk,
// browseren taber.
await time.fill('08:30');
await time.blur();
await page.waitForTimeout(400);
// Gemt hos serveren, ikke kun i feltet: genindlæst og åbnet igen.
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(page.getByLabel('Klokkeslæt'), 'klokkeslættet blev ikke gemt').toHaveValue('08:30');
// Og datoen kan rettes bagefter uden at tage timen med sig.
const date = page.locator('#due');
const day = await date.inputValue();
const datoGemt = opgaveGemt(page);
await date.fill(day);
await date.blur();
await datoGemt;
await page.reload();
await page.getByText('ringe til tandlægen').click();
await expect(
page.getByLabel('Klokkeslæt'),
'et gem af datoen ryddede klokkeslættet'
).toHaveValue('08:30');
await page.keyboard.press('Escape');
// I ugegitteret står den på sit klokkeslæt frem for i heldagsbåndet.
await page.goto('/upcoming');
await page.getByRole('button', { name: 'Uge', exact: true }).click();
const placed = page.locator('.tevent.task').filter({ hasText: 'ringe til tandlægen' });
await expect(placed, 'opgaven står ikke på døgnet').toBeVisible();
await expect(placed).toContainText('08:30');
// Trukket op i båndet over sin egen dag: så har den en dag og ingen tid. Den
// tomme streng siger "ryd", hvor ingenting siger "behold" — og de to må ikke
// forveksles.
//
// Et bånd, der er tomt. Prøven handler om at krydse mellem de to kasser, og en
// dag, hvis bånd allerede er fyldt af andre prøvers opgaver, gør slippunktet
// til en anden opgave frem for til båndet — hvilket er en anden gest end den,
// der prøves her.
const band = page
.locator('.weekgrid > .allday')
.filter({ hasNot: page.locator('.chip, .event') })
.first();
const today = await band.getAttribute('data-allday');
// Trukket i skridt frem for i ét spring. `dragTo` flytter musen i to træk, og
// over fire hundrede pixels i et travlt gitter giver det browseren for få
// mellemstationer til at holde gesten i live. Et menneske bevæger hånden hele
// vejen, og det er også det, der skal prøves.
const fromBox = await placed.boundingBox();
const toBox = await band.boundingBox();
await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + 5);
await page.mouse.down();
await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 25 });
await page.mouse.up();
await expect(
page.locator(`[data-allday="${today}"] .chip`).filter({ hasText: 'ringe til tandlægen' }),
'opgaven kom ikke op i båndet'
).toBeVisible();
await expect(
page.locator(`[data-date="${today}"] .tevent`).filter({ hasText: 'ringe til tandlægen' }),
'opgaven står stadig på et klokkeslæt'
).toBeHidden();
expect(trouble).toEqual([]);
});
/**
* Notelisten står i grupper.
*
* Den føltes tæt, og grunden var ikke afstanden mellem rækkerne: hver række sagde
* tre ting, og to af dem gentog sig selv nedad. Datoen stod på hver eneste række,
* også når tyve noter deler dag, og projektet fik en linje for sig selv.
*
* Datoen er flyttet op i en overskrift, favoritterne har fået deres egen gruppe —
* de lå allerede først, der stod bare ikke hvorfor — og arkivet grupperes på samme
* måde, for det er dér, de tolv hundrede importerede ligger.
*/
test('notelisten grupperer favoritter og datoer, også i arkivet', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
for (const title of ['Gruppe én', 'Gruppe to']) {
await page.getByRole('button', { name: 'Ny note' }).click();
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await ed.click();
await page.keyboard.type(title);
await page.keyboard.press('Enter');
await page.keyboard.type('Brødtekst i ' + title);
await expect(ed.locator('h1')).toHaveText(title);
await page.waitForTimeout(800);
}
await page.reload();
// Skrevet i dag, så de står under dagens overskrift.
const today = page.getByRole('heading', { name: /^I dag/ });
await expect(today, 'der er ingen datogruppe').toBeVisible();
// En favorit får sin egen gruppe. Uden overskriften ligger den bare først, og
// der står ikke hvorfor.
await expect(page.getByRole('heading', { name: /Favoritter/i })).toBeHidden();
const row = page.locator('li').filter({ hasText: 'Gruppe én' }).first();
await row.hover();
await row.getByRole('button', { name: /favorit/i }).click();
const favourites = page.getByRole('heading', { name: /Favoritter/i });
await expect(favourites).toBeVisible();
// Og den ligger under den, ikke under dagen.
const inFavourites = page
.locator('h3.group', { hasText: /Favoritter/i })
.locator('+ ul li')
.filter({ hasText: 'Gruppe én' });
await expect(inFavourites).toBeVisible();
// Grupper kan foldes sammen. Ikke husket på tværs af besøg: en gruppe, der
// åbner foldet uden at nogen bad om det, er en liste, der ser tom ud.
await favourites.getByRole('button').click();
await expect(inFavourites).toBeHidden();
await favourites.getByRole('button').click();
await expect(inFavourites).toBeVisible();
// En søgning grupperes ikke: serveren har sorteret efter hvor godt hver note
// matchede, og at dele det op i måneder ville stille rækkefølgen om efter noget
// andet end det, der blev spurgt om.
await page.getByPlaceholder('Søg i noter').fill('Brødtekst');
await expect(page.locator('li').filter({ hasText: 'Gruppe to' }).first()).toBeVisible();
await expect(page.locator('h3.group')).toHaveCount(0);
await page.getByPlaceholder('Søg i noter').fill('');
// Rækkerne skal kunne *ses*, ikke bare findes.
//
// Da listen blev delt i grupper, blev hver gruppe sit eget `ind i *
'en i stedet for at lave den om. Den vej spurgte efter `currentBlock()`,
* altså det ene blok markøren står i — så af en markering over ti linjers
* terminaludskrift blev den første linje til kode og resten stod tilbage som
* brødtekst.
*
* Og det skal være én blok, ikke tre. En udskrift, man indsætter, er ét stykke
* kode; tre blokke ved siden af hinanden ser næsten ens ud på skærmen og er tre
* hegn i filen.
*/
test('flere markerede afsnit bliver til én monotypeblok', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Udskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('linje et');
await page.keyboard.press('Enter');
await page.keyboard.type('linje to');
await page.keyboard.press('Enter');
await page.keyboard.type('linje tre');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(ed.locator('p')).toHaveCount(3);
// Markér alle tre, som man gør det med musen eller tastaturet: fra begyndelsen
// af den første til enden af den sidste.
await ed.getByText('linje et', { exact: true }).click();
await page.keyboard.press('Home');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+ArrowDown');
await page.keyboard.press('Shift+End');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
// Én blok med alle tre linjer, og ingen brødtekst tilbage.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
await expect(ed.locator('p')).toHaveCount(0);
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Udskrift/ }).click();
await expect(ed.locator('pre')).toHaveCount(1);
expect(await ed.locator('pre code').evaluate((el) => el.textContent.trim())).toBe(
'linje et\nlinje to\nlinje tre'
);
expect(trouble).toEqual([]);
});
/**
* Blokke, som retur efterlod som , bliver brødtekst — også bagefter.
*
* Chromium svarer på retur med en bar . Den læses som brødtekst og er ikke
* formateret som en, så afstanden mellem afsnit forsvinder stille. Rettelsen er
* udskudt ét hak — før delingen er der ingen ny blok at rette — og den spørger,
* hvor markøren står. Skriver man hurtigt, når næste retur igennem først, og så
* retter den den næste blok to gange og lader denne stå.
*
* Det er målt frem for gættet: på en CI-runner faldt "flere markerede afsnit
* bliver til én monotypeblok" med tre linjer rigtigt skrevet, hvor "linje et" var
* en og "linje to" og "linje tre" var .
*
* Racet selv kan ikke skrives som en pålidelig prøve — det afhænger af, hvornår
* runneren når sine timere. Derfor planter denne den tilstand, racet efterlader,
* og holder på invarianten i stedet: ingen blok i en note er browserens egen
* beholder, uanset hvor markøren har været i mellemtiden.
*/
test('blokke retur efterlod som
bliver brødtekst alligevel', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
const ed = await nyNote(page);
await page.keyboard.type('Overskrift');
await page.keyboard.press('Enter');
await page.keyboard.type('første afsnit');
await page.keyboard.press('Enter');
await page.keyboard.type('andet afsnit');
await expect(ed.locator('p')).toHaveCount(2);
// Tilstanden racet efterlader: rettelserne løb, mens markøren stod et andet sted,
// så ingen af blokkene blev lavet om. To af dem, fordi én er for let: en rettelse,
// der holder fast i et håndtag til blokken, kan nå den første og mister den næste,
// når den første ombytning tager dens node ud af dokumentet.
await ed.evaluate((el) => {
for (const para of [...el.querySelectorAll('p')]) {
const div = document.createElement('div');
while (para.firstChild) div.appendChild(para.firstChild);
para.replaceWith(div);
}
// Markøren tilbage for enden af den sidste, hvor racet ville have efterladt den:
// brugeren skriver videre. At erstatte blokken kollapser markeringen til
// begyndelsen, og så bliver plantningen selv den fejl, prøven måler.
const range = document.createRange();
range.selectNodeContents(el.lastElementChild);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
await expect(ed.locator('p')).toHaveCount(0);
await expect(ed.locator('> div')).toHaveCount(2);
// Næste retur henter dem begge ind igen, sammen med den nye linje.
await page.keyboard.press('Enter');
await page.keyboard.type('tredje afsnit');
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
await expect(ed.locator('h1')).toHaveCount(1);
// Og teksten er den samme hele vejen — en rettelse, der taber et ord, er ingen
// rettelse. Den skal også overleve en gemning.
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Overskrift/ }).click();
await expect(ed.locator('> div')).toHaveCount(0);
await expect(ed.locator('p')).toHaveCount(3);
expect(await ed.locator('p').nth(0).textContent()).toBe('første afsnit');
expect(await ed.locator('p').nth(1).textContent()).toBe('andet afsnit');
expect(await ed.locator('p').nth(2).textContent()).toBe('tredje afsnit');
expect(trouble).toEqual([]);
});
/**
* Hele kontoens opgaver kan hentes i én omgang.
*
* Eksporten pr. projekt virkede og var ubrugelig ved enhver størrelse: femten
* projekter betød femten downloads for at flytte sine egne opgaver et andet sted
* hen. Det er en eksport, der teknisk set findes, og som ingen bruger.
*
* Prøven henter filerne og ser i dem. At et link findes beviser ingenting — en tom
* zip og en kalender uden VTODO'er er begge 200 med en troværdig Content-Type, og
* det er præcis den form, en fejl i en eksport har.
*/
test('alle opgaver kan hentes som CSV og som kalender', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/');
await page.getByLabel('Ny opgave').fill('Betal moms i dag');
await page.getByLabel('Ny opgave').press('Enter');
await expect(page.getByText('Betal moms', { exact: true })).toBeVisible();
await page.goto('/indstillinger/data');
const hent = async (navn) => {
const venter = page.waitForEvent('download');
await page.getByRole('link', { name: navn }).click();
const fil = await venter;
const sti = await fil.path();
return { sti, navn: fil.suggestedFilename() };
};
const zip = await hent('Alle opgaver (CSV)');
expect(zip.navn).toMatch(/\.zip$/);
// En zip er ikke tom, når den har mere end den afsluttende blok i sig.
const { size } = await import('node:fs/promises').then((fs) => fs.stat(zip.sti));
expect(size).toBeGreaterThan(100);
const kal = await hent('Alle opgaver (kalender)');
expect(kal.navn).toMatch(/\.ics$/);
const tekst = await import('node:fs/promises').then((fs) => fs.readFile(kal.sti, 'utf8'));
expect(tekst).toContain('BEGIN:VTODO');
expect(tekst).toContain('Betal moms');
expect(trouble).toEqual([]);
});
/**
* En indsat liste bliver til opgaver og underopgaver.
*
* Quick-add er ét felt på én linje, og en browser smider linjeskiftene væk, når
* man indsætter i et `` — så en kopieret liste blev til én opgave med det
* hele mast sammen.
*
* Her måles sammenhængen: at indsætningen fanges, at visningen kommer FØR noget
* oprettes, at indrykningen bliver til en underopgave, og at en afkrydset linje
* lander som gjort. De enkelte læseregler står i pastelist.spec.js, hvor de er
* billigere at stille.
*/
test('en indsat liste bliver til opgaver, og de indrykkede bliver underopgaver', async ({
page
}) => {
const trouble = watchForTrouble(page);
await page.goto('/');
const liste = ['- Flytte garagen', ' - Rydde hylderne', ' - [x] Leje trailer', '- Male døren'].join(
'\n'
);
await page.getByLabel('Ny opgave').click();
// En rigtig indsættelses-hændelse. At skrive teksten ind i feltet ville springe
// netop det over, prøven handler om — browseren smider linjeskiftene væk.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('[data-quickadd]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, liste);
// Vist først, oprettet bagefter. Fyrre opgaver fra et fejlklik er ikke noget,
// man skal opdage bagefter.
const visning = page.getByRole('group', { name: 'Indsat liste' });
await expect(visning.locator('li')).toHaveCount(4);
await expect(page.getByText('Flytte garagen', { exact: true })).toHaveCount(1);
await visning.getByRole('button', { name: 'Opret 4 opgaver' }).click();
await expect(visning).toHaveCount(0);
// Ingen af linjerne har en dato, så de ligger i indbakken og ikke på I dag.
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'Indbakke' }).click();
await expect(page.getByText('Flytte garagen', { exact: true })).toBeVisible();
await expect(page.getByText('Male døren', { exact: true })).toBeVisible();
// Og de indrykkede hænger under den linje, de stod under — ikke under den
// første, den bedste. Det ses i opgavens egen rude.
await page.getByText('Flytte garagen', { exact: true }).click();
await expect(page.getByText('Rydde hylderne', { exact: true })).toBeVisible();
await expect(page.getByText('Leje trailer', { exact: true })).toBeVisible();
// Afkrydsningen var indhold: den linje kom ind som gjort, så rækken tæller
// én af to. Målt på tælleren frem for på en streg gennem teksten — tælleren
// er det, serveren har gemt, stregen er, hvordan den tegnes.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/2/ })).toBeVisible();
// Og "Male døren" hang ikke under noget: den stod uden indrykning.
await expect(page.getByRole('button', { name: /Male døren \d\/\d/ })).toHaveCount(0);
// Den anden halvdel af "enten opgaver eller undertasks": hvor man indsætter,
// afgør hvad linjerne bliver. Her er opgavens egen rude åben, så de bliver
// undertasks under den — uden at nogen skal vælge noget.
await page.evaluate((tekst) => {
const dt = new DataTransfer();
dt.setData('text/plain', tekst);
document
.querySelector('input[aria-label="Ny undertask"]')
.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, '- Købe maling\n- Låne stige');
const flere = page.getByRole('group', { name: 'Indsat liste' });
await expect(flere.locator('li')).toHaveCount(2);
await flere.getByRole('button', { name: 'Opret 2 opgaver' }).click();
// To mere under den samme opgave: fire undertasks, én af dem lukket.
await expect(page.getByRole('button', { name: /Flytte garagen 1\/4/ })).toBeVisible();
expect(trouble).toEqual([]);
});
/**
* En kodeblok med flere linjer kommer tilbage med dem alle.
*
* Shift+retur bliver i blokken, som prøven ovenfor viser — men det, browseren
* lægger ind, er et
, og `textContent` på et
er den tomme streng. Blokken
* blev derfor skrevet til Markdown som én linje: "linje etlinje to". Man kunne
* skrive flere linjer og ikke gemme dem.
*
* Målt efter en genindlæsning og ikke før. Før gemningen står begge linjer der —
* det er turen gennem Markdown, der taber dem, og det er præcis den, prøven
* ovenfor stopper lige før.
*/
test('en kodeblok med flere linjer overlever gemningen', async ({ page }) => {
const trouble = watchForTrouble(page);
await page.goto('/noter');
await nyNote(page);
await page.keyboard.type('Opskrift');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Formatér' }).click();
await page.getByRole('menuitem', { name: 'Monotype' }).click();
await page.keyboard.type('first line');
await page.keyboard.press('Shift+Enter');
await page.keyboard.type('second line');
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
const gemt = noteGemt(page);
await ed.blur();
await gemt;
await page.reload();
await page.getByRole('button', { name: /Opskrift/ }).click();
// Én blok, to linjer. Teksten læses rå frem for gennem `toHaveText`, som
// normaliserer mellemrum væk — og linjeskiftet ER det, der måles.
//
// Og på , ikke på : kopiér-knappen ligger inde i blokken for at
// kunne sidde i hjørnet af den, så `textContent` på har ordet "Kopiér"
// med. Det er samme fælde, som selve fejlen handler om — hvad der tæller med i
// en blocks tekst — bare fra den anden side.
const blok = ed.locator('pre');
await expect(blok).toHaveCount(1);
expect(await blok.locator('code').evaluate((el) => el.textContent.trim())).toBe(
'first line\nsecond line'
);
expect(trouble).toEqual([]);
});
/**
* Et indsat billede kan ses i fuld størrelse og kopieres videre.
*
* I arket er et billede skaleret ned til spaltens bredde — et foto fra en telefon
* er fire tusind pixels bredt — så det, man kigger på, er et miniaturebillede, der
* ikke ser ud som et. Der var ingen vej fra det til billedet selv.
*
* Prøven indsætter billedet ad den vej, folk gør det: en rigtig paste-hændelse med
* en fil i udklipsholderen. Et billede lagt ind ved at skrive markdown ville springe
* hele vedhæftningen over og dermed netop den kode, det her handler om.
*/
test('et indsat billede kan åbnes stort og kopieres', async ({ page, context }) => {
const trouble = watchForTrouble(page);
// Kopiering til udklipsholderen kræver lov. Uden den fejler knappen, og prøven
// ville måle en browserindstilling frem for programmet.
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const body = page.getByLabel('Notens tekst');
await body.fill('Billednote');
// En rigtig 1×1 JPEG, og med vilje ikke en PNG: udklipsholderen tager kun PNG,
// så et foto skal tegnes om undervejs — og et foto fra en telefon ER en JPEG.
// Med en PNG her ville prøven køre forbi netop den kode, den skal vogte.
await body.evaluate((el) => {
const jpeg =
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' +
'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA' +
'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==';
const bytes = Uint8Array.from(atob(jpeg), (c) => c.charCodeAt(0));
const file = new File([bytes], 'skud.jpg', { type: 'image/jpeg' });
const data = new DataTransfer();
data.items.add(file);
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }));
});
const image = page.locator('[aria-label="Notens tekst"] img');
await expect(image).toBeVisible();
// Klikket åbner det, og ruden viser det samme billede.
await image.click();
const shot = page.getByRole('dialog', { name: 'Billede' });
await expect(shot).toBeVisible();
await expect(shot.locator('img')).toBeVisible();
// Kopiér-knappen skal gøre noget. En knap, der tier, er værre end ingen knap:
// man indsætter noget andet og opdager det et helt andet sted.
await shot.getByRole('button', { name: 'Kopiér billedet' }).click();
await expect(shot.getByRole('button', { name: 'Kopieret' })).toBeVisible();
await expect(shot.getByText('Kunne ikke kopiere')).toHaveCount(0);
// Escape lukker, og teksten har fokus igen — ellers står fokus på en knap, der
// ikke findes mere, og den næste tast går ingen steder.
await page.keyboard.press('Escape');
await expect(shot).toHaveCount(0);
await expect(body).toBeFocused();
expect(trouble).toEqual([]);
});
/**
* En opgaves tekst kan læses færdig, også på en telefon.
*
* Rapporteret fra en telefon, med et billede af en beskrivelse, der manglede sit
* første bogstav på hver linje: "hermostat RU0910301440" og "atteries". Det er
* ikke manglende tegn — det er et felt, der er rullet til højre. En beskrivelse er
* tit et klippet stykke mail med en URL i, et