Files
dogfather-universe/server/pruef-chat-optik.mjs
T
DogFatherGitandClaude Opus 5 bede91921d Der sechste Bereich, und zwei Pruefungen, die den Lauf lahmgelegt haben
DER AGENTUR-BEREICH (Stufe 4 des Plans)
Kernprinzip 04 des Konzepts lautet woertlich "Die Agentur bleibt
angebunden" -- und dafuer gab es bis heute nichts. Fuenf Bereiche
standen, der sechste fehlte vollstaendig. Damit stand nirgends, welche
Kampagne laeuft, welche Schulung ansteht und wie ein Anliegen an die
Agentur ausgegangen ist. Das lief ueber private Nachrichten: nicht
auffindbar, nicht nachvollziehbar, beim naechsten Mal von vorn.

Vier Arten, und die vierte ist der Punkt: kampagne, schulung, anliegen
und ZUSTAENDIGKEIT. Die letzte ist keine Verlegenheit, sondern das, was
das Konzept ausdruecklich fordert -- Betreuung und Umsetzung sind unsere
Seite, offizielle Wege sind Agenturseite. Solange das nur im Kopf steht,
wird es bei jedem Streitfall neu verhandelt.

Der Umbau war das eigentliche Risiko, nicht der Bereich: Ein CHECK
laesst sich in SQLite nicht aendern, die Tabelle muss neu gebaut werden
-- und dabei werden ALLE vorhandenen Eintraege umgeschrieben. Deshalb
geht pruef-agentur.mjs (31 Pruefungen) den Weg wirklich: Sie baut eine
Datenbank im ALTEN Stand nach, fuellt sie mit allen fuenf Bereichen,
laesst die echte Anwendung darueberlaufen und zaehlt danach jede Zeile
UND jedes Feld nach -- auch die seltenen Spalten (hook, creator_extern),
die man beim Abschreiben des Bauplans vergisst. Wichtigste Gegenprobe:
Der CHECK muss danach noch BEISSEN. Eine Umstellung, die nebenbei die
Schranke entfernt, sieht aus wie ein Erfolg und ist der schlimmere
Ausgang.

Nebenbei drei abgeschriebene Bereichslisten beseitigt (Wochenbericht,
Suche, Report-Ziele). Im Wochenbericht waere der neue Bereich sonst
schlicht nicht vorgekommen -- ohne Fehler, ohne Luecke, einfach nicht da.

ZWEI PRUEFUNGEN, DIE DEN GESAMTLAUF ZUM STILLSTAND BRACHTEN

pruef-call-kategorien.mjs hing DREI STUNDEN. Ursache war ein Zeitzuender
in ihr selbst: Sie legte Calls auf "heute 18:00" und erwartete zwei
davon unter "Heute". Ab 18 Uhr sind die vorbei, der Server schiebt sie
nach "Protokoll fehlt", und die Oberflaeche unterteilt erst ab FUENF
anstehenden. Uebrig blieben vier, die Bloecke verschwanden, und die
Pruefung wartete auf einen Klick auf einen Block, den es nicht gab.
Genau die Sorte Fehler, vor der die Projektnotiz vom 06.09. warnt: Sie
war um 17:59 gruen und um 18:01 rot, ohne dass sich am Code etwas
geaendert hatte. Jetzt liegen alle Zeiten RELATIV zu jetzt, und die
Erwartung wird mit derselben Vorschrift gerechnet, die calls.js benutzt
-- statt Zahlen, die nur zu einer bestimmten Tageszeit stimmen.

Und der Grund, warum daraus ein Stillstand statt eines Fehlers wurde:
index.js setzt bewusst zwei Auffangnetze (uncaughtException,
unhandledRejection). Fuer den Betrieb richtig -- ein Fehler darf die
Website nicht offline nehmen. Fuer eine Pruefung fatal: Sie importiert
index.js in denselben Prozess und erbt die Netze. Ihr eigener Absturz
wird dann nur protokolliert, und der Express-Server haelt den Prozess
danach ewig am Leben. Kein Fehler, kein Ergebnis, kein Ende.

Deshalb zwei Abhilfen, die zusammengehoeren:
  * server/helfer-notbremse.mjs -- haengt eigene Zuhoerer DANEBEN
    (process.on ergaenzt, es ersetzt nicht) und beendet den Lauf mit
    Fehler; dazu ein Wecker. In alle 42 Browserpruefungen eingebaut.
    Der Betrieb bleibt unveraendert.
  * tools/alles-pruefen.mjs gibt jeder Datei eine Frist von 600 s. Wer
    sie reisst, wird abgebrochen und ZAEHLT ALS FEHLGESCHLAGEN, mit der
    letzten Ausgabe davor als Beleg. Einzelne Dateien nachzuruesten hilft
    nur bis zur naechsten ohne Notbremse -- deshalb sitzt sie dort, wo
    sie fuer alle gilt, auch fuer die, die es noch nicht gibt.

AUSSERDEM: pruef-breiten.mjs pruefte 16 Seiten aus einer Liste von Hand,
chat.html und leistung.html fehlten darin. Der Chat war bei keiner
einzigen der neun Breiten je gemessen worden. Wie bei pruef-handy kommt
die Liste jetzt aus dem Verzeichnis.

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

341 lines
16 KiB
JavaScript

/* CHAT — SCHREIBEN ZWEI LEUTE WIRKLICH MITEINANDER?
pruef-chat.mjs beweist, dass der Server richtig entscheidet, WER mit
wem darf. Diese Prüfung fragt das, was der Wunsch eigentlich meinte:
*"jeder kann dann eine nachricht reinschreiben, die andere person
bekommt eine benachrichtigung und kann dann antworten."*
Dafür laufen ZWEI Browser gleichzeitig. Luna schreibt, und ohne dass
irgendjemand etwas neu lädt, muss die Nachricht bei Patrick
erscheinen. Das ist der Kern -- alles andere ist Ausstattung.
WARUM ZWEI ECHTE BROWSER und nicht zwei Anfragen: Die sofortige
Zustellung hängt an einer offenen Verbindung (SSE), an einem
EventSource im Browser und daran, dass die Seite darauf richtig
reagiert. Mit fetch allein prüfte man den Server, nicht den Chat.
GEMESSEN WIRD AUSSERDEM:
* Die Zahl in der Kopfleiste -- auf einer ANDEREN Seite, denn dort
muss sie ankommen, damit jemand von der Nachricht erfährt.
* Dass die eigene Nachricht sofort dasteht, nicht erst nach der
Antwort des Servers.
* Dass "gelesen" die Zahl wieder wegnimmt.
GEGENPROBEN durchgehend. */
import { mkdtempSync, rmSync, appendFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-chatoptik-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
const { portMussFreiSein } = await import("./helfer-port.mjs");
await portMussFreiSein(4317, "die Chat-Optikpruefung");
process.env.PORT = "4317";
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
await import("./index.js");
/* DIE NOTBREMSE. Muss NACH index.js stehen: Der Server haengt dort
zwei Auffangnetze ein, die einen Absturz nur protokollieren --
richtig fuer den Betrieb, fatal fuer eine Pruefung. Ohne diese
Zeile bleibt der Prozess nach einem Fehler ewig stehen, weil der
Server ihn am Leben haelt (siehe helfer-notbremse.mjs). */
notbremse(480_000, "pruef-chat-optik");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = "http://127.0.0.1:4317";
const PROTOKOLL = join(process.cwd(), "pruef-chat-optik-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => {
console.log(z);
try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ }
};
let fehler = 0;
const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------ */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
for (let i = 0; i < 60; i++) {
try {
const p = new DatabaseSync(process.env.WORKSPACE_DB);
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
} catch { await new Promise((r) => setTimeout(r, 250)); }
}
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
function anlegen(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001");
const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001");
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt);
d.close();
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await pw.chromium.launch();
async function alsRolle(rolle, code, wohin = "chat.html", breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } });
const seite = await ctx.newPage();
await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await seite.click(`.rolle[data-rolle="${rolle}"]`);
await seite.fill("#code", code);
await seite.click("button[type=submit]");
await seite.waitForURL("**/start.html", { timeout: 25000 });
await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
return { ctx, seite };
}
/* =======================================================================
1. Ein Gespräch anfangen
======================================================================= */
melde("\n=== Ein Gespräch anfangen ===");
const luna = await alsRolle("creator", "CODE-CREA-0001");
{
const leer = await luna.seite.isVisible("#liste-leer");
ok(leer, "am Anfang steht da, dass es noch keine Gespräche gibt");
await luna.seite.click("#neu-oeffnen");
await luna.seite.waitForSelector("#neu-dialog[open]", { timeout: 5000 });
const knoepfe = await luna.seite.$$eval(".chat-wahl__knopf",
(b) => b.map((x) => x.textContent.trim()));
/* Luna erreicht ihren Scout und DogFather -- die Regel aus
pruef-chat, hier an der Oberfläche nachgewiesen. */
ok(knoepfe.length === 2, `${knoepfe.length} Personen zur Auswahl: ${knoepfe.join(", ")}`);
const los = await luna.seite.$eval("#neu-los", (b) => b.disabled);
ok(los, "der Knopf ist gesperrt, solange niemand gewählt ist");
await luna.seite.click('.chat-wahl__knopf[data-rolle="scout"]');
await luna.seite.waitForTimeout(200);
const frei = await luna.seite.$eval("#neu-los", (b) => !b.disabled);
const beschriftung = await luna.seite.$eval("#neu-los", (b) => b.textContent.trim());
ok(frei && beschriftung === "Schreiben",
`nach der Wahl ist er frei und heißt "${beschriftung}"`);
/* Der Gruppenname erscheint erst ab zwei -- vorher wäre er eine
Frage ohne Anlass. */
const nameVersteckt = await luna.seite.$eval("#gruppenname-zeile", (e) => e.hidden);
ok(nameVersteckt, "bei einer Person wird nicht nach einem Gruppennamen gefragt");
await luna.seite.click("#neu-los");
await luna.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
const titel = await luna.seite.$eval("#verlauf-titel", (e) => e.textContent);
ok(titel === "Patrick", `das Gespräch ist offen: "${titel}"`);
}
/* =======================================================================
2. DER KERN: schreiben, und es kommt an — ohne Neuladen
======================================================================= */
melde("\n=== Zwei Browser, eine Nachricht ===");
const pat = await alsRolle("scout", "CODE-SCOU-0001");
{
/* Patrick öffnet dasselbe Gespräch. */
await pat.seite.waitForSelector(".chat-raum__knopf", { timeout: 8000 });
await pat.seite.click(".chat-raum__knopf");
await pat.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
await pat.seite.waitForTimeout(600);
const vorher = (await pat.seite.$$(".chat-nachricht")).length;
/* Luna schreibt. */
await luna.seite.fill("#text", "Hey Patrick, kurze Frage zum Start-Check!");
await luna.seite.click("#senden");
/* SOFORT bei Luna sichtbar -- vor jeder Serverantwort. Deshalb wird
hier bewusst nur ganz kurz gewartet. */
await luna.seite.waitForTimeout(120);
const beiLuna = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent));
ok(beiLuna.some((t) => /Start-Check/.test(t)),
"die eigene Nachricht steht sofort da, ohne auf den Server zu warten");
/* UND SIE KOMMT BEI PATRICK AN, ohne dass er etwas tut. */
const angekommen = await pat.seite.waitForFunction(
() => [...document.querySelectorAll(".chat-nachricht__text")]
.some((t) => /Start-Check/.test(t.textContent)),
{ timeout: 8000 }).then(() => true).catch(() => false);
ok(angekommen, angekommen
? "sie erscheint bei Patrick von selbst — kein Neuladen nötig"
: "sie kam bei Patrick NICHT an (der Strom liefert nicht)");
const nachher = (await pat.seite.$$(".chat-nachricht")).length;
ok(nachher === vorher + 1, `Patrick sieht ${nachher} statt ${vorher} Nachrichten`);
/* Und die Seiten sind sich einig, WER sie geschrieben hat. */
const beiPatSelbst = await pat.seite.$eval(".chat-nachricht:last-of-type",
(e) => e.dataset.selbst === "ja");
const beiLunaSelbst = await luna.seite.$eval(".chat-nachricht:last-of-type",
(e) => e.dataset.selbst === "ja");
ok(!beiPatSelbst && beiLunaSelbst,
"bei Luna steht sie rechts (eigene), bei Patrick links (fremde)");
}
/* =======================================================================
3. Und zurück
======================================================================= */
melde("\n=== Patrick antwortet ===");
{
await pat.seite.fill("#text", "Klar, schieß los!");
await pat.seite.click("#senden");
const zurueck = await luna.seite.waitForFunction(
() => [...document.querySelectorAll(".chat-nachricht__text")]
.some((t) => /schieß los/.test(t.textContent)),
{ timeout: 8000 }).then(() => true).catch(() => false);
ok(zurueck, "die Antwort erscheint bei Luna von selbst");
/* Der Verlauf steht in der richtigen Reihenfolge -- eine
Unterhaltung, die durcheinandergerät, ist unbrauchbar. */
const texte = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent));
ok(/Start-Check/.test(texte[0]) && /schieß los/.test(texte[1]),
`Reihenfolge stimmt: "${texte.map((t) => t.slice(0, 18)).join('" → "')}"`);
}
/* =======================================================================
4. Die Zahl in der Kopfleiste — auf einer ANDEREN Seite
======================================================================= */
melde("\n=== Erfährt man davon, ohne im Chat zu sein? ===");
{
/* Patrick geht woandershin. Genau dort muss die Zahl erscheinen --
sonst merkt er von einer Nachricht nichts. */
await pat.seite.goto(`${BASIS}/workspace/aufgaben.html`, { waitUntil: "networkidle" });
await pat.seite.waitForTimeout(900);
const knopfDa = await pat.seite.$("#chat-knopf, .chat-knopf");
ok(!!knopfDa, "auf der Aufgabenseite steht ein Chat-Knopf in der Kopfleiste");
const vorher = await pat.seite.$eval(".chat-knopf__zahl",
(e) => (e.hidden ? "0" : e.textContent));
ok(vorher === "0", `vorher zeigt er nichts an (${vorher})`);
await luna.seite.fill("#text", "Wann hast du Zeit für einen Call?");
await luna.seite.click("#senden");
const erschienen = await pat.seite.waitForFunction(() => {
const z = document.querySelector(".chat-knopf__zahl");
return z && !z.hidden && z.textContent === "1";
}, { timeout: 8000 }).then(() => true).catch(() => false);
ok(erschienen, erschienen
? "die Zahl 1 erscheint von selbst in der Kopfleiste"
: "die Zahl erscheint NICHT — man erfährt nichts von der Nachricht");
/* Und der Knopf selbst trägt Farbe -- eine kleine Zahl allein
übersieht man beim Überfliegen. */
const markiert = await pat.seite.$eval(".chat-knopf", (e) => e.dataset.neu === "ja");
ok(markiert, "der Knopf ist zusätzlich hervorgehoben, nicht nur die Zahl");
}
/* =======================================================================
5. Lesen nimmt die Zahl wieder weg
======================================================================= */
melde("\n=== Gelesen ===");
{
await pat.seite.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" });
await pat.seite.waitForSelector(".chat-raum__knopf", { timeout: 8000 });
const zahlInListe = await pat.seite.$eval(".chat-raum__zahl", (e) => e.textContent)
.catch(() => null);
ok(zahlInListe === "1", `in der Gesprächsliste steht die Zahl (${zahlInListe})`);
await pat.seite.click(".chat-raum__knopf");
await pat.seite.waitForTimeout(1200);
const weg = await pat.seite.$(".chat-raum__zahl");
ok(!weg, "nach dem Öffnen ist sie weg");
/* Und sie bleibt weg -- auch nach einem Neuladen. Wäre "gelesen" nur
eine Anzeige im Browser, käme die Zahl beim nächsten Aufruf zurück. */
await pat.seite.reload({ waitUntil: "networkidle" });
await pat.seite.waitForTimeout(1000);
const wiederDa = await pat.seite.$(".chat-raum__zahl");
ok(!wiederDa, "auch nach dem Neuladen bleibt sie weg — es ist wirklich gespeichert");
}
/* =======================================================================
6. Zurücknehmen, im Bild
======================================================================= */
melde("\n=== Zurücknehmen ===");
{
await luna.seite.bringToFront();
const vorher = (await luna.seite.$$(".chat-nachricht")).length;
/* Playwright bestätigt den confirm-Dialog nicht von selbst. */
luna.seite.once("dialog", (dlg) => dlg.accept());
await luna.seite.click(".chat-nachricht[data-selbst='ja']:last-of-type .chat-nachricht__weg-knopf");
await luna.seite.waitForTimeout(1000);
const nachher = (await luna.seite.$$(".chat-nachricht")).length;
ok(nachher === vorher,
`die Zeile bleibt stehen (${nachher} statt ${vorher}) — kein Loch im Verlauf`);
const hinweis = await luna.seite.$(".chat-nachricht__weg");
ok(!!hinweis, "an ihrer Stelle steht 'Nachricht zurückgenommen'");
/* Und beim Gegenüber verschwindet der Text ebenfalls -- von selbst. */
const beiPat = await pat.seite.waitForFunction(
() => !![...document.querySelectorAll(".chat-nachricht__weg")].length,
{ timeout: 8000 }).then(() => true).catch(() => false);
ok(beiPat, "auch bei Patrick ist der Text weg, ohne Neuladen");
}
/* =======================================================================
GEGENPROBE — misst die Prüfung überhaupt etwas?
======================================================================= */
melde("\n=== Gegenprobe ===");
{
/* Träfe der Selektor nichts, wären alle Haken oben wertlos. */
const vorher = (await luna.seite.$$(".chat-nachricht")).length;
await luna.seite.evaluate(() => {
for (const n of document.querySelectorAll(".chat-nachricht")) n.remove();
});
const nachher = (await luna.seite.$$(".chat-nachricht")).length;
ok(vorher >= 3 && nachher === 0,
`der Selektor trifft (${vorher} gefunden, nach dem Entfernen ${nachher})`);
/* Und ein Text, der nie geschrieben wurde, darf NICHT gefunden
werden -- sonst prüfte "kam an" gar nichts. */
await luna.seite.reload({ waitUntil: "networkidle" });
await luna.seite.waitForTimeout(1200);
const erfunden = await luna.seite.$$eval(".chat-nachricht__text",
(t) => t.some((x) => /Diesen Satz gab es nie/.test(x.textContent)));
ok(!erfunden, "ein nie geschriebener Satz wird nicht gefunden");
}
await luna.ctx.close();
await pat.ctx.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
/* HIER IST process.exit() RICHTIG -- anders als in pruef-push-weg.
Diese Pruefung startet einen Express-Server, und der laeuft von
selbst weiter: offene Verbindungen, ein Zeitgeber fuer die
Sicherung, bei dieser Pruefung zusaetzlich die offenen
Ereignisstroeme des Chats. Mit `exitCode` allein wartet Node ewig
darauf, dass all das zu Ende geht -- der Lauf lief in die
Zeitueberschreitung, obwohl alle Pruefungen schon durch waren.
Der Absturz, der in pruef-push-weg gegen exit() sprach, kam von
fetch-Verbindungen mitten im Schliessen. Die gibt es hier nicht
mehr: Die Browser sind zu, bevor diese Zeile laeuft. */
process.exit(fehler ? 1 : 0);