/* ===================================================================== DER PLATZ HINTER DEM PULT (08.10.2026) Zwei Dinge, die Filipe am 08.10.2026 gemeldet hat, und beide lassen sich NUR in einem echten Browser messen: 1. „in der regie will ich auch video hinzufuegen koennen fuer andere tage und uhrzeiten, gerade geht das nicht. wie so ein kalender wo vanvan und dogfather videos eintragen und vorbereiten koennen." 2. „wenn ich texte eingebe oder videos und kurz was anderes mache und es nicht gespeichert hab loescht es sich von selbst. es soll bleiben bis ich fertig bin und speichern druecke. oder aus der seite gehe aber nicht solange wie ich noch da bin." --------------------------------------------------------------------- WARUM DER ZWEITE PUNKT HIER UND NICHT IN EINER PRUEFUNG STEHT Er handelt von einem Zustand, den es auf dem Server gar nicht gibt: getippter, noch nicht abgeschickter Text. Eine Schnittstellenpruefung kann ihn nicht sehen. Gemessen wird deshalb der Vorgang selbst -- tippen, ein Neuzeichnen ausloesen, nachsehen, ob es noch dasteht. DAS NEUZEICHNEN WIRD ECHT AUSGELOEST und nicht nachgeahmt: Die Messung schickt eine Aenderung von einer ZWEITEN Sitzung (VanVan), wie es an einem Sendeabend auch passiert. Ein `zeichnen()` von Hand aufzurufen wuerde beweisen, dass die Funktion funktioniert -- nicht, dass der Fall eintritt. NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes Zertifikat -- und am Ende ist alles weg. DREI AUSGAENGE: 0 in Ordnung · 1 ein Befund · 3 konnte nicht nachsehen Aufrufen mit: node server/mess-regie.mjs ===================================================================== */ import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { request } from "node:http"; import { createServer as httpsServer } from "node:https"; import { zertifikatBauen } from "./helfer-openssl.mjs"; import { notbremse } from "./helfer-notbremse.mjs"; import { eigenerPort } from "./helfer-port.mjs"; const HIER = dirname(fileURLToPath(import.meta.url)); const ordner = mkdtempSync(join(tmpdir(), "mess-regie-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); const PORT = await eigenerPort(import.meta); const HTTPS_PORT = await eigenerPort(import.meta, "mess-regie (HTTPS)", 1); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; 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"); notbremse(240_000, "mess-regie"); await new Promise((r) => setTimeout(r, 1000)); const CREW = "crew.dogfather-universe.com"; /* ---------- Leute ------------------------------------------------------- */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const kenn = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kenn, new Date().toISOString()); const ALT = new Date(Date.now() - 200 * 86400000).toISOString(); 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," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", kenn).update(code).digest("hex"), ALT); } const CODES = { admin: "CODE-M00-000001", hand: "CODE-M01-000001" }; anlegen("DogFather", "admin", CODES.admin); anlegen("VanVan", "hand", CODES.hand); d.close(); function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) { return new Promise((fertig, schief) => { const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method, headers: { Host: CREW, Origin: `https://${CREW}`, ...(keks ? { Cookie: keks } : {}), ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), }, }, (antwort) => { const teile = []; antwort.on("data", (s) => teile.push(s)); antwort.on("end", () => { const text = Buffer.concat(teile).toString("utf8"); let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); }); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmelden(wer) { const a = await roh("/workspace/api/anmelden", { method: "POST", rumpf: { rolle: wer, code: CODES[wer], alter_ok: true }, }); return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); } const k = { admin: await anmelden("admin"), hand: await anmelden("hand") }; /* ---------- Zaehlwerk --------------------------------------------------- */ let fehler = 0; let geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; function nichtNachsehbar(grund) { console.log("\n -- NICHT NACHSEHBAR"); console.log(` ${grund}`); console.log(" Das ist KEIN Befund am Code.\n"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(3); } /* ---------- Der Vorbau -------------------------------------------------- */ const schluesselDatei = join(ordner, "p.key"); const zertDatei = join(ordner, "p.crt"); try { zertifikatBauen(schluesselDatei, zertDatei, CREW); } catch (f) { nichtNachsehbar(`Kein Zertifikat zu bauen: ${f?.message}`); } const vorbau = httpsServer({ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei), }, (von, nach) => { const weiter = request({ host: "127.0.0.1", port: PORT, path: von.url, method: von.method, headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, }, (antwort) => { nach.writeHead(antwort.statusCode || 502, antwort.headers); antwort.pipe(nach); }); weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); von.pipe(weiter); }); await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); const BASIS = `https://${CREW}:${HTTPS_PORT}`; let pw = null; try { pw = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); } catch (f) { nichtNachsehbar(`Playwright ist nicht da: ${f?.message}`); } const browser = await pw.chromium.launch({ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors", "--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"], }); async function alsWer(wer, breite = 1440, hoehe = 980) { const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860, ignoreHTTPSErrors: true, permissions: ["camera", "microphone"], }); await kontext.addCookies((k[wer] || "").split("; ").filter(Boolean).map((z) => { const [n, ...r] = z.split("="); return { name: n, value: r.join("="), domain: CREW, path: "/" }; })); const seite = await kontext.newPage(); const konsole = []; seite.on("pageerror", (f) => konsole.push(String(f?.message || f))); seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); await seite.waitForTimeout(1200); return { kontext, seite, konsole }; } /** Die Regie aufklappen und auf ein Register gehen. */ async function regieAuf(seite, tafel) { await seite.evaluate(() => { const auf = document.getElementById("reiter-auf"); if (auf && auf.getAttribute("aria-expanded") !== "true") auf.click(); }); await seite.waitForTimeout(150); await seite.click(`#reiter-${tafel}`); await seite.waitForTimeout(250); } async function bild(seite, name) { await seite.screenshot({ path: join(HIER, `mess-regie-${name}.png`) }); console.log(` Bild: server/mess-regie-${name}.png`); } /* ===================================================================== 1. DER KALENDER ===================================================================== */ console.log("\n=== 1. Der Kalender ==="); /* Zwei Videos mit Termin, eines ohne -- beide Faelle muessen sich im Raster unterscheiden lassen. Die Termine werden RELATIV gerechnet: Ein festes Datum waere eine Zeitbombe, die stimmt, bis der Kalender sie einholt. */ const INZWEITAGEN = new Date(); INZWEITAGEN.setDate(INZWEITAGEN.getDate() + 2); INZWEITAGEN.setHours(20, 30, 0, 0); { const a = await roh("/workspace/api/reaktion/liste", { method: "POST", keks: k.admin, rumpf: { video: "https://youtu.be/9bZkp7q19f0", wann: INZWEITAGEN.toISOString() }, }); ok(a.code === 200, `ein Video laesst sich GLEICH mit Termin anhaengen (${a.code})`); const z = (a.j?.liste || []).find((x) => x.video === "9bZkp7q19f0"); ok(!!z && !!z.wann, `und der Termin steht daran (${z?.wann || "leer"})`); const zweit = new Date(INZWEITAGEN); zweit.setHours(22, 0, 0, 0); await roh("/workspace/api/reaktion/liste", { method: "POST", keks: k.admin, rumpf: { video: "https://www.youtube.com/watch?v=kJQP7kiw5Fk", wann: zweit.toISOString() }, }); await roh("/workspace/api/reaktion/liste", { method: "POST", keks: k.admin, rumpf: { video: "https://www.youtube.com/watch?v=aqz-KE-bpKQ" }, }); /* UND EIN UNSINNIGES DATUM WIRD ABGEWIESEN -- mit einem eigenen Wort, nicht mit „video_unklar". */ const b = await roh("/workspace/api/reaktion/liste", { method: "POST", keks: k.admin, rumpf: { video: "https://youtu.be/dQw4w9WgXcQ", wann: "irgendwann mal" }, }); ok(b.code === 400 && b.j?.fehler === "termin_unklar", `ein unsinniges Datum wird abgewiesen (${b.code} ${b.j?.fehler})`); } { const chef = await alsWer("admin"); await regieAuf(chef.seite, "liste"); /* ---- Die Umschaltung ---- */ const vorher = await chef.seite.evaluate(() => ({ liste: !document.getElementById("warteliste").hidden, kalender: !document.getElementById("plan-kalender").hidden, })); ok(vorher.liste && !vorher.kalender, "zuerst steht die Reihenfolge da"); await chef.seite.click("#plan-sicht-kalender"); await chef.seite.waitForTimeout(250); const nachher = await chef.seite.evaluate(() => ({ liste: !document.getElementById("warteliste").hidden, kalender: !document.getElementById("plan-kalender").hidden, monat: document.getElementById("plan-monat").textContent.trim(), zellen: document.querySelectorAll("#plan-raster .plan-zelle:not(.plan-zelle--leer)").length, voll: [...document.querySelectorAll("#plan-raster .plan-zelle[data-voll=\"ja\"]")] .map((z) => z.dataset.tag), heute: document.querySelectorAll("#plan-raster .plan-zelle[data-heute=\"ja\"]").length, })); ok(!nachher.liste && nachher.kalender, "der Knopf schaltet auf den Kalender"); ok(nachher.zellen >= 28 && nachher.zellen <= 31, `der Monat hat so viele Tage, wie er hat (${nachher.zellen})`); ok(/^[A-ZÄÖÜ][a-zäöü]+ \d{4}$/.test(nachher.monat), `und er steht ausgeschrieben da (${nachher.monat})`); ok(nachher.heute === 1, `heute ist genau einmal markiert (${nachher.heute})`); /* ==== BEIDE TERMINE LIEGEN AUF DEMSELBEN TAG ================== Deshalb darf GENAU EINE Zelle gefuellt sein -- zwei waeren ein Zeichen dafuer, dass in UTC statt in Ortszeit gruppiert wird, und genau dieser Fehler verschiebt eine Sendung um 00:30 auf den Vortag. */ ok(nachher.voll.length === 1, `zwei Termine am selben Tag ergeben EINE volle Zelle (${nachher.voll.length})`); const erwartet = `${INZWEITAGEN.getFullYear()}-` + `${String(INZWEITAGEN.getMonth() + 1).padStart(2, "0")}-` + `${String(INZWEITAGEN.getDate()).padStart(2, "0")}`; ok(nachher.voll[0] === erwartet, `und zwar die richtige (${nachher.voll[0]} statt ${erwartet})`); const anzahl = await chef.seite.textContent( `#plan-raster .plan-zelle[data-tag="${erwartet}"] .plan-zelle__anzahl`); ok(anzahl?.trim() === "2", `die Zelle zeigt die ANZAHL und nicht nur einen Punkt (${anzahl})`); await bild(chef.seite, "1-kalender"); /* ---- Einen Tag antippen ---- */ await chef.seite.click(`#plan-raster .plan-zelle[data-tag="${erwartet}"]`); await chef.seite.waitForTimeout(200); const tag = await chef.seite.evaluate(() => ({ kopf: document.querySelector("#plan-tag .plan-tag__kopf")?.textContent.trim() || "", eintraege: document.querySelectorAll("#plan-tag .plan-eintrag").length, zeiten: [...document.querySelectorAll("#plan-tag .plan-eintrag__zeit")] .map((z) => z.textContent.trim()), })); ok(tag.eintraege === 2, `der Tag zeigt beide Videos (${tag.eintraege})`); ok(tag.zeiten[0] === "20:30 Uhr" && tag.zeiten[1] === "22:00 Uhr", `nach Uhrzeit sortiert (${tag.zeiten.join(" / ")})`); /* Vor dem Bild in die Schublade rollen: Sie ist hoeher als das Fenster, und ein Bildschirmfoto vom oberen Rand zeigt genau das nicht, worum es hier geht. */ await chef.seite.evaluate(() => { document.getElementById("plan-tag")?.scrollIntoView({ block: "center" }); }); await chef.seite.waitForTimeout(250); await bild(chef.seite, "2-tag"); /* ---- „Fuer diesen Tag anhaengen" fuellt das Feld ---- */ await chef.seite.click(".plan-tag__anhaengen"); await chef.seite.waitForTimeout(150); const vorbelegt = await chef.seite.inputValue("#l-wann"); ok(vorbelegt === `${erwartet}T20:00`, `der Knopf schlaegt den Abend vor, statt ihn zu erzwingen (${vorbelegt})`); const wo = await chef.seite.evaluate(() => document.activeElement?.id); ok(wo === "l-neu", `und setzt den Finger in die Adresse (${wo})`); /* ---- Und ein Monat weiter ---- */ const jetztMonat = nachher.monat; await chef.seite.click("#plan-vor"); await chef.seite.waitForTimeout(200); const weiter = await chef.seite.textContent("#plan-monat"); ok(weiter?.trim() !== jetztMonat, `ein Monat vor wechselt (${jetztMonat} -> ${weiter?.trim()})`); await chef.seite.click("#plan-heute"); await chef.seite.waitForTimeout(200); const zurueck = await chef.seite.textContent("#plan-monat"); ok(zurueck?.trim() === jetztMonat, `und „Heute" bringt zurueck (${zurueck?.trim()})`); ok(chef.konsole.length === 0, "nichts in der Konsole" + (chef.konsole.length ? " -> " + chef.konsole.slice(0, 3).join(" | ") : "")); await chef.kontext.close(); } /* ===================================================================== 2. GETIPPTES ÜBERLEBT DAS NEUZEICHNEN Der eigentliche Befund. Gemessen wird der Vorgang, den Filipe beschrieben hat: tippen, den Finger woanders hin, etwas passiert -- und nachsehen, ob der Text noch dasteht. ===================================================================== */ console.log("\n=== 2. Getipptes ueberlebt das Neuzeichnen ==="); { const chef = await alsWer("admin"); await regieAuf(chef.seite, "sendung"); await chef.seite.fill("#p-titel", "Mein halber Satz"); /* ==== UND DANN WOANDERS HIN ================================ Genau das ist Filipes Fall: „und kurz was anderes mache". Der alte Schutz hing am Fokus und griff ab hier nicht mehr. */ await chef.seite.click("#p-video"); await chef.seite.waitForTimeout(100); const markiert = await chef.seite.getAttribute("#p-titel", "data-ungespeichert"); ok(markiert === "ja", `das Feld sagt, dass es ungespeichert ist (${markiert})`); /* ==== DAS NEUZEICHNEN WIRD ECHT AUSGELOEST ================== VanVan aendert etwas; der Rundruf kommt beim Host an und zeichnet die Regie neu. Ein `zeichnen()` von Hand wuerde beweisen, dass die Funktion laeuft -- nicht, dass der Fall eintritt. */ await roh("/workspace/api/reaktion/liste", { method: "POST", keks: k.hand, rumpf: { video: "https://youtu.be/ZZ5LpwO-An4" }, }); /* ==== AUF DAS EREIGNIS WARTEN, NICHT AUF DIE UHR =============== Der erste Entwurf wartete 1200 ms und zaehlte dann. Die Zahl stand auf 3 statt 4 -- das Neuzeichnen war also noch gar nicht passiert, und die Zeile darunter („der Text steht noch da") war damit wertlos: Er stand da, weil NICHTS ihn angefasst hatte. Das ist der gefaehrlichste Messfehler ueberhaupt, weil er GRUEN wird. Jetzt wird gewartet, bis VanVans Video wirklich angekommen ist -- und erst dann gefragt, ob der getippte Text ueberlebt hat. Scheitert das Warten, ist es ein Fehler und kein Ueberspringen. */ let gezeichnet = true; try { await chef.seite.waitForFunction( () => document.querySelectorAll("#warteliste .warte-zeile").length === 4, null, { timeout: 10000 }); } catch { gezeichnet = false; } ok(gezeichnet, "VanVans Video kommt an -- es wurde also wirklich neu gezeichnet" + (gezeichnet ? "" : " (nach 10 s nicht)")); const steht = await chef.seite.inputValue("#p-titel"); ok(steht === "Mein halber Satz", `und der getippte Text steht immer noch da ("${steht}")`); /* ---- Speichern macht es wieder sauber ---- */ await chef.seite.click("#p-sichern"); await chef.seite.waitForTimeout(900); const sauber = await chef.seite.getAttribute("#p-titel", "data-ungespeichert"); ok(sauber === "nein", `nach dem Speichern ist die Marke weg (${sauber})`); const nachSpeichern = await chef.seite.inputValue("#p-titel"); ok(nachSpeichern === "Mein halber Satz", `und der Text steht weiter da ("${nachSpeichern}")`); await bild(chef.seite, "3-getippt"); /* ---- Und in einer LISTE, die bei jedem Takt neu gebaut wird ---- Das ist der haertere Fall: Die Zeile selbst entsteht neu, das Feld ist danach ein anderes Element. */ await regieAuf(chef.seite, "liste"); await chef.seite.evaluate(() => { document.getElementById("plan-sicht-reihe").click(); }); await chef.seite.waitForTimeout(200); const felder = await chef.seite.evaluate(() => document.querySelectorAll("#warteliste .warte-zeile__wann").length); ok(felder >= 3, `die Warteschlange hat Terminfelder (${felder})`); await chef.seite.evaluate(() => { const f = document.querySelectorAll("#warteliste .warte-zeile__wann")[2]; f.value = "2027-03-05T19:45"; f.dispatchEvent(new Event("input", { bubbles: true })); }); await chef.seite.waitForTimeout(100); await roh("/workspace/api/reaktion/liste", { method: "POST", keks: k.hand, rumpf: { video: "https://youtu.be/L_jWHffIx5E" }, }); await chef.seite.waitForTimeout(1200); const inListe = await chef.seite.evaluate(() => { const f = [...document.querySelectorAll("#warteliste .warte-zeile__wann")] .find((x) => x.dataset.ungespeichert === "ja"); return f ? { wert: f.value, schluessel: f.dataset.schluessel } : null; }); ok(inListe?.wert === "2027-03-05T19:45", `auch in einer neu gebauten Liste bleibt es stehen (${inListe?.wert})`); ok(/^warte-wann-\d+$/.test(inListe?.schluessel || ""), `und haengt an der Kennung der Zeile, nicht an ihrer Nummer (${inListe?.schluessel})`); ok(chef.konsole.length === 0, "nichts in der Konsole" + (chef.konsole.length ? " -> " + chef.konsole.slice(0, 3).join(" | ") : "")); await chef.kontext.close(); } /* ===================================================================== 3. GEGENPROBEN ===================================================================== */ console.log("\n=== 3. Gegenproben ==="); { const chef = await alsWer("admin"); await regieAuf(chef.seite, "sendung"); /* ==== OHNE DAS GEDAECHTNIS MUSS DER TEXT VERSCHWINDEN ========= Sonst bewiese Abschnitt 2 nur, dass irgendetwas stehen bleibt. DER ERSTE ENTWURF HAT DAS GEGENTEIL GEMESSEN: Er machte `Getippt.binde` zu einer LEEREN Funktion -- damit schreibt niemand mehr in das Feld, und der Text bleibt erst recht stehen. „Ohne das Gedaechtnis" heisst nicht „ohne Schreiben", sondern: wieder so wie frueher, naemlich stumpf `feld.value = wert`. Genau das wird hier eingesetzt. */ await chef.seite.evaluate(() => { window.Getippt.binde = (feld, schluessel, vomServer) => { feld.value = vomServer === null || vomServer === undefined ? "" : String(vomServer); }; window.Getippt.vergissAlles(); }); await chef.seite.fill("#p-titel", "Dieser Satz geht verloren"); await chef.seite.click("#p-video"); await roh("/workspace/api/reaktion", { method: "PUT", keks: k.hand, rumpf: { titel: "Von VanVan" }, }); let kam = true; try { await chef.seite.waitForFunction( () => document.getElementById("p-titel").value === "Von VanVan", null, { timeout: 10000 }); } catch { kam = false; } const weg = await chef.seite.inputValue("#p-titel"); ok(kam && weg === "Von VanVan", `Gegenprobe: nach der alten Bauart ist der getippte Satz weg ("${weg}")`); await chef.kontext.close(); } /* ===================================================================== SCHLUSS ===================================================================== */ await browser.close(); await new Promise((r) => vorbau.close(r)); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(`\n${geprueft} Messungen, ${fehler} Fehler`); if (geprueft < 20) { console.log(`NICHT NACHSEHBAR: nur ${geprueft} Messungen gelaufen, erwartet mindestens 20.`); process.exit(3); } if (fehler) { console.log("NICHT IN ORDNUNG"); process.exit(1); } console.log("ALLES IN ORDNUNG"); process.exit(0);