/* Browserpruefung der Startseite: Gruppen, Zeichen, Farbtoene, Zahlen auf den Kacheln -- und was jede Rolle davon zu sehen bekommt. */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-start-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4186"; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-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:4186"; setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.(); 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"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); const heute = jetzt.slice(0, 10); const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10); 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", "scout", "CODE-SCOU-0001"); const idAnna = anlegen("Tili", "creator", "CODE-ANNA-0001"); d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idAnna, idScout, jetzt); /* Arbeit an mehreren Stellen -- damit auf den Kacheln wirklich Zahlen stehen und man sieht, ob sie an der richtigen landen. */ for (const [status, frist] of [["offen", gestern], ["offen", gestern], ["offen", heute], ["arbeit", null], ["review", null]]) { d.prepare("INSERT INTO aufgaben (titel, status, creator_id, frist, erstellt, erstellt_von) VALUES ('Test',?,?,?,?,?)") .run(status, idAnna, frist, jetzt, idDogi); } d.prepare("INSERT INTO termine (titel, beginn, creator_id, erledigt, erstellt, erstellt_von) VALUES ('Heute',?,?,0,?,?)") .run(heute + "T18:00", idAnna, jetzt, idDogi); d.prepare("INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) VALUES ('technik','problem','Ton weg',?,'hoch','offen',?,?,?)") .run(heute, idAnna, 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(); async function anmelden(kontext, rolle, code) { 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(900); return seite; } const lesen = (seite) => seite.evaluate(() => ({ gruss: document.getElementById("gruss")?.textContent, gruppen: [...document.querySelectorAll(".gruppe")].map((g) => ({ name: g.querySelector(".gruppe__name")?.textContent, kacheln: g.querySelectorAll(".kachel").length, })), kacheln: [...document.querySelectorAll(".kachel")].map((k) => ({ name: k.querySelector(".kachel__name")?.textContent, unter: k.querySelector(".kachel__unter")?.textContent, ton: k.dataset.ton, farbe: getComputedStyle(k.querySelector(".kachel__svg")).stroke, pfade: k.querySelectorAll(".kachel__svg path").length, zahl: k.querySelector(".kachel__zahl")?.textContent || null, warn: k.querySelector(".kachel__zahl")?.dataset.warn === "ja", ziel: k.querySelector(".kachel__link")?.getAttribute("href"), })), /* Abgeschnittener Text ist der haeufigste stille Fehler in einer Kachel -- er sieht auf den ersten Blick nach Absicht aus. Gemessen wird die tatsaechliche Textbreite gegen die verfuegbare. */ abgeschnitten: [...document.querySelectorAll(".kachel__name, .kachel__unter")] .filter((e) => e.scrollWidth > e.clientWidth + 1) .map((e) => e.textContent), hinweise: [...document.querySelectorAll("#dran .dran__punkt")] .filter((z) => z.offsetParent !== null).length, hinweiseGesamt: document.querySelectorAll("#dran .dran__punkt").length, /* Jede Hinweiszeile traegt Zeichen und Farbe der Kachel, zu der sie fuehrt. Geprueft wird beides an den echten Bildpunkten, nicht an der Absicht im Quelltext. */ dranZeilen: [...document.querySelectorAll("#dran .dran__punkt")].map((z) => ({ ton: z.dataset.ton || null, stufe: z.dataset.stufe, pfade: z.querySelectorAll(".dran__svg path").length, strich: getComputedStyle(z.querySelector(".dran__svg")).stroke, zahl: z.querySelector(".dran__zahl")?.textContent || null, zahlFarbe: getComputedStyle(z.querySelector(".dran__zahl")).color, text: z.querySelector(".dran__text")?.textContent || "", label: z.querySelector(".dran__link")?.getAttribute("aria-label") || "", })), zeichenGroesse: Math.round(document.querySelector(".kachel__zeichen")?.getBoundingClientRect().width || 0), wasserzeichen: document.querySelectorAll(".kachel__wasserzeichen").length, wasserzeichenDeckung: getComputedStyle(document.querySelector(".kachel__wasserzeichen") || document.body).opacity, wasserzeichenSchwach: Number(getComputedStyle( document.querySelector(".kachel__wasserzeichen") || document.body).opacity) <= 0.15, namenGroesse: Math.round(parseFloat(getComputedStyle(document.querySelector(".kachel__name")).fontSize) || 0), kachelHoehe: Math.round(document.querySelectorAll(".kachel")[1]?.getBoundingClientRect().height || 0), zahlenLeer: !document.getElementById("zahlen-leer")?.hidden, zahlen: [...document.querySelectorAll(".zahl")].length, ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, ueberstehend: [...document.querySelectorAll("main *")] .filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1) .map((e) => String(e.className).slice(0, 40)).slice(0, 4), })); for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) { console.log(`\n=== DogFather · ${wie}`); const kontext = await browser.newContext({ viewport: { width: breite, height: 1200 } }); const konsole = []; const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); await seite.reload({ waitUntil: "networkidle" }); await seite.waitForTimeout(900); const r = await lesen(seite); console.log(" Gruß: ", r.gruss); console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · ")); console.log(" Zahlen: ", r.kacheln.filter((k) => k.zahl).map((k) => `${k.name}=${k.zahl}${k.warn ? "!" : ""}`).join(" · ")); ok(r.gruppen.length === 3, `drei Gruppen (${r.gruppen.length})`); ok(r.kacheln.length === 17, `siebzehn Kacheln (${r.kacheln.length})`); ok(/^(Guten Morgen|Guten Tag|Guten Abend|Noch wach), Filipe$/.test(r.gruss || ""), `Begrüßung nach Tageszeit ("${r.gruss}")`); /* Jede Kachel muss ein Zeichen haben -- eine ohne waere ein leeres Kaestchen und faellt sofort auf. */ /* Das Wasserzeichen: dasselbe Zeichen noch einmal, riesig und schwach im Hintergrund. Es macht jede Kachel unverwechselbar -- fehlt es, sehen alle wieder gleich aus. */ ok(r.wasserzeichen === r.kacheln.length, `jede Kachel trägt ihr Wasserzeichen (${r.wasserzeichen} von ${r.kacheln.length})`); ok(r.wasserzeichenSchwach, `und es ist schwach genug, um nicht zu stören (${r.wasserzeichenDeckung})`); ok(r.kacheln.every((k) => k.pfade > 0), `jede Kachel hat ein Zeichen (${r.kacheln.filter((k) => !k.pfade).map((k) => k.name).join(", ") || "alle"})`); ok(r.kacheln.every((k) => k.unter && k.unter.length > 4), "jede Kachel sagt, was drin ist"); ok(r.kacheln.every((k) => k.ziel && k.ziel !== "#"), "jede Kachel führt irgendwohin"); /* Acht verschiedene Farben, und nirgends steht "ÖFFNEN" mehr. */ const toene = new Set(r.kacheln.map((k) => k.farbe)); /* JEDE Kachel ihre eigene Farbe -- siebzehn verschiedene, keine doppelt. (Der Creator sieht "Mein Profil" statt "Creator-Profile"; das sind zwei Namen fuer denselben Bereich und teilen sich einen Ton -- deshalb wird hier gegen die Zahl der KACHELN geprueft.) */ ok(toene.size === r.kacheln.length, `jede Kachel hat ihre eigene Farbe (${toene.size} Farben auf ${r.kacheln.length} Kacheln)`); const oeffnen = await seite.evaluate(() => document.body.textContent.includes("ÖFFNEN")); ok(!oeffnen, "das Wort ÖFFNEN kommt nicht mehr vor"); /* Die Zahlen müssen auf der RICHTIGEN Kachel stehen. */ const auf = (n) => r.kacheln.find((k) => k.name === n); ok(auf("Aufgaben")?.zahl === "4", `Aufgaben trägt 4 (2 überfällig + 1 heute + 1 Review) (${auf("Aufgaben")?.zahl})`); ok(auf("Aufgaben")?.warn === true, "und ist als Warnung markiert"); ok(auf("Kalender")?.zahl === "1", `Kalender trägt 1 (${auf("Kalender")?.zahl})`); ok(auf("LIVE-Analyse")?.zahl === "1", `der dringende Bereichspunkt (${auf("LIVE-Analyse")?.zahl})`); ok(!auf("Wissens-Bibliothek")?.zahl, "wo nichts liegt, steht keine Zahl"); /* Die Kacheln sollen spürbar größer sein als vorher (Zeichenfeld 38px, Name .93rem). Gemessen statt geglaubt -- "sieht größer aus" ist kein Nachweis. */ ok(r.zeichenGroesse >= 46, `Zeichenfeld mindestens 46px (${r.zeichenGroesse}px)`); ok(r.namenGroesse >= 16, `Name mindestens 16px (${r.namenGroesse}px)`); ok(r.kachelHoehe >= 80, `Kachel mindestens 80px hoch (${r.kachelHoehe}px)`); ok(r.abgeschnitten.length === 0, `kein Text wird abgeschnitten (${r.abgeschnitten.join(" | ") || "keiner"})`); ok(r.hinweise <= 4, `höchstens vier Hinweise sichtbar (${r.hinweise} von ${r.hinweiseGesamt})`); /* --- Die Hinweiszeilen ------------------------------------------------ Eine Zeile ohne Zeichen oder ohne Zahl waere ein halbes Bauteil und faellt beim Ueberfliegen sofort auf. */ ok(r.dranZeilen.length > 0, `es liegen Hinweise vor (${r.dranZeilen.length})`); ok(r.dranZeilen.every((z) => z.pfade > 0), `jede Hinweiszeile trägt ihr Zeichen (${r.dranZeilen.filter((z) => !z.pfade).length} ohne)`); ok(r.dranZeilen.every((z) => /^[1-9]\d*$/.test(z.zahl || "")), `jede Hinweiszeile stellt ihre Zahl voran (${r.dranZeilen.map((z) => z.zahl).join(",")})`); /* Die Zahl steht vorn -- im Satz darf sie nicht noch einmal stehen. */ ok(r.dranZeilen.every((z) => !/^\d/.test(z.text)), `und keine Zahl steht doppelt (${r.dranZeilen.map((z) => z.text).find((t) => /^\d/.test(t)) || "keine"})`); /* Vorleseprogramme bekommen den ganzen Satz, nicht "2" und "Aufgaben sind überfällig" als zwei Bruchstuecke. */ ok(r.dranZeilen.every((z) => z.label.includes(z.zahl) && z.label.length > 10), "und der ganze Satz steht für Vorleseprogramme dran"); /* Farbe folgt dem Bereich, nicht dem Bauteil: Der Ton der Zeile muss der Ton einer wirklich vorhandenen Kachel sein. */ const kachelToene = new Set(r.kacheln.map((k) => k.ton)); ok(r.dranZeilen.every((z) => z.ton && kachelToene.has(z.ton)), `jede Zeile trägt den Farbton ihrer Kachel (${r.dranZeilen.map((z) => z.ton).join(",")})`); /* Und zwar wirklich DIESELBE Farbe, an echten Bildpunkten gemessen -- "hat einen Ton" reicht nicht, sonst faellt nicht auf, wenn alle Zeilen denselben tragen. */ const kachelFarbe = new Map(r.kacheln.map((k) => [k.ton, k.farbe])); const falsch = r.dranZeilen.filter((z) => z.stufe !== "warnung") .filter((z) => z.zahlFarbe !== kachelFarbe.get(z.ton)); ok(falsch.length === 0, `und die Farbe ist die der Kachel (${falsch.map((z) => `${z.ton}: ${z.zahlFarbe} statt ${kachelFarbe.get(z.ton)}`).join(" | ") || "alle"})`); /* Ueberfaellig schlaegt Bereichsfarbe -- dort muss Rot stehen, sonst geht die Dringlichkeit in siebzehn Farben unter. */ const warnZeilen = r.dranZeilen.filter((z) => z.stufe === "warnung"); ok(warnZeilen.length > 0 && warnZeilen.every((z) => z.zahlFarbe === "rgb(255, 154, 162)"), `überfällige Zeilen sind rot (${warnZeilen.length} Stück, ${warnZeilen[0]?.zahlFarbe})`); /* Und die uebrigen eben NICHT rot -- sonst waere die Regel wertlos. */ ok(r.dranZeilen.filter((z) => z.stufe !== "warnung") .every((z) => z.zahlFarbe !== "rgb(255, 154, 162)"), "und nur die"); ok(r.zahlen === 6, `die Aufgabenzahlen stehen (${r.zahlen})`); ok(!r.zahlenLeer, "und nicht der Leer-Satz"); ok(r.ueber <= 0, `nichts steht über (${r.ueber}px, ${r.ueberstehend.join(", ")})`); ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`); await seite.screenshot({ path: breite === 1440 ? "pruef-start-computer.png" : "pruef-start-handy.png", fullPage: true }); await kontext.close(); } console.log("\n=== Creator"); { const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } }); const seite = await anmelden(kontext, "creator", "CODE-ANNA-0001"); const r = await lesen(seite); console.log(" Gruppen:", r.gruppen.map((g) => `${g.name} (${g.kacheln})`).join(" · ")); const namen = r.kacheln.map((k) => k.name); ok(namen.includes("Mein Profil"), "sieht sein eigenes Profil"); ok(!namen.includes("Creator-Profile"), "und nicht die Liste aller Creator"); ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung"); ok(!namen.includes("Scout-Pipeline"), "keine Scout-Pipeline"); ok(!namen.includes("Automationen"), "keine Automationen"); ok(r.gruppen.length === 3, `trotzdem drei Gruppen (${r.gruppen.length})`); ok(r.kacheln.every((k) => k.pfade > 0), "alle Zeichen da"); await seite.screenshot({ path: "pruef-start-creator.png", fullPage: true }); await kontext.close(); } console.log("\n=== Scout"); { const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } }); const seite = await anmelden(kontext, "scout", "CODE-SCOU-0001"); const r = await lesen(seite); const namen = r.kacheln.map((k) => k.name); ok(namen.includes("Scout-Pipeline"), "sieht seine Pipeline"); ok(!namen.includes("Personen & Zugänge"), "keine Personenverwaltung"); ok(!namen.includes("Automationen"), "keine Automationen"); ok(namen.includes("Creator-Profile"), "aber die Creator-Profile"); await kontext.close(); } /* Der Satz "Nichts offen" ist ein Kasten mit display:flex geworden. Damit ist er staerker als das hidden des Browsers -- ohne eine eigene Regel stuende er IMMER da, auch wenn Arbeit offen ist. Genau das wird hier gemessen, in beide Richtungen. */ console.log("\n=== Der Satz 'Nichts offen' erscheint nur, wenn nichts offen ist"); { const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } }); const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); const r = await seite.evaluate(() => { const k = document.getElementById("zahlen-leer"); const zu = getComputedStyle(k).display; k.hidden = false; const auf = getComputedStyle(k).display; const haken = k.querySelectorAll("svg path").length; k.hidden = true; return { zu, auf, haken }; }); ok(r.zu === "none", `versteckt heisst versteckt (${r.zu})`); ok(r.auf !== "none", `und sichtbar heisst sichtbar (${r.auf})`); ok(r.haken > 0, `der Haken ist da (${r.haken})`); await kontext.close(); } console.log("\n=== Weniger Bewegung: keine Animation"); { const kontext = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: "reduce" }); const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); const bewegung = await seite.evaluate(() => { const k = document.querySelector(".kachel"); const s = getComputedStyle(k); return { animation: s.animationName, sichtbar: s.opacity }; }); ok(bewegung.animation === "none", `keine Animation (${bewegung.animation})`); ok(Number(bewegung.sichtbar) === 1, `und trotzdem voll sichtbar (${bewegung.sichtbar})`); await kontext.close(); } console.log("\n=== Das Licht folgt dem Zeiger"); { const kontext = await browser.newContext({ viewport: { width: 1440, height: 1200 } }); const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); const kachel = seite.locator(".kachel").nth(1); const kasten = await kachel.boundingBox(); const ruhe = await kachel.evaluate((k) => k.style.getPropertyValue("--mx") || "(Ruhelage)"); ok(ruhe === "(Ruhelage)", `ruhend keine eigene Position gesetzt (${ruhe})`); await seite.mouse.move(kasten.x + kasten.width * 0.2, kasten.y + kasten.height * 0.3); await seite.waitForTimeout(250); const links = await kachel.evaluate((k) => k.style.getPropertyValue("--mx")); await seite.mouse.move(kasten.x + kasten.width * 0.85, kasten.y + kasten.height * 0.6); await seite.waitForTimeout(250); const rechts = await kachel.evaluate((k) => k.style.getPropertyValue("--mx")); console.log(` links: ${links} · rechts: ${rechts}`); ok(parseFloat(links) < 40, `links steht der Fleck links (${links})`); ok(parseFloat(rechts) > 60, `rechts steht er rechts (${rechts})`); /* Beim Verlassen zurück in die Ruhelage. Ohne das blieben die Kacheln nach einer Weile alle unterschiedlich beleuchtet stehen, ohne dass jemand wüsste, warum. */ await seite.mouse.move(kasten.x - 250, kasten.y - 250); await seite.waitForTimeout(250); const danach = await kachel.evaluate((k) => k.style.getPropertyValue("--mx") || "(Ruhelage)"); ok(danach === "(Ruhelage)", `nach dem Verlassen wieder in Ruhe (${danach})`); await kontext.close(); } console.log("\n=== Ohne feinen Zeiger bleibt es aus"); { const kontext = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true }); const seite = await anmelden(kontext, "admin", "CODE-DOGI-0001"); await seite.touchscreen.tap(200, 700).catch(() => {}); await seite.waitForTimeout(200); const gesetzt = await seite.evaluate(() => [...document.querySelectorAll(".kachel")].some((k) => k.style.getPropertyValue("--mx"))); ok(!gesetzt, "auf dem Handy wird nichts gesetzt"); await kontext.close(); } await browser.close(); console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(fehler === 0 ? 0 : 1);