Files
dogfather-universe/server/pruef-anruf.mjs
T
DogFatherGitandClaude Opus 5 6e46a08543 Portnummern werden abgeleitet, nicht mehr vergeben
Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.

Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.

Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.

--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------

1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
   Muster: "([^"]*4231[^"]*)". Das hielt

     { host: "127.0.0.1", port: 4231, path: "/404.html" }

   fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
   einer schliessenden Anfuehrung unterscheiden. Heraus kam

     { host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }

   also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
   alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
   gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
   39/0 vorher, 38/1 nachher.

   Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
   ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
   regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
   regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.

2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
   zweite Durchgang importierte den ersten, um seine Mechanik zu
   benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
   zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
   bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
   zweimal.

--- WAS DAS DAUERHAFT HAELT -----------------------------------------

pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.

Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.

--- NACHGEMESSEN ----------------------------------------------------

Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):

  crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
  anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
  kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
  push-ziel 10/0 · portnummern 8/0

Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-20 22:51:21 +02:00

1217 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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 { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta, "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";
/* ZEHN SEKUNDEN statt zwei Minuten -- sonst muesste Abschnitt 5e so
lange warten, und eine Pruefung, die zwei Minuten braucht, wird
abgeschaltet. Im Betrieb steht die Variable nirgends.
WARUM NICHT ZWEI: Die Dauer gilt fuer den GANZEN Lauf, auch fuer
den Browser-Teil weiter unten. Dort vergehen zwischen „anrufen" und
„der Server kennt den Anruf" mehrere Sekunden -- mit zwei war er
da schon verfallen, und die Pruefung meldete einen Fehler, den es
im Code nicht gab. Zehn Sekunden reichen fuer beides. */
process.env.ANRUF_KLINGELT_SEKUNDEN = "10";
/* 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);
/* Die Namen der Konten, die diese Pruefung anlegt -- gesammelt an der
Stelle, an der sie entstehen. Eine zweite Liste weiter unten waere
beim naechsten umbenannten Konto die veraltete: Genau das ist hier
passiert (die Liste nannte „Jonas, Luna, Mara, Pat", angelegt wurden
„Filipe, Rieke, Kessi, Tili"), und der Kommentar daneben behauptete
schon damals, sie sei abgeleitet. Jetzt ist sie es. */
const ANGELEGTE_NAMEN = [];
function person(name, rolle, code) {
ANGELEGTE_NAMEN.push(name);
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.
======================================================================= */
/* =======================================================================
5c. WER ERST DURCH DIE BENACHRICHTIGUNG KOMMT
Filipe, 18.09.2026: „wenn ich vanvan anrufe kriegt sie nur eine
benarichtigung aber keinen anruf was soll das."
Das war kein Missverstaendnis, sondern ein Loch. Das Klingeln lief
ausschliesslich ueber den offenen Ereignisstrom: Wer zusieht, hoert
es. Wer die Seite NICHT offen hat, bekommt die Push-Nachricht,
tippt darauf, die Seite laedt -- und bleibt still. Das Ereignis war
vorbei, bevor sie da war.
Der Anruf funktionierte damit ausgerechnet fuer die nicht, fuer die
die Benachrichtigung gebaut wurde.
DIESE PRUEFUNG BILDET GENAU DAS AB: Erst anrufen, DANN die Seite
oeffnen. Ohne sie beweist der Dreier-Test unten nur, dass es
funktioniert, wenn alle schon zusehen.
======================================================================= */
/* =======================================================================
5d. WAS IN DER BENACHRICHTIGUNG STEHT
Filipe: „wenn ich vanvan anrufe kriegt sie nur eine benarichtigung
aber keinen anruf was soll das."
Gemessen kam bei ihr an: „Nachricht von [object Object]", ohne Text.
Zwei Fehler in einer Zeile. `von` ist beim Klingeln ein OBJEKT und
keine Zeichenkette; und einen `text` gibt es bei einem Anruf gar
nicht. Moeglich wurde beides, weil die ART des Ereignisses die
Benachrichtigung nie erreichte -- jedes Ereignis galt dort als
Chatnachricht.
DIESE PRUEFUNG WAR VORHER NICHT MOEGLICH. Der Text entstand tief in
einer Funktion, die nur der Push-Weg aufruft. Er steht deshalb
jetzt in `pushTextFuer()` -- eine Zeile weniger Verschachtelung und
eine Frage, die sich beantworten laesst.
======================================================================= */
/* =======================================================================
5e. EIN VERPASSTER ANRUF HINTERLAESST EINE SPUR
Bisher verschwand er lautlos. Wer zu spaet kam, oeffnete den Chat
und sah -- nichts. Keine Zeile, kein Hinweis, nicht einmal, DASS
jemand angerufen hat.
Genau das fuehlt sich an wie „es geht einfach nicht", auch wenn
technisch alles funktioniert hat: Der Anruf kam an, es klingelte,
niemand war schnell genug, und danach war es, als waere nie etwas
gewesen. Jedes Telefon der Welt zeigt einen verpassten Anruf.
======================================================================= */
/* =======================================================================
5f. UND EIN GEFUEHRTER ANRUF -- MIT DAUER
Filipe: „im chat soll auch stehen verpasster anruf oder anruf
gehabt und wie lange."
Die zweite Haelfte. Ein verpasster Anruf steht seit heute im Chat;
ein GEFUEHRTER stand nirgends -- nach dem Auflegen war das Gespraech
spurlos weg. Tage spaeter ist die Frage aber nicht „hat er
angerufen", sondern „haben wir das besprochen oder nur kurz
telefoniert?". Drei Minuten sind ein Gespraech, zwoelf Sekunden
sind ein Verwaehlen.
======================================================================= */
/* =======================================================================
5g. WENN DER ANRUFER AUFLEGT, BEVOR JEMAND RANGEHT
Filipe, 00:30 Uhr: „hab angerufen aufgelegt und da steht immer noch
nichts im chat vom anruf...."
Vier Anrufe im Protokoll, je drei bis zehn Sekunden, kein einziger
Eintrag im Gespraech. Und mein Denkfehler dahinter:
„Verpasster Anruf" stand NUR in `aufraeumen()` -- also erst, wenn
ein Anruf nach zwei Minuten von selbst verfaellt. Wer vorher
auflegt, loeschte ihn spurlos. Abschnitt 5e hat genau den Weg
geprueft, den niemand geht: warten, bis es von allein aufhoert.
Im Alltag legt man auf.
Dazu kam eine zweite Falle: `verpasstVermerken` nahm den Anrufer
aus der Runde der Anwesenden. Beim Verfallen steht er noch darin --
beim Auflegen ist sie leer. Die Zeile waere also auch dann
ausgefallen, wenn der Aufruf an der richtigen Stelle gestanden
haette. Zwei Fehler, die einander verdeckt haben.
======================================================================= */
melde("");
melde("=== 5g. Anrufer legt auf, bevor jemand rangeht ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
} else {
const vorher = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
{ cookie: kDogi })).nachrichten || []).length;
await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS, { cookie: kDogi }, { video: false });
/* Kurz warten wie im echten Leben -- und auflegen, ohne dass
jemand rangegangen ist. */
await new Promise((r) => setTimeout(r, 800));
const ende = await roh(`/workspace/api/anruf/${raum.id}/ende`, HAUS, { cookie: kDogi }, {});
ok(ende.code === 200, `aufgelegt (${ende.code})`);
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
{ cookie: kDogi })).nachrichten || [];
ok(nachher.length === vorher + 1,
`es steht trotzdem eine Zeile im Gespraech (${vorher} -> ${nachher.length})`);
const letzte = nachher[nachher.length - 1];
ok(/[Vv]erpasst/.test(letzte?.text || ""),
`und sie sagt „verpasst" („${letzte?.text}")`);
ok(letzte?.von_id !== undefined && letzte?.von,
`mit dem Anrufer daran (${letzte?.von})`);
/* UND NICHT ZWEIMAL: `aufraeumen()` darf nicht spaeter noch eine
zweite Zeile schreiben -- der Anruf ist ja schon geloescht. */
await new Promise((r) => setTimeout(r, 300));
await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand });
const nochmal = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
{ cookie: kDogi })).nachrichten || []).length;
ok(nochmal === nachher.length,
`und bleibt bei einer (${nochmal})`);
}
}
melde("");
melde("=== 5f. Ein gefuehrter Anruf ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
} else {
const vorher = (json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
{ cookie: kDogi })).nachrichten || []).length;
await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS, { cookie: kDogi }, { video: false });
const dabei = await roh(`/workspace/api/anruf/${raum.id}/dabei`, CREW, { cookie: kHand }, {});
ok(dabei.code === 200, `die rechte Hand geht ran (${dabei.code})`);
/* Kurz reden, dann auflegen -- beide. */
await new Promise((r) => setTimeout(r, 1500));
await roh(`/workspace/api/anruf/${raum.id}/ende`, CREW, { cookie: kHand }, {});
await roh(`/workspace/api/anruf/${raum.id}/ende`, HAUS, { cookie: kDogi }, {});
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
{ cookie: kDogi })).nachrichten || [];
ok(nachher.length === vorher + 1,
`es steht eine Zeile im Gespraech (${vorher} -> ${nachher.length})`);
const letzte = nachher[nachher.length - 1];
ok(/Anruf/.test(letzte?.text || "") && !/[Vv]erpasst/.test(letzte?.text || ""),
`und sie sagt „Anruf", nicht „verpasst" („${letzte?.text}")`);
ok(/Sekunde|:\d\d/.test(letzte?.text || ""),
`mit Dauer („${letzte?.text}")`);
/* DIE DAUER ZAEHLT AB DEM RANGEHEN. Gezaehlt wurde hier rund
anderthalb Sekunden -- stuende dort die Zeit seit dem Klingeln,
waere es deutlich mehr. Die Pruefung bleibt grosszuegig: Sie
misst, dass es SEKUNDEN sind und keine Minuten. */
ok(/Sekunde/.test(letzte?.text || ""),
"und zwar ab dem Rangehen, nicht ab dem Klingeln");
}
}
melde("");
melde("=== 5e. Ein verpasster Anruf ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
} else {
const vorher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
{ cookie: kDogi })).nachrichten?.length ?? 0;
const start = await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS,
{ cookie: kDogi }, { video: false });
ok(start.code === 200 || start.code === 201, `Anruf gestartet (${start.code})`);
/* Niemand geht ran. Nach ANRUF_KLINGELT_SEKUNDEN (hier 2) gilt er
als verpasst -- `aufraeumen()` laeuft bei der naechsten Anfrage
mit. */
await new Promise((r) => setTimeout(r, 10600));
await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand });
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
{ cookie: kDogi })).nachrichten || [];
ok(nachher.length === vorher + 1,
`es steht genau EINE neue Zeile im Gespraech (${vorher} -> ${nachher.length})`);
const letzte = nachher[nachher.length - 1];
ok(/[Vv]erpasst/.test(letzte?.text || ""),
`und sie sagt, was los war („${letzte?.text}")`);
ok(letzte?.von_id !== undefined,
"sie steht beim Anrufer, nicht als Systemtext ohne Absender");
/* UND NICHT ZWEIMAL. `aufraeumen()` laeuft bei JEDER Anfrage --
ohne das Loeschen aus `laufend` staende hier bei jedem Aufruf
eine weitere Zeile. */
await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand });
await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand });
const nochmal = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, HAUS,
{ cookie: kDogi })).nachrichten || [];
ok(nochmal.length === nachher.length,
`und bleibt bei einer, auch nach weiteren Abfragen (${nochmal.length})`);
}
}
melde("");
melde("=== 5d. Was in der Benachrichtigung steht ===");
{
const { pushTextFuer } = await import("./workspace-chat.js");
const a = pushTextFuer("anruf", { von: { id: 7, name: "Filipe" }, video: false }, 3);
ok(a.titel === "Filipe ruft an", `beim Anruf steht der Name und „ruft an" (${a.titel})`);
ok(!/\[object/i.test(a.titel + a.text),
"und nirgends [object Object] -- genau das kam vorher an");
ok(a.text.includes("Rangehen"), `der Text sagt, was zu tun ist (${a.text})`);
ok(a.ziel === "/workspace/chat.html?raum=3",
`und es fuehrt in das richtige Gespraech (${a.ziel})`);
const v = pushTextFuer("anruf", { von: { id: 7, name: "Filipe" }, video: true }, 3);
ok(v.text.toLowerCase().includes("video"),
`ein Videoanruf sagt das auch (${v.text})`);
/* UND EINE NORMALE NACHRICHT BLEIBT EINE NORMALE NACHRICHT. */
const n = pushTextFuer("neu", { von: "Rieke", text: "Hallo!" }, 3);
ok(n.titel === "Nachricht von Rieke" && n.text === "Hallo!",
`eine Nachricht meldet sich weiter als Nachricht (${n.titel})`);
/* Und wenn dort doch einmal ein Objekt steht -- dieselbe Falle,
nur an der anderen Stelle -- kommt trotzdem ein Name heraus. */
const o = pushTextFuer("neu", { von: { name: "Rieke" }, text: "Hallo!" }, 3);
ok(!/\[object/i.test(o.titel), `auch mit einem Objekt als Absender (${o.titel})`);
}
melde("");
melde("=== 5c. Wer erst durch die Benachrichtigung kommt ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
} else {
/* Erst pruefen, dass nichts offen ist -- sonst misst der Rest
einen Rest von vorher. */
const vorher = json(await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand }));
ok(Array.isArray(vorher.anrufe) && vorher.anrufe.length === 0,
`vorher klingelt nichts (${vorher.anrufe?.length})`);
const start = await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS,
{ cookie: kDogi }, { video: false });
ok(start.code === 200 || start.code === 201, `DogFather ruft an (${start.code})`);
/* JETZT fragt die rechte Hand -- so, wie ihre Seite es beim Laden
tut, nachdem sie auf die Benachrichtigung getippt hat. */
const offen = json(await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand }));
ok(offen.anrufe?.length === 1,
`sie erfaehrt beim Laden, dass es klingelt (${offen.anrufe?.length})`);
ok(offen.anrufe?.[0]?.von?.name && offen.anrufe[0].von.id !== undefined,
`und von wem (${offen.anrufe?.[0]?.von?.name})`);
ok(offen.anrufe?.[0]?.raum_id === raum.id,
"und in welchem Gespraech");
/* DER ANRUFER SELBST DARF NICHT KLINGELN. Er hat den Anruf
gestartet; ein zweites Fenster von ihm wuerde sonst mitlaeuten. */
const beimAnrufer = json(await roh("/workspace/api/anruf/offen", HAUS, { cookie: kDogi }));
ok(beimAnrufer.anrufe?.length === 0,
`beim Anrufer selbst klingelt nichts (${beimAnrufer.anrufe?.length})`);
/* UND WER NICHT IN DEN RAUM GEHOERT, ERFAEHRT NICHTS. Dieselbe
Frage wie ueberall sonst -- eine neue Route ist eine neue Tuer. */
const fremd = json(await roh("/workspace/api/anruf/offen", HAUS, { cookie: kFremd }));
ok(fremd.anrufe?.length === 0,
`ein Fremder erfaehrt nichts davon (${fremd.anrufe?.length})`);
await roh(`/workspace/api/anruf/${raum.id}/ende`, HAUS, { cookie: kDogi }, {});
const danach = json(await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand }));
ok(danach.anrufe?.length === 0,
`nach dem Auflegen klingelt es nicht mehr (${danach.anrufe?.length})`);
}
}
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);
/* AUS EINEM EINTRAG WERDEN DREI WEGE (angepasst 19.09.2026).
Bis heute stand hier `adressen[0]` -- und das war der einzige
Eintrag. Seit ein schlichtes `turn:host:port` in stun + turn/udp
+ turn/tcp aufgefaechert wird (workspace-turn.js), ist
`adressen[0]` der STUN-Weg, und der hat RICHTIGERWEISE kein
Passwort. Die Pruefung suchte also am falschen Eintrag.
WARUM ES DIESE AUFFAECHERUNG GIBT: Ein `turn:` ohne
`?transport=` heisst UDP, und nur UDP. Wer in einem Netz sitzt,
das UDP sperrt, bekam damit keinen Vermittlungsweg -- es
klingelte, und danach passierte nichts. Genau der Fehler, den
Filipe am 19.09. gemeldet hat.
Gesucht wird jetzt nach ART, nicht nach Platz. Ein Index ist
eine Annahme darueber, wie die Liste aussieht -- und die hat
sich schon einmal geaendert. */
const turnWege = (r.adressen || []).filter(
(e) => /^turns?:/.test(String(e.urls || "")));
const stunWege = (r.adressen || []).filter(
(e) => /^stun:/.test(String(e.urls || "")));
const ersterTurn = turnWege[0];
ok(r.turn_gesamt === 2 && r.turn_bereit === 2,
`der Modi bekommt zwei einsatzbereite Wege (${r.turn_bereit}/${r.turn_gesamt})`);
ok(turnWege.length === 2
&& turnWege.some((e) => /transport=udp/.test(String(e.urls)))
&& turnWege.some((e) => /transport=tcp/.test(String(e.urls))),
`naemlich UDP und TCP (${turnWege.map((e) => e.urls).join(", ")})`);
ok(stunWege.length === 1, "dazu ein STUN-Weg, der ohne Vermittlung auskommt");
/* Die ZAHL in die Bedingung, nicht nur in den Meldetext: `every()`
auf einer leeren Liste waere sonst trivial wahr. */
ok(turnWege.length === 2 && turnWege.every((e) => e.credential && e.username),
"und beide tragen Benutzername und Passwort");
ok(!stunWege[0]?.credential,
"STUN bekommt keins -- es kennt keine Anmeldung");
/* 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 }));
const dogiTurn = (rDogi.adressen || []).find(
(e) => /^turns?:/.test(String(e.urls || "")));
ok(!!dogiTurn?.username && dogiTurn.username !== ersterTurn?.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(ersterTurn?.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"],
});
/* TOENE MITZAEHLEN -- vor allem anderen.
Filipe: „ist das normal dass es nicht mal klingelt wenn man
anruft?" Nein: Es gab gar keinen Ton. Der Anruf zeigte einen
Kasten, sonst nichts.
Gemessen wird am Oszillator, nicht am Lautsprecher. Ob wirklich
etwas zu hoeren ist, haengt an Geraet, Lautstaerke und daran,
ob der Browser Ton schon erlaubt -- das kann keine Pruefung
beantworten. Dass die Seite Toene ERZEUGT, kann sie sehr wohl.
`addInitScript` laeuft vor jedem Skript der Seite; danach
waere der Zaehler zu spaet. */
await sicht.addInitScript(() => {
window.__toene = 0;
const K = window.AudioContext || window.webkitAudioContext;
if (!K) return;
const alt = K.prototype.createOscillator;
K.prototype.createOscillator = function (...w) {
window.__toene = (window.__toene || 0) + 1;
return alt.apply(this, w);
};
});
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}")`);
/* WAEHREND ES KLINGELT, ZAEHLT DIE UHR NICHT (geaendert 19.09.2026).
Vorher stand hier, die Uhr MUESSE laufen -- und sie tat es,
ab dem Waehlen. Wer 30 Sekunden klingeln liess und 12
Sekunden sprach, las im Kasten "00:42" und danach im Chat
"Anruf . 12 Sekunden". Zwei Zahlen fuer dieselbe Sache.
Jetzt beginnt sie beim Rangehen. Solange es klingelt, ist
das Feld leer -- und GENAU DAS wird hier geprueft, statt
die Pruefung einfach zu streichen: Eine Zeile weniger
haette denselben Fehler beim naechsten Umbau wieder
durchgelassen. */
ok(kasten.zeit === "" || kasten.zeit === "–",
`waehrend es klingelt zaehlt die Uhr NICHT ("${kasten.zeit}")`);
/* Und es klingelt hoerbar. Waere der Ton wieder ausgebaut,
stuende hier 0. */
const toene = await seite.evaluate(() => window.__toene ?? -1);
ok(toene > 0, `es klingelt hoerbar -- ${toene} Toene erzeugt`);
/* 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}")`);
}
/* ---- WER IST DRAN (20.09.2026) -----------------------------
Filipe: „die soll auch viel besser geiler und spezieller
aussehen, aber nicht so raushaengen."
DER MANGEL WAR KEIN SCHOENHEITSFEHLER: In einem Anruf ohne
Video stand unter dem Kasten je Person das Wort „Verbunden"
-- bei dreien dreimal dasselbe Wort, und man sah nicht, WER
verbunden war. In einer Gruppe ist genau das die Frage.
Gemessen wird deshalb, dass NAMEN dastehen, nicht Kacheln:
Ein Gesichtsfeld ohne Namen bestuende jede Pruefung, die nur
zaehlt. */
/* WER MUSS WEN SEHEN. Nicht „irgendein echter Name", sondern
genau die beiden ANDEREN: Stuende bei DogFather sein eigener
Name im Kasten, waere das ein Fehler, den eine Pruefung auf
„ist ein echter Name" durchliesse. Die Namen kommen aus
`ANGELEGTE_NAMEN` -- sie werden beim Anlegen eingesammelt und
hier nur noch verteilt. */
const NAME = { [idDogi]: ANGELEGTE_NAMEN[0], [idHand]: ANGELEGTE_NAMEN[1],
[idModi]: ANGELEGTE_NAMEN[2] };
const ERWARTET = {
DogFather: [NAME[idHand], NAME[idModi]],
"die rechte Hand": [NAME[idDogi], NAME[idModi]],
"der Modi": [NAME[idDogi], NAME[idHand]],
};
for (const [wen, x] of [["DogFather", a], ["die rechte Hand", b], ["der Modi", c]]) {
const g = await x.se.evaluate(() => ({
felder: document.querySelectorAll("#anruf-leute .anruf__gesicht").length,
namen: [...document.querySelectorAll("#anruf-leute .anruf__gesicht .anruf__name")]
.map((e) => e.textContent.trim()).filter(Boolean),
verbunden: /Verbunden/.test(document.getElementById("anruf-leute")?.textContent || ""),
}));
ok(g.felder === 2,
`${wen} sieht die beiden anderen als Gesichtsfeld (${g.felder})`);
/* NICHT „laenger als ein Zeichen" -- der Rueckfalltext
„Jemand" ist sechs Zeichen lang und kam damit durch. Die
Pruefung war gruen, waehrend ausgerechnet der ANRUFER unter
jedem Kreis „Jemand" stehen sah. Verglichen wird deshalb
gegen die Namen, die es wirklich gibt. */
const soll = [...ERWARTET[wen]].sort().join(", ");
const ist = [...g.namen].sort().join(", ");
ok(g.namen.length === 2 && ist === soll,
` und zwar genau die beiden anderen (${ist || "keine"}, erwartet: ${soll})`);
ok(!g.verbunden,
" statt dreimal dem Wort „Verbunden“, das nichts unterscheidet");
}
/* ---- UND NICHTS HAENGT RAUS --------------------------------
„nicht so raushaengen" ist die schaerfere der beiden Vorgaben:
Der Kasten ist 340 px breit und liegt in der Ecke. Bei DREI
Gesichtsfeldern plus Knopfreihe ist das die engste Lage, die
im Betrieb vorkommt -- also die, in der gemessen wird. */
for (const [wen, x] of [["am Rechner", a]]) {
const raus = await x.se.evaluate(() => {
const k = document.querySelector(".anruf");
if (!k) return null;
const r = k.getBoundingClientRect();
const treffer = [];
for (const e of k.querySelectorAll("*")) {
const b2 = e.getBoundingClientRect();
if (b2.width < 1 || b2.height < 1) continue;
if (b2.right > r.right + 1 || b2.left < r.left - 1 || b2.bottom > r.bottom + 1) {
treffer.push((e.className.baseVal ?? e.className).toString().split(" ")[0]);
}
}
return { geprueft: k.querySelectorAll("*").length, treffer };
});
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG: Faende der Wahlausdruck
nichts, waere die Liste leer und die Pruefung gruen, ohne
etwas angesehen zu haben. */
ok(raus && raus.geprueft > 10 && raus.treffer.length === 0,
raus?.treffer.length
? `es haengt etwas raus: ${raus.treffer.slice(0, 3).join(", ")}`
: `nichts haengt aus dem Kasten (${raus?.geprueft} Teile geprueft, ${wen})`);
}
/* ---- 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));
{
/* ==== JEDES GESPRAECH FAENGT LEISE AN (20.09.2026) ==========
Filipe: "es soll ohne lautsprecher anfangen das gespraech und
nur wenn man drauf klickt soll es laut werden ... es soll
nicht raushaengen oder so, das ist nicht cool."
Diese Pruefung stand hier genau ANDERSHERUM und ist beim
Umbau rot geworden -- richtig so, sie hat die Aenderung
bemerkt. Der Grund fuer die Umkehr steht bei `hoeren` in
anruf.js: Ein Anruf, der beim Annehmen sofort aus den
Lautsprechern kommt, ist im schlimmsten Fall fuer alle
Zuschauer zu hoeren, bevor jemand reagieren kann. Das laesst
sich nicht zurueckholen. */
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 === true),
`das Gespraech faengt LEISE an (${vorher.length} Stroeme, alle stumm)`);
/* UND MAN SIEHT ES. Leise anfangen ohne Hinweis waere die
naechste Sackgasse: "ich hoere nichts" ohne einen Grund und
ohne einen Weg. */
const balken = await a.se.evaluate(() => {
const b = document.getElementById("anruf-stumm");
return b ? { da: !b.hidden, text: b.textContent.trim(),
hoch: Math.round(b.getBoundingClientRect().height) } : null;
});
ok(balken?.da === true, `ein Hinweis sagt es ("${balken?.text}")`);
ok((balken?.hoch || 0) >= 44,
` und er ist selbst antippbar (${balken?.hoch} px hoch)`);
const knopf = await a.se.evaluate(() => {
const k = document.getElementById("anruf-hoeren");
return k ? { text: k.textContent.trim(), an: k.getAttribute("aria-pressed") } : null;
});
/* DER ZUSTAND STEHT IN aria-pressed, NICHT MEHR IM TEXT
(geaendert 19.09.2026). Die Aufschrift "Lautsprecher an" war
zweideutig -- "ist an" oder "schalt an"? Jetzt traegt das
durchgestrichene Zeichen den Zustand und das Wort nur die
Sache. Geprueft wird deshalb aria-pressed; der Text bleibt
gleich und beweist nichts mehr. */
ok(knopf?.an === "false",
`der Lautsprecher-Knopf steht auf AUS ("${knopf?.text}")`);
/* ---- UND EIN DRUCK MACHT IHN LAUT ------------------------- */
await a.se.click("#anruf-hoeren");
await a.se.waitForTimeout(300);
const nachher = await stumm(a);
ok(nachher.length >= 2 && nachher.every((m) => m === false),
`nach dem Druecken hoert er alle (${nachher.length} Stroeme, keiner stumm)`);
const knopf2 = await a.se.evaluate(() => {
const k = document.getElementById("anruf-hoeren");
const b = document.getElementById("anruf-stumm");
return { an: k.getAttribute("aria-pressed"), balken: b ? !b.hidden : null };
});
ok(knopf2.an === "true",
`und der Knopf steht auf an (aria-pressed="${knopf2.an}")`);
ok(knopf2.balken === false,
"der Hinweis verschwindet in dem Moment, in dem er ueberfluessig wird");
/* NUR BEI IHM. Ein Schalter, der bei den anderen mitwirkt,
waere kein Lautsprecher, sondern eine Fernbedienung.
Bei ihr ist ALLES stumm -- sie hat ihren Ton nie angemacht. */
const beiB = await stumm(b);
ok(beiB.length >= 2 && beiB.every((m) => m === true),
`bei der rechten Hand aendert sich nichts (${beiB.length} Stroeme, alle noch 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, stuende
der Ton ploetzlich anders -- ohne dass irgendwo etwas anders
aussieht.
RICHTUNG UMGEDREHT AM 20.09.2026: DogFather hat den Ton oben
eingeschaltet, also muss er JETZT hoeren. Die Falle ist
dieselbe geblieben, nur der erwartete Zustand nicht. */
const nachWechsel = await stumm(a);
ok(nachWechsel.length === 1 && nachWechsel[0] === false,
`und der eingeschaltete Ton ueberlebt das Neuzeichnen (${nachWechsel.length} Strom)`);
const knopfNoch = await a.se.evaluate(() =>
document.getElementById("anruf-hoeren")?.getAttribute("aria-pressed"));
ok(knopfNoch === "true", "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);