Files
dogfather-universe/server/pruef-anruf.mjs
T
DogFatherGitandClaude Opus 5 e7cd1984d8 Ein Rollenname im ausgelieferten Code, die Seite zu breit, und drei Pruefungen, die den Umbau verschlafen hatten
Filipe: "falls es noch was gibt was fehlt oder nicht richtig funktioniert,
auf dem handy oder auf dem pc, oder als installiert, ich will dass du das
alles abcheckst und machst dass jede seite reibungslos klappt."

Gemessen wurde der ganze Bestand: 26 Pruefdateien einzeln, dazu drei neue
Messungen fuer Dinge, die keine Pruefung ansieht. Von 37 roten Meldungen
aus dem Nachtlauf sind nach dieser Runde die haelfte erledigt -- und die
Haelfte davon war gar nicht kaputt.

=== ECHTE FEHLER ===

1. EIN ROLLENNAME STAND IM AUSGELIEFERTEN QUELLTEXT.
   `aufgaben.js` sagte "Modis moechten das uebernehmen -- du
   entscheidest". Diese Datei bekommt JEDER, der die Seite oeffnet:
   jeder Manager, jeder Scout, jeder Creator im anderen Haus. Der
   verborgene Zugang haelt genau so lange, wie der Name dort nicht
   steht. Er stammte aus der Bewerbungsspalte von gestern -- einen Tag
   alt, gefunden von pruef-modi-wortleck. Jetzt: "Jemand moechte das
   uebernehmen". Wer es ist, steht ohnehin mit Namen auf den Karten
   darunter, und die sieht nur, wer sie sehen darf.

2. DIE SUPPORT-SEITE WAR AUF JEDER BREITE UNTER 1440 px ZU BREIT --
   13 px auf dem Handy, 41 px auf dem Tablet quer, 30 px auf dem
   Laptop. Schuld war das weiche Licht, das ich in der Nacht eingebaut
   habe: `inset: -8% -4% 0`. Die vier Prozent rechts sahen nach einer
   Kleinigkeit aus; ein absolut gesetztes Element zaehlt aber zur
   Scrollbreite, auch mit `pointer-events: none` und `z-index: -1`.

   `pruef-breiten` hat es gemeldet und konnte nicht sagen, WAS es ist
   ("13px Ueberstand []") -- ein Pseudoelement hat keine Box im DOM.
   Gefunden hat es eine neue Messung, die JEDES echte Element abfragt:
   keines ragte heraus, und genau das war der Hinweis.
   Jetzt: 0 px auf allen neun Breiten, 38 Seiten, 84 252 Elemente.

3. EIN AUFGABENLINK IM REPORT WAR 27 x 18 px GROSS. Mit der Maus gut,
   mit dem Daumen nicht -- eine Fingerkuppe ist rund 45 px breit.
   Jetzt fuellt der Link die Zeile und ist 44 px hoch, aber nur auf
   schmalen Bildschirmen und an groben Zeigegeraeten. Die zweite
   Bedingung habe ich beim ersten Anlauf vergessen, und der Link blieb
   27 x 21 -- eine Regel, die nur unter idealen Bedingungen greift,
   hilft niemandem auf dem halben Weg dorthin.

4. EINE SEITE LUD DAS FALSCHE MANIFEST. `anruf-probe.html` verwies
   fest auf `crew.webmanifest`; die Seite ist aber fuer ALLE Rollen
   offen und damit auf beiden Adressen erreichbar. Wer sie im
   Agenturhaus oeffnet und die App installiert, bekam "DogFather
   Universe" mit dem Crew-Symbol auf den Startbildschirm. Alle anderen
   38 Seiten machen es richtig: `app.webmanifest`, und die Adresse
   biegt es um.

5. DER KNOPF "+ GIF HINZUFUEGEN" WAR 40 px HOCH, und der Kommentar
   daneben nannte das "die Hausgroesse". Die Hausgroesse sind 44 --
   fuenfmal in derselben Datei so begruendet. Die erste Reparatur
   griff nicht: Die Fingerregel stand 400 Zeilen VOR der Grundregel,
   und bei gleicher Staerke gewinnt die spaetere. Jetzt steht sie
   direkt dahinter, und der Knopf misst 133 x 44 am Finger, 133 x 40
   an der Maus.

   KEINE PRUEFUNG KONNTE DAS FINDEN: Die GIF-Tafel ist zu, solange
   niemand sie aufmacht, und alle Rundgaenge messen, was auf dem
   Bildschirm steht. Dafuer gibt es jetzt server/mess-gifs-handy.mjs.

6. ZWEI KLEINIGKEITEN AUS DER NACHT: eine Fehlerkennung ohne Satz
   (`unbekannter_punkt`) und eine Stelle in support.js, die den
   Serverfehler roh anzeigte statt durch `fehlerText` -- die zwei
   anderen Stellen derselben Datei machen es richtig. So entsteht eine
   Ausnahme: nicht aus Absicht, sondern weil man die Hausregel beim
   Neuschreiben nicht danebenliegen hatte.

7. TOTES CSS (.e-leerwahl, vier Regeln). Der Leerkasten ist am
   24.09. auf Filipes Wunsch wieder verschwunden, sein Stil blieb
   einen Tag laenger stehen.

=== ROT, ABER NICHT KAPUTT ===

Drei Pruefungen haben den Umbau vom 24.09. nicht mitbekommen:

  pruef-anruf meldete 31 Fehler und "ein Gespraech entsteht (403)".
  Sie meldete DogFather auf der AGENTUR-Adresse an und liess ihn dann
  die rechte Hand anrufen -- die es dort seit der Haustrennung nicht
  gibt. Der Server hatte recht. Jetzt telefoniert Team Dogi auf crew.,
  und der Creator bleibt auf workspace. -- seine Gegenprobe ist damit
  sogar schaerfer als vorher (ein Fremder aus dem ANDEREN Haus).
  96 -> 127 Pruefungen, 0 Fehler.

  pruef-crew-wand-bild erwartete vier Rollen auf der Zugangswand. Es
  sind fuenf, seit die linke Hand am 21.09. dazukam. Die Zeile stand
  unter einem Kommentar, der wortwoertlich vor festen Namen in
  Pruefungen warnt ("eine Zeitbombe mit Datum") -- und war selbst
  einer. Jetzt leitet sie die Rollen aus `rollenImHaus("crew")` ab,
  derselben Quelle, aus der der Server die Wand baut.

  pruef-entwicklung-kacheln rechnete noch mit "x von 68". Seit
  Filipes Wunsch ("es sollen nur die menge angezeigt werden die wir
  zutragen") ist das Ganze das, was jemandem zugetragen ist. Sie baut
  jetzt BEIDE Faelle -- eine Person mit vier zugetragenen Punkten und
  eine ohne -- und misst Ring, Bogen, Zeile und Vorleseschild gegen
  die Zuteilung. Dazu eine Gegenprobe mit einer Einschaetzung auf
  einem NICHT zugetragenen Punkt: zaehlt die Uebersicht sie mit,
  stuende dort 2 statt 1. 31 -> 34 Pruefungen, 0 Fehler.

=== WAS DIE BILDSCHAU ANGEHT ===

Meine eigene Messung meldete erst "Mitte 174 von 640" -- die Schau sah
kaputt aus. Sie war es nicht: `querySelector("img")` nahm das
Husky-Zeichen in der Kopfzeile statt des Bildes. Nachgemessen am
richtigen Element steht es auf 195 von 195 (Handy) und 640 von 640
(Rechner), und ein GIF oeffnet sich als GIF. Ein Messfehler, der wie
ein Befund aussieht, kostet mehr Zeit als gar keine Messung -- deshalb
steht die Begruendung jetzt im Quelltext der Messung.

GEMESSEN: pruef-breiten (9 Breiten, 38 Seiten, 84 252 Elemente),
pruef-support 63/0, pruef-gifs 17/0, pruef-chat 63/0, pruef-chat-optik
62/0, pruef-tippziele 11/0, pruef-css-klassen, pruef-struktur,
pruef-meldungen, pruef-modi-wortleck, pruef-crew-adresse,
pruef-installieren, pruef-aufgabenbrett, pruef-entwicklung -- alle 0
Fehler.

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

1234 lines
57 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";
/* ==== ZWEI ADRESSEN, ZWEI HAEUSER (25.09.2026) ==============
Seit dem 24.09. filtert der Server heraus, wer nicht zum
Haus der Adresse gehoert. Diese Pruefung meldete DogFather
auf der AGENTUR-Adresse an und liess ihn dann ein Gespraech
mit der rechten Hand aufmachen -- die es dort nicht gibt.
Antwort: 403, und das war richtig. Dreissig Folgefehler
haengen daran, alle aus derselben Wurzel.
TEAM DOGI TELEFONIERT AUF crew., die Agentur auf workspace.
DogFather ist in beiden Haeusern -- welches gilt, entscheidet
die Adresse, ueber die er sich anmeldet, nicht seine Rolle.
DIE AGENTUR-ADRESSE BLEIBT IM SPIEL, und zwar dort, wo sie
hingehoert: beim Creator Tili. Seine Gegenprobe ist dadurch
schaerfer als vorher -- ein Fremder aus dem ANDEREN AGENTUR
bekommt 404 auf einen Team-Dogi-Anruf. */
const AGENTUR = "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", CREW);
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", AGENTUR);
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", CREW, { 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}`, CREW, { 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`, CREW,
{ cookie: kDogi }, { video: false });
ok(r.code === 200, `anrufen geht (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { 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`, CREW, { 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`, CREW, { 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`, CREW, { 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}`, AGENTUR,
{ 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}`, CREW, {});
ok(r.code === 401, `ohne Anmeldung gar nichts (${r.code})`);
}
{
const r = await roh("/workspace/api/anruf/999999/start", CREW,
{ 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}`, CREW, { 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`, CREW, { cookie: kDogi }, {}, "POST");
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { 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", CREW,
{ 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`, CREW,
{ cookie: kDogi })).nachrichten || []).length;
await roh(`/workspace/api/anruf/${raum.id}/start`, CREW, { 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`, CREW, { cookie: kDogi }, {});
ok(ende.code === 200, `aufgelegt (${ende.code})`);
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ 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`, CREW,
{ 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", CREW,
{ 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`, CREW,
{ cookie: kDogi })).nachrichten || []).length;
await roh(`/workspace/api/anruf/${raum.id}/start`, CREW, { 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`, CREW, { cookie: kDogi }, {});
const nachher = json(await roh(`/workspace/api/chat/raeume/${raum.id}/nachrichten`, CREW,
{ 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", CREW,
{ 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`, CREW,
{ cookie: kDogi })).nachrichten?.length ?? 0;
const start = await roh(`/workspace/api/anruf/${raum.id}/start`, CREW,
{ 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`, CREW,
{ 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`, CREW,
{ 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", CREW,
{ 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`, CREW,
{ 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", CREW, { 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", AGENTUR, { cookie: kFremd }));
ok(fremd.anrufe?.length === 0,
`ein Fremder erfaehrt nichts davon (${fremd.anrufe?.length})`);
await roh(`/workspace/api/anruf/${raum.id}/ende`, CREW, { 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", CREW, { 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", CREW, { 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", CREW, { cookie: kDogi },
{ adressen: [] }, "PUT");
const zurueck = json(await roh("/workspace/api/anruf/adressen", CREW, { 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", CREW, { 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", CREW, { 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", CREW, { cookie: kDogi },
{ adressen: [] }, "PUT");
try { unlinkSync(datei); } catch { /* schon weg */ }
const zurueck = json(await roh("/workspace/api/anruf/adressen", CREW, { 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}`, CREW, { 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}`, CREW, { 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", CREW, { 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}`, CREW, { 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}`, CREW, { 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}`, CREW, { 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);