Files
dogfather-universe/server/mess-reaktion.mjs
T
DogFatherGit dcd0298700 Spenden: vier Knoepfe, eine Karte im Bild -- und die Wahrheit ueber PayPal
Filipe: "ich will dass das richtig perfekt gemacht wird so dass die
leute so einfach wie moeglich eine spende aufs paypal machen koennen.
und wenn jemand spendet soll auch der betrag erscheinen mit einem
bild."

WAS GEHT UND WAS NICHT -- NACHGESEHEN, NICHT ANGENOMMEN

Hinterlegt ist ein PayPal.me-Link auf ein PRIVATES Konto. Daraus
folgt zweierlei, und beides bestimmt den ganzen Aufbau:

  ES GEHT: `paypal.me/<name>/5EUR` oeffnet PayPal mit schon
  eingetragenem Betrag. Ein Tipp, fertig. Belegt an PayPals eigener
  Hilfeseite zu PayPal.Me.

  ES GEHT NICHT VON SELBST: PayPal meldet eine Zahlung nur, wenn ein
  Webhook oder IPN eingerichtet ist -- beides braucht Zugangsdaten,
  die nur Filipe selbst anlegen kann. Ob ein PRIVATES Konto das
  ueberhaupt kann, sagt PayPals eigene Doku nicht eindeutig; ich habe
  es gesucht und nicht gefunden, und etwas zu behaupten, das ich
  nicht belegen kann, waere hier das Gefaehrlichste.

DESHALB DREI HERKUENFTE UND NICHT EINE

  "hand"      Filipe sieht die PayPal-Meldung auf dem Handy und tippt
              den Betrag ins Pult. Geht immer, braucht nichts, ist in
              drei Sekunden getan, und die Karte laeuft sofort.
  "gemeldet"  Der Zuschauer sagt nach dem Spenden selbst Bescheid.
              Landet als OFFEN und wird erst gezeigt, wenn die
              Leitung es bestaetigt.
  "paypal"    Kommt automatisch, sobald ein Webhook eingerichtet ist.
              Bis dahin steht dieser Weg leer da -- die Tabelle und
              die Sperre gegen doppelte Zahlungsnummern sind schon
              fertig.

WARUM EINE MELDUNG NICHT SOFORT ERSCHEINT: Sonst tippt irgendwer
"500 Euro" und steht damit gross im Bild. Eine Spende ist eine
Aussage ueber Geld; die gehoert bestaetigt, bevor sie oeffentlich
wird. Der Weg dahin ist EIN Tipp -- billig genug, dass niemand in
Versuchung kommt, ihn abzukuerzen. Beim Bestaetigen darf der Betrag
berichtigt werden: Die Leitung hat die PayPal-Meldung vor sich und
weiss es besser als die Behauptung.

FUER DIE ZUSCHAUER

Vier Betraege im Chat (2, 5, 10, 25 EUR) statt eines Links. Wer eine
Liste sieht, rechnet; wer vier Knoepfe sieht, tippt. Die Adresse wird
NICHT zweimal gepflegt -- sie steht auf der Unterstuetzen-Seite, und
von dort wird sie gelesen. Steht dort nichts oder ist der Weg auf
unsichtbar, gibt es hier auch keine Knoepfe. An einer Adresse, die
kein paypal.me ist, wird kein Betrag angehaengt: Er fuehrte sonst zu
einer Seite, auf der etwas anderes steht als auf dem Knopf.

DIE KARTE

Betrag gross, Name, Gruss, ein Bild dazu -- und eine Farbe, die von
der Stufe kommt. Drei Stufen ab Werk: Danke (ab 1), Starke Runde (ab
5), Rudel-Legende (ab 20), je mit eigener Vorlage, Farbe und Dauer.
Gilt immer die HOECHSTE, die noch passt; mit Ober- UND Untergrenze je
Stufe waere die doppelte Gelegenheit, eine Luecke zu lassen -- durch
die faellt dann ausgerechnet der grosse Betrag.

EINE NACH DER ANDEREN. Drei Spenden in zehn Sekunden sind keine
Seltenheit; uebereinander gelegt waere keine mehr lesbar, und
ausgerechnet die groesste ginge unter. Bei voller Schlange werden die
Zeiten gekuerzt, nicht die Karten weggeworfen -- wer gegeben hat,
soll es sehen.

DIE KARTE IST EIN EIGENES STUECK (spendenkarte.js/.css) und haengt an
nichts aus der Reaction. Dieselbe Karte laeuft spaeter als eigene
Seite fuer OBS und TikTok Studio; zweimal gebaut hiesse, sie sieht
nach der naechsten Aenderung an einem der beiden Orte anders aus --
und man merkt es erst im Livestream.

DER BETRAG STEHT IN CENT

Nie als Kommazahl. 0.1 + 0.2 ist in keiner Programmiersprache 0.3,
und bei Geld faellt das irgendwann jemandem auf -- meistens dem, der
zahlt. Formatiert wird erst auf dem Bildschirm.

DREI EIGENE FEHLER, VON DEN PRUEFUNGEN GEFUNDEN

1. Ein Weg aus einer Verzweigung: `/spenden/${id}/${ja ?
   "bestaetigen" : "ablehnen"}`. `pruef-struktur` hat das zu Recht
   beanstandet -- ein Tippfehler im selteneren Zweig faellt erst auf,
   wenn er mitten in einer Sendung gebraucht wird. Beide Wege stehen
   jetzt ausgeschrieben da.

2. "Genau 5 Register" -- zweimal am selben Tag dieselbe feste Zahl,
   in der Messung UND in der Pruefung. Beim sechsten Register wurden
   beide rot, obwohl nichts kaputt war. Jetzt wird gezaehlt: zu jedem
   Reiter gehoert eine Tafel, und keine steht ohne Reiter da.

3. Die Messung war zu ungeduldig: Die erste Karte laeuft elf
   Sekunden (die hoechste Stufe steht am laengsten), die zweite
   wartet in der Schlange -- richtig so. Die Messung wartete 1,6
   Sekunden und meldete "laeuft nicht". Jetzt wird auf das Merkmal
   gewartet, nicht auf die Uhr.

GEMESSEN

  mess-reaktion   4 Betragsknoepfe mit richtiger Adresse.
                  25 EUR von Hand -> Karte "Rudel-Legende" bei der
                  Zuschauerin. 500 EUR gemeldet -> steht NICHT im
                  Bild, wartet im Pult. Bestaetigt mit berichtigten
                  10 EUR -> Karte "Starke Runde". Stufe passt zum
                  Betrag, beide Male.
  pruef-spenden   46 Punkte, 0 Fehler (neu) -- darunter die
                  Gegenprobe, dass ohne Zahlungsnummer beliebig viele
                  Zeilen nebeneinander stehen duerfen (sonst liesse
                  sich nur EINE Spende von Hand eintragen).
  pruef-reaktion  154, unterstuetzung 70, aufbewahrung 45,
                  struktur 35, css-klassen 33, portnummern 15,
                  tippziele 11, ports 8, meldungen 8 -- alle 0 Fehler.

SCHEMA: zwei Tabellen (spenden, spenden_stufen) mit einem
Einmalig-Index auf die Zahlungsnummer. Auf einer Kopie der echten
Datenbank durchgespielt: 20 Personen, 227 Chatnachrichten, keine
Tabelle verliert eine Spalte.
2026-09-28 08:34:42 +02:00

690 lines
30 KiB
JavaScript

/* =====================================================================
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(),
},
});
/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf
einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist
ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel,
Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */
for (const adresse of ["https://youtu.be/9bZkp7q19f0",
"https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) {
await roh("/workspace/api/reaktion/liste", {
method: "POST", keks: k.admin, rumpf: { video: adresse },
});
}
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. */
/* LAEUFT DAS VIDEO UEBERHAUPT? Dieselbe Falle wie bei den Kameras:
Etwas, das Ton macht, darf nicht von allein losgehen. */
let videoLaeuft = 0;
for (const [wer, w] of [["Host", host.seite], ["Zuschauerin", zuschauer.seite]]) {
const v = await w.evaluate(() => ({
stand: document.getElementById("video-platz")?.dataset.spieler || "(nichts)",
rahmen: !!document.querySelector("#video-platz iframe"),
}));
console.log(` Video bei ${wer.padEnd(12)} Rahmen=${v.rahmen} Zustand=${v.stand}`);
if (v.stand === "laeuft" || v.stand === "puffert") videoLaeuft++;
}
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;
/* LAEUFT DAS VIDEO? Ein Zuschauer hat keine Bedienung -- startet es
nicht von selbst, sitzt er eine Stunde vor einem Standbild und
kann nichts dagegen tun. Gemessen war genau das der Fall. */
if (videoLaeuft < 2) {
console.log(` ACHTUNG: Das Video laeuft nur bei ${videoLaeuft} von 2.`);
ausgang = 1;
}
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;
}
/* ---- Die Regie ---------------------------------------------------
NICHT UMSCHALTEN, SONDERN HERSTELLEN. Ein Klick auf „Griff" heisst
„das Gegenteil von dem, was gerade ist" -- und setzt damit voraus,
dass die Messung den Zustand vorher kennt. Genau daran ist sie
einmal gescheitert: Seit das Pult aufgeklappt startet, hat der
Klick es ZUgemacht, und auf dem Bild war von der Regie nichts zu
sehen. */
console.log("");
console.log(" --- Die Regie ---");
await host.seite.evaluate(() => {
const p = document.getElementById("pult");
if (p && p.dataset.auf !== "ja") document.getElementById("pult-griff")?.click();
});
await host.seite.waitForTimeout(900);
const regie = await host.seite.evaluate(() => {
const t = (id) => document.getElementById(id)?.textContent?.trim() || "";
const sicht = (w) => {
const e = document.querySelector(w);
if (!e) return false;
const r = e.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
return {
auf: document.getElementById("pult")?.dataset.auf,
lampe: t("pult-wort"),
uhr: t("pult-zeit"),
dabei: t("z-dabei"),
sicht_zahl: t("z-sicht"),
last: t("z-last"),
spur: t("spur-titel"),
zeit: t("v-zeit"),
reiter: document.querySelectorAll("#reiter .reiter__knopf").length,
tafeln: document.querySelectorAll(".pult-tafel").length,
tafel_sichtbar: [...document.querySelectorAll(".pult-tafel")].filter((e) => !e.hidden).length,
regler: document.querySelectorAll("#regler .regler").length,
pegel: document.querySelectorAll("#regler .pegel").length,
knoepfe_sichtbar: ["p-vorbereiten", "p-starten", "p-beenden"]
.filter((id) => sicht("#" + id)).length,
/* WOHER KOMMT EIN UEBERLAUF? Die blosse Zahl „197 Pixel zum
Scrollen" schickt einen ins Raten. Die Hoehen daneben zeigen
in einer Zeile, welcher Kasten zu gross ist. */
hoehen: (() => {
const h = (w) => {
const e = document.querySelector(w);
return e ? Math.round(e.getBoundingClientRect().height) : -1;
};
return `fenster ${window.innerHeight} · saal ${h("#saal")}`
+ ` · kino ${h("#teil-live")} · pult ${h("#pult")}`
+ ` (leiste ${h(".pult__leiste")} + innen ${h("#pult-innen")})`
+ ` · tafeln sichtbar ${[...document.querySelectorAll(".pult-tafel")]
.filter((e) => e.getBoundingClientRect().height > 0).length}`
+ ` · innen rollt ${document.getElementById("pult-innen").scrollHeight}`;
})(),
kinder: [...document.getElementById("pult-innen").children].map((e) => {
const r = e.getBoundingClientRect();
const c = getComputedStyle(e);
return `${e.className || e.tagName}:${Math.round(r.top)}+${Math.round(r.height)}`
+ `/${c.display}/${c.position}`;
}).join(" "),
};
});
console.log(" " + regie.hoehen);
console.log(" " + regie.kinder);
console.log(` Lampe „${regie.lampe}" Uhr ${regie.uhr} `
+ `Sehen zu ${regie.dabei} mit Bild ${regie.sicht_zahl} Upload ${regie.last}`);
console.log(` Spur: „${regie.spur}" ${regie.zeit}`);
console.log(` ${regie.reiter} Register, ${regie.tafel_sichtbar} Tafel offen, `
+ `${regie.regler} Regler mit ${regie.pegel} Pegeln, `
+ `${regie.knoepfe_sichtbar}/3 grosse Knoepfe sichtbar`);
/* JEDE DIESER ZAHLEN KANN STILL FALSCH SEIN -- und genau dann sieht
ein Bildschirmfoto trotzdem gut aus. Deshalb werden sie geprueft
und nicht nur ausgegeben. */
if (regie.auf !== "ja") {
console.log(" ACHTUNG: Die Regie liess sich nicht aufklappen.");
ausgang = 1;
}
if (!/^\d+:\d\d/.test(regie.uhr) || regie.uhr === "0:00") {
console.log(` ACHTUNG: Die Laufzeit steht still (${regie.uhr}).`);
ausgang = 1;
}
if (!/kbit\/s|Mbit\/s/.test(regie.last)) {
console.log(` ACHTUNG: Der Upload wird nicht gemessen (${regie.last}).`);
ausgang = 1;
}
/* GEZAEHLT, NICHT FESTGESCHRIEBEN. Vorher stand hier „genau 5" --
und beim sechsten Register wurde die Messung rot, obwohl nichts
kaputt war. Was zaehlt, ist: zu jedem Reiter gehoert eine Tafel,
und genau eine ist offen. */
if (regie.reiter < 5 || regie.reiter !== regie.tafeln || regie.tafel_sichtbar !== 1) {
console.log(` ACHTUNG: Die Register stimmen nicht (${regie.reiter} Reiter, `
+ `${regie.tafeln} Tafeln, ${regie.tafel_sichtbar} offen).`);
ausgang = 1;
}
if (regie.regler !== 4 || regie.pegel !== 4 || regie.knoepfe_sichtbar !== 3) {
console.log(" ACHTUNG: Regler, Pegel oder die grossen Knoepfe fehlen.");
ausgang = 1;
}
/* ---- Kamera aus und Chat-Stufe ------------------------------------
Beides ist erst gebaut, wenn es beim ZUSCHAUER ankommt. */
console.log("");
console.log(" --- Kamera aus, Chat zu ---");
await host.seite.click("#s-kamera").catch(() => {});
await host.seite.waitForTimeout(1200);
const nachKamera = await zuschauer.seite.evaluate(() => {
const f = document.querySelector('#kameras .kamera[data-kamera="aus"]');
return {
schild: f ? f.querySelector(".kamera__aus")?.textContent?.trim() : null,
wessen: f ? f.querySelector(".kamera__name")?.textContent?.trim() : null,
videoVersteckt: f ? getComputedStyle(f.querySelector("video")).opacity === "0" : false,
};
});
console.log(` Zuschauerin sieht: „${nachKamera.schild || "(nichts)"}" `
+ `bei ${nachKamera.wessen || "(niemandem)"}, Bild verdeckt=${nachKamera.videoVersteckt}`);
if (nachKamera.schild !== "Kamera aus" || !nachKamera.videoVersteckt) {
console.log(" ACHTUNG: Das abgeschaltete Bild kommt nicht als Schild an.");
ausgang = 1;
}
await host.seite.click("#s-kamera").catch(() => {});
await host.seite.waitForTimeout(900);
await host.seite.click('#chat-stufen .stufe[data-modus="team"]').catch(() => {});
await host.seite.waitForTimeout(1200);
const nachChat = await zuschauer.seite.evaluate(() => {
const f = document.getElementById("chat-feld");
return { gesperrt: !!f?.disabled, grund: f?.placeholder || "",
stufe: document.getElementById("schiene")?.dataset.chat };
});
console.log(` Zuschauerin: Feld gesperrt=${nachChat.gesperrt} `
+ `Grund „${nachChat.grund}" Stufe=${nachChat.stufe}`);
/* GEGENPROBE AM SERVER. Ein ausgegrautes Feld haelt niemanden auf,
der die Schnittstelle kennt -- also wird auch dort nachgefragt. */
const trotzdem = await roh("/workspace/api/reaktion/chat", {
method: "POST", keks: k.gast, rumpf: { text: "geht das trotzdem?" },
});
console.log(` ... und der Server lehnt ab: ${trotzdem.code}/${trotzdem.j?.fehler}`);
if (!nachChat.gesperrt || !nachChat.grund.includes("Team") || trotzdem.code !== 403) {
console.log(" ACHTUNG: Die Chat-Stufe kommt nicht an.");
ausgang = 1;
}
await host.seite.click('#chat-stufen .stufe[data-modus="offen"]').catch(() => {});
await host.seite.waitForTimeout(900);
/* ---- Die Spenden -------------------------------------------------
Der ganze Weg: eintragen -> Karte laeuft bei allen. Melden ->
wartet. Bestaetigen -> laeuft. Und eine unbestaetigte Meldung
darf NICHT im Bild stehen. */
console.log("");
console.log(" --- Spenden ---");
/* Damit es Betragsknoepfe gibt, braucht die Unterstuetzen-Seite eine
PayPal.me-Adresse. Sie wird ueber den echten Weg gesetzt -- eine
Zeile direkt in die Datenbank zu schreiben wuerde genau die
Pruefungen umgehen, um die es geht. */
const wege = await roh("/workspace/api/unterstuetzung", { keks: k.admin });
const ppWeg = (wege.j?.wege || wege.j?.liste || []).find((w) => w.schluessel === "paypal");
if (ppWeg) {
await roh(`/workspace/api/unterstuetzung/${ppWeg.id}`, {
method: "PUT", keks: k.admin,
rumpf: {
ziel: "https://paypal.me/probehase", knopf: "Dogi was ausgeben", sichtbar: true,
},
});
}
const spLage = await roh("/workspace/api/spenden", { keks: k.gast });
console.log(` Betragsknoepfe: ${(spLage.j?.knoepfe || []).map((b) => b.ziel.split("/").pop()).join(", ") || "(keine)"}`);
if ((spLage.j?.knoepfe || []).length !== 4) {
console.log(" ACHTUNG: Die Betragsknoepfe fehlen.");
ausgang = 1;
}
/* 1. Von Hand eintragen -- die Karte muss bei der Zuschauerin laufen. */
await zuschauer.seite.evaluate(() => { window.__karten = []; });
await zuschauer.seite.evaluate(() => {
const t = document.getElementById("spendentafel");
new MutationObserver((m) => {
for (const e of m) {
for (const n of e.addedNodes) {
if (n.querySelector) {
window.__karten.push({
betrag: n.querySelector(".spendenkarte__betrag")?.textContent?.trim(),
wer: n.querySelector(".spendenkarte__wer")?.textContent?.trim(),
stufe: n.querySelector(".spendenkarte__stufe")?.textContent?.trim(),
});
}
}
}
}).observe(t, { childList: true });
});
await roh("/workspace/api/spenden", {
method: "POST", keks: k.admin,
rumpf: { betrag_cent: 2500, name: "Nachtfalter", gruss: "Fuer das Rudel!" },
});
await zuschauer.seite.waitForFunction(
() => document.querySelector('.spendenkarte[data-da="ja"]'), null, { timeout: 8000 },
).catch(() => {});
await zuschauer.seite.waitForTimeout(700);
await bild(zuschauer.seite, "10-spendenkarte");
/* 2. Eine Meldung ohne Bestaetigung darf NICHT laufen. */
await roh("/workspace/api/spenden/melden", {
method: "POST", keks: k.gast, rumpf: { betrag_cent: 50000 },
});
await zuschauer.seite.waitForTimeout(1200);
const nachMelden = await zuschauer.seite.evaluate(() => window.__karten);
console.log(` Karten bei der Zuschauerin: ${JSON.stringify(nachMelden)}`);
if (nachMelden.length !== 1 || !/25/.test(nachMelden[0]?.betrag || "")) {
console.log(" ACHTUNG: Die eingetragene Spende kommt nicht als Karte an.");
ausgang = 1;
}
if (nachMelden.some((k2) => /500/.test(k2.betrag || ""))) {
console.log(" ACHTUNG: Eine UNBESTAETIGTE Meldung stand im Bild.");
ausgang = 1;
}
/* 3. Bestaetigen -- jetzt muss sie laufen. */
const offen = await roh("/workspace/api/spenden", { keks: k.admin });
const wartet = (offen.j?.offen || [])[0];
console.log(` Im Pult wartet: ${wartet ? wartet.betrag_cent / 100 + " EUR von " + wartet.name : "(nichts)"}`);
if (!wartet) {
console.log(" ACHTUNG: Die Meldung kommt nicht im Pult an.");
ausgang = 1;
} else {
await roh(`/workspace/api/spenden/${wartet.id}/bestaetigen`, {
method: "POST", keks: k.admin, rumpf: { betrag_cent: 1000 },
});
/* AUF DAS MERKMAL WARTEN, NICHT AUF DIE UHR. Die erste Karte
laeuft elf Sekunden (die hoechste Stufe steht am laengsten); die
zweite wartet in der Schlange, damit sie nicht uebereinander
liegen. Eine feste Pause haette das als Fehler gemeldet. */
await zuschauer.seite.waitForFunction(
() => (window.__karten || []).length >= 2, null, { timeout: 20000 },
).catch(() => {});
const nachJa = await zuschauer.seite.evaluate(() => window.__karten);
console.log(` Nach dem Bestaetigen: ${JSON.stringify(nachJa.slice(-1))}`);
if (nachJa.length !== 2 || !/10/.test(nachJa[1]?.betrag || "")) {
console.log(" ACHTUNG: Die bestaetigte Spende laeuft nicht -- oder mit falschem Betrag.");
ausgang = 1;
}
/* DIE STUFE MUSS ZUM BETRAG PASSEN. 25 EUR ist die hoechste
(„Rudel-Legende"), 10 EUR die mittlere („Starke Runde"). Ohne
diese Probe koennte jede Karte dieselbe Stufe tragen und es
faellt niemandem auf. */
console.log(` Stufen: ${nachMelden[0]?.stufe} / ${nachJa[1]?.stufe}`);
/* Der Text, nicht das Bild: Grossbuchstaben macht das Stilblatt. */
if (nachMelden[0]?.stufe !== "Rudel-Legende" || nachJa[1]?.stufe !== "Starke Runde") {
console.log(" ACHTUNG: Die Stufe passt nicht zum Betrag.");
ausgang = 1;
}
}
await host.seite.click('#reiter .reiter__knopf[data-tafel="spenden"]').catch(() => {});
await host.seite.waitForTimeout(700);
await bild(host.seite, "9-live-regie-spenden");
await vermessen(host.seite, "Host am Rechner");
await bild(host.seite, "3-live-host");
/* Und einmal die Warteschlange -- das ist der Teil, den Filipe
vorher benutzt, nicht waehrend der Sendung. */
await host.seite.click('#reiter .reiter__knopf[data-tafel="liste"]').catch(() => {});
await host.seite.waitForTimeout(600);
const warte = await host.seite.evaluate(() => ({
zeilen: document.querySelectorAll("#warteliste .warte-zeile").length,
bilder: [...document.querySelectorAll(".warte-zeile__bild")]
.filter((b) => b.naturalWidth > 0).length,
titel: [...document.querySelectorAll(".warte-zeile__titel")]
.map((e) => e.textContent.trim()).join(" | "),
naechstes: document.getElementById("v-naechstes")?.disabled,
}));
console.log(` Warteschlange: ${warte.zeilen} Zeilen, `
+ `${warte.bilder} Vorschaubilder geladen, „Naechstes" `
+ `${warte.naechstes ? "grau" : "nutzbar"}`);
console.log(` ${warte.titel}`);
if (warte.zeilen !== 2 || warte.naechstes) {
console.log(" ACHTUNG: Die Warteschlange kommt nicht an.");
ausgang = 1;
}
await bild(host.seite, "7-live-regie-liste");
await host.seite.click('#reiter .reiter__knopf[data-tafel="ton"]').catch(() => {});
await host.seite.waitForTimeout(500);
await bild(host.seite, "8-live-regie-ton");
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);