/* Prüft die neuen Bereiche der Verwaltung: Projekte mit Aufgabenliste und das Postfach. Wieder mit abgefangenen Netzanfragen, aber mit Daten, die den heiklen Faellen entsprechen: - ein Punkt, der auf den KUNDEN wartet (muss auffallen) - ein Punkt "nicht enthalten" (muss als solcher erkennbar sein) - eine INTERNE Notiz im Postfach (darf nicht wie eine gesendete Nachricht aussehen) */ import { chromium } from "playwright"; import { createServer } from "node:http"; import { readFileSync, existsSync } from "node:fs"; import { join, extname } from "node:path"; const W = process.cwd(); const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" }; const srv = createServer((q, r) => { const p = join(W, decodeURIComponent(q.url.split("?")[0])); if (existsSync(p) && !p.endsWith("/")) { r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" }); return r.end(readFileSync(p)); } r.writeHead(404).end(""); }); await new Promise((r) => srv.listen(4325, r)); const PROJEKT = { id: "p1", nummer: "P-0001", titel: "Website für Musterbau", paket: "website", status: "design", kunde_id: "k1", kunde_name: "Anna Muster", kunde_firma: "Musterbau", preis_cent: 180000, waehrung: "EUR", richttermin: null, archiviert: 0, anzahlung_bezahlt: 1, rest_bezahlt: 0, erstellt_am: new Date().toISOString(), aufg_gesamt: 6, aufg_erledigt: 2, aufg_kunde: 2, ungelesen: 1, naechster_schritt: "Deine Rueckmeldung zum Entwurf.", wartet_auf: "kunde", kunde_email: "anna@example.com", portfolio_freigabe: 0, }; const AENDERUNGEN = [ { id: "ae1", titel: "Noch eine Unterseite", beschreibung: "Eine Seite fuer Referenzen waere schoen.", status: "eingereicht", zusatzpreis_cent: null, zusatzdauer_text: null, erstellt_am: new Date(Date.now() - 86400000).toISOString() }, { id: "ae2", titel: "Anderes Logo", beschreibung: "Wir haben ein neues.", status: "angenommen", zusatzpreis_cent: 12000, zusatzdauer_text: "2 Tage", erstellt_am: new Date(Date.now() - 172800000).toISOString() }, ]; const PNACHRICHTEN = [ { id: "pn1", autor: "kunde", text: "Wann kommt der naechste Entwurf?", erstellt_am: new Date(Date.now() - 7200000).toISOString() }, { id: "pn2", autor: "dogfather", text: "Morgen Abend.", erstellt_am: new Date(Date.now() - 3600000).toISOString() }, ]; const AUFGABEN = [ { id: "a1", titel: "Briefing-Gespräch führen", beschreibung: "Ziel und Umfang klären.", kategorie: "start", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 10 }, { id: "a2", titel: "Texte und Bilder zusammenstellen", beschreibung: "Lieber früh als vollständig.", kategorie: "start", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null, reihenfolge: 20 }, { id: "a3", titel: "Entwurf der Startseite", beschreibung: null, kategorie: "gestaltung", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 110 }, { id: "a4", titel: "Entwurf ansehen und Rückmeldung geben", beschreibung: null, kategorie: "gestaltung", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null, reihenfolge: 130 }, { id: "a5", titel: "Alle Unterseiten bauen", beschreibung: null, kategorie: "umsetzung", status: "in_arbeit", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 210 }, { id: "a6", titel: "Mehrsprachigkeit", beschreibung: null, kategorie: "umsetzung", status: "offen", wer_dran: "dogfather", nicht_enthalten: 1, hinweis: "Nicht im Paket enthalten — auf Wunsch als Zusatz.", reihenfolge: 260 }, { id: "a7", titel: "Seite live schalten", beschreibung: null, kategorie: "abschluss", status: "offen", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 860 }, ]; const FORTSCHRITT = { gesamt: 6, erledigt: 2, inArbeit: 1, beimKunden: 2, prozent: 33, jeKategorie: { start: { gesamt: 2, erledigt: 1, inArbeit: 0 }, gestaltung: { gesamt: 2, erledigt: 1, inArbeit: 0 }, umsetzung: { gesamt: 1, erledigt: 0, inArbeit: 1 }, abschluss: { gesamt: 1, erledigt: 0, inArbeit: 0 }, }, }; const VERLAUF = [ { id: "n1", autor: "kunde", text: "Hallo! Können wir das Logo noch etwas grösser machen?", intern: 0, projekt_titel: null, erstellt_am: new Date(Date.now() - 7200000).toISOString() }, { id: "n2", autor: "dogfather", text: "Klar, mache ich heute noch.", intern: 0, projekt_titel: "Website für Musterbau", erstellt_am: new Date(Date.now() - 3600000).toISOString() }, { id: "n3", autor: "dogfather", text: "Nicht vergessen: Rechnung erst nach Abnahme.", intern: 1, projekt_titel: null, erstellt_am: new Date(Date.now() - 1800000).toISOString() }, ]; const ergebnisse = []; function pruefe(name, ok, zusatz) { ergebnisse.push({ name, ok }); console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : "")); } const browser = await chromium.launch(); for (const [wo, breite, hoehe] of [["Computer", 1440, 950], ["Handy", 390, 844]]) { const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } }); const fehlerImProtokoll = []; seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message)); /* Mitschrift dessen, was die Seite tatsächlich an den Server schickt. Nur so lässt sich prüfen, ob ein Klick auch WIRKLICH das Richtige speichert — dass sich ein Knopf einfärbt, beweist gar nichts. */ const gesendet = []; let beziffert = null; let projektNachricht = null; // NUR die API abfangen. Ein Muster, das den ganzen Ordner /webdesign // umfasst, trifft auch die Seite SELBST -- dann bekommt der Browser // statt der HTML-Datei ein JSON serviert. Genau das ist beim ersten // Lauf passiert: Die Seite blieb leer, und der Test meldete // "Abzeichen fehlt" statt "Seite gar nicht geladen". // (Und der Hinweis steht in Zeilenkommentaren, weil das Glob-Muster // ein Sternchen-Schrägstrich enthält und einen Blockkommentar // vorzeitig beenden würde -- auch das ist hier schon passiert.) await seite.route("**/webdesign/admin/**", (route) => { const u = route.request().url(); if (u.includes("/admin/projekte/p1/alles")) { return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: AUFGABEN, fortschritt: FORTSCHRITT, aenderungen: AENDERUNGEN, nachrichten: PNACHRICHTEN } }); } if (u.includes("/admin/projekte/p1/aufgaben")) { return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: AUFGABEN, fortschritt: FORTSCHRITT } }); } if (u.match(/\/admin\/projekte\/p1$/)) { gesendet.push(route.request().postDataJSON()); return route.fulfill({ json: { ok: true } }); } if (u.includes("/admin/aenderungen/")) { beziffert = route.request().postDataJSON(); return route.fulfill({ json: { ok: true, preisText: "250,00 €" } }); } if (u.includes("/admin/projekt-nachricht")) { projektNachricht = route.request().postDataJSON(); return route.fulfill({ json: { ok: true } }); } if (u.includes("/admin/projekte")) return route.fulfill({ json: { ok: true, projekte: [PROJEKT] } }); if (u.includes("/admin/postfach-zaehler")) return route.fulfill({ json: { ok: true, ungelesen: 1 } }); if (u.includes("/admin/postfach/k1")) { return route.fulfill({ json: { ok: true, kunde: { id: "k1", name: "Anna Muster", email: "anna@example.com", sprache: "de" }, nachrichten: VERLAUF, projekte: [{ id: "p1", titel: "Website für Musterbau" }] } }); } if (u.includes("/admin/postfach")) { return route.fulfill({ json: { ok: true, ungelesen: 1, verlaeufe: [{ kunde_id: "k1", name: "Anna Muster", firma: "Musterbau", email: "anna@example.com", anzahl: 3, ungelesen: 1, letzte: new Date(Date.now() - 1800000).toISOString(), letzter_text: "Nicht vergessen: Rechnung erst nach Abnahme.", letzter_autor: "dogfather" }] } }); } /* Die Uebersicht ist seit dem 23.08.2026 die Startansicht und wird deshalb bei JEDEM Laden abgerufen. Ohne eigene Ersatzantwort fiel sie in den Auffang unten und bekam ein blankes {ok:true} -- was einen Fehler ausloeste, der als "keine Fehler im Protokoll" fehlschlug, ohne zu verraten, woher er kam. */ if (u.includes("/admin/uebersicht")) return route.fulfill({ json: { ok: true, beiMir: { anfragen: { n: 0 }, nachrichten: { n: 0 }, wuensche: { n: 0 }, widerrufe: { n: 0 }, projekte: 0 }, beiKunde: { projekte: 0, aufgaben: 0, zahlungen: { n: 0, summe: 0 }, zahlungenText: "0,00 €" }, geld: { monat: { n: 0, brutto: 0, netto: 0, bruttoText: "0,00 €", nettoText: "0,00 €" }, gesamt: { n: 0, bruttoText: "0,00 €" }, gebuehrenText: "0,00 €" }, projekte: [], verlauf: [], bestand: {} } }); if (u.includes("/admin/anfragen")) return route.fulfill({ json: { ok: true, anfragen: [] } }); if (u.includes("/admin/kunden")) return route.fulfill({ json: { ok: true, kunden: [] } }); return route.fulfill({ json: { ok: true } }); }); await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } })); await seite.addInitScript(() => { sessionStorage.setItem("wd-vw-token", "t"); sessionStorage.setItem("wd-sitzung-aktiv", "1"); }); await seite.goto("http://127.0.0.1:4325/webdesign/verwaltung.html"); await seite.waitForTimeout(900); console.log("\n=== " + wo + " ==="); // Abzeichen am Reiter const abz = await seite.locator("#vw-pf-zaehler").innerText().catch(() => ""); const abzSichtbar = await seite.locator("#vw-pf-zaehler").isVisible().catch(() => false); pruefe(wo + ": Postfach-Abzeichen zeigt die ungelesene Nachricht", abzSichtbar && abz === "1", abz); // ---- Projekte ---- await seite.click('[data-tab="projekte"]'); await seite.waitForTimeout(500); pruefe(wo + ": Projektliste zeigt das Projekt", await seite.locator("[data-projekt]").count() === 1); pruefe(wo + ": „wartet auf Kunde“ ist sichtbar", await seite.locator(".vw-wartet").count() === 1); await seite.click("[data-projekt]"); await seite.waitForTimeout(600); const m = await seite.evaluate(() => { const gruppen = Array.from(document.querySelectorAll(".vw-aufg-gruppe")).map((g) => g.dataset.k); const farben = Array.from(document.querySelectorAll(".vw-aufg-gruppe")).map((g) => getComputedStyle(g.querySelector(".vw-aufg-kopf")).color); const balken = Array.from(document.querySelectorAll(".vw-fs-teil > i")).map((i) => getComputedStyle(i).backgroundColor); const zeile = document.querySelector('[data-aufgabe="a2"]'); const normal = document.querySelector('[data-aufgabe="a5"]'); return { gruppen, farben, balkenFarben: [...new Set(balken)].length, kundenZeileAnders: zeile && normal ? getComputedStyle(zeile).backgroundColor !== getComputedStyle(normal).backgroundColor : false, nichtEnthalten: document.querySelectorAll(".vw-aufg-nicht").length, haken: document.querySelectorAll(".vw-aufg-haken").length, erledigtDurchgestrichen: getComputedStyle( document.querySelector('[data-aufgabe="a1"] .vw-aufg-titel')).textDecorationLine, blickFelder: document.querySelectorAll(".vw-blick-feld").length, /* Die REIHENFOLGE im Bild, nicht im Quelltext. Diese Pruefung fehlte im ersten Anlauf, und deshalb meldete der Test 37 von 37 gruen, waehrend jede Zeile sichtbar falsch herum gebaut war: Kaestchen, dann die kleinen Knoepfe, und der Titel ganz rechts. Alle inhaltlichen Pruefungen (Farben, Anzahl, Durchgestrichenes) trafen zu -- keine einzige schaute darauf, WO die Dinge liegen. Aufgefallen ist es erst am Screenshot. Lehre: Wer eine Anordnung baut, muss die Anordnung pruefen, nicht nur ihre Bestandteile. */ anordnung: (() => { const z = document.querySelector('[data-aufgabe="a2"]'); if (!z) return null; const k = z.querySelector(".vw-aufg-haken").getBoundingClientRect(); const t = z.querySelector(".vw-aufg-titel").getBoundingClientRect(); const r = z.querySelector(".vw-aufg-rechts").getBoundingClientRect(); const b = z.querySelector(".vw-aufg-text"); return { reihenfolgeStimmt: k.right <= t.left + 1 && t.right <= r.left + 1, textUnterTitel: b ? b.getBoundingClientRect().top >= t.bottom - 2 : true, textNichtUnterKaestchen: b ? b.getBoundingClientRect().left >= k.right - 1 : true, }; })(), }; }); pruefe(wo + ": alle vier Abschnitte erscheinen", m.gruppen.join(",") === "start,gestaltung,umsetzung,abschluss", m.gruppen.join(",")); pruefe(wo + ": jeder Abschnitt hat eine EIGENE Farbe", new Set(m.farben).size === 4, m.farben.join(" | ")); pruefe(wo + ": Punkt für den Kunden hebt sich ab", m.kundenZeileAnders); pruefe(wo + ": „nicht enthalten“ ist gekennzeichnet", m.nichtEnthalten === 1); pruefe(wo + ": jeder Punkt hat ein Kästchen zum Abhaken", m.haken === 7, "n=" + m.haken); pruefe(wo + ": Erledigtes ist durchgestrichen", m.erledigtDurchgestrichen.includes("line-through"), m.erledigtDurchgestrichen); pruefe(wo + ": Zusammenfassung mit vier Zahlen", m.blickFelder === 4); pruefe(wo + ": Kästchen — Titel — Knöpfe stehen in dieser Reihenfolge", m.anordnung?.reihenfolgeStimmt); pruefe(wo + ": die Erklärung steht UNTER dem Titel", m.anordnung?.textUnterTitel); pruefe(wo + ": die Erklärung ist am Titel ausgerichtet, nicht am Rand", m.anordnung?.textNichtUnterKaestchen); /* ---- Stand ändern ---- Dafür gab es bis zu dieser Erweiterung KEINE Oberfläche. Der Kunde sah Phase und "nächster Schritt" ganz oben in seinem Portal, und beides liess sich nirgends ändern — ein Projekt blieb für immer im Briefing stehen, egal wie weit es wirklich war. */ pruefe(wo + ": alle Phasen sind wählbar", await seite.locator("[data-phase]").count() === 9, "7 Phasen + 2 Sonderfälle"); pruefe(wo + ": die aktuelle Phase ist hervorgehoben", await seite.locator('[data-phase="design"].vw-an').count() === 1); pruefe(wo + ": „wer ist am Zug“ zeigt den Kunden", await seite.locator('[data-wartet="kunde"].vw-an').count() === 1); gesendet.length = 0; await seite.click('[data-phase="entwicklung"]'); await seite.waitForTimeout(400); pruefe(wo + ": Phasenklick speichert sofort", gesendet.some((g) => g && g.status === "entwicklung"), JSON.stringify(gesendet)); await seite.click('[data-wartet="dogfather"]'); await seite.waitForTimeout(400); pruefe(wo + ": „wer ist am Zug“ speichert sofort", gesendet.some((g) => g && g.wartetAuf === "dogfather")); await seite.fill("#vw-p-schritt", "Entwurf ansehen und Rückmeldung geben"); await seite.fill("#vw-p-termin", "Mitte Oktober"); gesendet.length = 0; await seite.click("#vw-p-speichern"); await seite.waitForTimeout(500); pruefe(wo + ": „nächster Schritt“ und Termin werden gespeichert", gesendet.some((g) => g && g.naechsterSchritt === "Entwurf ansehen und Rückmeldung geben" && g.richttermin === "Mitte Oktober"), JSON.stringify(gesendet)); gesendet.length = 0; await seite.check("#vw-p-rest"); await seite.waitForTimeout(400); pruefe(wo + ": Zahlungsstand wird sofort gespeichert", gesendet.some((g) => g && g.restBezahlt === true), JSON.stringify(gesendet)); /* ---- Änderungswunsch beziffern ---- Der Kunde konnte seit jeher Ideen einreichen — beziffern ging nur serverseitig. Der Kreis war offen: Er schickt etwas los und hört nie wieder davon. */ pruefe(wo + ": beide Änderungswünsche erscheinen", await seite.locator("[data-ae]").count() === 2); pruefe(wo + ": nur der OFFENE hat ein Bezifferungsfeld", await seite.locator("[data-beziffern]").count() === 1); // Ohne Preis darf nichts rausgehen — der Kunde könnte sonst nicht entscheiden. beziffert = null; await seite.click("[data-beziffern]"); await seite.waitForTimeout(400); pruefe(wo + ": ohne Preis wird NICHT gesendet", beziffert === null, JSON.stringify(beziffert)); await seite.fill(".vw-ae-preis", "250"); await seite.fill(".vw-ae-dauer", "3 Tage"); await seite.click("[data-beziffern]"); await seite.waitForTimeout(600); pruefe(wo + ": mit Preis geht das Angebot raus", beziffert && beziffert.preisEuro === 250 && beziffert.dauer === "3 Tage", JSON.stringify(beziffert)); /* ---- Nachricht zum Projekt ---- Ohne sie hiess es: Der Kunde schreibt im Projekt, und ich kann ihm nur woanders antworten. */ pruefe(wo + ": Projektnachrichten erscheinen", await seite.locator("#vw-p-blasen .vw-blase").count() === 2); await seite.fill("#vw-p-nachricht", "Der Entwurf kommt morgen."); await seite.click("#vw-p-senden"); await seite.waitForTimeout(500); pruefe(wo + ": Antwort zum Projekt geht raus", projektNachricht && projektNachricht.text === "Der Entwurf kommt morgen." && projektNachricht.projektId === "p1", JSON.stringify(projektNachricht)); // Touch-Ziele auf dem Handy if (breite < 900) { const zuKlein = await seite.evaluate(() => Array.from(document.querySelectorAll(".vw-aufg-haken, .vw-mini")) .filter((b) => { const r = b.getBoundingClientRect(); return r.width < 24 || r.height < 24; }).length); pruefe(wo + ": alle Knöpfe sind gross genug zum Antippen", zuKlein === 0, "zu klein: " + zuKlein); } await seite.screenshot({ path: `pruef-aufgaben-${wo.toLowerCase()}.png` }); await seite.keyboard.press("Escape"); await seite.waitForTimeout(300); // ---- Postfach ---- await seite.click('[data-tab="postfach"]'); await seite.waitForTimeout(500); pruefe(wo + ": Postfach zeigt den Verlauf", await seite.locator("[data-verlauf]").count() === 1); await seite.click("[data-verlauf]"); await seite.waitForTimeout(600); const pf = await seite.evaluate(() => { const intern = document.querySelector(".vw-blase--intern"); const wir = document.querySelector(".vw-blase--wir"); const kunde = document.querySelector(".vw-blase--kunde"); const lauf = document.getElementById("vw-blasen"); return { /* Auf den Postfach-Verlauf eingegrenzt. Ein document-weites ".vw-blase" zaehlt auch die Nachrichten der Projektansicht mit -- die ist zwar geschlossen, steht aber weiter im Dokument. */ anzahl: document.querySelectorAll("#vw-blasen .vw-blase").length, internDa: !!intern, internAnders: intern && wir ? getComputedStyle(intern).borderStyle !== getComputedStyle(wir).borderStyle : false, kundeLinks: kunde && wir ? kunde.getBoundingClientRect().left < wir.getBoundingClientRect().left : false, amEnde: lauf ? lauf.scrollTop + lauf.clientHeight >= lauf.scrollHeight - 5 : false, schalterDa: !!document.getElementById("vw-pf-intern"), }; }); pruefe(wo + ": alle drei Nachrichten erscheinen", pf.anzahl === 3, "n=" + pf.anzahl); pruefe(wo + ": die interne Notiz ist da", pf.internDa); pruefe(wo + ": interne Notiz sieht ANDERS aus als eine gesendete", pf.internAnders); pruefe(wo + ": Kunde links, eigene Nachrichten rechts", pf.kundeLinks); pruefe(wo + ": Verlauf springt ans Ende", pf.amEnde); // Der Schalter muss das Schreibfeld sichtbar umfaerben const vorher = await seite.evaluate(() => getComputedStyle(document.getElementById("vw-schreibfeld")).borderColor); await seite.check("#vw-pf-intern"); await seite.waitForTimeout(250); const nachher = await seite.evaluate(() => getComputedStyle(document.getElementById("vw-schreibfeld")).borderColor); pruefe(wo + ": „interne Notiz“ färbt das Schreibfeld sichtbar um", vorher !== nachher, vorher + " -> " + nachher); pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | ")); await seite.screenshot({ path: `pruef-postfach-${wo.toLowerCase()}.png` }); await seite.close(); } await browser.close(); srv.close(); const fehler = ergebnisse.filter((e) => !e.ok).length; console.log(`\n===== ${ergebnisse.length - fehler} bestanden, ${fehler} fehlgeschlagen =====`); process.exitCode = fehler ? 1 : 0;