/* ===================================================================== WIE DIE REACTION AUSSIEHT (28.09.2026) Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das sagt nichts darueber, ob ein Mensch damit einen Abend verbringen moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser, schaltet sie durch alle drei Zustaende und legt Bilder ab. Gemessen wird dabei auch, was man sonst uebersieht: - laeuft etwas in die Konsole? - ragt etwas seitlich hinaus? - ist der Saal so hoch wie das Fenster, oder muss man scrollen? NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes Zertifikat -- und am Ende ist alles weg. Aufrufen mit: node server/mess-reaktion.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 { execFileSync } from "node:child_process"; import { createServer as httpsServer } from "node:https"; const HIER = dirname(fileURLToPath(import.meta.url)); const ordner = mkdtempSync(join(tmpdir(), "mess-reakt-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); const PORT = 5483; const HTTPS_PORT = PORT + 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"; for (const art of ["uncaughtException", "unhandledRejection"]) { process.on(art, (f) => { console.error(`\nABBRUCH (${art}): ${f?.stack || f}`); process.exit(8); }); } 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, 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); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" }; const IDS = { admin: anlegen("DogFather", "admin", CODES.admin), gast: anlegen("Nachtfalter", "gast", CODES.gast), gast2: anlegen("Lenny", "gast", CODES.gast2), }; 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(rolle) { const a = await roh("/workspace/api/anmelden", { method: "POST", rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], alter_ok: true }, }); return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); } const k = { admin: await anmelden("admin"), gast: await anmelden("gast"), gast2: await anmelden("gast2"), }; /* ---------- Die Buehne fuellen ------------------------------------------ */ await roh("/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Wir schauen den neuen Trailer", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(), }, }); const REDEN = [ ["gast", "Bin schon daaa 🐺"], ["gast2", "Ich auch! Hab extra Popcorn geholt"], ["gast", "Wie lange noch?"], ["admin", "Zwei Minuten, ich richte noch die Kamera"], ["gast2", "Kein Stress, wir sitzen bequem"], ["gast", "Letztes Mal war so gut, ich hab geheult"], ]; /* ---------- Der Vorbau -------------------------------------------------- */ const schluesselDatei = join(ordner, "p.key"); const zertDatei = join(ordner, "p.crt"); execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schluesselDatei, "-out", zertDatei, "-days", "2", "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" }); 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}`; const pw = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch({ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors", /* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede Aufnahme aus, als waere die Kamera kaputt. */ "--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"], }); /* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht schliesst, nimmt den spaeteren den Platz weg. GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben offen, und danach kam Lennys Strom gar nicht mehr zustande. Die Signale gingen ins Leere, und die Kamerafenster blieben schwarz -- ein Messfehler, der wie ein Programmfehler aussah. */ async function alsWer(rolle, breite, hoehe) { const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe }, ignoreHTTPSErrors: true, permissions: ["camera", "microphone"], }); await kontext.addCookies((k[rolle] || "").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 = []; const anfragen = []; seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); }); seite.on("pageerror", (f) => konsole.push(String(f?.message || f))); seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); seite.on("response", (a) => { if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`); }); return { kontext, seite, konsole, anfragen }; } async function bild(seite, name) { await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) }); console.log(` Bild: server/mess-reaktion-${name}.png`); } async function vermessen(seite, wo) { const m = await seite.evaluate(() => ({ ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight), stand: document.getElementById("saal")?.dataset.stand || "?", schild: document.getElementById("schild-wort")?.textContent || "", })); console.log(` ${wo}: Stand „${m.stand}", Schild „${m.schild}", ` + `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`); return m; } console.log("\n=== ZU ==="); { const { kontext, seite, konsole } = await alsWer("gast", 1440, 900); await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); await seite.waitForTimeout(900); await vermessen(seite, "Rechner"); await bild(seite, "1-zu-rechner"); if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | ")); await kontext.close(); } console.log("\n=== VORBEREITUNG ==="); await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } }); for (const [wer, text] of REDEN) { await roh("/workspace/api/reaktion/chat", { method: "POST", keks: k[wer], rumpf: { text } }); } { const { kontext, seite, konsole } = await alsWer("gast", 1440, 900); await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); await seite.waitForTimeout(1400); await vermessen(seite, "Rechner"); await bild(seite, "2-warte-rechner"); if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | ")); await kontext.close(); } console.log("\n=== LIVE ==="); await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); await roh("/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 } }); /* ==== BEIDE SEITEN GLEICHZEITIG ==================================== Der Host sendet, die Zuschauerin empfaengt -- das geht nur, wenn beide zur selben Zeit offen sind. */ const host = await alsWer("admin", 1440, 900); await host.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); await host.seite.waitForTimeout(2500); const gast2 = await alsWer("gast2", 1280, 800); await gast2.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); await gast2.seite.waitForTimeout(2500); const zuschauer = await alsWer("gast", 1440, 900); await zuschauer.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); /* Verbindungen brauchen ein paar Sekunden: Adressen holen, Angebot, Antwort, Kandidaten. Acht Sekunden sind grosszuegig -- es geht darum, ob es UEBERHAUPT ankommt, nicht wie schnell. */ await zuschauer.seite.waitForTimeout(8000); async function bildBreiten(seite) { return seite.evaluate(() => { const raus = {}; for (const k2 of document.querySelectorAll("#kameras .kamera")) { const v = k2.querySelector("video"); raus[k2.querySelector(".kamera__name")?.textContent || k2.dataset.wer] = { breite: v ? v.videoWidth : -1, strom: !!(v && v.srcObject), laeuft: !!(v && !v.paused), stumm: !!(v && v.muted), }; } return raus; }); } console.log("\n --- Fliesst ein Bild? ---"); for (const [wer, w] of [["Host", host.seite], ["Gast", gast2.seite], ["Zuschauerin", zuschauer.seite]]) { const z = await w.evaluate(() => ({ url: location.pathname, saal: !!document.getElementById("saal"), stand: document.getElementById("saal")?.dataset.stand, kameras: document.querySelectorAll("#kameras .kamera").length, })).catch((f) => ({ url: "FEHLER " + f.message })); console.log(` ${wer.padEnd(12)} ${z.url} saal=${z.saal} stand=${z.stand} kameras=${z.kameras}`); const q = { Host: host, Gast: gast2, Zuschauerin: zuschauer }[wer]; console.log(" Strom-Anfragen: " + q.anfragen.filter((x) => x.includes("strom")).length + " | Signale: " + q.anfragen.filter((x) => x.includes("signal")).length); } const beiZuschauer = await bildBreiten(zuschauer.seite); for (const [name, m] of Object.entries(beiZuschauer)) { const gut = m.breite > 0; console.log(` Zuschauerin sieht ${name.padEnd(12)} ` + `${gut ? "BILD " + m.breite + " px" : (m.strom ? "Strom da, aber 0 px" : "NICHTS")}` + ` ${m.laeuft ? "laeuft" : "STEHT"} ${m.stumm ? "stumm" : "mit Ton"}`); } /* AUCH DER HOST MUSS SEINEN GAST SEHEN. Wenn nur die Zuschauer etwas sehen, redet der Host gegen eine schwarze Kachel -- und merkt nicht, wenn beim Gast die Kamera aus ist. */ const beimHost = await bildBreiten(host.seite); for (const [name, m] of Object.entries(beimHost)) { console.log(` Host sieht ${name.padEnd(12)} ` + `${m.breite > 0 ? "BILD " + m.breite + " px" : "NICHTS"}` + ` ${m.laeuft ? "laeuft" : "STEHT"}`); } const zaehlt = Object.values(beiZuschauer).filter((m) => m.breite > 0).length; console.log(` -> ${zaehlt} von ${Object.keys(beiZuschauer).length} Kameras kommen an.`); /* DIESE MESSUNG HAT EINEN RUECKGABEWERT -- und zwar wegen dieser einen Zahl. Alles andere kann gruen sein: Die Schnittstelle antwortet, die Seite sieht gut aus, die Verbindung steht. Wenn hier 0 steht, sitzt trotzdem jeder vor einem schwarzen Rechteck. Gemessen wurde genau das zweimal: einmal, weil das Angebot hinausging, bevor der Empfaenger zuhoeren konnte -- einmal, weil ein Video mit Ton nicht von allein startet. Beide Male sahen die Bildschirmfotos plausibel aus. */ let ausgang = 0; const beimHostAn = Object.values(beimHost).filter((m) => m.breite > 0).length; if (beimHostAn < Object.keys(beimHost).length) { console.log(" ACHTUNG: Der Host sieht nicht alle Kameras."); ausgang = 1; } if (!zaehlt) { console.log(" ACHTUNG: Es kommt kein einziges Bild an."); ausgang = 1; } const stehen = [...Object.values(beiZuschauer), ...Object.values(beimHost)] .filter((m) => m.breite > 0 && !m.laeuft).length; if (stehen) { console.log(` ACHTUNG: ${stehen} Bild(er) kommen an, stehen aber still ` + "-- ein Video mit Ton startet nicht von allein."); ausgang = 1; } await host.seite.click("#pult-griff").catch(() => {}); await host.seite.waitForTimeout(700); await vermessen(host.seite, "Host am Rechner"); await bild(host.seite, "3-live-host"); if (host.konsole.length) console.log(" KONSOLE:", host.konsole.slice(0, 4).join(" | ")); await vermessen(zuschauer.seite, "Zuschauerin am Rechner"); await bild(zuschauer.seite, "4-live-zuschauer"); if (zuschauer.konsole.length) console.log(" KONSOLE:", zuschauer.konsole.slice(0, 4).join(" | ")); await host.kontext.close(); await gast2.kontext.close(); await zuschauer.kontext.close(); { const handy = await alsWer("gast2", 390, 844); await handy.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); await handy.seite.waitForTimeout(4000); await vermessen(handy.seite, "Handy"); await bild(handy.seite, "5-live-handy"); await handy.seite.waitForTimeout(500); await bild(handy.seite, "6-live-handy-chat"); const hm = await handy.seite.evaluate(() => { const g = (w) => { const e = document.querySelector(w); if (!e) return "(fehlt)"; const r = e.getBoundingClientRect(); const c = getComputedStyle(e); return `${Math.round(r.width)}x${Math.round(r.height)} display=${c.display}`; }; return { saal: g("#saal"), kino: g("#teil-live"), leinwand: g(".leinwand"), schiene: g(".schiene"), schieneWert: document.getElementById("teil-live")?.dataset.schiene }; }); console.log(" Handy-Masse:", JSON.stringify(hm)); if (handy.konsole.length) console.log(" KONSOLE:", handy.konsole.slice(0, 4).join(" | ")); } await browser.close(); await new Promise((r) => vorbau.close(r)); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log("\nFertig."); process.exit(ausgang);