/* =================================================================== Die Handy-Pruefung. Auf einem Telefon geht anderes kaputt als am Rechner, und man sieht es beim Entwickeln nie: Ein Knopf steht 3 px aus dem Bild, eine Ueberschrift ist auf halbem Wort abgeschnitten, ein Feld ist so schmal, dass man es nicht trifft. Alles davon faellt erst auf, wenn jemand das Geraet in der Hand hat. Geprueft wird auf DREI echten Geraetegroessen, jede Seite einzeln: 1. Nichts steht ueber waagerechtes Scrollen ist auf einem Handy der schlimmste Fehler -- die Seite ruckelt und man verliert den Rand 2. Kein Text abgeschnitten gemessen wird die tatsaechliche Textbreite gegen die verfuegbare, nicht "sieht ganz aus" 3. Beruehrziele gross genug 24x24 CSS-Pixel ist das Mindestmass (WCAG 2.2, Kriterium 2.5.8). Darunter trifft man mit dem Daumen daneben 4. Nichts ueberlappt zwei Bedienelemente duerfen sich nicht ueberdecken -- man traefe das falsche 5. Keine Fehler in der Konsole =================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-handy-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4198"; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; 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 }); }; import { notbremse } from "./helfer-notbremse.mjs"; await import("./index.js"); /* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort zwei Auffangnetze ein, die einen Absturz nur protokollieren -- richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */ notbremse(480_000, "pruef-handy"); await new Promise((r) => setTimeout(r, 700)); const BASIS = "http://127.0.0.1:4198"; setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 400_000).unref?.(); /* ORTSZEIT, nicht UTC. Die Anwendung rechnet "heute" nach der Uhr des Benutzers; toISOString() liefert dagegen UTC und damit zwischen Mitternacht und 2 Uhr den VORTAG. In diesem Fenster schlug diese Pruefung fehl, ohne dass etwas kaputt war. */ const tagText = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}` + `-${String(d.getDate()).padStart(2, "0")}`; let fehler = 0; const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); 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)); } } /* Echte Daten statt leerer Seiten: Eine leere Liste kann nicht ueberlaufen. Geprueft wird mit LANGEN Namen und vollen Listen -- genau dort bricht es. */ const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); const heute = tagText(); const gestern = tagText(new Date(Date.now() - 86400_000)); 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; } const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idScout = anlegen("Patrick-Maximilian", "scout", "CODE-SCOU-0001"); const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); /* Die beiden fehlenden Rollen -- gebraucht fuer die Mittenpruefung ganz unten. Filipe: "das soll bei jedem und jeder rolle verbessert werden", und eine Pruefung an drei von fuenf Rollen haette genau diese Aussage nicht belegen koennen. */ const idSpicy = anlegen("Cigdem", "spicy", "CODE-SPIC-0001"); const idMgr = anlegen("Mara", "manager", "CODE-MANA-0001"); d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt); const LANG = "Ein sehr langer Eintrag ohne Leerzeichenumbruch fuer die Handypruefung"; for (const [status, frist] of [["offen", gestern], ["offen", heute], ["arbeit", null], ["review", null], ["erledigt", null]]) { d.prepare(`INSERT INTO aufgaben (titel, status, creator_id, verantwortlich_id, frist, prioritaet, erstellt, erstellt_von) VALUES (?,?,?,?,?,'hoch',?,?)`) .run(LANG, status, idTili, idTili, frist, jetzt, idDogi); } for (const [art, stunde] of [["call", "10:00"], ["termin", "14:30"], ["review", "18:00"]]) { d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id, erledigt, erstellt, erstellt_von) VALUES (?,?,?,60,?,?,0,?,?)`) .run(LANG, art, heute + "T" + stunde, idTili, idTili, jetzt, idDogi); } d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) VALUES ('technik','problem',?,?,'hoch','offen',?,?,?)`) .run(LANG, heute, idTili, jetzt, idDogi); d.close(); const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); /* ---------- WELCHE SEITEN? ALLE. ----------------------------------------- Hier stand bis zum 06.09.2026 eine Liste von Hand -- und sie war veraltet, ohne dass es jemandem auffiel: chat.html, leistung.html und steckbrief.html fehlten darin, also DREI von neunzehn Seiten waren nie auf einem Handy gemessen worden. Der Chat ausgerechnet ist die Seite, die man am ehesten unterwegs benutzt. Das ist genau die Sorte Fehler, die eine Liste macht: Sie altert in dem Moment, in dem jemand weiterarbeitet, und sie sagt nichts dazu. Der Lauf blieb gruen, die Zahl der Pruefungen stimmte -- gemessen wurde trotzdem zu wenig. Deshalb wird die Liste jetzt aus dem VERZEICHNIS gelesen. Eine neue Seite ist damit von selbst dabei, so wie sie durch die Schranke in workspace.js von selbst geschuetzt ist -- derselbe Gedanke: Der sichere Rueckfall muss der VORGABEwert sein, nicht der, an den man denken muss. Ausgenommen ist einzig index.html -- das ist die Anmeldeseite, sie wird weiter unten eigens geprueft (vor der Anmeldung, denn danach leitet sie weiter). */ const { readdirSync } = await import("node:fs"); const { dirname } = await import("node:path"); const { fileURLToPath } = await import("node:url"); /* NICHT process.cwd(): Diese Datei wird mal aus dem Wurzelverzeichnis gestartet (tools/alles-pruefen.mjs) und mal aus server/ von Hand. Im zweiten Fall zeigte cwd auf server/, dort gibt es kein workspace/, und readdirSync wuerfe -- oder faende, schlimmer, ein anderes Verzeichnis. Der Ort DIESER Datei steht dagegen fest. */ const SEITEN_ORDNER = join(dirname(fileURLToPath(import.meta.url)), "..", "workspace"); const ALLE_SEITEN = readdirSync(SEITEN_ORDNER) .filter((n) => n.endsWith(".html") && n !== "index.html") .sort(); /* Ein paar Seiten brauchen eine Abfrage, sonst zeigen sie nur ihre leere Huelle. Fuer die steht hier, WOMIT sie aufgerufen werden. */ const MIT_ABFRAGE = { "bereich.html": "?b=live" }; const SEITEN = ALLE_SEITEN.map((n) => n + (MIT_ABFRAGE[n] || "")); /* Und die Zahl gehoert in die BEDINGUNG, nicht nur in den Meldetext: Faende readdirSync nichts (falsches Arbeitsverzeichnis), liefe die Schleife unten null Mal -- und der Lauf waere gruen, ohne eine einzige Seite angesehen zu haben. Genau der Fehler, den ich schon zweimal hatte. */ if (SEITEN.length < 15) { console.log(` FEHL nur ${SEITEN.length} Seiten in ${SEITEN_ORDNER} gefunden ` + "-- die Handypruefung kann so nichts messen"); process.exit(1); } console.log(`${SEITEN.length} Workspace-Seiten werden gemessen: ${SEITEN.join(", ")}`); /* Drei echte Geraete, nicht "irgendwas Schmales": 320 iPhone SE (1. Gen) -- das schmalste noch benutzte Geraet 390 iPhone 14/15, das haeufigste Mass 412 Pixel 8 / Galaxy S24, typisches Android */ const GERAETE = [[320, 568, "sehr schmal"], [390, 844, "iPhone"], [412, 915, "Android"]]; const messen = () => ({ /* 1. Waagerechtes Scrollen. Der schlimmste Fehler auf einem Handy. */ ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, schuld: [...document.querySelectorAll("body *")] .filter((e) => { const r = e.getBoundingClientRect(); /* Nur was wirklich sichtbar ist. Ein Wasserzeichen hinter overflow:hidden meldet ein Rechteck, das nie zu sehen war -- das hat frueher schon einmal falschen Alarm ausgeloest. */ if (r.width < 1 || r.height < 1) return false; const st = getComputedStyle(e); if (st.visibility === "hidden" || st.display === "none") return false; /* Steckt es in einem Vorfahren mit overflow:hidden? Dann ist es abgeschnitten und steht nicht wirklich ueber. */ for (let v = e.parentElement; v; v = v.parentElement) { const vs = getComputedStyle(v); if (vs.overflow !== "visible" || vs.overflowX !== "visible") return false; } return r.right > window.innerWidth + 1; }) .map((e) => `${e.tagName}.${String(e.className).slice(0, 26)}(+${Math.round(e.getBoundingClientRect().right - window.innerWidth)}px)`) .slice(0, 4), /* 2. Abgeschnittener Text. Gemessen wird die tatsaechliche Breite gegen die verfuegbare -- nicht "sieht ganz aus". Elemente, die ausdruecklich kuerzen duerfen (ellipsis), sind ausgenommen: Dort ist das Kuerzen die Absicht und der volle Text steht woanders. */ abgeschnitten: [...document.querySelectorAll("h1, h2, h3, p, span, a, label, button, li, td, th")] .filter((e) => { if (e.children.length) return false; const t = e.textContent.trim(); if (t.length < 3) return false; const st = getComputedStyle(e); if (st.display === "none" || st.visibility === "hidden") return false; if (st.textOverflow === "ellipsis") return false; if (st.overflow === "hidden" && st.whiteSpace === "nowrap") return false; return e.scrollWidth > e.clientWidth + 2; }) .map((e) => `"${e.textContent.trim().slice(0, 22)}" (${e.scrollWidth}>${e.clientWidth})`) .slice(0, 5), /* 3. Beruehrziele. 24x24 CSS-Pixel ist das Mindestmass nach WCAG 2.2 (2.5.8). Gemessen wird das Ziel selbst ODER sein umschliessender Bereich -- ein 16 px grosses Symbol in einem 44 px grossen Knopf ist in Ordnung. */ klein: [...document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")] .filter((e) => { const r = e.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; const st = getComputedStyle(e); if (st.display === "none" || st.visibility === "hidden") return false; return r.width < 24 || r.height < 24; }) .map((e) => { const r = e.getBoundingClientRect(); return `${e.tagName}"${(e.textContent || e.getAttribute("aria-label") || "").trim().slice(0, 14)}" ` + `${Math.round(r.width)}x${Math.round(r.height)}`; }) .slice(0, 5), /* 4. Ueberlappende Bedienelemente. Zwei Knoepfe, die sich decken, heissen: Man trifft den falschen. Geprueft wird der Mittelpunkt -- liegt dort ein ANDERES Bedienelement, ist das Ziel verdeckt. */ verdeckt: [...document.querySelectorAll("button, a[href], input, select")] .filter((e) => { const r = e.getBoundingClientRect(); if (r.width < 8 || r.height < 8) return false; if (r.top < 0 || r.bottom > window.innerHeight) return false; const st = getComputedStyle(e); if (st.display === "none" || st.visibility === "hidden" || st.pointerEvents === "none") return false; /* NICHT ZAEHLEN, WAS IN EINEM ZUGEKLAPPTEN
STECKT. Chromium versteckt dessen Inhalt ueber `content-visibility`, nicht ueber `display` -- die Kaesten behalten deshalb eine Groesse und sehen fuer jede Messung aus wie sichtbare Bedienelemente. Am 06.09.2026 meldete das einen "verdeckten" Knopf, den auf dem Bildschirm niemand sehen konnte. Eine Warnung ueber etwas Unsichtbares gewoehnt man sich ab zu lesen. */ if (e.closest("details:not([open])")) return false; const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return oben && oben !== e && !e.contains(oben) && !oben.contains(e); }) /* WER verdeckt WEN, und wo. Vorher stand hier nur der Name des verdeckten Knopfes -- eine Meldung wie `BUTTON"Meine Sicht"` sagt, DASS etwas nicht stimmt, und laesst einen danach raten, WAS darueberliegt. Ich habe daran am 06.09.2026 eine Stunde gesucht. Ein Befund, den man nicht verorten kann, ist ein halber Befund. */ .map((e) => { const r = e.getBoundingClientRect(); const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); const kette = (x) => { const a = []; for (let v = x; v && a.length < 4; v = v.parentElement) { a.push(v.tagName + (v.className && typeof v.className === "string" ? "." + v.className.split(" ")[0] : "")); } return a.join("<"); }; const q = oben ? oben.getBoundingClientRect() : null; return `${kette(e)} "${(e.textContent || "").trim().slice(0, 16)}" ` + `[${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}]` + ` <- verdeckt von ${oben ? kette(oben) : "?"}` + (q ? ` [${Math.round(q.left)},${Math.round(q.top)} ${Math.round(q.width)}x${Math.round(q.height)}]` : ""); }) .slice(0, 3), /* DIE GEOMETRIE DER KOPFLEISTE, aber nur wenn etwas nicht stimmt. Am 06.09.2026 meldete diese Pruefung auf zwoelf Seiten "BUTTON 'Meine Sicht' verdeckt" -- und mehr nicht. Ich habe danach eine Stunde gesucht, weil aus der Meldung nicht hervorging, WAS darueberlag und bei WELCHER Breite. Erst dieser Auszug zeigte es in einer Zeile: bei 412 px stand `wrap=nowrap`, der Umschalter war auf 50 px zusammengedrueckt, sein Knopf 104 px breit. Bleibt deshalb drin. Ein Befund, den man nicht verorten kann, ist ein halber Befund -- und er kostet beim naechsten Mal wieder eine Stunde. */ kopfDump: (() => { const k = document.querySelector(".kopfleiste__rechts"); const l = document.querySelector(".kopfleiste"); if (!k || !l) return null; const box = (e) => { const q = e.getBoundingClientRect(); return `${Math.round(q.left)},${Math.round(q.top)} ${Math.round(q.width)}x${Math.round(q.height)}`; }; const m = document.querySelector(".kopfleiste > .marke"); return { w: window.innerWidth, leiste: box(l) + " wrap=" + getComputedStyle(l).flexWrap, rechts: box(k) + " wrap=" + getComputedStyle(k).flexWrap, marke: m ? box(m) + " flex=" + getComputedStyle(m).flex : "keine", kinder: [...k.children].map((e) => `${e.tagName}.${String(e.className||"").split(" ")[0]} ${box(e)}`), }; })(), /* 5. Die Kopfleiste muss vollstaendig im Bild sein -- sie ist die einzige Stelle, ueber die man wieder wegkommt. */ kopfDrin: (() => { const a = document.querySelector(".abmelden")?.getBoundingClientRect(); return a ? (a.right <= window.innerWidth + 1 && a.left >= -1) : null; })(), /* 6. Schriftgroesse: unter 12 px liest niemand auf einem Handy. */ /* NUR SICHTBARER TEXT. Ein ausgeblendetes Element hat weiterhin eine Schriftgroesse -- die Pruefung meldete deshalb ein Schild, das auf dem Handy per display:none gar nicht erscheint. Eine Pruefung, die Unsichtbares anmahnt, gewoehnt man sich ab zu lesen. */ winzig: [...document.querySelectorAll("p, span, li, a, label, small")] .filter((e) => { if (e.children.length || e.textContent.trim().length <= 3) return false; const r = e.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) return false; return parseFloat(getComputedStyle(e).fontSize) < 11.5; }) .map((e) => `"${e.textContent.trim().slice(0, 18)}" ${getComputedStyle(e).fontSize}`) .slice(0, 3), }); for (const [breite, hoehe, wie] of GERAETE) { console.log(`\n=== ${wie} (${breite}x${hoehe})`); const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, }); const seite = await kontext.newPage(); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); /* Auch die Anmeldeseite selbst wird geprueft -- sie ist die erste, die jemand auf dem Handy sieht. */ { await seite.waitForTimeout(500); const r = await seite.evaluate(messen); const gut = r.ueber <= 0 && !r.abgeschnitten.length && !r.klein.length && !r.verdeckt.length; ok(gut, `Anmeldung über=${r.ueber}px abgeschnitten=${r.abgeschnitten.length} ` + `klein=${r.klein.length} verdeckt=${r.verdeckt.length}` + (r.schuld.length ? `\n steht über: ${r.schuld.join(", ")}` : "") + (r.abgeschnitten.length ? `\n abgeschnitten: ${r.abgeschnitten.join(", ")}` : "") + (r.klein.length ? `\n zu klein: ${r.klein.join(", ")}` : "") + (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "")); } await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); for (const s of SEITEN) { const konsole = []; const auf = (m) => { if (m.type() === "error") konsole.push(m.text()); }; const err = (e) => konsole.push("pageerror: " + e.message); seite.on("console", auf); seite.on("pageerror", err); await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" }); await seite.waitForTimeout(700); const r = await seite.evaluate(messen); const gut = r.ueber <= 0 && !r.abgeschnitten.length && !r.klein.length && !r.verdeckt.length && !r.winzig.length && r.kopfDrin !== false && !konsole.length; console.log(` ${gut ? "ok " : "FEHL"} ${String(s).padEnd(20)}` + `über=${String(r.ueber).padStart(3)}px` + (r.schuld.length ? `\n steht über: ${r.schuld.join(", ")}` : "") + (r.abgeschnitten.length ? `\n abgeschnitten: ${r.abgeschnitten.join(", ")}` : "") + (r.klein.length ? `\n Berührziel zu klein: ${r.klein.join(", ")}` : "") + (r.verdeckt.length ? `\n verdeckt: ${r.verdeckt.join(", ")}` : "") + (r.winzig.length ? `\n zu kleine Schrift: ${r.winzig.join(", ")}` : "") + (r.kopfDrin === false ? "\n der Abmelden-Knopf steht aus dem Bild" : "") + (konsole.length ? `\n KONSOLE: ${konsole.join(" | ")}` : "") + (!gut && r.kopfDump ? `\n KOPF: ${JSON.stringify(r.kopfDump)}` : "")); if (!gut) fehler++; seite.off("console", auf); seite.off("pageerror", err); } if (breite === 390) { await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(600); await seite.screenshot({ path: "pruef-handy.png", fullPage: true }); } await kontext.close(); } /* --------------------------------------------------------------------- Bedienbarkeit mit dem Finger: Auf einem Geraet ohne feinen Zeiger darf das Licht gar nicht erst eingebaut werden -- es kostete Arbeit fuer einen Effekt, den dort niemand sehen kann. --------------------------------------------------------------------- */ console.log("\n=== Kein Licht ohne Zeiger"); { const kontext = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); const seite = await kontext.newPage(); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); await seite.waitForTimeout(800); /* Eine Kachel antippen und wieder loslassen -- danach darf nichts leuchten bleiben. */ await seite.tap(".kachel >> nth=1").catch(() => {}); await seite.waitForTimeout(400); const r = await seite.evaluate(() => ({ lichter: document.querySelectorAll(".licht").length, an: document.querySelectorAll('[data-licht="an"]').length, })); ok(r.lichter === 0, `auf dem Handy wird gar kein Licht eingebaut (${r.lichter})`); ok(r.an === 0, `und keines bleibt an (${r.an})`); await kontext.close(); } /* --------------------------------------------------------------------- RING UND UHR STEHEN IN DER MITTE -- bei jeder Rolle (09.09.2026) Filipe, mit Bildschirmfoto: "ich will dass auf dem handy die uhr und der kreis ganz oben mit den erledigten aufgaben, schoen mittig, zentriert sind. die stehen sehr verzogen von der mitte. das soll bei jedem und jeder rolle verbessert werden." Gemessen war es bei allen fuenf Rollen und beiden Breiten dieselbe Zahl: Ring 31 px nach links, Uhr 31 px nach rechts. Ursache war `justify-content: center` auf einer Reihe, in der neben dem Instrument noch ein Knopf steht -- zentriert wurde der Block aus beiden. 62 px Knopfbreite (`--instrument-knoepfe`), halbiert, macht genau die 31. Warum das hier ALLE Rollen durchgeht, obwohl die Ursache eine einzige CSS-Regel ist: Weil genau das die Behauptung war, die geprueft gehoert. Eine Regel kann von einer rollenabhaengigen ueberschrieben werden, und dann faellt es nur bei einer auf. --------------------------------------------------------------------- */ console.log("\n=== Ring und Uhr stehen mittig (alle Rollen)"); for (const [breite, hoehe] of [[390, 844], [412, 915]]) { for (const [rolle, code] of [["admin", "CODE-DOGI-0001"], ["spicy", "CODE-SPIC-0001"], ["manager", "CODE-MANA-0001"], ["scout", "CODE-SCOU-0001"], ["creator", "CODE-ANNA-0001"]]) { const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } }); const seite = await kontext.newPage(); await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); await seite.click(`.rolle[data-rolle="${rolle}"]`); await seite.fill("#code", code); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 15000 }); await seite.waitForTimeout(1500); const m = await seite.evaluate(() => { const ab = (w) => { const e = document.querySelector(w); if (!e) return null; const r = e.getBoundingClientRect(); if (r.width < 4) return null; return Math.round((r.left + r.right) / 2 - window.innerWidth / 2); }; return { ring: ab("#zring"), uhr: ab(".uhr"), ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth }; }); /* 3 px Nachsicht: Ein ungerader Rest bei ungerader Breite ist kein Befund. 31 px waren einer. */ const gut = m.ring !== null && m.uhr !== null && Math.abs(m.ring) <= 3 && Math.abs(m.uhr) <= 3 && m.ueber <= 1; ok(gut, `${breite}px ${rolle.padEnd(8)} Ring ${m.ring} px, Uhr ${m.uhr} px von der Mitte` + (m.ueber > 1 ? `, Ueberstand ${m.ueber}` : "")); await kontext.close(); } } await browser.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); process.exit(fehler ? 1 : 0);