/* 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, zeichenGroesse: Math.round(document.querySelector(".kachel__zeichen")?.getBoundingClientRect().width || 0), 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. */ 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)); ok(toene.size === 8, `acht verschiedene Farbtöne im Einsatz (${toene.size})`); 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})`); 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(); } 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);