diff --git a/server/pruef-handy-teamdogi.mjs b/server/pruef-handy-teamdogi.mjs new file mode 100644 index 00000000..b9c89f3c --- /dev/null +++ b/server/pruef-handy-teamdogi.mjs @@ -0,0 +1,675 @@ +/* ===================================================================== + pruef-handy-teamdogi.mjs — Der Rundgang, den es für diese Rollen + noch nie gab. (19.09.2026) + + Filipe: „mach einen kompletten check dass die app auf dem handy + perfekt funktioniert … weil die modis haben schon probleme." + Auf die Rückfrage, was genau: „einfach ALLES ABCHECKEN ALLES + MÖGLICHE." + + --------------------------------------------------------------------- + DER BEFUND, DER DIESE DATEI AUSGELÖST HAT + + Es gibt seit dem 02.09.2026 einen großen Rundgang (`pruef-grosscheck`). + Er geht über jede Seite, zwei Bildschirmgrößen und vier Rollen: + + admin · manager · scout · creator + + Vier von acht. Es fehlen **modi, hand und gast** — also genau die drei + Rollen, die auf `crew.dogfather-universe.com` leben, und genau die, + von denen die Beschwerden kommen. Ihre Seiten sind nie im Ganzen auf + einem Handy durchgemessen worden. + + Das ist kein Vorwurf an den Rundgang: Er wurde für das Agenturhaus + gebaut, und das Team-Dogi-Haus kam später dazu. Aber es erklärt, + warum Fehler dort überleben konnten. + + --------------------------------------------------------------------- + WARUM DIE PRÜFUNG WIRKLICH `crew.` AUFRUFT + + Das Haus entscheidet am **Host**, wer was sieht: + `haus = istCrewAdresse(req.get("host")) ? "crew" : …`. Gegen + `127.0.0.1` gemessen bekäme man ein anderes Haus, andere Kacheln und + eine Adressregel, die alles durchlässt — und damit ein Ergebnis über + eine Seite, die es so nicht gibt. + + Deshalb bekommt der Browser `--host-resolver-rules`, die + `crew.dogfather-universe.com` auf den lokalen Server zeigen lassen. + Die Anwendung sieht den echten Namen, die Prüfung trifft trotzdem + niemals den echten Server. + + --------------------------------------------------------------------- + ZWEI BREITEN, UND BEIDE HABEN EINEN GRUND + + 390 px iPhone 12/13/14/15 — die schmalste verbreitete Breite + 412 px die häufigste Android-Breite überhaupt + + 412 ist der wichtigere Wert, und das ist gemessen, nicht geraten: Im + Haus sind schon zweimal Umbrüche für „das Handy" auf 380 px gerechnet + worden. Bei 390 ging es gut, bei 412 lag ein Knopf über dem anderen — + und beim Rundgang fiel es nicht auf, weil er nur 390 kannte. + + --------------------------------------------------------------------- + WAS GEMESSEN WIRD + + 1. KOMMT DIE SEITE AN? Statuscode, Inhalt, keine Umleitung ins + Leere. + 2. STÜRZT ETWAS AB? Jede Fehlermeldung der Konsole und jeder + nicht abgefangene Fehler. Ein Skript, + das aussteigt, lässt alles Folgende + tot zurück — das sieht aus wie „die + Seite ist leer". + 3. LÄUFT ETWAS ÜBER? Waagerechter Überstand, und WER ihn + verursacht. + 4. IST TEXT ABGESCHNITTEN? Mit den Ausnahmen, die Absicht sind + (Wasserzeichen, Klassen nur für + Vorleseprogramme). + 5. KANN MAN ES TREFFEN? Jedes Bedienelement unter 44 px — mit + der Ausnahme, die im Standard steht + (Links mitten im Fließtext). + 6. LIEGT ETWAS ÜBEREINANDER? Zwei Bedienelemente, die sich + überdecken. Das ist der Fehler, bei dem + man auf A tippt und B bekommt — und + genau der, den Nutzer als „der Knopf + geht nicht" melden. + 7. WEISS MAN, WAS ES TUT? Ein Bedienelement ohne lesbaren Namen + ist für ein Vorleseprogramm ein leerer + Knopf. + + --------------------------------------------------------------------- + UND EINE GEGENPROBE + + Am Ende wird eine absichtlich zu breite Kachel eingebaut und ein + absichtlich zu kleiner Knopf. Beide MÜSSEN gemeldet werden. Eine + Prüfung, die immer bestätigt, bestätigt nichts — das ist hier seit + dem 01.09.2026 Hausregel. + + --------------------------------------------------------------------- + DIE SEITENLISTE WIRD GERECHNET, NICHT GEPFLEGT + + Welche Seite zu welcher Rolle gehört, steht in `rechte.js` und + `workspace.js`. Diese Prüfung liest beides und leitet ihre Liste + daraus ab. Eine abgeschriebene Liste wäre die Stelle, an der in drei + Monaten eine neue Seite fehlt — und niemand würde es merken, weil + der Lauf ja grün bleibt. + + AUFRUF: node server/pruef-handy-teamdogi.mjs + Dauer: rund drei Minuten (4 Rollen × 2 Breiten × ~20 Seiten). + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { notbremse } from "./helfer-notbremse.mjs"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const REPO = join(dirname(fileURLToPath(import.meta.url)), ".."); +const ordner = mkdtempSync(join(tmpdir(), "ws-handy-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const PORT = await portMussFreiSein(4231, "pruef-handy-teamdogi"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +/* Der Server setzt seine Kekse mit `secure` -- über http bliebe die + Anmeldung damit wirkungslos, und ALLES weitere wäre eine Messung an + der Anmeldewand. */ +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { + return ec.call(this, n, w, { ...(o || {}), secure: false }); +}; + +await import("./index.js"); +/* NACH index.js: Der Server hängt dort zwei Auffangnetze ein, die einen + Absturz nur protokollieren. Richtig für den Betrieb, fatal für eine + Prüfung -- ohne die Notbremse bliebe der Prozess ewig stehen. */ +notbremse(600_000, "pruef-handy-teamdogi"); +await new Promise((r) => setTimeout(r, 900)); + +/* --------------------------------------------------------------------- + Die Leute, über die geprüft wird + ------------------------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); + +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} + +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,1,?)").run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +/* LANGE NAMEN MIT ABSICHT. Ein „Max" passt überall hin; ein Name, der + wirklich vorkommt, findet die Stellen, an denen zu wenig Platz ist. + Dasselbe macht der große Rundgang. */ +const idDogi = anlegen("Filipe Fernandes Ribeiro", "admin", "CODE-DOGI-0001"); +const idHand = anlegen("VanVan Schmidt-Hohenberg", "hand", "CODE-HAND-0001"); +const idModi = anlegen("Diene Margarethe Oberländer", "modi", "CODE-MODI-0001"); +const idGast = anlegen("Melli aus der Community", "gast", "CODE-GAST-0001"); + +/* Die Community muss ihr Alter bestätigt haben -- sonst hängt die + Anmeldung an der Altersfrage, und wir messen die Wand statt der App. */ +try { + d.prepare("UPDATE personen SET alter_bestaetigt_am = ? WHERE id = ?").run(jetzt, idGast); +} catch { /* Spalte gibt es (noch) nicht -- dann klickt die Prüfung die Frage an. */ } + +/* ---- Inhalt, damit die Seiten nicht leer sind ---------------------- + Leere Seiten prüfen nichts: Ein Raster ohne Karten läuft nie über, + und ein Knopf, der nie gezeichnet wird, ist nie zu klein. */ +const setze = (sql, ...w) => { try { d.prepare(sql).run(...w); } catch { /* Tabelle fehlt */ } }; + +for (const [titel, wer] of [ + ["Thumbnail für den Freitagsstream bauen und hochladen", idModi], + ["Regelverstoß von gestern Abend nachbereiten", idModi], + ["Community-Umfrage auswerten", idHand], +]) { + setze("INSERT INTO aufgaben (titel, beschreibung, status, verantwortlich_id, erstellt_von," + + " erstellt, frist) VALUES (?,?,?,?,?,?,?)", + titel, "Beschreibung mit genug Text, dass die Karte wirklich Höhe bekommt " + + "und ein Umbruch entstehen kann.", "offen", wer, idDogi, jetzt, + new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10)); +} + +for (const [bereich, titel] of [ + ["treff", "Hallo zusammen, ich bin neu hier und freu mich auf die Streams"], + ["anschlag", "Ab Montag starten wir eine Stunde früher"], + ["wunsch", "Zockernacht am Samstag mit der ganzen Community"], + ["ansteht", "Stream am Freitag um 20 Uhr"], + ["highlight", "Der Moment, als Dogi den Boss gelegt hat"], + ["mitmachen", "Wie werde ich Modi?"], + ["rueckmeldung", "Die neue Aufgabenübersicht ist viel klarer geworden"], +]) { + setze("INSERT INTO eintraege (bereich, titel, text, status, erstellt_von, erstellt)" + + " VALUES (?,?,?,?,?,?)", bereich, titel, + "Ein Text, der lang genug ist, dass er über mehrere Zeilen läuft und damit " + + "zeigt, ob die Karte auf einem schmalen Bildschirm sauber umbricht oder nicht.", + "offen", idDogi, jetzt); +} + +setze("INSERT INTO wissen (titel, kategorie, text, erstellt_von, erstellt)" + + " VALUES (?,?,?,?,?)", "Wie du einen Stream vorbereitest", "Live", + "Schritt für Schritt erklärt.", idDogi, jetzt); + +d.close(); + +/* --------------------------------------------------------------------- + Welche Seite gehört zu welcher Rolle? — gerechnet, nicht gepflegt + ------------------------------------------------------------------- */ +const rechte = await import("./rechte.js"); +const ws = await import("./workspace.js"); + +const CREW = "crew.dogfather-universe.com"; + +/* ===================================================================== + WARUM DIESE PRÜFUNG ÜBER https SPRICHT — 19.09.2026 + + Der erste Anlauf lief über `http://crew.dogfather-universe.com:PORT`. + Ergebnis: JEDE Stilvorlage, jedes Bild und jedes Skript schlug mit + `ERR_SSL_PROTOCOL_ERROR` fehl. Gemessen wurde also eine Seite ganz + ohne CSS — und die meldete brav ein 1647 px breites Bild auf einem + 390 px breiten Schirm. + + Das sah aus wie ein schwerer Fund und war einer in MEINER Prüfung: + `dogfather-universe.com` steht in der fest eingebauten HSTS-Liste von + Chromium. Der Browser schaltet dort von sich aus auf https um, und + zwar unabänderlich — auch `--disable-features=HttpsUpgrades` hilft + nicht, das ist nachgemessen. + + Eine plausible Herleitung ersetzt eben keine Messung. Hätte ich den + Befund gemeldet statt nachzusehen, wäre ein halber Tag in eine + Reparatur geflossen, die nichts repariert. + + Deshalb spricht die Prüfung jetzt selbst https: ein eigenes + Zertifikat für genau diesen Namen, ein winziger Vorbau davor, der + alles unverändert an den echten Server weiterreicht. Die Anwendung + merkt davon nichts — sie sieht denselben Host wie im Betrieb. + ===================================================================== */ +const { execFileSync } = await import("node:child_process"); +const { createServer: httpsServer } = await import("node:https"); +const { request: httpAnfrage } = await import("node:http"); + +const schluessel = join(ordner, "pruef.key"); +const zertifikat = join(ordner, "pruef.crt"); +execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", schluessel, "-out", zertifikat, "-days", "2", + "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], + { stdio: "ignore" }); + +const HTTPS_PORT = PORT + 1; +const vorbau = httpsServer({ + key: (await import("node:fs")).readFileSync(schluessel), + cert: (await import("node:fs")).readFileSync(zertifikat), +}, (von, nach) => { + /* UNVERÄNDERT WEITERREICHEN -- besonders die Host-Kopfzeile. An ihr + entscheidet das Haus, wer was sieht; würde sie hier umgeschrieben, + misst die Prüfung wieder das falsche Haus. */ + const weiter = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: von.url, method: von.method, + headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, + }, (antwort) => { + nach.writeHead(antwort.statusCode || 502, antwort.headers); + antwort.pipe(nach); + }); + weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); + von.pipe(weiter); +}); +await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); + +const BASIS = `https://${CREW}:${HTTPS_PORT}`; + +function seitenFuer(rolle) { + const tafel = rechte.tafelJetzt(); + const person = { rolle, haus: "crew" }; + const seiten = Object.entries(tafel) + .filter(([, liste]) => Array.isArray(liste) && liste.includes(rolle)) + .map(([pfad]) => pfad) + .filter((pfad) => ws.gehoertAufDieseAdresse(person, pfad)); + + /* Die Bretter hinter `bereich.html?b=…` sind eigene Seiten, auch wenn + sie sich eine Datei teilen. Wer nur `bereich.html` prüft, prüft + eines von vierzehn. Welche es gibt, steht in den Kacheln. */ + const bretter = [...(ws.bereicheFuer(person) || []), + ...(ws.zusatzBereicheFuer(person) || [])] + .map((k) => String(k?.ziel || "")) + .filter((z) => z.startsWith("bereich.html?b=")) + .map((z) => "/workspace/" + z); + + return [...new Set([...seiten.filter((p) => !p.endsWith("bereich.html")), ...bretter])]; +} + +/* --------------------------------------------------------------------- + Die Messung — sie läuft IM Browser + ------------------------------------------------------------------- */ +const messen = () => { + const sichtbar = (e) => { + const r = e.getBoundingClientRect(); + const st = getComputedStyle(e); + return r.width > 0 && r.height > 0 && st.visibility !== "hidden" + && st.display !== "none" && Number(st.opacity) > 0.05; + }; + const name = (e) => (e.tagName.toLowerCase() + + (e.id ? "#" + e.id : "." + String(e.className || "").split(" ")[0])).slice(0, 36); + + const BEDIENBAR = 'a[href], button, input, select, textarea, [role="button"],' + + ' [role="tab"], [role="radio"], [tabindex]:not([tabindex="-1"])'; + + /* NUR-FUER-VORLESEPROGRAMME IST KEIN FEHLER. Solche Elemente sind + absichtlich 1 px groß und weggeschnitten -- sie als „zu klein" zu + melden wäre ein Fehlalarm, der die echten Funde zudeckt. */ + const nurVorlesen = (e) => { + const st = getComputedStyle(e); + const r = e.getBoundingClientRect(); + return (r.width <= 2 && r.height <= 2) + || st.clipPath === "inset(50%)" || st.clip === "rect(0px, 0px, 0px, 0px)"; + }; + + const alle = [...document.querySelectorAll("body *")]; + const bedienbar = [...document.querySelectorAll(BEDIENBAR)] + .filter(sichtbar).filter((e) => !nurVorlesen(e)); + + /* ---- Überstand: wer ragt über den rechten Rand? ------------------ */ + const breite = document.documentElement.clientWidth; + const ueberstand = alle.filter((e) => { + if (!sichtbar(e)) return false; + const r = e.getBoundingClientRect(); + /* ZIERRAT DARF HINAUSRAGEN (geschärft 19.09.2026). + + Der erste Lauf meldete 26 Überstände -- und beim Nachsehen waren + es fast durchweg Dinge, die ein Mensch weder sieht noch bedient: + `div.aussen__schein` (ein Lichtschein, `aria-hidden="true"`), + `span.logo-feld__stueck` (leere Spans, aus denen ein Logo + zusammengesetzt wird), dekorative SVG. Alle werden von + `overflow: hidden` sauber weggeschnitten. + + Ein Überstand ist ein Fehler, wenn er etwas WEGSCHNEIDET, das + jemand lesen oder antippen wollte. Bei einem Lichtschein ist er + die Gestaltung. Gemeldet wird deshalb nur noch, was Text trägt + oder bedienbar ist -- sonst hätte die Liste 26 Einträge, von + denen keiner stimmt, und die echten gingen darin unter. */ + if (e.closest(".kopf-wasserzeichen")) return false; + if (e.closest('[aria-hidden="true"]')) return false; + const hatText = (e.textContent || "").trim().length > 0; + const bedienbarHier = e.matches(BEDIENBAR) || !!e.closest(BEDIENBAR); + if (!hatText && !bedienbarHier) return false; + return r.right > breite + 2; + }).slice(0, 4).map((e) => `${name(e)} bis ${Math.round(e.getBoundingClientRect().right)}px`); + + /* ---- Abgeschnittener Text ---------------------------------------- */ + const abgeschnitten = alle.filter((e) => { + if (!sichtbar(e) || e.children.length > 0) return false; + /* NUR-FUER-VORLESEPROGRAMME IST KEIN ABGESCHNITTENER TEXT + (nachgetragen 19.09.2026). + + Der erste Lauf meldete 192-mal `span.zurueck-knopf__text` und + 154-mal `span.teilen__text` als abgeschnitten. Beide sind + ABSICHTLICH 1 px gross und weggeschnitten: Auf dem Handy sagt + der Pfeil allein „zurueck", das Wort daneben kostet 65 px -- + und ein Vorleseprogramm liest es trotzdem. + + Gemeldet als Fehler waeren das rund 350 Fehlalarme, die jeden + echten Fund zudecken. Eine Warnung, die immer kommt, ist keine + Warnung mehr -- diese Lehre steht seit dem 03.09.2026 im Haus, + und sie gilt fuer meine eigene Pruefung genauso. */ + if (nurVorlesen(e)) return false; + const t = (e.textContent || "").trim(); + if (t.length < 4) return false; + const st = getComputedStyle(e); + if (st.overflow === "visible" && st.textOverflow !== "ellipsis") return false; + /* `text-overflow: ellipsis` ist eine ABSICHT, keine Panne -- dort + ist das Kürzen gewollt und die volle Fassung steht im title. */ + if (st.textOverflow === "ellipsis") return false; + /* `-webkit-line-clamp` IST AUCH ABSICHT (nachgetragen 19.09.2026). + Eine Beschreibung, die nach zwei Zeilen endet, ist gewollt -- + die volle Fassung steht beim Aufklappen. Ohne diese Zeile meldete + die Pruefung `.kachel__beschreibung` 24-mal als abgeschnitten. */ + if (st.webkitLineClamp && st.webkitLineClamp !== "none") return false; + return e.scrollWidth > e.clientWidth + 3 || e.scrollHeight > e.clientHeight + 3; + }).slice(0, 4).map((e) => `${name(e)} „${(e.textContent || "").trim().slice(0, 28)}…"`); + + /* ---- Zu kleine Tippziele ----------------------------------------- */ + const klein = bedienbar.filter((e) => { + const r = e.getBoundingClientRect(); + /* 43,5 STATT 44: Ein Knopf von 43,98 px wird als „44x44" + angezeigt und waere trotzdem ein Fund -- ein halbes Pixel + aus einer Zeilenhoehe ist kein Bedienproblem, aber es kostet + Vertrauen in die Liste. */ + if (r.width >= 43.5 && r.height >= 43.5) return false; + /* DIE AUSNAHME AUS DEM STANDARD (WCAG 2.5.8): Ein Link mitten in + einem Satz darf klein sein -- ihn zu vergrößern würde den Satz + zerreißen. Erkannt daran, dass der Elternteil deutlich mehr Text + trägt als der Link selbst. */ + const p = e.parentElement; + const eigen = (e.textContent || "").trim().length; + const drumherum = ((p?.textContent || "").trim().length) - eigen; + if (e.tagName === "A" && drumherum > eigen + 20) return false; + return true; + }).slice(0, 5).map((e) => { + const r = e.getBoundingClientRect(); + return `${name(e)} ${Math.round(r.width)}×${Math.round(r.height)}`; + }); + + /* ---- Überlappende Bedienelemente --------------------------------- + DER WICHTIGSTE PUNKT. Wer auf A tippt und B bekommt, meldet „der + Knopf geht nicht" -- und niemand findet etwas, weil beide Knöpfe + für sich genommen tadellos sind. Genau dieser Fehler ist im Haus + schon zweimal aufgetreten. */ + const ueberdeckt = []; + for (let i = 0; i < bedienbar.length && ueberdeckt.length < 4; i++) { + for (let j = i + 1; j < bedienbar.length; j++) { + const a = bedienbar[i], b = bedienbar[j]; + if (a.contains(b) || b.contains(a)) continue; + const ra = a.getBoundingClientRect(), rb = b.getBoundingClientRect(); + const breiteUeber = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left); + const hoeheUeber = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top); + if (breiteUeber <= 6 || hoeheUeber <= 6) continue; + /* Ein Element in einem geöffneten Dialog darf den Rest + überdecken -- das ist der Zweck eines Dialogs. */ + if (a.closest("dialog[open], .anruf, .anruf-klingelt") + !== b.closest("dialog[open], .anruf, .anruf-klingelt")) continue; + ueberdeckt.push(`${name(a)} ⨯ ${name(b)} (${Math.round(breiteUeber)}×${Math.round(hoeheUeber)}px)`); + break; + } + } + + /* ---- Bedienelemente ohne lesbaren Namen -------------------------- */ + const namenlos = bedienbar.filter((e) => { + const t = (e.textContent || "").trim(); + if (t.length > 0) return false; + if (e.getAttribute("aria-label")) return false; + if (e.getAttribute("aria-labelledby")) return false; + if (e.getAttribute("title")) return false; + /* `labels` GIBT ES AN JEDEM BESCHRIFTBAREN ELEMENT -- auch an + TEXTAREA (berichtigt 19.09.2026). + + Vorher standen hier nur INPUT und SELECT. Ergebnis: `#sb-ueber` + wurde achtmal als „Bedienelement ohne Namen" gemeldet, obwohl + direkt darüber `` + steht. Wieder ein Fehlalarm aus meiner eigenen Prüfung -- und + hätte ich ihn geglaubt, hätte ich eine Beschriftung ergänzt, die + längst da ist. + + Jetzt wird `labels` gefragt, wo es das gibt, statt eine Liste von + Elementnamen zu pflegen, die irgendwann eines vergisst. */ + if (e.labels && e.labels.length) return false; + return true; + }).slice(0, 4).map(name); + + return { + inhalt: (document.querySelector("main")?.textContent || "").trim().length, + elemente: alle.length, + bedienbar: bedienbar.length, + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + ueberstand, abgeschnitten, klein, ueberdeckt, namenlos, + }; +}; + +/* --------------------------------------------------------------------- + Der Rundgang + ------------------------------------------------------------------- */ +/* DERSELBE PFAD WIE IN DEN UEBRIGEN BROWSER-PRUEFUNGEN. Playwright + liegt nicht in diesem Repo, sondern einmal zentral daneben -- und + auf Windows braucht ein absoluter Pfad ein `file://` davor, sonst + haelt Node das "C:" fuer ein Protokoll. */ +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + +const browser = await chromium.launch({ + /* DAMIT DER SERVER SEINEN ECHTEN NAMEN SIEHT. Ohne diese Zeile + misst die Prüfung gegen 127.0.0.1 -- ein anderes Haus, andere + Kacheln, eine Adressregel, die alles durchlässt. + + `--ignore-certificate-errors` gehört dazu: Der Name steht in der + HSTS-Liste, und dort lässt Chromium sonst kein eigenes Zertifikat + durch -- auch nicht mit ignoreHTTPSErrors am Kontext. */ + args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, + "--ignore-certificate-errors"], + ignoreHTTPSErrors: true, +}); + +const ROLLEN = [ + ["hand", "CODE-HAND-0001", "Rechte Hand"], + ["modi", "CODE-MODI-0001", "Modi"], + ["gast", "CODE-GAST-0001", "Community"], + ["admin", "CODE-DOGI-0001", "DogFather"], +]; +const BREITEN = [[390, 844, "iPhone"], [412, 915, "Android"]]; + +let fehler = 0, geprueft = 0, elementeGesamt = 0, bedienbarGesamt = 0; +const probleme = []; +const melde = (t) => console.log(t); + +for (const [breite, hoehe, geraet] of BREITEN) { + console.log(`\n══════════ ${geraet} · ${breite}px ══════════`); + + for (const [rolle, code, wie] of ROLLEN) { + const seiten = seitenFuer(rolle); + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, + hasTouch: true, isMobile: true, deviceScaleFactor: 3, + ignoreHTTPSErrors: true, + }); + const konsole = []; + const seite = await kontext.newPage(); + seite.on("console", (m) => { + if (m.type() === "error") konsole.push(m.text().slice(0, 100)); + }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message.slice(0, 100))); + + /* ---- Anmelden, wie ein Mensch es tut ---------------------------- */ + let drin = false; + try { + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded", timeout: 20000 }); + await seite.click(`.rolle[data-rolle="${rolle}"]`, { timeout: 8000 }); + await seite.fill("#code", code); + /* Die Altersfrage erscheint nur für die Community. */ + const frage = await seite.$("#alter-ok"); + if (frage && await frage.isVisible().catch(() => false)) await frage.check(); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 20000 }); + drin = true; + } catch (f) { + /* EINE GESCHEITERTE ANMELDUNG IST DER SCHLIMMSTE BEFUND -- danach + wird gar nichts mehr gemessen. Sie gehoert deshalb SOFORT auf + den Bildschirm und nicht nur in die Liste am Ende. */ + const wo = seite.url(); + const sichtbar = await seite.evaluate(() => ({ + fehler: document.getElementById("fehler")?.textContent?.trim() || "", + rollen: [...document.querySelectorAll(".rolle")].map((r) => r.dataset.rolle).join(","), + })).catch(() => ({ fehler: "(nicht lesbar)", rollen: "?" })); + melde(` FEHL ${wie.padEnd(12)} ANMELDUNG SCHEITERT`); + melde(` · ${String(f.message).slice(0, 110)}`); + melde(` · steht auf: ${wo}`); + melde(` · Meldung der Seite: "${sichtbar.fehler}"`); + melde(` · Rollen auf der Wand: ${sichtbar.rollen}`); + probleme.push(`[${geraet} ${wie}] ANMELDUNG SCHEITERT: ${String(f.message).slice(0, 80)}`); + fehler++; + } + if (!drin) { await kontext.close(); continue; } + + let schlecht = 0; + const details = []; + + for (const pfad of seiten) { + geprueft++; + let antwort = null; + try { + antwort = await seite.goto(BASIS + pfad, + { waitUntil: "domcontentloaded", timeout: 20000 }); + /* Kurz warten: Diese Seiten holen ihren Inhalt nach. Ohne das + misst man ein leeres Gerüst und meldet es als tadellos. */ + await seite.waitForTimeout(900); + } catch (f) { + details.push(`${pfad}: lädt nicht (${String(f.message).slice(0, 50)})`); + schlecht++; continue; + } + + const lage = antwort?.status() ?? 0; + const wo = new URL(seite.url()).pathname; + if (lage >= 400) { + details.push(`${pfad}: Statuscode ${lage}`); schlecht++; continue; + } + /* Eine Umleitung auf die Startseite heißt: Die Seite gibt es für + diese Rolle auf dieser Adresse nicht -- obwohl die Rechtetafel + sie erlaubt. Das ist eine Sackgasse, kein Erfolg. */ + if (wo !== pfad && wo.endsWith("/start.html") && !pfad.endsWith("/start.html")) { + details.push(`${pfad}: wird auf start.html umgeleitet (Sackgasse)`); + schlecht++; continue; + } + + const m = await seite.evaluate(messen); + elementeGesamt += m.elemente; bedienbarGesamt += m.bedienbar; + + const kurz = pfad.replace("/workspace/", ""); + if (m.inhalt < 40) { details.push(`${kurz}: fast kein Inhalt (${m.inhalt} Zeichen)`); schlecht++; } + /* GEMESSEN WIRD AM ELEMENT, NICHT AM DOKUMENT (berichtigt 19.09.2026). + + Vorher entschied `scrollWidth - clientWidth`. Die Gegenprobe hat + gezeigt, dass das nichts findet: `body { overflow-x: hidden }` + schneidet den Überhang weg, also sind beide Werte immer gleich. + Die Prüfung war blind und hätte trotzdem grün gemeldet -- die + schlimmste Sorte, weil man ihr glaubt. + + Jetzt zählt, ob ein sichtbares Element über den rechten Rand + ragt. Das sieht man auch dann, wenn der Balken versteckt ist. */ + if (m.ueberstand.length) { + details.push(`${kurz}: ragt über den Rand — ${m.ueberstand.join(", ")}`); + schlecht++; + } + if (m.abgeschnitten.length) { + details.push(`${kurz}: Text abgeschnitten — ${m.abgeschnitten.join(", ")}`); schlecht++; + } + if (m.klein.length) { + details.push(`${kurz}: zu kleine Tippziele — ${m.klein.join(", ")}`); schlecht++; + } + if (m.ueberdeckt.length) { + details.push(`${kurz}: LIEGT ÜBEREINANDER — ${m.ueberdeckt.join(", ")}`); schlecht++; + } + if (m.namenlos.length) { + details.push(`${kurz}: Bedienelement ohne Namen — ${m.namenlos.join(", ")}`); schlecht++; + } + } + + if (konsole.length) { + details.push(`Konsole: ${[...new Set(konsole)].slice(0, 3).join(" | ")}`); + schlecht++; + } + + const zeichen = schlecht === 0 ? "ok " : "FEHL"; + melde(` ${zeichen} ${wie.padEnd(12)} ${String(seiten.length).padStart(2)} Seiten` + + (schlecht ? ` — ${schlecht} Befund${schlecht === 1 ? "" : "e"}` : " — sauber")); + /* ALLE BEFUNDE, NICHT DIE ERSTEN ZWOELF. Eine Liste, die abschneidet, + versteckt genau die Fundstellen, die man noch nicht kennt -- und + man repariert dann dreimal dasselbe, weil das Neue nie + auftaucht. */ + for (const t of details) melde(` · ${t}`); + if (schlecht) { fehler += schlecht; probleme.push(...details.map((t) => `[${geraet} ${wie}] ${t}`)); } + + await kontext.close(); + } +} + +/* ===================================================================== + DIE GEGENPROBE + + Eine Prüfung, die immer „alles in Ordnung" sagt, sagt nichts. Hier + werden ein zu breites Element und ein zu kleiner Knopf eingebaut -- + beide MÜSSEN gefunden werden. + ===================================================================== */ +melde("\n --- Gegenprobe (beide Fehler MÜSSEN gefunden werden) ---"); +{ + const kontext = await browser.newContext({ viewport: { width: 390, height: 844 }, ignoreHTTPSErrors: true }); + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "load" }); + await seite.waitForTimeout(600); + await seite.evaluate(() => { + /* MIT TEXT, NICHT LEER (berichtigt 19.09.2026). + + Der erste Entwurf hängte ein leeres
an -- und seit die + Messung Zierrat ausnimmt (nur Text oder Bedienbares zählt), fiel + genau dieses
durch den Filter. Die Gegenprobe meldete + „nichts" und bewies damit ihren eigenen Zweck: Sie hat gemerkt, + dass die Messung nichts mehr findet. + + Jetzt trägt das Element Text -- also genau das, dessen + Abschneiden ein echter Fehler wäre. */ + const breit = document.createElement("div"); + breit.textContent = "Absichtlich zu breit, damit die Pruefung es finden muss"; + breit.style.cssText = "width:900px;height:24px;background:#333;white-space:nowrap"; + document.body.append(breit); + const winzig = document.createElement("button"); + winzig.textContent = "x"; + winzig.style.cssText = "width:18px;height:18px"; + document.body.append(winzig); + }); + const g = await seite.evaluate(messen); + const ok1 = g.ueberstand.length > 0; + const ok2 = g.klein.length > 0; + melde(` ${ok1 ? "ok " : "FEHL"} ein zu breites Element wird gemeldet (${g.ueberstand.join(", ") || "nichts"})`); + melde(` ${ok2 ? "ok " : "FEHL"} ein zu kleiner Knopf wird gemeldet (${g.klein.join(", ") || "nichts"})`); + if (!ok1 || !ok2) { fehler += 1; probleme.push("GEGENPROBE VERSAGT — die Messung findet nichts mehr"); } + await kontext.close(); +} + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +melde(""); +melde(` ${geprueft} Seitenaufrufe, ${elementeGesamt} Elemente, ${bedienbarGesamt} Bedienelemente gemessen`); +melde(` ${fehler} Befund${fehler === 1 ? "" : "e"}`); +melde(fehler ? " NICHT IN ORDNUNG" : " ALLES IN ORDNUNG"); +process.exit(fehler ? 1 : 0); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index e0d6e57a..e27aa28d 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +