Files
dogfather-universe/server/pruef-draussen.mjs
T
DogFatherGitandClaude Opus 5 eca9aed279 Der Treff sagt jetzt, ob gerade gestreamt wird
Filipe zu dieser Seite: "mach was anderes daraus oder mach es viel
krass und geiler ... erstell was was mich von den socken schmeisst. es
soll wirklich sogar ein wow effekt fuer die community ergeben." Dazu
die eine Auflage: die Kachel bleibt.

WAS SIE WAR: zwei Karten mit zwei Adressen. Sauber gebaut, richtig
beschriftet -- und niemand kam ein zweites Mal wieder. Wer im Treff
ist, kennt die Website. Den Rabattcode merkt man sich beim ersten Mal.

DIE FRAGE, DIE WIRKLICH JEMAND HAT, IST EINE ANDERE: Laeuft er
gerade? Und wenn nicht -- wie lange noch? Die Antwort kennt das Haus
seit dem 20.08.: postfach.dogfather-universe.com/live-status, derselbe
Dienst, von dem der oeffentliche Streamplan lebt. Im Treff gab es sie
bisher nicht.

Jetzt steht oben ein Puls: ein Ring, der sich ueber den Tag bis zur
naechsten Sendezeit fuellt, darin der Countdown auf die Sekunde. Sendet
er, wird die Karte rot, atmet im Ruhepuls und traegt den Titel des
Streams plus einen Weg hin. Darunter die drei Accounts und -- wenn die
Verwaltung eines eintraegt -- das besondere Event mit eigener Uhr.

DREI AUSGAENGE, NICHT ZWEI. Das ist der eigentliche Unterschied zur
oeffentlichen Seite: streamplan.js faengt dort jeden Fehler ab und
setzt `istLive = false`. Aus "wir konnten nicht fragen" wird "er sendet
nicht" -- und wer das liest, macht die Seite zu und verpasst den
Stream. Der Dienst liefert `autoHealthy` sogar mit; gelesen wird es
draussen nicht. Hier schon: Bei einer Stoerung steht "Konnten wir
gerade nicht nachsehen", dazu der Hinweis, dass das NICHT heisst, dass
kein Stream laeuft. Der Countdown laeuft dabei weiter -- er braucht
kein Netz.

NICHTS IST ABGESCHRIEBEN. Die drei Accounts kommen ueber
/workspace/api/draussen aus derselben KANAELE-Liste, an der die
Aufgabenverteilung haengt; die Adressen baut der Steckbrief. Die
Sendezeit steht im Server, und pruef-draussen haelt sie gegen
FIX_STUNDE in streamplan.js -- verschiebt jemand den Termin und aendert
nur einen Ort, wird die Pruefung rot.

Gemessen, bevor gebaut wurde: der Dienst antwortet und meldet sich
gesund, CORS steht auf *, und `connect-src` der Treff-Seiten fuehrt
postfach bereits. Ohne das Letzte waere der Abruf still blockiert
worden und haette ausgesehen wie ein toter Dienst.

Die Kachel heisst jetzt "Draussen" statt "Unsere Seiten" -- drei Dinge
statt zwei Adressen. Ziel und Datei bleiben gleich.
pruef-wege-nach-draussen schreibt den Namen nicht mehr ab, sondern
verlangt, dass Kachel und Seitenueberschrift uebereinstimmen. Zwei
Namen fuer denselben Ort waren schon einmal ein echter Fehler.

Nebenbefund, dort gleich behoben: Dieselbe Pruefung verlangte, dass
die Community-Kacheln restlos in Dreierreihen aufgehen -- obwohl ihr
eigener Kommentar sagt, eine freie Zelle am Ende sei erlaubt. Sie war
rot bei einwandfreiem Raster. Gemessen wird jetzt, was wirklich
schiefgeht: eine doppelt breite Kachel, die in der letzten Spalte
beginnt und die Zelle davor leer laesst. Mit Gegenprobe.

pruef-draussen: 39 Pruefungen, alle vier Lagen im echten Browser --
die Stoerung wird dafuer absichtlich hergestellt.
pruef-wege-nach-draussen: 67 statt 66, keine Fehler mehr.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 12:06:23 +02:00

408 lines
19 KiB
JavaScript

/* =====================================================================
DRAUSSEN -- der Puls auf der Community-Seite (21.09.2026)
Filipe: "mach was anderes aus dieser seite oder mach es viel krass
und geiler ... erstell was was mich von den socken schmeisst. es
soll wirklich sogar ein wow effekt fuer die community ergeben."
---------------------------------------------------------------------
WAS HIER GEPRUEFT WIRD, UND WARUM AUSGERECHNET DAS
Die Seite beantwortet jetzt die Frage "laeuft er gerade?". Eine
Anzeige, die diese Frage beantwortet, hat genau eine Art, wirklich
Schaden anzurichten: Sie sagt "nicht live", ohne nachgesehen zu
haben. Wer das liest, macht die Seite zu -- und verpasst den Stream.
DAS IST KEIN GEDANKENSPIEL. Genau so macht es die oeffentliche
Seite: `streamplan.js` faengt jeden Fehler ab und setzt
`istLive = false`. Aus "wir konnten nicht fragen" wird "er sendet
nicht". Der Dienst liefert sogar `autoHealthy` mit, damit man das
unterscheiden kann -- gelesen wird es dort nicht.
DESHALB WIRD DIE STOERUNG HIER ABSICHTLICH HERGESTELLT. Vier Lagen,
vier Messungen, und die wichtigste ist die, in der nichts antwortet:
1. er sendet -> "JETZT live", Weg zum Stream
2. er sendet nicht -> Countdown
3. der Pruefer ist gestoert -> "konnten wir nicht nachsehen"
4. gar keine Antwort -> dasselbe, nicht "nicht live"
Die Lagen 3 und 4 tragen zusaetzlich eine GEGENPROBE: Es reicht
nicht, dass irgendein Text dasteht -- es darf ausdruecklich NICHT
"Gerade nicht live" dort stehen. Ohne diese Zeile waere die Pruefung
gruen, sobald ueberhaupt etwas angezeigt wird.
---------------------------------------------------------------------
UND DIE ZAHL, DIE ES ZWEIMAL GIBT
Die Sendezeit (20 Uhr) steht im Server und in `streamplan.js` der
oeffentlichen Seite. Zwei Zahlen, die dasselbe bedeuten, laufen
irgendwann auseinander -- beide sehen fuer sich plausibel aus, und
es faellt erst auf, wenn jemand vergeblich wartet. Abschnitt 4 haelt
sie gegeneinander, ohne Netz.
Aufrufen mit: node server/pruef-draussen.mjs
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "pruef-draussen");
const ordner = mkdtempSync(join(tmpdir(), "ws-draussen-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(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 });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(180_000, "pruef-draussen");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const CREW = "crew.dogfather-universe.com";
const WURZEL = join(import.meta.dirname, "..");
function roh(pfad, host, kopf = {}, koerper = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: koerper ? "POST" : "GET",
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
for (const [name, rolle, code] of [["Mara", "gast", "CODE-GAST-0001"]]) {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salz, 32768, new Date().toISOString());
}
d.close();
}
async function anmelden(rolle, code, host) {
let a = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
if (a.code === 400 && a.text.includes("alter_offen")) {
a = await roh("/workspace/api/anmelden", host, {}, { rolle, code, alter_ok: true });
}
if (a.code !== 200) throw new Error(`Anmeldung ${rolle}: ${a.code} ${a.text}`);
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
const kGast = await anmelden("gast", "CODE-GAST-0001", CREW);
const { KANAELE } = await import("./workspace.js");
const { SENDEZEIT_STUNDE } = await import("./workspace-draussen.js");
/* =======================================================================
1. DER WEG ZU DEN KANAELEN
======================================================================= */
melde("");
melde("=== 1. Die Kanaele kommen vom Server ===");
{
const zu = await roh("/workspace/api/draussen", CREW);
ok(zu.code === 401, `ohne Anmeldung verschlossen (${zu.code})`);
const a = await roh("/workspace/api/draussen", CREW, { Cookie: kGast });
ok(a.code === 200, `ein Mitglied bekommt sie (${a.code})`);
const d = JSON.parse(a.text || "{}");
const liste = d.kanaele || [];
ok(liste.length === KANAELE.length && liste.length > 0,
`es sind ${liste.length} Kanaele, so viele wie im Haus (${KANAELE.length})`);
/* NICHT ABGESCHRIEBEN, SONDERN VERGLICHEN. Wuerde hier ein Handle
als Text stehen, waere diese Pruefung genau so lange gruen wie der
Tippfehler, den sie finden soll -- am 21.09. war das bei zwei
anderen Pruefungen der Fall. */
const falsch = liste.filter((k, i) => k.handle !== KANAELE[i].handle);
ok(falsch.length === 0,
falsch.length ? `ABWEICHUNG: ${falsch.map((k) => k.handle).join(", ")}`
: `die Handles stimmen mit KANAELE ueberein (${liste.map((k) => k.handle).join(", ")})`);
const schlechteAdresse = liste.filter(
(k) => !k.adresse || !k.adresse.startsWith("https://www.tiktok.com/@")
|| !k.adresse.endsWith(k.handle.replace(/^@/, "")));
ok(schlechteAdresse.length === 0,
schlechteAdresse.length
? `ADRESSE PASST NICHT ZUM HANDLE: ${schlechteAdresse.map((k) => k.adresse).join(", ")}`
: "jede Adresse endet auf ihrem eigenen Handle");
const ohneSatz = liste.filter((k) => !k.satz || k.satz.length < 10);
ok(ohneSatz.length === 0,
ohneSatz.length ? `ohne Satz: ${ohneSatz.map((k) => k.name).join(", ")}`
: "jeder Kanal sagt in einem Satz, was es dort gibt");
/* Die Saetze fuer draussen sind ANDERE als die fuer das Team. Stuende
dort derselbe Text, waere das Feld ueberfluessig -- und jemand
wuerde es beim naechsten Kanal weglassen. */
const gleich = liste.filter((k, i) => k.satz === KANAELE[i].text);
ok(gleich.length === 0,
gleich.length ? `${gleich.length} Kanaele zeigen den internen Text`
: "und es ist nicht der interne Text aus der Aufgabenverteilung");
}
/* =======================================================================
2. DIE INHALTSRICHTLINIE MUSS DEN ABRUF ERLAUBEN
======================================================================= */
melde("");
melde("=== 2. Darf die Seite den Live-Dienst ueberhaupt fragen? ===");
{
/* WARUM DAS EINE EIGENE PRUEFUNG IST: Verbietet `connect-src` den
Abruf, bricht `fetch` im Browser ab -- und zwar mit demselben
Fehler wie ein toter Server. Die Seite landet in "unklar" und
sieht dabei voellig in Ordnung aus. Der Unterschied steht nur in
der Browserkonsole, die niemand aufmacht. */
const a = await roh("/workspace/unsere-seiten.html", CREW, { Cookie: kGast });
const regel = String(a.kopf["content-security-policy"] || "");
const zeile = regel.split(";").map((t) => t.trim())
.find((t) => t.startsWith("connect-src")) || "";
ok(zeile.length > 0, `es gibt eine connect-src-Regel (${zeile.slice(0, 60)}…)`);
ok(zeile.includes("postfach.dogfather-universe.com"),
"und sie fuehrt postfach.dogfather-universe.com");
ok(!zeile.includes("*") || zeile.includes("postfach"),
"Gegenprobe: die Erlaubnis steht namentlich da, nicht als Sternchen");
}
/* =======================================================================
3. DIE SEITE SELBST
======================================================================= */
melde("");
melde("=== 3. Was im Quelltext der Seite steht ===");
{
const html = readFileSync(join(WURZEL, "workspace/unsere-seiten.html"), "utf8");
ok(/id="puls"/.test(html), "der Puls steht auf der Seite");
ok(/data-lage="laedt"/.test(html),
"und beginnt in der Lage „laedt“ -- nicht in „nicht live“");
/* KEIN HANDLE IM QUELLTEXT DER KANALREIHE. Der Stream-Knopf traegt
bewusst einen: Er ist der eine feste Weg zum Hauptkanal und muss
auch dann funktionieren, wenn der Server-Abruf scheitert. Alles
andere kommt vom Server. */
const kanalReihe = (html.match(/id="kanaele-reihe"[^>]*>([\s\S]*?)<\/div>/) || [])[1] || "";
ok(kanalReihe.trim() === "",
"die Kanalreihe ist im Quelltext leer -- sie kommt vom Server");
const streamKnopf = (html.match(/id="puls-knopf"[\s\S]{0,200}?href="([^"]+)"/) || [])[1] || "";
const haupt = KANAELE[0].handle.replace(/^@/, "");
ok(streamKnopf.includes(haupt),
`der Weg zum Stream zeigt auf den Hauptkanal (${streamKnopf})`);
const css = readFileSync(join(WURZEL, "workspace/assets/css/unsere-seiten.css"), "utf8");
ok(/prefers-reduced-motion/.test(css),
"wer keine Bewegung moechte, bekommt keine");
ok(/\[data-lage="live"\][\s\S]{0,400}animation/.test(css),
"und die Bewegung haengt am Live-Zustand, laeuft also nicht dauernd");
}
/* =======================================================================
4. EINE ZAHL, ZWEI ORTE
======================================================================= */
melde("");
melde("=== 4. Die Sendezeit steht nicht zweimal verschieden da ===");
{
const streamplan = readFileSync(join(WURZEL, "assets/js/streamplan.js"), "utf8");
const treffer = streamplan.match(/FIX_STUNDE\s*=\s*(\d+)/);
/* DRITTER AUSGANG: Findet sich die Zahl nicht, ist das kein "in
Ordnung" -- dann hat jemand `streamplan.js` umgebaut, und dieser
Vergleich misst ab sofort nichts mehr. Das muss auffallen. */
ok(!!treffer,
treffer ? `die oeffentliche Seite sendet um ${treffer[1]} Uhr`
: "FIX_STUNDE steht nicht mehr in assets/js/streamplan.js -- "
+ "dieser Vergleich kann nichts mehr messen");
ok(!!treffer && Number(treffer[1]) === SENDEZEIT_STUNDE,
`und der Treff rechnet mit derselben Stunde (${SENDEZEIT_STUNDE})`);
ok(SENDEZEIT_STUNDE >= 0 && SENDEZEIT_STUNDE <= 23,
`Gegenprobe: die Stunde ist ueberhaupt eine Uhrzeit (${SENDEZEIT_STUNDE})`);
}
/* =======================================================================
5. DIE VIER LAGEN -- IM ECHTEN BROWSER
======================================================================= */
melde("");
melde("=== 5. Was ein Mensch wirklich sieht ===");
{
let pw = null;
try {
pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
} catch { /* dritter Ausgang unten */ }
if (!pw) {
melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen.");
melde(" Das ist kein Befund. Die Abschnitte 1-4 gelten weiter.");
} else {
const browser = await pw.chromium.launch();
try {
const ctx = await browser.newContext({ viewport: { width: 412, height: 950 },
isMobile: true, hasTouch: true });
await ctx.addCookies(kGast.split("; ").filter(Boolean).map((z) => {
const [name, ...rest] = z.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await ctx.newPage();
const kaputt = [];
seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140)));
/** Stellt die Antwort des Live-Dienstes her und laedt neu.
* `antwort === null` heisst: gar keine Antwort -- so verhaelt
* sich ein Ausfall, und genau dieser Fall wird sonst nie
* geprueft, weil man ihn nicht herbeifuehren kann, ohne ihn
* herbeizufuehren. */
async function lage(antwort, event = null) {
await seite.unrouteAll?.().catch(() => {});
await seite.route("**/postfach.dogfather-universe.com/**", async (weg) => {
const u = weg.request().url();
if (u.includes("/special-event")) {
await weg.fulfill({ status: 200, contentType: "application/json",
body: JSON.stringify({ ok: true, event }) });
return;
}
if (antwort === null) { await weg.abort("failed"); return; }
await weg.fulfill({ status: 200, contentType: "application/json",
body: JSON.stringify(antwort) });
});
await seite.goto(`http://127.0.0.1:${PORT}/workspace/unsere-seiten.html`,
{ waitUntil: "domcontentloaded" });
await seite.waitForTimeout(1200);
return seite.evaluate(() => ({
lage: document.getElementById("puls")?.dataset.lage,
satz: document.getElementById("puls-lage")?.textContent?.trim(),
zahl: document.getElementById("puls-zahl")?.textContent?.trim(),
klein: document.getElementById("puls-klein")?.textContent?.trim(),
knopfDa: !document.getElementById("puls-knopf")?.hidden,
eventDa: !document.getElementById("event")?.hidden,
eventZahl: document.getElementById("event-zahl")?.textContent?.trim(),
kanaele: [...document.querySelectorAll(".kanal")].map((a) => ({
handle: a.querySelector(".kanal__handle")?.textContent?.trim(),
ziel: a.getAttribute("href"),
})),
}));
}
/* ---- 5a. Er sendet ------------------------------------------ */
melde("");
melde(" --- Er sendet ---");
{
const s = await lage({ ok: true, live: true, source: "automatisch",
title: "Casper raeumt das Wohnzimmer aus", autoHealthy: true });
ok(s.lage === "live", `die Lage ist „live“ (${s.lage})`);
ok(/JETZT live/i.test(s.satz || ""), `und es steht dort „${s.satz}“`);
ok((s.zahl || "").includes("Casper"),
`der Titel des Streams steht da („${s.zahl}“)`);
ok(s.knopfDa === true, "und ein Weg zum Stream ist da");
}
/* ---- 5b. Er sendet nicht ------------------------------------ */
melde("");
melde(" --- Er sendet nicht ---");
{
const s = await lage({ ok: true, live: false, source: "automatisch",
title: null, autoHealthy: true });
ok(s.lage === "wartet", `die Lage ist „wartet“ (${s.lage})`);
ok(/^\d{2}:\d{2}:\d{2}$/.test(s.zahl || ""),
`ein Countdown laeuft („${s.zahl}“)`);
ok((s.klein || "").includes(`${SENDEZEIT_STUNDE}:00`),
`und er nennt den naechsten Termin („${s.klein}“)`);
}
/* ---- 5c. Der Pruefer ist gestoert --------------------------- */
melde("");
melde(" --- Der Live-Pruefer meldet eine Stoerung ---");
{
const s = await lage({ ok: true, live: false, source: "automatisch",
title: null, autoHealthy: false });
ok(s.lage === "unklar", `die Lage ist „unklar“ (${s.lage})`);
/* DIE EIGENTLICHE GEGENPROBE. Ohne sie waere die Zeile darueber
auch dann gruen, wenn dort "Gerade nicht live" stuende -- und
genau das ist der Fehler, um den es geht. */
ok(!/Gerade nicht live/i.test(s.satz || ""),
`es steht NICHT „nicht live“ da, sondern „${s.satz}“`);
ok(/^\d{2}:\d{2}:\d{2}$/.test(s.zahl || ""),
`der Countdown laeuft trotzdem weiter („${s.zahl}“) -- er braucht kein Netz`);
}
/* ---- 5d. Gar keine Antwort ---------------------------------- */
melde("");
melde(" --- Der Dienst antwortet gar nicht ---");
{
const s = await lage(null);
ok(s.lage === "unklar", `die Lage ist „unklar“ (${s.lage})`);
ok(!/Gerade nicht live/i.test(s.satz || ""),
`auch hier steht NICHT „nicht live“ da, sondern „${s.satz}“`);
ok(/TikTok/i.test(s.klein || ""),
`und es steht da, was man stattdessen tun kann („${s.klein}“)`);
ok(s.knopfDa === true, "der Weg zu TikTok bleibt offen");
/* Die Kanaele haengen NICHT am Live-Dienst -- sie kommen vom
eigenen Server. Faellt der eine aus, muss der andere
weiterhin dastehen. */
ok(s.kanaele.length === KANAELE.length,
`die ${s.kanaele.length} Kanaele stehen trotzdem da`);
const falsch = s.kanaele.filter((k, i) => k.handle !== KANAELE[i].handle);
ok(falsch.length === 0,
falsch.length ? `falsche Handles: ${falsch.map((k) => k.handle).join(", ")}`
: `mit den richtigen Handles (${s.kanaele.map((k) => k.handle).join(", ")})`);
}
/* ---- 5e. Das besondere Event -------------------------------- */
melde("");
melde(" --- Das besondere Event ---");
{
const ohne = await lage({ ok: true, live: false, source: "automatisch",
autoHealthy: true }, null);
ok(ohne.eventDa === false,
"ohne Event ist der Kasten nicht da (kein „nichts geplant“)");
const inZweiTagen = new Date(Date.now() + 2 * 86400000 + 3600000).toISOString();
const mit = await lage({ ok: true, live: false, source: "automatisch",
autoHealthy: true }, { titel: { de: "Die grosse Halloween-Nacht" },
zieldatum: inZweiTagen, link: "https://dogfather-universe.com/streamplan.html" });
ok(mit.eventDa === true, "mit Event ist er da");
ok(/noch 2 Tage/.test(mit.eventZahl || ""),
`und zaehlt in Tagen herunter („${mit.eventZahl}“)`);
}
ok(kaputt.length === 0,
kaputt.length ? `Fehler in der Browserkonsole: ${kaputt.join(" | ")}`
: "keine Fehler in der Browserkonsole");
} finally {
await browser.close();
}
}
}
/* ------------------------------------------------------------------- */
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);