From 6e63fec49b02045e35b0e750f898545fb02544a7 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 09:50:36 +0200 Subject: [PATCH] test: Ende-zu-Ende-Tests mit Playwright 13 Tests gegen die gebaute Anwendung mit eigener Datenbank auf Port 3111 - also durch Middleware, Authentifizierung, Validierung und Datenbank hindurch. Genau die Schicht, die Unit-Tests nicht erreichen. Laufzeit rund 20 Sekunden. api.spec.ts prueft, was bisher nur von Hand geprueft war: - Einrichtung sperrt sich nach dem ersten Konto - ohne Anmeldung liefert jeder Endpunkt 401 - fremdes Origin und falscher Content-Type werden abgewiesen - SSRF-Sperren greifen - Datentrennung: ein zweites Konto bekommt auf fremde Projekte, Aufgaben und Adminwege durchgaengig 404 beziehungsweise 403, das Original bleibt unveraendert - Aufgaben: Reihenfolge, abgeleiteter Fortschritt, untergeschobene IDs - Export ohne Geheimnisse, Import je Strategie ui.spec.ts geht den Weg eines Menschen: anmelden, Projekt anlegen, Aufgaben anlegen und ziehen, suchen, abmelden. Das Ziehen wird nach einem Neuladen erneut geprueft - sonst waere nur die Anzeige getestet, nicht das Speichern. Zwei Vorkehrungen: - global-setup.ts bricht ab, wenn der Datenbankname nicht "test" enthaelt, und zwar vor dem TRUNCATE. Der teuerste denkbare Fehler waere eine Testreihe, die die Produktivdaten leert. - Playwright startet den Server selbst mit eigener DATABASE_URL und eigenem Port; eine laufende Instanz auf 3000 stoert nicht. Beim Schreiben zeigte sich, dass synthetische Zieh-Gesten eine Pause vor dem Loslassen brauchen - dnd-kit wertet die Bewegung in einem eigenen Frame aus und faende sonst kein Ziel. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 6 ++ e2e/api.spec.ts | 227 +++++++++++++++++++++++++++++++++++++++++++ e2e/global-setup.ts | 62 ++++++++++++ e2e/ui.spec.ts | 109 +++++++++++++++++++++ package-lock.json | 67 ++++++++++++- package.json | 8 +- playwright.config.ts | 63 ++++++++++++ 7 files changed, 538 insertions(+), 4 deletions(-) create mode 100644 e2e/api.spec.ts create mode 100644 e2e/global-setup.ts create mode 100644 e2e/ui.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 26ee177..3412ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,12 @@ yarn-error.log* # Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz /backups +# Playwright +/test-results +/playwright-report +/blob-report +/playwright/.cache + # editors .idea/ .vscode/* diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts new file mode 100644 index 0000000..c280cef --- /dev/null +++ b/e2e/api.spec.ts @@ -0,0 +1,227 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +/** + * Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware, + * Authentifizierung, Validierung und Datenbank hindurch. Genau die Dinge, + * die Unit-Tests nicht erreichen. + * + * Die Tests bauen aufeinander auf und laufen in dieser Reihenfolge, weil sie + * sich eine Datenbank teilen (siehe playwright.config.ts, workers: 1). + */ + +const ADMIN = { username: "chefin", password: "ein-langes-testpasswort" }; +const ZWEITER = { username: "zweiter", password: "noch-ein-langes-passwort" }; + +async function login(request: APIRequestContext, user: { username: string; password: string }) { + const response = await request.post("/api/auth/login", { data: user }); + expect(response.ok(), `Anmeldung als ${user.username}`).toBeTruthy(); +} + +test.describe.configure({ mode: "serial" }); + +test("Einrichtung legt das erste Konto an und sperrt sich danach", async ({ request }) => { + const response = await request.post("/api/auth/setup", { + data: { ...ADMIN, mode: "MULTI_USER" }, + }); + expect(response.status()).toBe(200); + expect((await response.json()).user.role).toBe("ADMIN"); + + // Ein zweiter Aufruf darf kein weiteres Adminkonto erzeugen. + const wieder = await request.post("/api/auth/setup", { + data: { username: "eindringling", password: "ein-langes-testpasswort" }, + }); + expect(wieder.status()).toBe(409); +}); + +test("ohne Anmeldung ist nichts zu holen", async ({ playwright }) => { + const anonym = await playwright.request.newContext({ baseURL: test.info().project.use.baseURL }); + for (const pfad of ["/api/projects", "/api/connections", "/api/admin/users"]) { + expect((await anonym.get(pfad)).status(), pfad).toBe(401); + } + await anonym.dispose(); +}); + +test("Fremdes Origin wird abgewiesen", async ({ request }) => { + await login(request, ADMIN); + const response = await request.post("/api/projects", { + data: { name: "Von woanders" }, + headers: { Origin: "https://boese.example" }, + }); + expect(response.status()).toBe(403); +}); + +test("Content-Type wird erzwungen", async ({ request }) => { + await login(request, ADMIN); + const response = await request.post("/api/projects", { + headers: { "Content-Type": "text/plain" }, + data: JSON.stringify({ name: "Umgehung" }), + }); + expect(response.status()).toBe(415); +}); + +test("SSRF: interne Adressen werden geblockt", async ({ request }) => { + await login(request, ADMIN); + for (const ziel of ["http://127.0.0.1:5432", "http://169.254.169.254", "http://0.0.0.0"]) { + const response = await request.post("/api/connections", { + data: { name: `ssrf-${ziel}`, provider: "GITEA", baseUrl: ziel }, + }); + expect(response.status(), ziel).toBe(422); + } +}); + +test("Datentrennung zwischen zwei Konten", async ({ request, playwright }) => { + await login(request, ADMIN); + + const angelegt = await request.post("/api/projects", { + data: { name: "Geheimprojekt", status: "IN_DEVELOPMENT" }, + }); + expect(angelegt.status()).toBe(201); + const projektId = (await angelegt.json()).project.id; + + const aufgabe = await request.post(`/api/projects/${projektId}/tasks`, { + data: { title: "Nicht fuer fremde Augen" }, + }); + expect(aufgabe.status()).toBe(201); + const aufgabenId = (await aufgabe.json()).task.id; + + // Zweites Konto anlegen und in einem eigenen Kontext anmelden. + const neu = await request.post("/api/admin/users", { + data: { ...ZWEITER, role: "USER" }, + }); + expect(neu.status()).toBe(201); + + const fremd = await playwright.request.newContext({ + baseURL: test.info().project.use.baseURL, + }); + await login(fremd, ZWEITER); + + expect((await (await fremd.get("/api/projects")).json()).projects).toHaveLength(0); + + // Fremde IDs muessen 404 liefern, nicht 403 – sonst waere die Existenz + // des Datensatzes bestaetigt. + expect((await fremd.get(`/api/projects/${projektId}`)).status()).toBe(404); + expect( + (await fremd.patch(`/api/projects/${projektId}`, { data: { name: "uebernommen" } })).status(), + ).toBe(404); + expect((await fremd.delete(`/api/projects/${projektId}`)).status()).toBe(404); + expect((await fremd.get(`/api/projects/${projektId}/tasks`)).status()).toBe(404); + expect((await fremd.patch(`/api/tasks/${aufgabenId}`, { data: { title: "x" } })).status()).toBe( + 404, + ); + + // Adminwege bleiben zu. + expect((await fremd.get("/api/admin/users")).status()).toBe(403); + expect( + (await fremd.patch("/api/admin/settings", { data: { mode: "SINGLE_USER" } })).status(), + ).toBe(403); + + // Und das Original ist unveraendert. + const danach = await (await request.get(`/api/projects/${projektId}`)).json(); + expect(danach.project.name).toBe("Geheimprojekt"); + expect(danach.project.tasks).toHaveLength(1); + + await fremd.dispose(); +}); + +test("Aufgaben: Reihenfolge, abgeleiteter Fortschritt, fremde IDs", async ({ request }) => { + await login(request, ADMIN); + + const projekt = ( + await (await request.post("/api/projects", { data: { name: "Aufgabenprojekt" } })).json() + ).project; + + const titel = ["eins", "zwei", "drei"]; + const ids: string[] = []; + for (const title of titel) { + const response = await request.post(`/api/projects/${projekt.id}/tasks`, { + data: { title, status: "TODO" }, + }); + ids.push((await response.json()).task.id); + } + + // Neue Aufgaben haengen sich hinten an. + const angelegt = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks; + expect(angelegt.map((task: { position: number }) => task.position)).toEqual([0, 1, 2]); + + // Umdrehen. + const gedreht = [...ids].reverse(); + const reorder = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, { + data: { columns: { TODO: gedreht } }, + }); + expect(reorder.ok()).toBeTruthy(); + const nachher = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks; + expect(nachher.map((task: { title: string }) => task.title)).toEqual(["drei", "zwei", "eins"]); + + // Eine nach DONE – Fortschritt folgt, sobald eingeschaltet. + await request.patch(`/api/projects/${projekt.id}`, { data: { progressFromTasks: true } }); + await request.patch(`/api/tasks/${ids[0]}`, { data: { status: "DONE" } }); + + const mitFortschritt = ( + await (await request.get(`/api/projects/${projekt.id}`)).json() + ).project; + expect(mitFortschritt.progress).toBe(33); + + // Eine ID aus einem anderen Projekt darf nicht verschoben werden. + const anderes = ( + await (await request.post("/api/projects", { data: { name: "Anderes" } })).json() + ).project; + const fremdeAufgabe = ( + await ( + await request.post(`/api/projects/${anderes.id}/tasks`, { data: { title: "fremd" } }) + ).json() + ).task; + + const untergeschoben = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, { + data: { columns: { DOING: [fremdeAufgabe.id] } }, + }); + const ergebnis = await untergeschoben.json(); + expect(ergebnis.updated).toBe(0); + expect(ergebnis.ignored).toBe(1); + + const unveraendert = ( + await (await request.get(`/api/projects/${anderes.id}/tasks`)).json() + ).tasks[0]; + expect(unveraendert.status).toBe("TODO"); +}); + +test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => { + await login(request, ADMIN); + + await request.post("/api/connections", { + data: { + name: "Mit Token", + provider: "GITEA", + baseUrl: "https://gitea.example.com", + token: "streng-geheimes-token", + }, + }); + + const roh = await (await request.get("/api/projects/export")).text(); + expect(roh).not.toContain("streng-geheimes-token"); + expect(roh).not.toContain("tokenCipher"); + expect(roh).not.toContain("ownerId"); + + const datei = JSON.parse(roh); + const vorher = datei.projects.length; + + // Ueberspringen aendert nichts. + const uebersprungen = await request.post("/api/projects/import", { + data: { data: datei, strategy: "skip", dryRun: false }, + }); + expect((await uebersprungen.json()).summary.skipped).toBe(vorher); + + // Zusaetzlich anlegen verdoppelt. + const zusaetzlich = await request.post("/api/projects/import", { + data: { data: datei, strategy: "rename", dryRun: false }, + }); + expect((await zusaetzlich.json()).summary.renamed).toBe(vorher); + + const jetzt = (await (await request.get("/api/projects")).json()).projects; + expect(jetzt).toHaveLength(vorher * 2); + + // Muell wird abgewiesen. + const muell = await request.post("/api/projects/import", { + data: { data: { irgendwas: true }, strategy: "skip", dryRun: true }, + }); + expect(muell.status()).toBe(422); +}); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..d65efa6 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,62 @@ +import { execFileSync } from "node:child_process"; +import { PrismaClient } from "@prisma/client"; + +/** + * Bereitet die Testdatenbank vor: Schema einspielen und leeren. + * + * Die Datenbank muss vorher existieren. Anlegen ist bewusst nicht Teil des + * Setups – in CI erzeugt sie der Postgres-Dienst, oertlich einmalig: + * + * docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_e2e_test" + */ + +const DATABASE_URL = + process.env.E2E_DATABASE_URL ?? + "postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public"; + +/** + * Reihenfolge egal, weil CASCADE – aber vollstaendig muss sie sein, sonst + * schleppen Laeufe Reste mit. + */ +const TABLES = [ + "Task", + "Note", + "RepoSnapshot", + "Project", + "Credential", + "Session", + "WebAuthnChallenge", + "GitConnection", + "User", + "AppSettings", +]; + +export default async function globalSetup() { + const name = new URL(DATABASE_URL).pathname.replace(/^\//, ""); + + // Schutz vor dem teuersten aller Fehler: eine Testreihe, die die echte + // Datenbank leert. Der Name muss "test" enthalten, sonst passiert nichts. + if (!name.includes("test")) { + throw new Error( + `Die Testdatenbank heisst "${name}" und enthaelt nicht "test". ` + + "Aus Sicherheitsgruenden abgebrochen – E2E_DATABASE_URL pruefen.", + ); + } + + console.log(`[e2e] Datenbank: ${name}`); + + execFileSync("npx", ["prisma", "migrate", "deploy"], { + env: { ...process.env, DATABASE_URL }, + stdio: "pipe", + shell: process.platform === "win32", + }); + + const prisma = new PrismaClient({ datasources: { db: { url: DATABASE_URL } } }); + try { + const list = TABLES.map((table) => `"${table}"`).join(", "); + await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`); + console.log("[e2e] Datenbank geleert."); + } finally { + await prisma.$disconnect(); + } +} diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts new file mode 100644 index 0000000..d4593ee --- /dev/null +++ b/e2e/ui.spec.ts @@ -0,0 +1,109 @@ +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); + await page.mouse.up(); + + await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible(); + + // 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("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/); +}); diff --git a/package-lock.json b/package-lock.json index 2d53ab9..3304b92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nebula", - "version": "0.1.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nebula", - "version": "0.1.0", + "version": "0.5.0", "hasInstallScript": true, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -25,6 +25,7 @@ "zod": "^3.25.28" }, "devDependencies": { + "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.1.7", "@types/node": "^22.15.21", "@types/react": "^19.1.5", @@ -1513,6 +1514,22 @@ "node": ">=20.0.0" } }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@prisma/client": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", @@ -4489,6 +4506,52 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", diff --git a/package.json b/package.json index 88ae46d..67b9d32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula", - "version": "0.5.0", + "version": "0.6.0", "private": true, "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "scripts": { @@ -25,7 +25,10 @@ "setup": "node scripts/setup.mjs", "postinstall": "prisma generate", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "npm run build && playwright test", + "test:e2e:ui": "playwright test --ui", + "test:all": "npm test && npm run test:e2e" }, "prisma": { "seed": "tsx prisma/seed.ts" @@ -47,6 +50,7 @@ "zod": "^3.25.28" }, "devDependencies": { + "@playwright/test": "^1.62.0", "@tailwindcss/postcss": "^4.1.7", "@types/node": "^22.15.21", "@types/react": "^19.1.5", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..80981fd --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,63 @@ +import { defineConfig } from "@playwright/test"; + +/** + * Ende-zu-Ende-Tests gegen die echte Anwendung. + * + * Playwright startet den Produktionsserver selbst und richtet ihn auf eine + * eigene Datenbank. Der Port ist bewusst ein anderer als 3000 und 3100, + * damit eine laufende Instanz nicht stoert. + * + * Voraussetzung: ein Bau (`npm run build`) und eine leere Datenbank, deren + * Name "test" enthaelt – siehe global-setup.ts. Beides erledigt + * `npm run test:e2e`. + */ + +const PORT = Number(process.env.E2E_PORT ?? 3111); +const BASE_URL = `http://localhost:${PORT}`; + +const DATABASE_URL = + process.env.E2E_DATABASE_URL ?? + "postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public"; + +export default defineConfig({ + testDir: "./e2e", + // Die Tests teilen sich eine Datenbank, deshalb nacheinander. + fullyParallel: false, + workers: 1, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]], + timeout: 60_000, + expect: { timeout: 10_000 }, + + globalSetup: "./e2e/global-setup.ts", + + use: { + baseURL: BASE_URL, + locale: "de-DE", + timezoneId: "Europe/Berlin", + trace: "retain-on-failure", + screenshot: "only-on-failure", + // Oertlich der installierte Chrome, damit kein Browser geladen werden + // muss. In CI gibt es keinen – dort das mitgelieferte Chromium + // (siehe "playwright install chromium" im Arbeitsablauf). + ...(process.env.CI ? {} : { channel: "chrome" as const }), + }, + + webServer: { + command: "npm start", + url: `${BASE_URL}/api/health`, + reuseExistingServer: false, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + env: { + DATABASE_URL, + PORT: String(PORT), + APP_URL: BASE_URL, + NODE_ENV: "production", + // Fester Schluessel, damit Laeufe vergleichbar bleiben. + APP_SECRET: "e2e-test-secret-nur-fuer-die-testlaeufe-1234567890", + }, + }, +});