/* Browserpruefung der Content-Planung: Strecke, Kennzahlen, Balance, Ueberfuehren -- und was ein Creator 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-content-b-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = "4185"; 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:4185"; 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); 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("Dogfather", "admin", "CODE-DOGI-0001"); const idAnna = anlegen("Anna Sturm", "creator", "CODE-ANNA-0001"); /* Vier Saeulen -- damit vier der fuenf Farbplaetze im Bild sind. */ const saeulen = {}; for (const [i, [name, ziel]] of [["Tutorials", 55], ["Community", 25], ["Hinter den Kulissen", 10], ["Eigenwerbung", 10]].entries()) { d.prepare("INSERT INTO content_saeulen (creator_id, name, ziel, slot, erstellt) VALUES (?,?,?,?,?)") .run(idAnna, name, ziel, i + 1, jetzt); saeulen[name] = d.prepare("SELECT last_insert_rowid() AS id").get().id; } const video = (art, titel, hook, saeule, format, geplant) => d.prepare(`INSERT INTO eintraege (bereich, art, titel, hook, format, saeule_id, geplant, datum, status, creator_id, erstellt, erstellt_von) VALUES ('content',?,?,?,?,?,?,?,'offen',?,?,?)`) .run(art, titel, hook, format, saeule ? saeulen[saeule] : null, geplant, heute, idAnna, jetzt, idDogi); video("idee", "3 Fehler beim ersten LIVE", null, "Tutorials", null, null); video("idee", "Warum ich fast aufgehört hätte", null, "Hinter den Kulissen", null, null); video("skript", "Setup für unter 100 €", "Das hier kostet weniger als dein Handy.", "Tutorials", "Tutorial", null); video("skript", "Antwort auf eure Fragen", null, "Community", "Talking Head", null); video("gedreht", "Mein Tag als Creator", "Um 6 klingelt der Wecker – freiwillig.", "Hinter den Kulissen", "Story", null); video("gedreht", "Die 3 häufigsten Fragen", "Nummer 2 fragt mir wirklich jeder.", "Community", "Talking Head", null); video("fertig", "Licht richtig setzen", "Ein Fenster reicht. Ehrlich.", "Tutorials", "Tutorial", new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 10)); video("fertig", "Verpasst und überfällig", "Guck bis zum Schluss.", "Tutorials", "Tutorial", "2026-01-05"); for (let i = 0; i < 9; i++) video("veroeffentlicht", "Draußen " + i, "x", "Tutorials", "Tutorial", null); for (let i = 0; i < 4; i++) video("veroeffentlicht", "Community " + i, "x", "Community", "Talking Head", null); for (let i = 0; i < 2; i++) video("veroeffentlicht", "Kulissen " + i, "x", "Hinter den Kulissen", "Story", null); video("veroeffentlicht", "Ohne Zuordnung", "x", null, null, null); 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 seiteFuer(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.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" }); await seite.waitForTimeout(700); return seite; } const lesen = (seite) => seite.evaluate(() => ({ spalten: [...document.querySelectorAll(".spalte")].map((s) => ({ name: s.querySelector(".spalte__name")?.textContent, zahl: s.querySelector(".spalte__zahl")?.textContent, karten: s.querySelectorAll(".video").length, })), kacheln: [...document.querySelectorAll(".kachel")].map((k) => ({ wert: k.querySelector(".kachel__wert")?.textContent, name: k.querySelector(".kachel__name")?.textContent, zusatz: k.querySelector(".kachel__zusatz")?.textContent, stufe: k.dataset.stufe, })), stuecke: [...document.querySelectorAll(".balance__stueck")].map((s) => ({ slot: s.dataset.slot, breite: s.style.width, farbe: getComputedStyle(s).backgroundColor, })), legende: [...document.querySelectorAll(".legende__punkt")].map((l) => l.textContent), hooks: [...document.querySelectorAll(".video__hook")].map((h) => h.textContent).slice(0, 3), fehlendeHooks: document.querySelectorAll(".video__hook--fehlt").length, anlegenDa: !document.getElementById("neu-oeffnen")?.hidden, saeulenKnopfDa: !document.getElementById("saeulen-oeffnen")?.hidden, /* Nur SICHTBARE zaehlen -- versteckte Knoepfe waeren zwar unschoen, aber die Frage ist, was der Creator vor sich hat. Gezaehlt wird zusaetzlich, was ueberhaupt im Dokument steht (siehe knoepfeGesamt): Ein Knopf, den nur CSS versteckt, ist mit der Tastatur erreichbar. */ knoepfe: [...document.querySelectorAll(".tu-knopf")].filter((b) => b.offsetParent !== null).length, knoepfeGesamt: document.querySelectorAll(".tu-knopf").length, mehrKnopf: document.querySelectorAll(".spalte__mehr").length, unterzeile: document.getElementById("unterzeile")?.textContent, 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: 1100 } }); const konsole = []; const seite = await seiteFuer(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(700); const r = await lesen(seite); console.log(" Spalten:", r.spalten.map((s) => `${s.name}=${s.zahl}`).join(" · ")); console.log(" Kacheln:", r.kacheln.map((k) => `${k.name}=${k.wert}`).join(" · ")); ok(r.spalten.length === 5, `fünf Stufen (${r.spalten.length})`); ok(r.spalten[0].zahl === "2" && r.spalten[4].zahl === "16", `Ideen 2, Veröffentlicht 16 (${r.spalten[0].zahl}/${r.spalten[4].zahl})`); ok(r.kacheln.length === 5, `fünf Kennzahlen (${r.kacheln.length})`); const vorrat = r.kacheln.find((k) => k.name === "Vorrat"); ok(vorrat?.wert === "4", `Vorrat 4 (${vorrat?.wert})`); ok(/reicht noch/.test(vorrat?.zusatz || ""), `als Tage ausgedrückt ("${vorrat?.zusatz}")`); const ohneHook = r.kacheln.find((k) => k.name === "ohne Hook"); ok(ohneHook?.wert === "1" && ohneHook?.stufe === "warn", `ein Video ohne Hook, auffällig (${ohneHook?.wert}/${ohneHook?.stufe})`); const ueberf = r.kacheln.find((k) => k.name === "überfällig"); ok(ueberf?.wert === "1", `eines überfällig (${ueberf?.wert})`); /* Die Balance -- vier Farben, jede eine andere, keine zwei gleich. */ ok(r.stuecke.length === 4, `vier Stücke im Balken (${r.stuecke.length})`); const farben = new Set(r.stuecke.map((s) => s.farbe)); ok(farben.size === r.stuecke.length, `jede Farbe kommt genau einmal vor (${farben.size})`); console.log(" Farben:", [...farben].join(" ")); ok(r.legende.some((l) => /Ziel 55/.test(l)), "die Legende nennt das Ziel"); ok(r.legende.some((l) => /über|unter/.test(l)), "und die Abweichung davon"); ok(r.fehlendeHooks === 1, `genau eine Karte sagt „kein Hook" (${r.fehlendeHooks})`); ok(r.hooks.some((h) => /„/.test(h)), "Hooks stehen in Anführungszeichen"); ok(r.anlegenDa && r.saeulenKnopfDa, "DogFather kann anlegen und Säulen verwalten"); 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(" | ") : ""}`); /* Die lange Spalte darf nicht alles erschlagen -- und der Aufklapper muss sie wirklich vollstaendig zeigen. */ ok(r.mehrKnopf === 1, `ein Aufklapper an der zu langen Spalte (${r.mehrKnopf})`); ok(r.spalten[4].karten === 6, `Veröffentlicht zeigt 6 von 16 (${r.spalten[4].karten})`); await seite.click(".spalte__mehr"); await seite.waitForTimeout(300); const offen = await lesen(seite); ok(offen.spalten[4].karten === 16, `aufgeklappt sind es alle 16 (${offen.spalten[4].karten})`); await seite.click(".spalte__mehr"); await seite.waitForTimeout(300); ok((await lesen(seite)).spalten[4].karten === 6, "und wieder zu"); if (breite === 1440) { await seite.screenshot({ path: "pruef-content-computer.png", fullPage: true }); console.log("\n Überführen im Browser"); /* Die Karte mit dem geplanten Termin -- dort gibt es alle drei Wege. */ const knopf = seite.locator('.video:has-text("Licht richtig setzen") .tu-knopf', { hasText: "→ Aufgabe" }); await knopf.first().click(); await seite.waitForTimeout(1200); const meldung = await seite.evaluate(() => document.getElementById("fehler")?.textContent); ok(/Aufgabe angelegt/.test(meldung || ""), `Rückmeldung erscheint ("${meldung}")`); const link = await seite.evaluate(() => document.querySelector("#fehler a")?.getAttribute("href")); ok(link === "aufgaben.html", `mit Weg dorthin (${link})`); } else { await seite.screenshot({ path: "pruef-content-handy.png", fullPage: true }); } await kontext.close(); } console.log("\n=== Creator sieht, ändert aber nichts"); { const kontext = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); const seite = await seiteFuer(kontext, "creator", "CODE-ANNA-0001"); const r = await lesen(seite); ok(r.spalten.length === 5, `sieht die ganze Strecke (${r.spalten.length} Spalten)`); /* 2+2+2+2 plus die gekuerzten 6 der letzten Spalte = 14. Die Kuerzung gilt fuer JEDE Rolle -- sie ist eine Frage der Lesbarkeit, nicht der Rechte. Der Rest ist einen Klick entfernt. */ ok(r.spalten.reduce((n, s) => n + s.karten, 0) === 14, `und seine Videos, letzte Spalte gekürzt (${r.spalten.reduce((n, s) => n + s.karten, 0)})`); ok(r.mehrKnopf === 1, `mit einem Aufklapper an der langen Spalte (${r.mehrKnopf})`); ok(r.kacheln.length === 5, "sieht seine Kennzahlen"); ok(r.stuecke.length === 4, "sieht die Verteilung"); ok(!r.anlegenDa, "aber keinen Anlegen-Knopf"); ok(!r.saeulenKnopfDa, "und keine Säulenverwaltung"); ok(r.knoepfe === 0, `keinen einzigen sichtbaren Knopf (${r.knoepfe})`); ok(r.knoepfeGesamt === 0, `und auch keinen versteckten im Dokument (${r.knoepfeGesamt})`); ok(/deine Betreuung/.test(r.unterzeile || ""), `und die Unterzeile erklärt es ("${r.unterzeile}")`); await seite.screenshot({ path: "pruef-content-creator.png", fullPage: true }); 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);