Files
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

589 lines
27 KiB
JavaScript

/* =====================================================================
DER VERTRAULICHE MELDEWEG -- 19.09.2026
Filipe: "es soll auch eine kategorie also eine hauptkachel geben wo
die community leute sich anonym melden koennen wenn sie probleme
haben ... rechte hand und dogfather sollen zugriff auf diese anonyme
nachrichten haben. also anonym fuer die anderen."
HIER LIEGEN DIE EMPFINDLICHSTEN TEXTE DES GANZEN HAUSES. Jemand
beschreibt ein Problem, oft mit anderen Menschen darin. Diese
Pruefung fragt deshalb vor allem eines: Kann irgendjemand etwas
sehen, das nicht fuer ihn ist?
DREI SORTEN FEHLER WERDEN GESUCHT:
1. EIN FALL BEIM FALSCHEN. Ein zweites Mitglied darf einen fremden
Fall weder in der Liste noch ueber die Fallnummer sehen. Auch ein
Modi nicht -- sehr oft geht es genau um eine
Moderationsentscheidung.
2. DER WECHSEL LAESST SICH UMGEHEN. Die Oberflaeche blendet den
Knopf aus; wer die Route direkt aufruft, kaeme daran vorbei. Die
Regel muss im Server stehen, und genau das wird hier geprueft --
nicht, ob ein Knopf verschwindet.
3. EIN GESCHLOSSENER FALL NIMMT NOCH NACHRICHTEN AN. Dann ist
"zugemacht" eine Meinung und keine Tatsache.
Aufrufen mit: node server/pruef-hilfe.mjs
===================================================================== */
import { mkdtempSync, rmSync } 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-hilfe");
const ordner = mkdtempSync(join(tmpdir(), "ws-hilfe-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(150_000, "pruef-hilfe");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const CREW = "crew.dogfather-universe.com";
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 {}; } };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
const LEUTE = [
["Filipe", "admin", "CODE-DOGI-0001"],
["Rieke", "hand", "CODE-HAND-0001"],
["Kessi", "modi", "CODE-MODI-0001"],
["Mara", "gast", "CODE-GAST-0001"],
["Tim", "gast", "CODE-GAST-0002"],
];
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
for (const [name, rolle, code] of LEUTE) {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salz, 32768, new Date().toISOString());
}
d.close();
}
async function anmelden(rolle, code) {
let a = await roh("/workspace/api/anmelden", CREW, {}, { rolle, code });
if (a.code === 400 && a.text.includes("alter_offen")) {
a = await roh("/workspace/api/anmelden", CREW, {}, { rolle, code, alter_ok: true });
}
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
const k = {};
k.dogi = await anmelden("admin", "CODE-DOGI-0001");
k.hand = await anmelden("hand", "CODE-HAND-0001");
k.modi = await anmelden("modi", "CODE-MODI-0001");
k.mara = await anmelden("gast", "CODE-GAST-0001");
k.tim = await anmelden("gast", "CODE-GAST-0002");
/* =======================================================================
1. WER DIE SEITE UEBERHAUPT OEFFNEN DARF
======================================================================= */
melde("");
melde("=== 1. Die Tuer ===");
{
for (const [wer, keks, soll] of [
["Mara (Mitglied)", k.mara, 200],
["DogFather", k.dogi, 200],
["die rechte Hand", k.hand, 200],
]) {
const a = await roh("/workspace/hilfe.html", CREW, { cookie: keks });
ok(a.code === soll, `${wer}: ${a.code}`);
}
/* MODIS AUSDRUECKLICH NICHT -- sehr oft geht es um sie. */
const m = await roh("/workspace/hilfe.html", CREW, { cookie: k.modi });
ok(m.code === 302 || m.code === 403, `ein Modi kommt NICHT hinein (${m.code})`);
const o = await roh("/workspace/hilfe.html", CREW);
ok(o.code === 302 || o.code === 401 || o.code === 403, `ohne Anmeldung nicht (${o.code})`);
}
/* =======================================================================
2. MELDEN
======================================================================= */
melde("");
melde("=== 2. Eine Meldung aufmachen ===");
let fallId = 0;
{
const zuKurz = await roh("/workspace/api/hilfe", CREW, { cookie: k.mara },
{ betreff: "Hm", text: "kurz" });
ok(zuKurz.code === 400, `zu kurz wird abgelehnt (${zuKurz.code})`);
const a = await roh("/workspace/api/hilfe", CREW, { cookie: k.mara }, {
betreff: "Jemand geht mich im Chat immer wieder an",
text: "Seit einer Woche schreibt mir dieselbe Person nach jedem Stream. "
+ "Ich habe schon geblockt, dann kommt ein neuer Zugang.",
});
ok(a.code === 201, `Mara kann melden (${a.code})`);
fallId = json(a).id;
ok(Number.isInteger(fallId) && fallId > 0, `der Fall hat eine Nummer (${fallId})`);
/* DIE LEITUNG MELDET SICH NICHT SELBST. */
const d = await roh("/workspace/api/hilfe", CREW, { cookie: k.dogi },
{ betreff: "Test von DogFather", text: "Das sollte nicht gehen, denke ich." });
ok(d.code === 400, `DogFather kann keinen Fall aufmachen (${d.code})`);
}
/* =======================================================================
3. WER SIEHT WAS -- der wichtigste Abschnitt
======================================================================= */
melde("");
melde("=== 3. Vertraulich heisst vertraulich ===");
{
const mara = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.mara }));
ok(mara.faelle?.length === 1, `Mara sieht ihren Fall (${mara.faelle?.length})`);
ok(mara.leitung === false, "und ist nicht Leitung");
/* IHR EIGENER NAME KOMMT NICHT MIT -- sie weiss, wer sie ist. Das
Feld gibt es fuer sie gar nicht, also kann es auch nicht
versehentlich angezeigt werden. */
ok(!("melder" in (mara.faelle?.[0] || {})),
"in ihrer Antwort steht kein Melder-Feld");
/* EIN ZWEITES MITGLIED SIEHT NICHTS. Das ist der Kern. */
const tim = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.tim }));
ok(tim.faelle?.length === 0, `Tim sieht Maras Fall NICHT (${tim.faelle?.length})`);
const timDirekt = await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.tim });
/* 404 UND NICHT 403: Ein "darfst du nicht" verriete, dass es den
Fall gibt. */
ok(timDirekt.code === 404, `und auch nicht ueber die Nummer (${timDirekt.code})`);
/* EIN MODI AUCH NICHT. */
const modi = await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.modi });
ok(modi.code === 404 || modi.code === 302 || modi.code === 403,
`ein Modi kommt nicht an den Fall (${modi.code})`);
/* DIE LEITUNG SIEHT IHN -- MIT NAMEN. Genau so bestellt. */
const dogi = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.dogi }));
ok(dogi.faelle?.length === 1, `DogFather sieht den Fall (${dogi.faelle?.length})`);
ok(dogi.faelle?.[0]?.melder === "Mara",
`und den Namen dazu (${dogi.faelle?.[0]?.melder})`);
const hand = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.hand }));
ok(hand.faelle?.[0]?.melder === "Mara", "die rechte Hand ebenso");
}
/* =======================================================================
4. DER WECHSEL -- und zwar im Server, nicht im Knopf
======================================================================= */
melde("");
melde("=== 4. Immer abwechselnd ===");
{
/* Mara ist gerade NICHT dran -- sie hat gerade geschrieben. */
const nochmal = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.mara }, { text: "Und noch etwas dazu." });
ok(nochmal.code === 409,
`Mara kann nicht zweimal hintereinander schreiben (${nochmal.code})`);
const d = json(await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.mara }));
ok(d.ich_bin_dran === false, "und der Server sagt ihr das auch");
/* Die Leitung antwortet. */
const antwort = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.dogi }, { text: "Danke, dass du dich meldest. Wie heisst der Zugang?" });
ok(antwort.code === 200, `DogFather antwortet (${antwort.code})`);
/* Jetzt ist die Leitung NICHT mehr dran. */
const nochmal2 = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.hand }, { text: "Ergaenzung." });
ok(nochmal2.code === 409,
`auch die Leitung kann nicht zweimal (${nochmal2.code})`);
/* Und Mara schon. */
const maraJetzt = json(await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.mara }));
ok(maraJetzt.ich_bin_dran === true, "Mara ist jetzt dran");
const maraAntwort = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.mara }, { text: "Der Zugang heisst xyz123." });
ok(maraAntwort.code === 200, `und kann antworten (${maraAntwort.code})`);
/* GEGENPROBE: Tim kann in einen fremden Fall nichts schreiben. */
const timSchreibt = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.tim }, { text: "Ich mische mich mal ein." });
ok(timSchreibt.code === 404, `Tim kann nicht hineinschreiben (${timSchreibt.code})`);
}
/* =======================================================================
5. ZUMACHEN
======================================================================= */
melde("");
melde("=== 5. Zumachen kann nur die Leitung ===");
{
const mara = await roh(`/workspace/api/hilfe/${fallId}/schliessen`, CREW,
{ cookie: k.mara }, { text: "Passt schon." });
ok(mara.code === 403, `Mara kann ihren Fall NICHT schliessen (${mara.code})`);
const zu = await roh(`/workspace/api/hilfe/${fallId}/schliessen`, CREW,
{ cookie: k.hand }, { text: "Zugang gesperrt, melde dich wenn wieder etwas ist." });
ok(zu.code === 200, `die rechte Hand schliesst ihn (${zu.code})`);
/* EIN GESCHLOSSENER FALL IST GESCHLOSSEN -- auch fuer die Leitung. */
const danach = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.dogi }, { text: "Noch ein Nachtrag." });
ok(danach.code === 409, `danach nimmt er nichts mehr an (${danach.code})`);
const maraDanach = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.mara }, { text: "Doch noch etwas." });
ok(maraDanach.code === 409, `auch von Mara nicht (${maraDanach.code})`);
const d = json(await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.mara }));
ok(d.fall?.stand === "geschlossen", `der Stand steht auf geschlossen (${d.fall?.stand})`);
ok(d.nachrichten?.length === 4,
`vier Nachrichten im Verlauf, das Schlusswort mitgezaehlt (${d.nachrichten?.length})`);
}
/* =======================================================================
6. DIE GRENZE FUER OFFENE FAELLE
======================================================================= */
melde("");
melde("=== 6. Drei offene sind genug ===");
{
let letzte = 0;
for (let i = 1; i <= 4; i++) {
const a = await roh("/workspace/api/hilfe", CREW, { cookie: k.tim }, {
betreff: `Sache Nummer ${i}`,
text: `Das ist die ${i}. Meldung und sie ist lang genug fuer die Pruefung.`,
});
letzte = a.code;
if (i <= 3) ok(a.code === 201, `Meldung ${i} geht durch (${a.code})`);
}
ok(letzte === 429, `die vierte wird abgelehnt (${letzte})`);
/* UND NACH DEM SCHLIESSEN GEHT WIEDER EINE. Ohne diese Zeile waere
die Grenze eine Sackgasse statt einer Bremse. */
const tim = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.tim }));
const ersterOffener = tim.faelle?.[tim.faelle.length - 1]?.id;
await roh(`/workspace/api/hilfe/${ersterOffener}/schliessen`, CREW, { cookie: k.dogi }, {});
const wieder = await roh("/workspace/api/hilfe", CREW, { cookie: k.tim }, {
betreff: "Jetzt wieder eine",
text: "Nachdem einer geschlossen wurde, sollte das hier wieder gehen.",
});
ok(wieder.code === 201, `nach dem Schliessen geht wieder eine (${wieder.code})`);
}
/* =======================================================================
7. AUFRAEUMEN
======================================================================= */
melde("");
melde("=== 7. Was niemand mehr braucht, verschwindet ===");
{
/* GEPRUEFT WIRD, WAS WIRKLICH LAEUFT (20.09.2026).
------------------------------------------------------------------
Hier stand `hilfeAufraeumen()` aus `workspace-hilfe.js`. Die
Funktion gab es, sie war richtig -- und NIEMAND rief sie auf. Der
einzige Aufrufer war diese Pruefung. Sie war gruen und bewies
nichts ueber den Betrieb: geschlossene Faelle blieben unbegrenzt
stehen.
Jetzt steht die Regel im Loeschkonzept, das beim Start und danach
taeglich laeuft -- und geprueft wird genau dieser Eintrag, mit
derselben Rechnung, die auch im Betrieb greift. */
const { FRISTEN, aufbewahrungLauf } = await import("./workspace-aufbewahrung.js");
const { db } = await import("./workspace.js");
const eintrag = FRISTEN.find((f) => f.schluessel === "hilfe_geschlossen");
ok(!!eintrag && typeof eintrag.raeumen === "function",
"der vertrauliche Meldeweg steht im Loeschkonzept und raeumt selbst");
ok(eintrag?.art === "raeumen" && /90/.test(String(eintrag?.frist)),
`mit der Frist aus hilfe-tabellen.js („${eintrag?.frist}")`);
/* Einen geschlossenen Fall kuenstlich altern lassen. KEIN echtes
Warten -- eine Pruefung, die 90 Tage braucht, ist keine. */
const alt = new Date(Date.now() - 100 * 86400000).toISOString();
db().prepare("UPDATE hilfe_faelle SET geschlossen_am = ? WHERE id = ?").run(alt, fallId);
/* ZUERST ZAEHLEN, DANN RAEUMEN. `offen()` ist die Zahl, die in der
Auskunft steht -- stimmt sie nicht mit dem ueberein, was wirklich
verschwindet, steht dort eine Zahl, der niemand glauben darf. */
const angekuendigt = eintrag.offen(db());
const vorher = db().prepare("SELECT COUNT(*) AS n FROM hilfe_faelle").get().n;
const weg = eintrag.raeumen(db());
const nachher = db().prepare("SELECT COUNT(*) AS n FROM hilfe_faelle").get().n;
ok(weg === 1 && nachher === vorher - 1,
`ein alter, geschlossener Fall wird geloescht (${weg}, ${vorher} -> ${nachher})`);
ok(angekuendigt === weg,
`und die angekuendigte Zahl stimmt mit der geloeschten ueberein (${angekuendigt} = ${weg})`);
/* UND DIE NACHRICHTEN MIT IHM. Ein Fall ohne Text waere geloescht,
die Texte laegen aber weiter in der Datenbank -- genau das, was
man beim Aufraeumen vermeiden will. */
const reste = db().prepare(
"SELECT COUNT(*) AS n FROM hilfe_nachrichten WHERE fall_id = ?").get(fallId).n;
ok(reste === 0, `und seine ${4 - reste} Nachrichten ebenfalls (${reste} uebrig)`);
/* GEGENPROBE: Ein OFFENER Fall wird nicht angefasst, egal wie alt. */
/* Einen OFFENEN Fall kuenstlich uralt machen -- er darf trotzdem
nicht angefasst werden. Ohne diese Gegenprobe wuerde ein
vergessenes `stand = ?` erst auffallen, wenn ein laufender Fall
verschwindet. */
const offenId = db().prepare(
"SELECT id FROM hilfe_faelle WHERE stand != 'geschlossen' LIMIT 1").get()?.id;
if (offenId) {
db().prepare("UPDATE hilfe_faelle SET erstellt = ?, geaendert = ?, geschlossen_am = ? WHERE id = ?")
.run(alt, alt, alt, offenId);
}
const offen = db().prepare("SELECT COUNT(*) AS n FROM hilfe_faelle WHERE stand != 'geschlossen'").get().n;
const weg2 = eintrag.raeumen(db());
const offenDanach = db().prepare("SELECT COUNT(*) AS n FROM hilfe_faelle WHERE stand != 'geschlossen'").get().n;
ok(offen === offenDanach && offen > 0,
`ein OFFENER Fall bleibt, selbst mit uraltem Datum (${offen} vorher, ${offenDanach} danach, ${weg2} weg)`);
/* Und der Lauf, der taeglich stattfindet, geht wirklich durch --
nicht nur der einzelne Eintrag. */
let liefDurch = true;
try { aufbewahrungLauf(); } catch { liefDurch = false; }
ok(liefDurch, "der taegliche Aufbewahrungslauf laeuft ohne Fehler durch");
}
/* =======================================================================
8. IM BROWSER
======================================================================= */
melde("");
melde("=== 8. Im Browser ===");
{
let pw = null;
try {
pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
} catch { /* dritter Ausgang */ }
if (!pw) {
melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen.");
} else {
const browser = await pw.chromium.launch();
try {
for (const [name, keks, breite] of [
["Mara", k.mara, 1400], ["Mara am Handy", k.mara, 412], ["DogFather", k.dogi, 1400],
]) {
const ctx = await browser.newContext({
viewport: { width: breite, height: 950 },
isMobile: breite < 700, hasTouch: breite < 700,
reducedMotion: "reduce",
});
await ctx.addCookies(keks.split("; ").filter(Boolean).map((z) => {
const [n, ...r] = z.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await ctx.newPage();
const kaputt = [];
seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140)));
await seite.goto(`http://127.0.0.1:${PORT}/workspace/hilfe.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const z = await seite.evaluate(() => ({
titel: document.getElementById("seiten-titel")?.textContent?.trim() || "",
hinweis: document.getElementById("vertrauen-text")?.textContent?.trim() || "",
neuSichtbar: !document.getElementById("neu-block")?.hidden,
zeilen: document.querySelectorAll(".fall-zeile").length,
/* Nichts darf ueber den rechten Rand laufen. */
ueber: Math.max(0, document.documentElement.scrollWidth - window.innerWidth),
}));
ok(z.titel.length > 0, `${name}: die Seite hat einen Titel (${z.titel})`);
/* DER HINWEIS MUSS DA SEIN, IMMER. Er ist der Unterschied
zwischen "vertraulich" und einem falschen Versprechen. */
ok(/sonst niemand|nur du und die rechte Hand/i.test(z.hinweis),
" der Hinweis, wer mitliest, steht da");
ok(z.ueber === 0, ` nichts laeuft ueber den Rand (${z.ueber} px)`);
ok(kaputt.length === 0,
` kein Skriptfehler${kaputt.length ? ": " + kaputt.join(" | ") : ""}`);
if (name.startsWith("Mara")) {
ok(z.neuSichtbar, " sie kann etwas Neues melden");
} else {
ok(!z.neuSichtbar, " DogFather bekommt KEIN Melde-Formular");
ok(z.zeilen > 0, ` und sieht die Meldungen (${z.zeilen})`);
}
await ctx.close();
}
} finally {
await browser.close();
}
}
}
/* =====================================================================
9. WAS NACH DEM ABSCHLUSS DASTEHT (20.09.2026)
Bis heute stand dort ein Satz: „Dieser Fall ist abgeschlossen
(Datum)." Er beantwortet keine der drei Fragen, die man in diesem
Moment hat:
Kann ich noch etwas schreiben? Nein -- und das ist endgültig.
Bleibt das jetzt hier stehen? 90 Tage, dann ist es weg.
Und wenn es wieder passiert? Neu melden.
Die zweite ist die, die man nicht erst merken darf, wenn es weg
ist -- und auf die man ein Recht hat (Art. 13 Abs. 2 lit. a DSGVO).
Geprüft wird an echten Bildpunkten, nicht am Quelltext: Ein
<p> in einem <p> wäre ungültig, der Browser schlösse das äußere
selbst, und die Sätze stünden nebeneinander statt untereinander.
Das sieht man nur, wenn man misst.
===================================================================== */
melde("");
melde("=== 9. Was nach dem Abschluss dasteht ===");
{
let pw = null;
try {
pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
} catch { /* dritter Ausgang */ }
if (!pw) {
melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen.");
} else {
const { db } = await import("./workspace.js");
/* Einen frisch geschlossenen Fall anlegen -- Abschnitt 7 hat den
alten absichtlich geräumt. */
const nun = new Date().toISOString();
/* DER FALL MUSS MARA GEHOEREN. Erster Anlauf nahm „irgendeine
Person aus einem vorhandenen Fall" -- und Mara sah ihn dann
zu Recht NICHT: `fallFuer` gibt einem Melder nur die eigenen
Faelle. Die Pruefung meldete zehn Fehler ueber eine Sperre,
die genau richtig funktioniert hat. */
const wer = db().prepare("SELECT id FROM personen WHERE name = ? LIMIT 1")
.get("Mara")?.id;
ok(!!wer, `Maras Nummer ist bekannt (${wer})`);
db().prepare(
"INSERT INTO hilfe_faelle (person_id, betreff, stand, erstellt, geaendert,"
+ " geschlossen_am) VALUES (?,?,?,?,?,?)")
.run(wer, "Abgeschlossen zum Ansehen", "geschlossen", nun, nun, nun);
const zuId = db().prepare("SELECT last_insert_rowid() AS id").get().id;
db().prepare(
"INSERT INTO hilfe_nachrichten (fall_id, von_id, von_leitung, text, erstellt)"
+ " VALUES (?,?,?,?,?)").run(zuId, wer, 0, "Der Text dieser Meldung.", nun);
const browser = await pw.chromium.launch();
try {
for (const [name, keks] of [["Mara", k.mara], ["DogFather", k.dogi]]) {
const ctx = await browser.newContext({
viewport: { width: 1280, height: 950 }, reducedMotion: "reduce",
});
await ctx.addCookies(keks.split("; ").filter(Boolean).map((z) => {
const [n, ...r] = z.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await ctx.newPage();
const kaputt = [];
seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140)));
await seite.goto(`http://127.0.0.1:${PORT}/workspace/hilfe.html?fall=${zuId}`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(1000);
const z = await seite.evaluate(() => {
const kasten = document.getElementById("fall-warten");
if (!kasten || kasten.hidden) return null;
const zeilen = [...kasten.querySelectorAll(".fall-schluss__zeile")];
const r = kasten.getBoundingClientRect();
return {
anzahl: zeilen.length,
text: kasten.textContent.replace(/\s+/g, " ").trim(),
/* UNTEREINANDER, NICHT NEBENEINANDER. Bei ungültiger
Verschachtelung stünden sie in einer Zeile. */
untereinander: zeilen.length < 2 ? null
: zeilen[1].getBoundingClientRect().top > zeilen[0].getBoundingClientRect().top,
formularZu: document.getElementById("antwort-form")?.hidden === true,
/* Der Knopf zum Schliessen darf bei einem geschlossenen
Fall nicht dastehen -- sonst sieht es aus, als ginge
noch etwas. */
schliessenWeg: document.getElementById("fall-schliessen")?.hidden === true,
hoehe: Math.round(r.height),
};
});
ok(z !== null, `${name}: der Abschluss-Kasten steht da`);
ok(z?.anzahl === 3, ` drei Sätze statt einem (${z?.anzahl})`);
ok(z?.untereinander === true, " und sie stehen untereinander, nicht nebeneinander");
ok(/abgeschlossen/i.test(z?.text || ""), " er sagt, dass der Fall zu ist");
ok(/nicht wieder aufmachen|niemand mehr schreiben/i.test(z?.text || ""),
" und dass das endgültig ist");
ok(/gelöscht|geloescht/i.test(z?.text || ""),
" er sagt, dass der Fall gelöscht wird");
ok(/\d{1,2}\.\d{1,2}\.\d{4}/.test(z?.text || ""),
` und WANN, mit Datum („${(z?.text || "").slice(0, 90)}…“)`);
ok(z?.formularZu === true, " das Antwortfeld ist weg");
ok(z?.schliessenWeg === true, " und der Schließen-Knopf auch");
if (name === "Mara") {
ok(/melde es einfach neu/i.test(z?.text || ""),
" und sie erfährt, was bei einer Wiederholung gilt");
} else {
ok(/neuer Fall/i.test(z?.text || ""),
" und die Leitung erfährt, dass eine Wiederholung ein neuer Fall wird");
}
ok(kaputt.length === 0,
` kein Skriptfehler${kaputt.length ? ": " + kaputt.join(" | ") : ""}`);
if (process.env.ZEIG_TEXT === "ja") melde(` [Text] ${z?.text}`);
/* DER DIALOG BEIM SCHLIESSEN muss die Frist auch nennen --
dort faellt die Entscheidung. Danach ist es zu spaet. */
if (name === "DogFather") {
await seite.goto(`http://127.0.0.1:${PORT}/workspace/hilfe.html`,
{ waitUntil: "networkidle" });
await seite.waitForTimeout(800);
const offeneZeile = await seite.$(".fall-zeile");
if (offeneZeile) {
await offeneZeile.click();
await seite.waitForTimeout(600);
const knopf = await seite.$("#fall-schliessen:not([hidden])");
if (knopf) {
await knopf.click();
await seite.waitForSelector("dialog.nachfrage[open]", { timeout: 3000 });
const dlg = await seite.evaluate(() => {
const d = document.querySelector("dialog.nachfrage[open]");
const t = (n) => d.querySelector(`[data-teil="${n}"]`)?.textContent || "";
return { was: t("was"), bleibt: t("bleibt") };
});
ok(/90 Tage/.test(dlg.bleibt),
` der Abschluss-Dialog nennt die Frist („${dlg.bleibt.slice(0, 80)}…“)`);
ok(/gelöscht/.test(dlg.bleibt),
" und sagt, dass danach gelöscht wird");
await seite.keyboard.press("Escape");
} else { ok(false, " ein offener Fall zum Schließen war da"); }
} else { ok(false, " es gab eine Fallzeile zum Anklicken"); }
}
await ctx.close();
}
} finally {
await browser.close();
}
}
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }
process.exit(fehler ? 1 : 0);