/* Prüft die Aufgabenliste und das Postfach im Kundenportal. Wichtig sind hier drei Dinge, die man leicht falsch baut: 1. Der Kunde darf NUR seine eigenen Punkte anklicken. Ein Knopf, der nichts tut, lässt die Seite kaputt wirken. 2. Offene Kundenpunkte müssen VOR der langen Liste stehen — sonst liest man die Liste als Bericht und überliest seinen Teil. 3. Alle fünf Sprachen müssen vollständig sein. Ein fehlender Schlüssel fällt sonst erst einem französischen Kunden auf. */ 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(4326, r)); 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 }, { 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 }, { id: "a3", titel: "Entwurf der Startseite", beschreibung: null, kategorie: "gestaltung", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null }, { id: "a4", titel: "Entwurf ansehen und Rückmeldung geben", beschreibung: null, kategorie: "gestaltung", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null }, { id: "a5", titel: "Alle Unterseiten bauen", beschreibung: null, kategorie: "umsetzung", status: "in_arbeit", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null }, { id: "a6", titel: "Mehrsprachigkeit", beschreibung: null, kategorie: "umsetzung", status: "offen", wer_dran: "dogfather", nicht_enthalten: 1, hinweis: "Auf Wunsch als Zusatz." }, { id: "a7", titel: "Seite live schalten", beschreibung: null, kategorie: "abschluss", status: "offen", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null }, ]; const FORTSCHRITT = { gesamt: 6, erledigt: 2, inArbeit: 1, beimKunden: 2, prozent: 33, jeKategorie: { start: { gesamt: 2, erledigt: 1 }, gestaltung: { gesamt: 2, erledigt: 1 }, umsetzung: { gesamt: 1, erledigt: 0 }, abschluss: { gesamt: 1, erledigt: 0 } } }; const PROJEKT = { id: "p1", nummer: "P-0001", titel: "Website für Musterbau", status: "design", wartet_auf: "kunde", naechster_schritt: "Deine Rückmeldung zum Entwurf.", /* Ein echtes Datum UND der freie Text daneben. Genau diese Kombination war vorher kaputt: Der freie Text ("Mitte Oktober") wurde durch einen Datumsformatierer geschickt und erschien als "Invalid Date". */ termin_am: "2026-10-15", richttermin: "Mitte Oktober, sobald deine Texte da sind", verbleibende_werktage: 12 }; const ergebnisse = []; function pruefe(name, ok, zusatz) { ergebnisse.push({ name, ok }); console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : "")); } const browser = await chromium.launch(); /* ---- 1. Vollständigkeit aller fünf Sprachen ---- */ { const p = await browser.newPage(); await p.goto("http://127.0.0.1:4326/webdesign/portal.html"); await p.waitForTimeout(600); const luecken = await p.evaluate(() => { const w = window.I18N_WD || {}; const sprachen = ["de", "de-CH", "en", "fr", "pt"]; const fehlt = []; for (const [k, v] of Object.entries(w)) { for (const sp of sprachen) { if (!v[sp] || !String(v[sp]).trim()) fehlt.push(k + "/" + sp); } } return { fehlt, anzahl: Object.keys(w).length }; }); console.log("\n=== Sprachen ==="); pruefe("alle Texte in allen fünf Sprachen vorhanden", luecken.fehlt.length === 0, luecken.fehlt.slice(0, 6).join(", ")); console.log(" (" + luecken.anzahl + " Texte geprüft)"); await p.close(); } for (const [wo, breite, hoehe] of [["Computer", 1280, 950], ["Handy", 390, 844]]) { const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } }); const fehlerImProtokoll = []; seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message)); let abgehakt = null; // Nur die API abfangen, nicht die Seite selbst. await seite.route("**/webdesign/portal/**", (route) => { /* Auf den PFAD prüfen, nicht auf die ganze Adresse. Der API-Server heisst "postfach.dogfather-universe.com" -- ein url.includes("/postfach") trifft also den HOSTNAMEN und damit JEDE Anfrage. Beim ersten Lauf bekam deshalb auch die Übersicht die Postfach-Antwort, und der Fehler sah aus wie ein Fehler im Portal ("Cannot read properties of undefined"). Er lag im Test. */ const u = new URL(route.request().url()).pathname; if (u.includes("/projekt/p1/aufgaben")) { return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: AUFGABEN, fortschritt: FORTSCHRITT } }); } if (u.includes("/aufgabe/") && u.includes("/erledigt")) { abgehakt = u.split("/aufgabe/")[1].split("/")[0]; return route.fulfill({ json: { ok: true, status: "erledigt", fortschritt: FORTSCHRITT } }); } if (u.includes("/projekt/p1")) { return route.fulfill({ json: { ok: true, projekt: PROJEKT, zahlungen: [], dateien: [], aenderungen: [], nachrichten: [] } }); } if (u.includes("/postfach")) { if (route.request().method() === "POST") return route.fulfill({ json: { ok: true, id: "x" } }); return route.fulfill({ json: { ok: true, projekte: [{ id: "p1", titel: "Website für Musterbau" }], nachrichten: [ { id: "m1", autor: "kunde", text: "Kurze Frage zur Domain.", projekt_titel: null, erstellt_am: new Date(Date.now() - 7200000).toISOString() }, { id: "m2", autor: "dogfather", text: "Die läuft schon auf dich, alles gut.", projekt_titel: null, erstellt_am: new Date(Date.now() - 3600000).toISOString() }, ] } }); } if (u.includes("/uebersicht")) { return route.fulfill({ json: { ok: true, kunde: { name: "Anna" }, projekte: [PROJEKT], offeneZahlungen: [], abos: [] } }); } return route.fulfill({ json: { ok: true } }); }); await seite.addInitScript(() => { sessionStorage.setItem("wd-portal-token", "t"); sessionStorage.setItem("wd-sitzung-aktiv", "1"); }); await seite.goto("http://127.0.0.1:4326/webdesign/portal.html"); await seite.waitForTimeout(1000); console.log("\n=== " + wo + " ==="); // ---- Postfach auf der Übersicht ---- pruefe(wo + ": Postfach steht auf der Übersicht", await seite.locator("#po-pf-senden").count() === 1); pruefe(wo + ": bisherige Nachrichten sind da", await seite.locator("#po-postfach .po-nachricht").count() === 2); // Leere Nachricht muss abgelehnt werden, nicht stillschweigend gesendet await seite.click("#po-pf-senden"); await seite.waitForTimeout(300); pruefe(wo + ": leere Nachricht wird abgelehnt", await seite.locator("#po-pf-feld.wd-feld--fehler").count() === 1); await seite.screenshot({ path: `pruef-portal-postfach-${wo.toLowerCase()}.png`, fullPage: true }); // ---- Projekt öffnen ---- await seite.click("[data-projekt]"); await seite.waitForTimeout(900); const m = await seite.evaluate(() => { const gruppen = Array.from(document.querySelectorAll(".po-aufg-gruppe")); const farben = gruppen.map((g) => getComputedStyle(g.querySelector(".po-aufg-kopf")).color); const dubist = document.querySelector(".po-dubist"); const ersteGruppe = gruppen[0]; const knoepfe = document.querySelectorAll("button.po-aufg-haken"); const feste = document.querySelectorAll(".po-aufg-haken--fest"); const aufgKarte = document.querySelector(".po-aufg-stand"); const zahlKarte = Array.from(document.querySelectorAll(".wd-karte h3")) .find((h) => h.textContent.includes("Zahlung")); // Anordnung einer Zeile pruefen (Lehre aus der Verwaltung) const z = document.querySelector(".po-aufg-gruppe .po-aufg"); const k = z?.querySelector(".po-aufg-haken")?.getBoundingClientRect(); const t = z?.querySelector(".po-aufg-titel")?.getBoundingClientRect(); return { gruppen: gruppen.map((g) => g.dataset.k), farbenVerschieden: new Set(farben).size, dubistDa: !!dubist, dubistVorListe: dubist && ersteGruppe ? dubist.getBoundingClientRect().top < ersteGruppe.getBoundingClientRect().top : false, knoepfe: knoepfe.length, feste: feste.length, standDa: !!aufgKarte, aufgabenVorZahlungen: aufgKarte && zahlKarte ? aufgKarte.getBoundingClientRect().top < zahlKarte.getBoundingClientRect().top : false, kaestchenVorTitel: k && t ? k.right <= t.left + 1 : false, balkenTeile: document.querySelectorAll(".po-fs-teil").length, }; }); pruefe(wo + ": alle vier Abschnitte erscheinen", m.gruppen.join(",") === "start,gestaltung,umsetzung,abschluss", m.gruppen.join(",")); pruefe(wo + ": jeder Abschnitt hat eine eigene Farbe", m.farbenVerschieden === 4, "n=" + m.farbenVerschieden); pruefe(wo + ": Fortschrittsbalken hat vier Teile", m.balkenTeile === 4); pruefe(wo + ": „Das brauche ich von dir“ ist da", m.dubistDa); pruefe(wo + ": … und steht VOR der langen Liste", m.dubistVorListe); pruefe(wo + ": Aufgabenliste steht vor den Zahlungen", m.aufgabenVorZahlungen); pruefe(wo + ": grosse Zahl statt Prozent", m.standDa); pruefe(wo + ": Kästchen steht links vom Titel", m.kaestchenVorTitel); /* Der Kunde hat 2 offene eigene Punkte. Sie erscheinen zweimal (oben und in der Gruppe) -> 4 Knöpfe. Alle übrigen Kästchen sind fest. */ pruefe(wo + ": nur eigene Punkte sind anklickbar", m.knoepfe === 4, "Knöpfe=" + m.knoepfe + ", fest=" + m.feste); pruefe(wo + ": fremde Punkte haben KEINEN Knopf", m.feste === 5, "n=" + m.feste); // Abhaken loest den richtigen Aufruf aus await seite.locator("button.po-aufg-haken").first().click(); await seite.waitForTimeout(700); pruefe(wo + ": Abhaken meldet den richtigen Punkt", abgehakt === "a2", "gemeldet: " + abgehakt); if (breite < 900) { const zuKlein = await seite.evaluate(() => Array.from(document.querySelectorAll("button.po-aufg-haken")) .filter((b) => { const r = b.getBoundingClientRect(); return r.width < 24 || r.height < 24; }).length); pruefe(wo + ": Kästchen sind gross genug zum Antippen", zuKlein === 0, "zu klein: " + zuKlein); } /* ---- Liefertermin und verbleibende Zeit ---- Der Kunde soll ab der Annahme sehen, WANN es fertig wird und wie viel Zeit noch ist. Geprueft wird die Projektkarte, nicht die Detailseite. */ const karte = await seite.locator("[data-projekt]").first().innerText(); pruefe(wo + ": der Liefertermin steht lesbar auf der Karte", karte.includes("15.10.2026"), karte.slice(0, 220)); pruefe(wo + ": die verbleibende Zeit steht dabei", /12 Werktage/.test(karte), karte.slice(0, 220)); /* Der wichtigste Punkt: Der freie Text darf NICHT durch einen Datumsformatierer laufen. Vorher stand hier "Invalid Date". */ pruefe(wo + ": der freie Terminhinweis erscheint als Text, nicht als Invalid Date", karte.includes("Mitte Oktober") && !/Invalid/i.test(karte), karte.slice(0, 220)); /* Farbe allein traegt die Aussage nicht -- bei 12 Tagen ist es entspannt, also darf nichts alarmieren. */ const fristTon = await seite.locator(".po-frist").first() .getAttribute("class").catch(() => ""); pruefe(wo + ": bei 12 Tagen wird nicht gewarnt", !/--rot|--warn/.test(fristTon || ""), fristTon); pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | ")); await seite.screenshot({ path: `pruef-portal-aufgaben-${wo.toLowerCase()}.png`, fullPage: true }); 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;