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) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,12 @@ yarn-error.log*
|
|||||||
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
|
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
|
||||||
/backups
|
/backups
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/blob-report
|
||||||
|
/playwright/.cache
|
||||||
|
|
||||||
# editors
|
# editors
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
+227
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+109
@@ -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/);
|
||||||
|
});
|
||||||
Generated
+65
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"zod": "^3.25.28"
|
"zod": "^3.25.28"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.0",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
@@ -1513,6 +1514,22 @@
|
|||||||
"node": ">=20.0.0"
|
"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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
@@ -4489,6 +4506,52 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.23",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
|
|||||||
+6
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,7 +25,10 @@
|
|||||||
"setup": "node scripts/setup.mjs",
|
"setup": "node scripts/setup.mjs",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
"test": "vitest run",
|
"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": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
"zod": "^3.25.28"
|
"zod": "^3.25.28"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.0",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user