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("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/); });