Files
nebula/e2e/ui.spec.ts
T
florian.wilde 52f2d0ce8e
CI / pruefen (pull_request) Canceled after 0s
Wochenrueckblick ueber alle Projekte
Eine Seite, die aus dem vorhandenen Aktivitaetsverlauf zusammenfasst, was in
einer Woche ueber alle Projekte hinweg passiert ist: Kennzahlen oben, darunter
die Vorgaenge nach Kalendertagen gebuendelt, neuester Tag zuerst. Blaettern in
frueher Wochen ueber ?woche=-n.

Der Verlauf kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als erste Bedingung.

Zwei Dinge, die sonst still falsch werden:

- **Wochengrenzen** stehen als pruefbare Funktion in lib/review, nicht in der
  Seite. Gerechnet wird ueber Berliner Kalendertage, nicht ueber "sieben mal
  24 Stunden": liegt eine Zeitumstellung in der Woche, hat sie 167 oder 169
  Stunden. Und im Container laeuft die Anwendung nach UTC, ein Eintrag von
  Sonntagabend fiele sonst in die falsche Woche.
- **"Aufgaben erledigt"** wird am meta-Feld abgelesen (to: "DONE") und nicht
  am Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
  Formulierung; eine Zaehlung daran wuerde beim naechsten umformulierten
  Einzeiler unbemerkt kippen. Eintraege ohne meta zaehlen nicht mit - lieber
  untertreiben als raten.

Damit completeTaskFromIssue dieselbe Grundlage liefert, schreibt es jetzt
ebenfalls from/to ins meta, wie es das Verschieben von Hand schon tut.

Die Zuordnung Symbol/Farbe je Eintragsart lag bisher in ActivityFeed und wird
jetzt von zwei Stellen gebraucht - herausgezogen nach components/activityKinds,
sonst laufen die Kopien beim naechsten neuen Eintragstyp auseinander.

Erreichbar ueber die Schnellsuche und einen Verweis vom Dashboard. Bewusst
kein vierter Punkt in der Kopfleiste - die ist auf dem Telefon schon eng.

Geprueft: 20 neue Unit-Tests (Wochengrenzen inklusive beider Zeitumstellungen,
Jahreswechsel, Zaehlung), ein E2E-Test ueber Aufruf und Blaettern, axe-core auf
375px und 1280px ueber drei Zustaende - auch die leere Woche - ohne
Beanstandung, kein seitlicher Ueberlauf.

Refs #2
2026-08-18 00:55:02 +02:00

349 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "@playwright/test";
/**
* Ein Weg durch die Oberflaeche, wie ihn ein Mensch geht: anmelden, Projekt
* anlegen, Aufgaben verwalten. Keine Vollabdeckung – die Wege, die kaputt
* gehen duerfen, ohne dass es jemand merkt.
*
* Setzt voraus, dass api.spec.ts das Konto bereits angelegt hat; laeuft
* alphabetisch danach.
*/
const KONTO = { username: "chefin", password: "ein-langes-testpasswort" };
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.fill("#login-username", KONTO.username);
await page.fill("#login-password", KONTO.password);
await page.click('button[type="submit"]');
await expect(page.getByText("Vibe Coding Kontrollzentrum")).toBeVisible();
});
test("Dashboard zeigt Kennzahlen und Projekte", async ({ page }) => {
await expect(page.getByRole("heading", { name: /Deine Projekte/ })).toBeVisible();
await expect(page.getByText("Projekte", { exact: false }).first()).toBeVisible();
// Keine unbeabsichtigten Fehler in der Konsole.
expect(page.url()).toContain("/");
});
test("Projekt anlegen und wiederfinden", async ({ page }) => {
await page.getByRole("button", { name: /Neues Projekt/ }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.fill("#project-name", "Oberflaechentest");
await page.fill("#project-summary", "Von Playwright angelegt.");
await page.getByRole("button", { name: "Projekt anlegen" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
await expect(page.getByText("Oberflaechentest")).toBeVisible();
});
test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => {
await page.getByRole("link", { name: /Projekt Oberflaechentest/ }).click();
await expect(page.getByRole("heading", { name: "Oberflaechentest" })).toBeVisible();
// Zwei Aufgaben ueber den Dialog anlegen.
for (const titel of ["Erste Aufgabe", "Zweite Aufgabe"]) {
await page.getByRole("button", { name: "Aufgabe", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.fill("#task-title", titel);
await page.getByRole("button", { name: "Anlegen" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
}
const offen = page.locator('[data-task-column="TODO"]');
const inArbeit = page.locator('[data-task-column="DOING"]');
await expect(offen.getByText("Erste Aufgabe")).toBeVisible();
await expect(offen.getByText("Zweite Aufgabe")).toBeVisible();
// Erste Aufgabe nach "In Arbeit" ziehen.
const griff = offen.locator('button[aria-label$="verschieben"]').first();
const von = await griff.boundingBox();
const nach = await inArbeit.boundingBox();
expect(von && nach).toBeTruthy();
await page.mouse.move(von!.x + von!.width / 2, von!.y + von!.height / 2);
await page.mouse.down();
// Erst ueber die Aktivierungsschwelle des Sensors (5 Pixel) hinaus.
await page.mouse.move(von!.x + 30, von!.y + 10, { steps: 6 });
await page.mouse.move(nach!.x + nach!.width / 2, nach!.y + 60, { steps: 20 });
// dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
await page.waitForTimeout(250);
// Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen
// den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl,
// obwohl die Anwendung in Ordnung ist – genau so ist es passiert.
const gespeichert = page.waitForResponse(
(response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH",
);
await page.mouse.up();
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
expect((await gespeichert).ok()).toBeTruthy();
// Und der Server hat es auch so gespeichert.
await page.reload();
await expect(
page.locator('[data-task-column="DOING"]').getByText("Erste Aufgabe"),
).toBeVisible();
});
test("Volltextsuche findet Notizen", async ({ page }) => {
await page.getByRole("link", { name: /Projekt Oberflaechentest/ }).click();
await page.getByRole("button", { name: "Notiz", exact: true }).click();
await page.getByPlaceholder(/Gedanken/).fill("Suchbegriff Zaunkoenig steht hier.");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText("Zaunkoenig", { exact: false })).toBeVisible();
// exact, sonst trifft es auch "Zurueck zum Dashboard".
await page.getByRole("link", { name: "Dashboard", exact: true }).click();
await page.getByPlaceholder(/durchsuchen/).fill("Zaunkoenig");
await expect(page.getByText("Treffer in Notizen")).toBeVisible();
});
test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Liste" }).click();
await expect(page.getByRole("link", { name: /Projekt Oberflaechentest/ })).toBeVisible();
// Der eigentliche Punkt der Ansicht: sie passt in die Breite. Frueher schob
// die Steuerleiste den Knopf "Neues Projekt" aus dem Bild und die ganze
// Seite liess sich seitwaerts schieben.
const masse = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(masse.scrollWidth).toBeLessThanOrEqual(masse.clientWidth + 1);
// Auch der Knopf muss erreichbar bleiben – auf dieser Breite traegt er nur
// noch das Pluszeichen und lebt von seinem aria-label. Geprueft wird die
// rechte Kante: genau die lag vorher ausserhalb des Bildschirms.
const knopf = page.getByRole("button", { name: "Neues Projekt" });
await expect(knopf).toBeVisible();
const kasten = await knopf.boundingBox();
expect(kasten).not.toBeNull();
expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth);
});
test("Schnellerfassung legt Idee und Aufgabe mit einem Feld an", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Schnell erfassen" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Idee: nur der Name, alles andere kommt aus den Vorgaben des Schemas.
await page.fill("#quick-title", "Aus der Bahn notiert");
await dialog.getByRole("button", { name: "Anlegen" }).click();
// Der Dialog bleibt offen und bestaetigt – das ist der Punkt der
// Schnellerfassung: die naechste Idee soll ohne Umweg hineingehen.
await expect(dialog.getByText("Angelegt")).toBeVisible();
await expect(dialog.getByRole("link", { name: "Aus der Bahn notiert" })).toBeVisible();
await expect(page.locator("#quick-title")).toHaveValue("");
// Aufgabe: derselbe Dialog, ein Feld mehr.
await dialog.getByRole("button", { name: "Aufgabe", exact: true }).click();
await page.fill("#quick-title", "Unterwegs eingefallen");
await expect(page.locator("#quick-project")).toBeVisible();
await dialog.getByRole("button", { name: "Anlegen" }).click();
await expect(dialog.getByRole("link", { name: "Unterwegs eingefallen" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
// Die Idee steht als Projekt im Dashboard.
await page.goto("/");
await expect(page.getByText("Aus der Bahn notiert")).toBeVisible();
});
test("Dialoge weichen der Bildschirmtastatur aus", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Schnell erfassen" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const TASTATUR = 336;
/**
* Eine echte Tastatur laesst sich nicht einblenden. Stattdessen meldet
* visualViewport eine kleinere Hoehe – genau die Schnittstelle, an der
* der Dialog haengt. Ueberschrieben wird am echten Objekt: der Dialog
* merkt sich beim Oeffnen eine Referenz darauf.
*/
await page.evaluate((tastatur) => {
const fenster = window.visualViewport;
if (!fenster) throw new Error("visualViewport fehlt");
const hoehe = window.innerHeight - tastatur;
Object.defineProperty(fenster, "height", { get: () => hoehe, configurable: true });
Object.defineProperty(fenster, "offsetTop", { get: () => 0, configurable: true });
fenster.dispatchEvent(new Event("resize"));
}, TASTATUR);
await page.waitForTimeout(300);
// Eingabefeld und Absendeknopf muessen oberhalb der Tastatur liegen.
// Vorher stand der Dialog mittig im vollen Fenster – und damit zur
// Haelfte darunter.
const frei = 667 - TASTATUR;
for (const ziel of [
page.locator("#quick-title"),
page.getByRole("dialog").getByRole("button", { name: "Anlegen" }),
]) {
const kasten = await ziel.boundingBox();
expect(kasten).not.toBeNull();
expect(kasten!.y + kasten!.height).toBeLessThanOrEqual(frei);
}
await page.keyboard.press("Escape");
});
test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => {
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
// Die zuvor per Schnellerfassung angelegte Aufgabe taucht hier auf,
// obwohl sie in einem anderen Projekt haengt.
const zeile = page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" });
await expect(zeile).toBeVisible();
// Abhaken aus der Uebersicht heraus.
await zeile.getByRole("button", { name: /abhaken/ }).click();
// Ohne "Erledigte einblenden" verschwindet sie damit aus der Liste.
await expect(zeile).toBeHidden();
await page.getByLabel("Erledigte einblenden").check();
await expect(
page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" }),
).toBeVisible();
});
test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) => {
// Erreichbar vom Dashboard aus, ohne vierten Punkt in der Kopfleiste.
await page.getByRole("link", { name: /Wochenrückblick/ }).click();
await expect(page).toHaveURL(/\/rueckblick$/);
await expect(page.getByRole("heading", { name: "Diese Woche", level: 1 })).toBeVisible();
// Die Vorgaenge der bisherigen Tests liegen in dieser Woche, es muss also
// etwas dastehen – und das Projekt gehoert an jede Zeile.
await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
// Blättern: zurück und wieder her.
await page.getByRole("link", { name: "Eine Woche zurück" }).click();
await expect(page).toHaveURL(/woche=-1/);
await expect(page.getByRole("heading", { name: "Vorige Woche", level: 1 })).toBeVisible();
await page.getByRole("link", { name: "Diese Woche", exact: true }).click();
await expect(page).toHaveURL(/\/rueckblick$/);
// Auch ueber die Schnellsuche erreichbar.
await page.goto("/");
await page.keyboard.press("Control+k");
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Rückblick");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/rueckblick/);
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
// Ohne Eingabe stehen Projekte und Bereiche bereit.
await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible();
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Zaunkoenig");
await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible();
// Enter springt zum ersten Treffer.
await page.keyboard.press("Enter");
await expect(page.getByRole("heading", { name: "Oberflaechentest" })).toBeVisible();
// Escape schliesst wieder.
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden();
});
test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => {
const kaestchen = page.locator('input[aria-label$="auswaehlen"]');
const anzahl = await kaestchen.count();
expect(anzahl).toBeGreaterThan(0);
// Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
await kaestchen.first().check();
await expect(page.getByText("1 ausgewählt")).toBeVisible();
await page.getByRole("button", { name: "Tags", exact: true }).click();
await page.fill("#bulk-tags", "sammeltest");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText("#sammeltest").first()).toBeVisible();
// Nach getaner Arbeit ist die Auswahl aufgehoben.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
});
test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => {
await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click();
// Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der
// Eintraege, und ein blosser Textvergleich waere mehrdeutig.
const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" });
await expect(dialog).toBeVisible();
// Die laufende Fassung ist als solche gekennzeichnet.
await expect(dialog.getByText("installiert")).toBeVisible();
// Und es steht wirklich etwas drin.
await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible();
await expect(dialog.getByText("Neu").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => {
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung
// nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist.
const antwort = await anonym.get("/manifest.webmanifest");
expect(antwort.ok()).toBeTruthy();
const manifest = await antwort.json();
expect(manifest.name).toContain("Nebula");
expect(manifest.display).toBe("standalone");
expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true);
for (const icon of manifest.icons) {
const bild = await anonym.get(icon.src);
expect(bild.ok(), icon.src).toBeTruthy();
expect(bild.headers()["content-type"]).toContain("image/png");
}
await anonym.dispose();
// Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser
// /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit
// 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert.
await page.goto("/");
await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/);
});
test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
await page.getByRole("button", { name: /Benutzermenue/ }).click();
await page.getByRole("menuitem", { name: "Abmelden" }).click();
await expect(page).toHaveURL(/\/login/);
// Und die geschuetzte Seite ist wieder zu.
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
});