/* ===================================================================== Die Verwaltung auf dem Handy — mit echten Inhalten. WARUM MIT NACHGEBILDETEN DATEN Die Verwaltung liegt hinter der Zugangswand, ein Zugangscode liegt hier nicht vor. Eine leere Seite zu prüfen bringt aber nichts: Genau die Fehler, um die es geht — überlaufende Zeilen, abgeschnittene Knöpfe, zu enge Tippziele — entstehen erst, wenn richtige Inhalte darin stehen. Ein leerer Kasten läuft nie über. Deshalb werden die Datenabrufe abgefangen und mit Inhalten beantwortet, wie sie im Betrieb vorkommen: lange Firmennamen, lange E-Mail-Adressen, mehrstellige Beträge. Absichtlich am oberen Rand des Realistischen, denn dort brechen Layouts. WAS GEMESSEN WIRD 1. Läuft etwas seitlich über? (der klassische Handy-Fehler) 2. Ragt ein Element aus dem Bildschirm? 3. Sind Tippziele groß genug? (WCAG 2.2: 24px Minimum, Apple und Google empfehlen 44px) 4. Überlappen sich Elemente? 5. Ist Text lesbar groß? ===================================================================== */ import { chromium } from "playwright"; import http from "http"; import fs from "fs"; import path from "path"; const TYP = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".json": "application/json", ".ico": "image/x-icon", ".jpg": "image/jpeg", ".webmanifest": "application/manifest+json", }; const srv = http.createServer((q, r) => { const p = path.join(process.cwd(), decodeURIComponent(q.url.split("?")[0])); if (fs.existsSync(p) && !p.endsWith("/")) { r.writeHead(200, { "content-type": TYP[path.extname(p)] || "text/plain" }); return r.end(fs.readFileSync(p)); } r.writeHead(404); r.end(""); }); let ok = 0, fehl = 0; const funde = []; const pruefe = (name, gut, info) => { if (gut) { ok++; console.log(" ok " + name + (info ? " -> " + info : "")); } else { fehl++; console.log(" FEHL " + name + (info ? " -> " + info : "")); funde.push(name + " — " + info); } }; /* --------------------------------------------------------------------- NACHGEBILDETE DATEN Bewusst unbequem: lange Namen, lange Adressen, grosse Zahlen. Wer mit "Max Muster" und "12,00" prueft, findet die Fehler nicht, die im Betrieb auftreten. --------------------------------------------------------------------- */ const jetzt = new Date().toISOString(); const vorTagen = (n) => new Date(Date.now() - n * 864e5).toISOString(); const ANFRAGEN = [ { id: "a1", nummer: "A-2608-0012", name: "Immobilienverwaltung Sonnenhof GmbH & Co. KG", email: "kontakt.projektanfragen@immobilienverwaltung-sonnenhof.example.com", paket: "shop", status: "neu", budget: "5.000–8.000 €", termin: "Q4", sprache: "de", erstellt_am: vorTagen(9), kunde_id: null }, { id: "a2", nummer: "A-2608-0011", name: "Bäckerei Krumm", email: "info@baeckerei-krumm.example", paket: "onepager", status: "rueckfrage", budget: "1.200 €", termin: "sofort", sprache: "de", erstellt_am: vorTagen(2), kunde_id: null }, { id: "a3", nummer: "A-2608-0010", name: "Praxis Dr. med. Hohenstein-Wittelsbach", email: "empfang@praxis-hohenstein-wittelsbach.example.de", paket: "website", status: "angenommen", budget: "3.400 €", termin: "November", sprache: "de", erstellt_am: vorTagen(21), kunde_id: "k1" }, ]; const ANTWORTEN = { "/webdesign/admin/uebersicht": { ok: true, beiMir: { anfragen: { n: 3, aeltestes: vorTagen(9) }, nachrichten: { n: 7, aeltestes: vorTagen(4) }, wuensche: { n: 2, aeltestes: vorTagen(6) }, projekte: 4, widerrufe: { n: 1, aeltestes: vorTagen(1) }, }, beiKunde: { projekte: 3, aufgaben: 11, zahlungen: { n: 2, aeltestes: vorTagen(31) }, zahlungenText: "4.870,00 €", }, geld: { monat: { n: 6, bruttoText: "7.412,50 €", nettoText: "7.148,22 €" }, gesamt: { n: 38, bruttoText: "48.930,00 €" }, gebuehrenText: "264,28 €", }, projekte: [ { id: "p1", nummer: "P-2608-0004", titel: "Onlineshop für Bastelbedarf mit Rabattsystem", kunde_name: "Immobilienverwaltung Sonnenhof GmbH & Co. KG", wartet_auf: "dogfather", aufg_gesamt: 24, aufg_fertig: 17 }, { id: "p2", nummer: "P-2608-0003", titel: "Einseiter Bäckerei", kunde_name: "Bäckerei Krumm", wartet_auf: "kunde", aufg_gesamt: 12, aufg_fertig: 12 }, ], }, "/webdesign/admin/anfragen": { ok: true, anfragen: ANFRAGEN }, "/webdesign/admin/kunden": { ok: true, kunden: [ { id: "k1", nummer: "K-2608-0003", name: "Praxis Dr. med. Hohenstein-Wittelsbach", email: "empfang@praxis-hohenstein-wittelsbach.example.de", firma: "Praxisgemeinschaft", projekte: 2, erstellt_am: vorTagen(40) }, { id: "k2", nummer: "K-2608-0002", name: "Bäckerei Krumm", email: "info@baeckerei-krumm.example", firma: "", projekte: 1, erstellt_am: vorTagen(60) }, ] }, "/webdesign/admin/projekte": { ok: true, projekte: [ { id: "p1", nummer: "P-2608-0004", titel: "Onlineshop für Bastelbedarf mit Rabattsystem", kunde_name: "Immobilienverwaltung Sonnenhof GmbH & Co. KG", status: "umsetzung", wartet_auf: "dogfather", preis: 684000, aufg_gesamt: 24, aufg_fertig: 17, liefertermin: vorTagen(-14), erstellt_am: vorTagen(30) }, { id: "p2", nummer: "P-2608-0003", titel: "Einseiter Bäckerei", kunde_name: "Bäckerei Krumm", status: "abnahme", wartet_auf: "kunde", preis: 120000, aufg_gesamt: 12, aufg_fertig: 12, liefertermin: vorTagen(-3), erstellt_am: vorTagen(45) }, ] }, "/webdesign/admin/zahlungen": { ok: true, zahlungen: [ { id: "z1", nummer: "R-2608-0009", kunde_name: "Immobilienverwaltung Sonnenhof GmbH & Co. KG", betrag: 342000, gebuehr: 12400, status: "offen", faellig_am: vorTagen(31), erstellt_am: vorTagen(38), art: "anzahlung" }, { id: "z2", nummer: "R-2608-0008", kunde_name: "Bäckerei Krumm", betrag: 60000, gebuehr: 2100, status: "bezahlt", faellig_am: vorTagen(10), erstellt_am: vorTagen(20), art: "schluss" }, ] }, "/webdesign/admin/postfach": { ok: true, nachrichten: [ { id: "n1", von: "Immobilienverwaltung Sonnenhof GmbH & Co. KG", email: "kontakt.projektanfragen@immobilienverwaltung-sonnenhof.example.com", betreff: "Rückfrage zur Rechnung und zum weiteren Ablauf im vierten Quartal", text: "Guten Tag, wir hätten noch eine Frage zur Aufteilung der Anzahlung.", gelesen: 0, erstellt_am: vorTagen(4) }, ] }, "/webdesign/admin/postfach-zaehler": { ok: true, ungelesen: 7 }, "/webdesign/admin/einstellungen": { ok: true, werte: {} }, "/webdesign/admin/push/stand": { ok: true, eingerichtet: true, geraete: 1, geraeteMitFehlern: 0, wirktMeldung: true, oeffentlicherSchluessel: "BTest", geraete_liste: [] }, }; function antwortFuer(url) { const pfad = url.split("?")[0].replace(/^https?:\/\/[^/]+/, ""); if (ANTWORTEN[pfad]) return ANTWORTEN[pfad]; /* Einzelabrufe wie /anfragen/a1 */ if (/^\/webdesign\/admin\/anfragen\/[^/]+$/.test(pfad)) { const a = ANFRAGEN[0]; return { ok: true, anfrage: { ...a, ziel: "Ein neuer Auftritt für unsere Hausverwaltung.", zielgruppe: "Eigentümer und Mieter", funktionen: ["Kontaktformular", "Objektliste"], details: {}, bemerkung: "" }, verlauf: [] }; } return { ok: true }; } /* --------------------------------------------------------------------- */ srv.listen(4411, async () => { const browser = await chromium.launch(); /* Ein echtes Handy-Format, kein schmales Fenster: Handys haben ein anderes Verhältnis und einen anderen Zeigertyp. */ const kontext = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true, userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", }); const seite = await kontext.newPage(); const skriptfehler = []; /* Mit Aufrufverlauf, nicht nur mit der Meldung: "Cannot read properties of undefined" sagt allein nichts darueber, WO es passiert -- und danach sucht man in einer 4000-Zeilen-Datei. */ seite.on("pageerror", (e) => skriptfehler.push(String(e.stack || e).split(String.fromCharCode(10)).slice(0, 3).join(" <- ").slice(0, 320))); await seite.route("**/webdesign/admin/**", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(antwortFuer(route.request().url())), }); }); await seite.goto("http://127.0.0.1:4411/webdesign/verwaltung.html"); await seite.waitForTimeout(1200); /* An der Anmeldung vorbei — geprueft wird das Layout, nicht die Zugangswand. */ await seite.evaluate(() => { document.getElementById("vw-anmeldung").hidden = true; document.getElementById("vw-bereich").hidden = false; if (typeof window.vwBalkenSetzen === "function") window.vwBalkenSetzen(); }); await seite.waitForTimeout(600); const BEREICHE = ["uebersicht", "anfragen", "projekte", "kunden", "zahlungen", "postfach"]; for (const bereich of BEREICHE) { console.log(`\n===== ${bereich.toUpperCase()} =====`); await seite.evaluate((b) => { const knopf = document.querySelector('[data-tab="' + b + '"]'); if (knopf) knopf.click(); }, bereich); await seite.waitForTimeout(1400); /* 1. Seitlicher Ueberlauf */ const ueberlauf = await seite.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); pruefe(` kein seitlicher Ueberlauf`, ueberlauf <= 1, ueberlauf + "px"); /* 2. Welche Elemente ragen heraus? Die Ursache, nicht nur das Symptom -- ohne sie sucht man den Schuldigen von Hand. */ const heraus = await seite.evaluate(() => { const breite = document.documentElement.clientWidth; const raus = []; /* ⚠️ Was ABSICHTLICH hinausragt, ist kein Fehler. Die erste Fassung meldete die Reiter als Fehler -- sie liegen auf dem Handy in einem Wischstreifen, und dass dort etwas ausserhalb des Bildschirms liegt, ist der ganze Sinn der Sache. Ein Test, der das als Mangel meldet, verleitet dazu, einen funktionierenden Streifen "zu reparieren". Deshalb: Alles, was in einem seitlich scrollbaren Behaelter liegt, wird uebersprungen -- dort GEHOERT es hin. */ const istInWischstreifen = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const st = getComputedStyle(p); if ((st.overflowX === "auto" || st.overflowX === "scroll") && p.scrollWidth > p.clientWidth + 1) return true; } return false; }; for (const el of document.querySelectorAll("#vw-bereich *")) { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; if (istInWischstreifen(el)) continue; if (r.right > breite + 1 || r.left < -1) { raus.push({ was: el.tagName.toLowerCase() + (el.className && typeof el.className === "string" ? "." + el.className.split(" ").filter(Boolean).slice(0, 2).join(".") : ""), rechts: Math.round(r.right), breite: Math.round(r.width), text: (el.textContent || "").trim().slice(0, 30), }); } } /* Nur die aeussersten melden -- sonst steht jedes Kind seines ueberlaufenden Elternteils mit in der Liste. */ return raus.slice(0, 6); }); pruefe(` nichts ragt aus dem Bildschirm`, heraus.length === 0, heraus.length ? heraus.map((h) => `${h.was} bis ${h.rechts}px ("${h.text}")`).join(" | ") : "sauber"); /* 3. Tippziele */ const klein = await seite.evaluate(() => { const raus = []; const auswahl = "#vw-bereich button, #vw-bereich a, #vw-bereich input, " + "#vw-bereich select, #vw-bereich summary, #vw-bereich [role='button']"; for (const el of document.querySelectorAll(auswahl)) { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; const st = getComputedStyle(el); if (st.visibility === "hidden" || st.display === "none") continue; /* ⚠️ Ein Ankreuzfeld misst man an seinem Label, nicht an sich selbst. Die erste Fassung meldete eine Checkbox mit 1x1 Punkt als Mangel. Sie ist absichtlich so klein: Sichtbar ist ein selbst gestalteter Schalter, angetippt wird das Label — 315x162 Punkte, also weit über jedem Mindestmaß. Wer dem Test folgt und die Checkbox vergrößert, macht eine funktionierende Bedienung kaputt und bekommt obendrein ein doppeltes Bedienelement. */ if (el.type === "checkbox" || el.type === "radio") { const lab = document.querySelector('label[for="' + el.id + '"]') || el.closest("label"); if (lab) { const lr = lab.getBoundingClientRect(); if (lr.height >= 24 && lr.width >= 24) continue; } } /* WCAG 2.2 Erfolgskriterium 2.5.8 verlangt 24x24. Apple und Google empfehlen 44. Gemeldet wird alles unter 44, gezaehlt als Fehler nur unter 24. */ if (r.height < 44 || r.width < 44) { raus.push({ was: el.tagName.toLowerCase() + (typeof el.className === "string" && el.className ? "." + el.className.split(" ").filter(Boolean)[0] : ""), b: Math.round(r.width), h: Math.round(r.height), text: (el.textContent || el.value || "").trim().slice(0, 22), hart: r.height < 24 || r.width < 24, }); } } return raus; }); const hart = klein.filter((k) => k.hart); pruefe(` Tippziele erreichen 24px (Mindestmass)`, hart.length === 0, hart.length ? hart.slice(0, 4).map((k) => `${k.was} ${k.b}x${k.h} ("${k.text}")`).join(" | ") : klein.length ? klein.length + " unter 44px, aber alle ueber 24px" : "alle gross genug"); if (klein.length && !hart.length) { console.log(" Hinweis (unter 44px, empfohlen):"); for (const k of klein.slice(0, 5)) { console.log(` ${k.was} ${k.b}x${k.h}px "${k.text}"`); } } /* 4. Schriftgroesse */ const winzig = await seite.evaluate(() => { const raus = []; for (const el of document.querySelectorAll("#vw-bereich *")) { if (el.children.length || (el.textContent || "").trim().length <= 3) continue; /* ⚠️ Nur SICHTBARE Schrift zaehlt. Die erste Fassung meldete "Strg" und "Enter" mit 10,4px als Mangel -- beide sind auf schmalen Bildschirmen laengst ausgeblendet (display:none ab 620px, mit Begruendung im Stilblatt: auf einem Handy gibt es keine Strg-Taste). getComputedStyle liefert auch fuer verborgene Elemente eine Schriftgroesse. Ohne diese Pruefung meldet der Test also genau die Stellen, an denen bereits richtig gehandelt wurde -- und verleitet dazu, eine funktionierende Loesung "zu reparieren". */ const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue; const st = getComputedStyle(el); if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") continue; const g = parseFloat(st.fontSize); if (g && g < 11) raus.push({ g: Math.round(g * 10) / 10, t: el.textContent.trim().slice(0, 24) }); } return raus.slice(0, 5); }); pruefe(` Schrift ist nicht unter 11px`, winzig.length === 0, winzig.length ? winzig.map((w) => `${w.g}px "${w.t}"`).join(" | ") : "lesbar"); await seite.screenshot({ path: `handy-${bereich}.png`, fullPage: false }); } console.log("\n===== ALLGEMEIN ====="); pruefe("kein Skriptfehler", skriptfehler.length === 0, skriptfehler[0] || "keiner"); await browser.close(); srv.close(); console.log(`\n===== ${ok} bestanden, ${fehl} fehlgeschlagen =====`); if (funde.length) { console.log("\nZU BEHEBEN:"); funde.forEach((f, i) => console.log(` ${i + 1}. ${f}`)); } process.exit(fehl ? 1 : 0); });