/* =================================================================== 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 }); }; await import("./index.js"); 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"); 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(); const SEITEN = [ "start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "personen.html", "profil.html", "wissen.html", "dateien.html", "calls.html", "startcheck.html", "report.html", "scouting.html", "automation.html", "content.html", "bereich.html?b=live", ]; /* 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; const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return oben && oben !== e && !e.contains(oben) && !oben.contains(e); }) .map((e) => `${e.tagName}"${(e.textContent || "").trim().slice(0, 16)}"`) .slice(0, 3), /* 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(" | ")}` : "")); 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(); } 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);