/* ===================================================================== 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, 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; } if (regie.reiter !== 5 || regie.tafel_sichtbar !== 1) { console.log(` ACHTUNG: Die Register stimmen nicht (${regie.reiter} Reiter, ` + `${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; } 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);