Files
dogfather-universe/server/pruef-anruf.mjs
T
DogFatherGitandClaude Opus 5 e933de3248 Anruf: der Lautsprecher -- und warum es kein Freisprech-Knopf ist
Filipe: "die funktion lautsprecher fehlt."

Er ist das Gegenstueck zum Mikrofon: "Mikro aus" heisst, die anderen
hoeren MICH nicht -- "Lautsprecher aus" heisst, ICH hoere sie nicht.
Beides braucht man, aus verschiedenen Gruenden: das Mikro, wenn es bei
einem selbst laut ist; den Lautsprecher, wenn nebenher etwas laeuft
oder jemand ins Zimmer kommt.

WAS BEWUSST NICHT GEBAUT WURDE: der Umschalter zwischen Hoermuschel
und Freisprechen, den ein Telefon hat. Recherchiert statt geraten --
den gibt es im Browser nicht: Auf dem iPhone entscheidet Safari
selbst, wohin der Ton geht, und Android laesst einzelne Toene gar
nicht auf ein anderes Geraet legen (setSinkId ist dort nicht
verfuegbar, weil die Plattform es nicht hergibt). Ein Knopf, der dort
nichts tut, waere schlimmer als keiner -- man drueckt ihn und sucht
den Fehler dann bei sich.

Wo es GEHT (Rechner mit Chrome, Edge, Safari), steht dafuer eine echte
Geraeteauswahl daneben. Sie erscheint nur, wenn es mehr als ein Geraet
gibt UND die Namen bekannt sind; eine Auswahl mit einem Eintrag ist
keine Auswahl, sondern eine Behauptung.

DIE PRUEFUNG, AUF DIE ES ANKOMMT: Kommt jemand dazu oder geht jemand,
werden die Toenelemente NEU GEBAUT -- und neue Elemente wissen nichts
von einem Schalter, der vorher umgelegt wurde. Genau daran scheitern
solche Knoepfe sonst: Sie wirken, bis sich die Runde aendert, und dann
hoert man ploetzlich wieder mit. Gegenprobe (Anwenden nach dem
Neuzeichnen ausgebaut) macht genau diese eine Pruefung rot, waehrend
alle anderen gruen bleiben.

Geprueft: pruef-anruf 73 -> 80, im echten Dreiergespraech im Browser.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 17:08:31 +02:00

724 lines
32 KiB
JavaScript

/* =====================================================================
TELEFONIEREN IM CHAT -- die Riegel (18.09.2026)
Filipe: „kann man machen dass die modis, rechte hand und ich auch
telefonieren können im chat?"
DER WICHTIGSTE ABSCHNITT IST DER LETZTE. Ein Anruf ist der
vertraulichste Vorgang im ganzen Haus: Was gesagt wird, steht
nirgends, und wer mithört, hinterlässt keine Spur. Die Frage „kommt
jemand hinein, der nicht in den Raum gehört?" muss deshalb an JEDER
Route beantwortet sein und nicht an der Liste davor.
Besonders die Signalisierung: Sie reicht beliebiges JSON an eine
Personennummer weiter. Ohne Prüfung wäre das ein Weg, jedem
Angemeldeten etwas zu schicken -- an jeder Chatberechtigung vorbei.
Aufrufen mit: node server/pruef-anruf.mjs
===================================================================== */
import { mkdtempSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4196, "pruef-anruf");
const ordner = mkdtempSync(join(tmpdir(), "ws-anruf-"));
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";
/* Der Vermittlungsserver holt sein Geheimnis aus einer Datei. Hier muss
der Pfad UMGEBOGEN werden, bevor index.js geladen wird -- sonst
zeigte er auf die echte Datei unter /etc, und die Pruefung wuerde
entweder am echten Betrieb messen oder gar nichts sehen koennen. */
process.env.TURN_GEHEIMNIS_DATEI = join(ordner, "turn.geheimnis");
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-anruf");
await new Promise((r) => setTimeout(r, 800));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
let fehler = 0, geprueft = 0, offen = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const unklar = (t) => { offen++; console.log(" -- " + t); };
const CREW = "crew.dogfather-universe.com";
const HAUS = "workspace.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
const anrufModul = await import("./workspace-anruf.js");
/* ---- Leute anlegen ---- */
const d = new DatabaseSync(process.env.WORKSPACE_DB);
function person(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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, new Date().toISOString());
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = person("Filipe", "admin", "CODE-DOGI-0001");
const idHand = person("Rieke", "hand", "CODE-HAND-0001");
const idModi = person("Kessi", "modi", "CODE-MODI-0001");
const idFremd = person("Tili", "creator", "CODE-TILI-0001");
d.close();
function roh(pfad, host, kopf = {}, koerper = null, methode = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (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 json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
async function anmelden(rolle, code, host) {
const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS);
const kHand = await anmelden("hand", "CODE-HAND-0001", CREW);
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
const kFremd = await anmelden("creator", "CODE-TILI-0001", HAUS);
ok(!!kDogi && !!kHand && !!kModi && !!kFremd, "vier Leute sind angemeldet");
/* =======================================================================
1. DER KATALOG
======================================================================= */
melde("");
melde("=== 1. Was fest steht ===");
ok(anrufModul.GRUPPE_MAX >= 2 && anrufModul.GRUPPE_MAX <= 8,
`die Gruppengrenze steht als Zahl im Code (${anrufModul.GRUPPE_MAX})`);
/* =======================================================================
2. EIN GESPRAECH UND EIN ANRUF
======================================================================= */
melde("");
melde("=== 2. Anrufen ===");
/* Ein Zweier-Gespraech zwischen DogFather und der rechten Hand. */
let raumId = null;
{
/* Die Route heisst `direkt`, nicht `mit` -- nachgesehen statt
geraten. */
const r = await roh("/workspace/api/chat/direkt", HAUS, { cookie: kDogi },
{ mit: idHand });
raumId = json(r).raum?.id || json(r).raum_id || json(r).id;
ok(!!raumId, `ein Gespraech entsteht (${r.code}, Raum ${raumId})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi });
ok(r.code === 200 && json(r).laeuft === false,
`am Anfang laeuft kein Anruf (${json(r).laeuft})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/start`, HAUS,
{ cookie: kDogi }, { video: false });
ok(r.code === 200, `anrufen geht (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l.laeuft === true, "und danach laeuft einer");
ok(l.wer?.length === 1 && l.wer[0].id === idDogi,
`mit genau einem Teilnehmer (${l.wer?.map((w) => w.name).join(", ")})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/dabei`, CREW, { cookie: kHand }, {}, "POST");
ok(r.code === 200, `die rechte Hand geht ran (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kHand }));
ok(l.wer?.length === 2, `jetzt sind es zwei (${l.wer?.map((w) => w.name).join(", ")})`);
ok(l.ich_dabei === true, "und sie weiss, dass sie dabei ist");
}
/* =======================================================================
3. DIE SIGNALISIERUNG
Sie reicht beliebiges JSON an eine Personennummer weiter. Ohne
Pruefung waere das ein Weg, jedem Angemeldeten etwas zu schicken --
an jeder Chatberechtigung vorbei.
======================================================================= */
melde("");
melde("=== 3. Die Signalisierung ===");
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
{ an: idHand, inhalt: { angebot: { type: "offer", sdp: "v=0" } } });
ok(r.code === 200, `ein Angebot geht durch (${r.code})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
{ an: idFremd, inhalt: { angebot: {} } });
ok(r.code === 404,
`an jemanden AUSSERHALB des Raums geht nichts (${r.code}) -- sonst waere das `
+ "ein Weg, jedem Angemeldeten JSON zu schicken");
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
{ an: idHand });
ok(r.code === 400, `ohne Inhalt wird abgewiesen (${r.code})`);
}
/* =======================================================================
4. WER NICHT HINEINGEHOERT
Der wichtigste Abschnitt. Ein Anruf hinterlaesst keine Spur -- wer
mithoert, faellt nicht auf. Die Frage muss an JEDER Route
beantwortet sein.
======================================================================= */
melde("");
melde("=== 4. Wer nicht hineingehoert ===");
for (const [pfad, methode, koerper] of [
["", "GET", null],
["/start", "POST", { video: false }],
["/dabei", "POST", {}],
["/ende", "POST", {}],
["/signal", "POST", { an: idDogi, inhalt: { weg: {} } }],
]) {
const r = await roh(`/workspace/api/anruf/${raumId}${pfad}`, HAUS,
{ cookie: kFremd }, koerper, methode);
ok(r.code === 404,
`${(pfad || "/lage").padEnd(8)} ist fuer Fremde zu -- und zwar 404, `
+ `nicht „kein Zugriff" (${r.code})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, {});
ok(r.code === 401, `ohne Anmeldung gar nichts (${r.code})`);
}
{
const r = await roh("/workspace/api/anruf/999999/start", HAUS,
{ cookie: kDogi }, { video: false });
ok(r.code === 404, `ein erfundener Raum wird abgewiesen (${r.code})`);
}
/* =======================================================================
5. AUFLEGEN
======================================================================= */
melde("");
melde("=== 5. Auflegen ===");
{
const r = await roh(`/workspace/api/anruf/${raumId}/ende`, CREW, { cookie: kHand }, {}, "POST");
ok(r.code === 200, `die rechte Hand legt auf (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l.laeuft === true && l.wer?.length === 1,
`der Anruf laeuft weiter, solange noch jemand drin ist (${l.wer?.length})`);
await roh(`/workspace/api/anruf/${raumId}/ende`, HAUS, { cookie: kDogi }, {}, "POST");
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l2.laeuft === false, "wenn der Letzte geht, ist er vorbei");
}
/* =======================================================================
6. DIE ADRESSEN
Sie enthalten Zugangsdaten (TURN braucht Benutzer und Passwort).
Deshalb darf nur DogFather sie setzen.
======================================================================= */
melde("");
melde("=== 6. Die Verbindungsadressen ===");
{
const r = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(Array.isArray(r.adressen), `die Liste kommt (${r.adressen?.length} Eintraege)`);
ok(r.nur_gleiches_netz === true,
"und sie sagt ehrlich, dass es ohne Eintrag nur im selben Netz geht");
}
{
const r = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kHand },
{ adressen: [{ urls: "stun:beispiel.de:3478" }] }, "PUT");
ok(r.code === 404, `die rechte Hand darf sie nicht setzen (${r.code})`);
}
{
const r = await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
adressen: [
{ urls: "stun:turn.dogfather-universe.com:3478" },
{ urls: "turn:turn.dogfather-universe.com:3478", username: "a", credential: "b" },
/* DAS HIER MUSS RAUSFALLEN: kein STUN/TURN, sondern irgendetwas. */
{ urls: "https://beispiel.de/boese" },
],
}, "PUT");
ok(r.code === 200 && json(r).anzahl === 2,
`DogFather setzt sie, und nur echte Adressen bleiben (${json(r).anzahl} von 3)`);
const l = json(await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi }));
ok(l.adressen?.length === 2, `alle bekommen sie zu sehen (${l.adressen?.length})`);
ok(l.nur_gleiches_netz === false, "und der Hinweis verschwindet");
/* UND WIEDER WEG. Die Adressen oben sind erfunden -- es gibt unter
`turn.dogfather-universe.com` keinen STUN-Dienst. Ein Browser
versucht sie trotzdem zu erreichen und wartet auf das Zeitlimit;
der Dreier-Test unten haette danach gegen diese Wartezeit
gekaempft statt gegen den Code.
Eine Pruefung, die der naechsten den Boden verstellt, macht deren
Ergebnis unlesbar -- genau das ist heute schon einmal passiert
(die Anfragen, Abschnitt 8b). */
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
{ adressen: [] }, "PUT");
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(zurueck.adressen?.length === 0,
"danach ist die Liste wieder leer -- der naechste Abschnitt faengt sauber an");
}
/* =======================================================================
6b. DIE ZUGANGSDATEN ZUM VERMITTLUNGSSERVER
Hier steht die Frage, an der sich entscheidet, ob coturn ein Dienst
fuer uns ist oder eine offene Weiterleitung fuer jeden: Das
Geheimnis, aus dem die Zugangsdaten gerechnet werden, darf den
Browser NIE erreichen. Wer es haette, koennte sich selbst beliebig
viele Zugaenge ausstellen -- dauerhaft, ohne Konto, ueber unsere
IP-Adresse.
Deshalb wird hier nicht nur geprueft, dass Zugangsdaten ANKOMMEN,
sondern vor allem, dass das Geheimnis es NICHT tut -- und zwar im
rohen Antworttext, nicht im ausgewerteten Objekt. Ein Feld, an das
niemand denkt, faellt beim Auswerten nicht auf.
======================================================================= */
melde("");
melde("=== 6b. Zugangsdaten fuer die Vermittlung ===");
{
const turn = await import("./workspace-turn.js");
const datei = process.env.TURN_GEHEIMNIS_DATEI;
/* Erfunden, nur fuer diesen Lauf, und am Ende wieder weg. Lang genug,
dass die Laengenpruefung nicht zufaellig anschlaegt. */
const GEHEIM = "pruef-geheimnis-nur-fuer-diesen-lauf-0123456789";
/* --- Die drei Ausgaenge beim Lesen ------------------------------- */
{
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "fehlt",
`ohne Datei sagt es "fehlt" statt stillschweigend nichts (${g.grund})`);
}
{
writeFileSync(datei, "zu-kurz");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "zu_kurz",
`ein zu kurzes Geheimnis wird abgelehnt, nicht benutzt (${g.grund})`);
}
{
writeFileSync(datei, " \n");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "leer", `eine leere Datei meldet "leer" (${g.grund})`);
}
{
writeFileSync(datei, GEHEIM + "\n");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === GEHEIM && g.grund === "",
"mit Datei kommt das Geheimnis an (Zeilenumbruch abgeschnitten)");
}
/* --- Ein WECHSEL muss auffallen ---------------------------------- */
{
writeFileSync(datei, GEHEIM + "-zweite-fassung-mit-anderer-laenge");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert.endsWith("-zweite-fassung-mit-anderer-laenge"),
"wird die Datei getauscht, wird der neue Wert gelesen (kein Festhaengen)");
writeFileSync(datei, GEHEIM + "\n");
}
/* --- Die Rechnung ------------------------------------------------ */
{
/* Eine FESTE Zeit als Eingabe, nicht die Wanduhr: Sonst waere die
Pruefung eine Zeitbombe, die irgendwann etwas anderes misst. */
const JETZT = 1_700_000_000_000;
const z = turn.turnZugang("w7", GEHEIM, JETZT);
const [ablaufStr, kennung] = z.username.split(":");
ok(kennung === "w7", `im Benutzernamen steht die Kennung (${kennung})`);
ok(Number(ablaufStr) === Math.floor(JETZT / 1000) + turn.TURN_GUELTIG_STUNDEN * 3600,
`und davor der Ablauf, genau ${turn.TURN_GUELTIG_STUNDEN} Stunden spaeter`);
/* FESTER VERGLEICHSWERT, ausgerechnet und nicht behauptet. Er
beweist NICHT, dass coturn ihn akzeptiert -- das beweist nur ein
Versuch am laufenden coturn (turnutils_uclient). Er beweist,
dass sich die Rechenvorschrift nicht unbemerkt aendert; genau
das wuerde jeden Anruf ueber die Vermittlung scheitern lassen,
ohne dass irgendwo ein Fehler steht. */
ok(z.credential === "9AbMBJeTDL6uebcR6KRpYVTFwt8=",
`das Passwort ist die bekannte HMAC-SHA1-Rechnung (${z.credential})`);
const nochmal = turn.turnZugang("w7", GEHEIM, JETZT);
ok(nochmal.credential === z.credential, "dieselbe Eingabe ergibt dasselbe Passwort");
const anderer = turn.turnZugang("w8", GEHEIM, JETZT);
ok(anderer.credential !== z.credential,
"eine andere Person bekommt ein anderes Passwort");
const spaeter = turn.turnZugang("w7", GEHEIM, JETZT + 60_000);
ok(spaeter.credential !== z.credential,
"und eine Minute spaeter ein anderes -- die Daten verfallen wirklich");
}
/* --- Wer bekommt Zugangsdaten und wer nicht ---------------------- */
{
const liste = [
{ urls: "stun:turn.dogfather-universe.com:3478" },
{ urls: "turn:turn.dogfather-universe.com:3478" },
{ urls: "turn:fremder-dienst.example:3478", username: "fremd", credential: "eigenes" },
];
const e = turn.mitZugangsdaten(liste, "w7", 1_700_000_000_000, datei);
ok(!e.adressen[0].credential, "ein STUN-Eintrag bekommt keine Zugangsdaten");
ok(!!e.adressen[1].credential && e.adressen[1].username.includes(":w7"),
"unser TURN-Eintrag bekommt gerechnete");
ok(e.adressen[2].credential === "eigenes" && e.adressen[2].username === "fremd",
"ein fremder Dienst behaelt seine eigenen -- wir ueberschreiben nichts");
ok(e.turn_gesamt === 2 && e.turn_bereit === 2,
`und die Zahlen stimmen (${e.turn_bereit} von ${e.turn_gesamt} bereit)`);
}
{
/* OHNE Geheimnis: Der Eintrag bleibt stehen, aber es wird gesagt. */
unlinkSync(datei);
const e = turn.mitZugangsdaten(
[{ urls: "turn:turn.dogfather-universe.com:3478" }], "w7", Date.now(), datei);
ok(e.turn_gesamt === 1 && e.turn_bereit === 0 && e.grund === "fehlt",
`ohne Geheimnis ist nichts bereit, und der Grund steht dabei (${e.grund})`);
writeFileSync(datei, GEHEIM + "\n");
}
/* --- Und jetzt ueber die Schnittstelle, wie der Browser sie sieht - */
{
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
adressen: [{ urls: "turn:turn.dogfather-universe.com:3478" }],
}, "PUT");
const a = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi });
const r = json(a);
ok(r.turn_gesamt === 1 && r.turn_bereit === 1,
`der Modi bekommt einen einsatzbereiten Eintrag (${r.turn_bereit}/${r.turn_gesamt})`);
ok(!!r.adressen?.[0]?.credential && !!r.adressen?.[0]?.username,
"mit Benutzername und Passwort");
/* DIE WICHTIGSTE PRUEFUNG DES ABSCHNITTS. Im ROHEN Text gesucht,
nicht im ausgewerteten Objekt. */
ok(!a.text.includes(GEHEIM),
"und das Geheimnis selbst steht NIRGENDS in der Antwort");
/* Jeder bekommt EIGENE Zugangsdaten -- im coturn-Protokoll steht
damit die Nummer, nicht ein Sammelkonto. */
const rDogi = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(rDogi.adressen?.[0]?.username !== r.adressen?.[0]?.username,
"DogFather und der Modi bekommen verschiedene Zugangsdaten");
/* Der Ablauf muss fuer den Browser LESBAR sein -- darauf beruht das
Auffrischen vor jedem Anruf in anruf.js. */
const ablauf = Number(String(r.adressen?.[0]?.username || "").split(":")[0]);
ok(ablauf > Math.floor(Date.now() / 1000) + 3600,
`der Ablauf steht vorn im Benutzernamen und liegt in der Zukunft (${ablauf})`);
}
/* --- Aufraeumen: der naechste Abschnitt faengt sauber an ---------- */
{
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
{ adressen: [] }, "PUT");
try { unlinkSync(datei); } catch { /* schon weg */ }
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(zurueck.adressen?.length === 0 && !zurueck.turn_grund,
"danach ist wieder alles leer -- keine erfundene Adresse bleibt stehen");
}
}
/* =======================================================================
7. IM BROWSER
======================================================================= */
melde("");
melde("=== 7. Im Browser ===");
{
let chromium;
try {
({ chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"));
} catch { chromium = null; }
if (!chromium) {
unklar("kein Playwright gefunden -- die Oberflaeche wurde nicht angesehen");
} else {
const browser = await chromium.launch({
/* Mikrofon ohne Rueckfrage und mit einem Testton statt echter
Hardware -- sonst haengt getUserMedia im Prueflauf. */
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
});
try {
const sicht = await browser.newContext({
viewport: { width: 1280, height: 900 },
permissions: ["microphone", "camera"],
});
await sicht.addCookies(kDogi.split("; ").map((c) => {
const [name, ...rest] = c.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await sicht.newPage();
const konsole = [];
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
await seite.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`,
{ waitUntil: "domcontentloaded" });
await seite.waitForTimeout(1800);
ok(await seite.evaluate(() => typeof window.anrufStarten === "function"),
"der Anruf ist geladen");
/* Ein Gespraech oeffnen -- erst dann gibt es Knoepfe. */
const raeume = await seite.locator("#raeume li").count();
ok(raeume > 0, `${raeume} Gespraech(e) in der Liste`);
await seite.locator("#raeume li").first().click();
await seite.waitForTimeout(1200);
const knoepfe = await seite.evaluate(() => ({
ton: !!document.getElementById("anruf-ton-start"),
video: !!document.getElementById("anruf-video-start"),
sichtbar: document.getElementById("anruf-ton-start")?.offsetHeight > 0,
}));
ok(knoepfe.ton && knoepfe.video, "beide Anrufknoepfe stehen im Kopf");
ok(knoepfe.sichtbar, "und man sieht sie");
/* ANRUFEN -- und zwar wirklich. Ein Test, der nur prueft, dass
ein Knopf da ist, prueft, dass jemand einen Knopf gebaut hat. */
await seite.click("#anruf-ton-start");
await seite.waitForTimeout(2500);
const kasten = await seite.evaluate(() => {
const k = document.getElementById("anruf");
return {
da: !!k && !k.hidden,
lage: document.getElementById("anruf-lage")?.textContent || "",
zeit: document.getElementById("anruf-zeit")?.textContent || "",
};
});
ok(kasten.da, `der Anrufkasten erscheint ("${kasten.lage}")`);
ok(/klingelt|Verbind/i.test(kasten.lage),
`und sagt, was gerade passiert ("${kasten.lage}")`);
ok(/^\d\d:\d\d$/.test(kasten.zeit), `die Uhr laeuft (${kasten.zeit})`);
/* Und der Server weiss davon. */
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l.laeuft === true, "der Server kennt den Anruf");
await seite.screenshot({ path: "server/pruef-anruf.png", fullPage: false });
melde(" -- Bildschirmfoto: server/pruef-anruf.png");
await seite.click("#anruf-auflegen");
await seite.waitForTimeout(900);
ok(await seite.evaluate(() => document.getElementById("anruf")?.hidden === true),
"nach dem Auflegen ist der Kasten weg");
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l2.laeuft === false, "und der Server auch");
ok(konsole.length === 0,
konsole.length ? `Konsolenfehler: ${konsole[0]}` : "kein Konsolenfehler");
} finally {
await browser.close();
}
}
}
/* =======================================================================
8. ZU DRITT -- UND ZWAR WIRKLICH (18.09.2026)
GEBAUT IST NICHT BEWIESEN. Der Anruf ist fuer bis zu vier Leute
geschrieben, geprueft war er zu zweit. Genau diese Luecke zwischen
Behauptung und Beleg ist die, an der im Haus schon mehrfach etwas
haengengeblieben ist -- zuletzt bei den Kachelfarben, wo ein
Werkzeug 21 Farben kannte und 37 gemeint waren.
DER ENTSCHEIDENDE PUNKT IST DIE ZAHL DER VERBINDUNGEN. Bei drei
Teilnehmern muss JEDER zwei haben -- eine zu jedem anderen. Haette
`verbindungen` eine einzelne Variable statt einer Karte, ginge zu
zweit alles gut und der Dritte ueberschriebe stillschweigend den
Ersten: Man sieht sich zu dritt und hoert nur einen.
UND DIE ANGEBOTSREGEL. Bei WebRTC muss genau EINER das Angebot
machen. Machen beide eins, heben sie sich auf ("glare") und es kommt
keine Verbindung zustande -- ohne Fehlermeldung, man wartet nur.
Die Regel „die kleinere Personennummer beginnt" ist auf beiden
Seiten ohne Absprache dieselbe Entscheidung. Ob sie traegt, sieht
man erst ab drei Leuten.
======================================================================= */
melde("");
melde("=== 8. Zu dritt ===");
{
let chromium;
try {
({ chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"));
} catch { chromium = null; }
if (!chromium) {
unklar("kein Playwright -- der Gruppenanruf wurde nicht ausprobiert");
} else {
/* Ein Gruppenraum mit allen dreien. */
const g = await roh("/workspace/api/chat/gruppe", HAUS, { cookie: kDogi },
{ name: "Testrunde", mit: [idHand, idModi] });
const gruppeId = json(g).raum?.id || json(g).raum_id || json(g).id;
ok(!!gruppeId, `ein Gruppengespraech entsteht (${g.code}, Raum ${gruppeId})`);
const browser = await chromium.launch({
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
});
try {
const machSeite = async (keks) => {
const k = await browser.newContext({
viewport: { width: 1100, height: 800 },
permissions: ["microphone"],
});
await k.addCookies(keks.split("; ").map((c) => {
const [name, ...rest] = c.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const se = await k.newPage();
await se.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`,
{ waitUntil: "domcontentloaded" });
await se.waitForTimeout(1600);
return { k, se };
};
const a = await machSeite(kDogi);
const b = await machSeite(kHand);
const c = await machSeite(kModi);
/* Alle drei oeffnen denselben Raum. */
for (const x of [a, b, c]) {
await x.se.evaluate((id) => {
const li = [...document.querySelectorAll("#raeume li")]
.find((e) => Number(e.dataset.raum || e.dataset.id) === id);
(li || document.querySelector("#raeume li"))?.click();
}, gruppeId);
await x.se.waitForTimeout(700);
}
/* DogFather ruft an. */
await a.se.evaluate((id) => window.anrufStarten(id, false), gruppeId);
await a.se.waitForTimeout(1500);
/* Die anderen beiden gehen ran -- ueber den Klingelknopf, nicht
ueber eine Abkuerzung: Geprueft werden soll der Weg, den ein
Mensch geht. */
for (const x of [b, c]) {
const da = await x.se.locator("#anruf-klingelt .anruf-klingelt__ja").count();
ok(da === 1, `es klingelt bei ${x === b ? "der rechten Hand" : "dem Modi"} (${da})`);
if (da) await x.se.click("#anruf-klingelt .anruf-klingelt__ja");
}
/* WebRTC braucht ein paar Sekunden, bis die Verbindungen stehen. */
await a.se.waitForTimeout(6000);
/* Der Server: drei drin. */
const lage = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
ok(lage.wer?.length === 3,
`der Server zaehlt drei Teilnehmer (${lage.wer?.map((w) => w.name).join(", ")})`);
/* DER EIGENTLICHE PUNKT: Jeder hat ZWEI Verbindungen. */
const zaehlen = (x) => x.se.evaluate(() => ({
/* Ueber die Oberflaeche nicht erreichbar -- deshalb ueber die
Zahl der Video-/Audio-Elemente im Kasten, die jede
Gegenstelle erzeugt. */
stroeme: document.querySelectorAll("#anruf-leute audio, #anruf-leute video").length,
lage: document.getElementById("anruf-lage")?.textContent || "",
}));
for (const [wer, x] of [["DogFather", a], ["die rechte Hand", b], ["der Modi", c]]) {
const z = await zaehlen(x);
ok(z.stroeme >= 2,
`${wer} hoert BEIDE anderen (${z.stroeme} Stroeme, „${z.lage}")`);
}
/* ---- DER LAUTSPRECHER (18.09.2026) -------------------------
Filipe: „die funktion lautsprecher fehlt."
Gegenstueck zum Mikrofon: „Mikro aus" heisst, die anderen
hoeren mich nicht; „Lautsprecher aus" heisst, ich hoere sie
nicht.
DIE PRUEFUNG, AUF DIE ES ANKOMMT, ist die dritte: Kommt
jemand dazu oder geht jemand, werden die Toenelemente NEU
GEBAUT -- und neue Elemente wissen nichts von einem Schalter,
der vorher umgelegt wurde. Genau daran scheitern solche
Knoepfe sonst: Sie wirken, bis sich die Runde aendert, und
dann hoert man ploetzlich wieder mit. */
const stumm = (x) => x.se.evaluate(() =>
[...document.querySelectorAll("#anruf-leute audio, #anruf-leute video")]
.filter((m) => !m.classList.contains("anruf__bild--ich"))
.map((m) => m.muted));
{
const vorher = await stumm(a);
/* Die ANZAHL gehoert in die Bedingung: `every` ist auf einer
leeren Liste wahr, und dann waere diese Pruefung gruen,
ohne ein einziges Element gesehen zu haben. */
ok(vorher.length >= 2 && vorher.every((m) => m === false),
`vorher hoert DogFather beide (${vorher.length} Stroeme, keiner stumm)`);
const knopf = await a.se.evaluate(() => {
const k = document.getElementById("anruf-hoeren");
return k ? { text: k.textContent.trim(), an: k.getAttribute("aria-pressed") } : null;
});
ok(knopf?.text === "Lautsprecher an" && knopf?.an === "true",
`der Lautsprecher-Knopf ist da und steht auf an („${knopf?.text}")`);
await a.se.click("#anruf-hoeren");
await a.se.waitForTimeout(300);
const nachher = await stumm(a);
ok(nachher.length >= 2 && nachher.every((m) => m === true),
`nach dem Druecken ist ALLES stumm (${nachher.length} Stroeme)`);
const knopf2 = await a.se.evaluate(() => {
const k = document.getElementById("anruf-hoeren");
return { text: k.textContent.trim(), an: k.getAttribute("aria-pressed") };
});
ok(knopf2.text === "Lautsprecher aus" && knopf2.an === "false",
`und der Knopf sagt es auch („${knopf2.text}")`);
/* NUR BEI IHM. Ein Schalter, der bei den anderen mitwirkt,
waere kein Lautsprecher, sondern eine Fernbedienung. */
const beiB = await stumm(b);
ok(beiB.length >= 2 && beiB.every((m) => m === false),
`bei der rechten Hand aendert sich nichts (${beiB.length} Stroeme, keiner stumm)`);
}
/* Einer legt auf -- fuer die anderen laeuft es weiter. */
await c.se.click("#anruf-auflegen");
await c.se.waitForTimeout(2000);
const lage2 = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
ok(lage2.laeuft === true && lage2.wer?.length === 2,
`einer geht, zwei telefonieren weiter (${lage2.wer?.length})`);
const zA = await zaehlen(a);
ok(zA.stroeme === 1,
`und bei DogFather ist genau ein Strom weniger (${zA.stroeme})`);
/* DIE FALLE: Die Runde hat sich geaendert, die Elemente wurden
neu gebaut. Bliebe der Schalter dabei auf der Strecke, wuerde
DogFather ploetzlich wieder mithoeren -- ohne dass irgendwo
etwas anders aussieht. */
const nachWechsel = await stumm(a);
ok(nachWechsel.length === 1 && nachWechsel[0] === true,
`und der Lautsprecher bleibt aus, obwohl neu gezeichnet wurde (${nachWechsel.length} Strom)`);
const knopfNoch = await a.se.evaluate(() =>
document.getElementById("anruf-hoeren")?.getAttribute("aria-pressed"));
ok(knopfNoch === "false", "auch der Knopf steht noch richtig");
await a.se.click("#anruf-auflegen");
await b.se.click("#anruf-auflegen");
await a.se.waitForTimeout(1200);
const lage3 = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
ok(lage3.laeuft === false, "wenn alle auflegen, ist der Anruf vorbei");
await a.k.close(); await b.k.close(); await c.k.close();
} finally {
await browser.close();
}
}
}
/* ------------------------------------------------------------------- */
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`
+ (offen ? `, ${offen} nicht nachsehbar` : ""));
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);