Files
dogfather-universe/server/pruef-chat-anhaenge.mjs
DogFatherGitandClaude Opus 5 3d859cbed5 Eine untaugliche Zweitfassung erkennt der Server selbst
NACHGEMESSEN AN DEN SECHS, DIE HEUTE NACHT LIVE ENTSTANDEN SIND --
vier tragen das Loch am Anfang, weil sie gebaut wurden, bevor es
bekannt war:

    387,6 s Zeitachse fuer wenige Sekunden Ton
    164,2 s
    134,1 s
     63,8 s
      9,7 s  (in Ordnung)
      3,3 s  (in Ordnung)

Sie gehoeren dem Dienstbenutzer; von aussen sind sie nicht
wegzuraeumen. Und mit „gibt es schon, dann weiter" waeren sie fuer
immer so geblieben.

EIN SCHALTER „alles neu ab Fassung 2" WAERE DIE BEQUEME LOESUNG und
die falsche: eine Zahl, die jemand hochzaehlen muss, wird beim
naechsten Mal vergessen. Gefragt wird deshalb die DATEI SELBST --
nach dem, was schiefgehen kann: Passt ihre Zeitachse zu der
Tonmenge, die sie traegt? AAC packt 1024 Abtastwerte in einen
Rahmen; Rahmenzahl mal 1024 durch die Abtastrate ist die echte
Laenge. Mehr als anderthalb Sekunden darueber heisst: Loch.

Was nicht passt, wird beim naechsten Nachruestlauf weggeraeumt und
neu gebaut -- einmal, denn danach passt es.

ZWEI FEHLER IN DIESER FUNKTION, BEIDE VON DER GEGENPROBE GEFUNDEN

Und beide waeren still geblieben:

 1. ffprobe GIBT DIE WERTE IN SEINER REIHENFOLGE AUS, nicht in
    meiner: erst `sample_rate`, dann `nb_frames`. Ich hatte
    `[rahmen, rate, dauer]` destrukturiert und damit 48000 Rahmen
    bei 95 Hz gerechnet -- eine „echte Laenge" von einer halben
    Million Sekunden. Die Funktion haette JEDE Datei fuer tauglich
    erklaert, immer. Gelesen wird jetzt mit Namen.

 2. DER DATEINAME FEHLTE IM AUFRUF. ffprobe antwortete „You have to
    specify one input file", der `catch` machte daraus ein
    freundliches „taugt" -- und wieder sagte die Funktion zu allem
    ja.

Das ist zweimal dieselbe Sorte: gruen und wertlos. Gefunden hat es
nicht das Lesen, sondern die Frage „kann sie ueberhaupt NEIN
sagen?". Genau dafuer gibt es die Gegenprobe.

WIE MAN EINE DATEI MIT LOCH NACHSTELLT -- UND WIE NICHT

`-itsoffset 40` war der naheliegende Weg und ergab 2,5 s: Der
MP4-Baukasten rechnet einen reinen Anfangsversatz wieder heraus.
Dasselbe mit `-copyts`, `-output_ts_offset` und `-muxdelay`. Was
bleibt, ist eine Zeitachse, die laenger ist als ihr Ton -- und das
stellt eine stumme Bildspur von 40 Sekunden her. Der Weg dorthin
ist ein anderer als bei `MediaRecorder`; die ZAHLEN, die die
Funktion liest, sind dieselben.

GEPRUEFT -- pruef-chat-anhaenge 164 -> 168 ok

  eine Fassung mit zu langer Zeitachse nachgestellt (40,0 s)
  und sie wird als untauglich erkannt
  der Nachruestlauf ersetzt sie (40,0 s → 2,5 s)
  und die neue gilt als tauglich — er wandelt sie nicht ewig weiter

Die letzte Zeile ist die zweite Gegenprobe: Ein Lauf, der bei jedem
Durchgang alles neu wandelt, waere eine Warnung, die immer kommt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-03 02:04:23 +02:00

1809 lines
91 KiB
JavaScript
Raw Permalink 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.
/* CHAT: ANHÄNGE UND WEGRÄUMEN (09.09.2026)
Filipe: "man muss die chats auch gelöscht bekommen!!!
am besten wäre es auch wenn man da auch im chat pdfs schicken
könnte. pdfs und fotos."
Zwei Dinge, eine Prüfung, weil sie sich an einer Stelle berühren:
Wer ein Gespräch wegräumt, darf auch an dessen Anhänge nicht mehr
herankommen. Getrennt geprüft würde genau diese Naht niemandem
auffallen.
---------------------------------------------------------------------
DIE WICHTIGSTEN PRÜFUNGEN STEHEN IN ABSCHNITT 3
Ein Upload ist die gefährlichste Stelle einer Website. Der Dateiname
kommt vom Absender, der Content-Type kommt vom Absender -- wer sich
darauf verlässt, liefert irgendwann eine HTML-Datei aus, die im
Browser des Empfängers als Seite DIESER Domain läuft, mit Zugriff
auf seine Sitzung. Deshalb wird hier ausdrücklich versucht, genau
das zu tun: HTML als bild.png, SVG als foto.png, eine Datei ohne
erkennbaren Inhalt. Alle drei MÜSSEN abgewiesen werden.
Und danach: Was ausgeliefert wird, muss den Typ tragen, der beim
Hochladen ERKANNT wurde -- nicht den, der mitgeschickt wurde.
DREI AUSGÄNGE: 0 in Ordnung · 1 Befunde · 2 konnte nicht arbeiten. */
import { mkdtempSync, rmSync, appendFileSync, existsSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const ordner = mkdtempSync(join(tmpdir(), "ws-chatanhang-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
import { notbremse } from "./helfer-notbremse.mjs";
import { bestaetige } from "./helfer-nachfrage.mjs";
const { eigenerPort } = await import("./helfer-port.mjs");
const PORT = await eigenerPort(import.meta, "die Chat-Anhangpruefung");
/* ---- DIE BLENDE ABSCHALTEN, NICHT AUF SIE WARTEN (01.10.2026) ----
Die Begleiter und die Schriftzuege der Bildschau blenden sich mit
`transition: opacity .32s` ein. Die Behauptungen weiter unten
filtern auf „Deckung ueber 0,5" -- und meldeten unter Last „0
Figuren" an einer Seite, die in Ordnung ist.
GEMESSEN, STATT GERATEN: lage „hoch", Figuren da, Bilder geladen,
Deckung exakt „0". Nicht 0,3 oder 0,7 -- die Ueberblendung hatte
nicht ANGEFANGEN. Mit vier parallelen Browsern wird
`requestAnimationFrame` gedrosselt, und eine Blende, die auf
Bildern laeuft, steht still.
Davor standen feste Wartezeiten (600 und 700 ms, „reichlich ueber
den 320"). Mein erster Versuch, „warten bis sich nichts mehr
aendert", war genauso falsch: Stillstand heisst auch „noch nicht
angefangen", und er kam sofort zurueck.
Auf eine Bewegung zu warten, die gar nicht laeuft, geht nicht.
Also wird sie abgeschaltet -- das Haus hat die Regel schon:
@media (prefers-reduced-motion: reduce) {
.bildschau__figur, .bildschau__wort { transition: none; }
}
Der Endwert steht damit sofort da, die Messung haengt nicht mehr
am Rechner, und dieser Weg wird nebenbei zum ersten Mal wirklich
begangen. Dieselbe Einstellung benutzt diese Datei weiter unten
ohnehin.
SIE BLEIBT DANACH AN: Nach diesem Abschnitt behauptet nichts mehr
etwas ueber Bewegung -- nachgesehen, nicht angenommen. */
async function blendeSteht(seite) {
await seite.emulateMedia({ reducedMotion: "reduce" });
await seite.waitForFunction(
() => !!document.querySelector("dialog.bildschau[open]"),
null, { timeout: 8000 },
).catch(() => { /* die Behauptung darunter sagt dann, was da war */ });
/* Ein Bild Zeit, damit die neue Regel auch angewandt ist. `raf` ist
unter Last gedrosselt, deshalb zusaetzlich eine kurze Frist --
hier geht es nur noch um das Anwenden einer Regel, nicht um eine
Bewegung. */
await seite.evaluate(() => new Promise((r) => requestAnimationFrame(() => r())));
await seite.waitForTimeout(120);
}
process.env.PORT = `${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 }); };
await import("./index.js");
notbremse(600_000, "pruef-chat-anhaenge");
await new Promise((r) => setTimeout(r, 1000));
const BASIS = `http://127.0.0.1:${PORT}`;
const ANHANG_ORDNER = join(ordner, "chat-anhaenge");
/* Der Datenordner dieses Laufs. Gebraucht wird er, um nachzusehen, WO
eine Datei liegt: in der GIF-Kiste oder bei den Anhaengen. Genau
dieser Unterschied ist der Kern des GIF-Abschnitts -- ein
verschicktes GIF ist eine KOPIE, kein Verweis. */
const DATEN_ORDNER_P = ordner;
const PROTOKOLL = join(process.cwd(), "pruef-chat-anhaenge-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, geprueft = 0;
/* DER DRITTE AUSGANG BRAUCHT EINEN ZAEHLER (03.10.2026). „Konnte
nicht nachsehen" ist weder in Ordnung noch kaputt -- ohne eigene
Zahl verschwaende es zwischen den gruenen Zeilen, und am Ende
stuende „alles in Ordnung" ueber etwas, das niemand gemessen hat. */
let nichtNachsehbar = 0;
const ok = (b, t) => { geprueft++; 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 jetztT = 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, jetztT);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = 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, jetztT);
d.close();
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
/* EIN ECHTES MIKROFON, NUR ERFUNDEN (23.09.2026).
Fuer Sprachnachrichten braucht diese Pruefung eine Tonquelle.
Chromium bringt eine mit: `--use-fake-device-for-media-stream`
liefert einen gleichmaessigen Ton, `--use-fake-ui-for-media-stream`
beantwortet die Nachfrage nach der Erlaubnis mit Ja.
WARUM NICHT EINE SELBST GEBAUTE BYTEFOLGE: Aus demselben Grund, der
zwanzig Zeilen weiter unten beim PNG steht. Eine gebastelte
WebM-Kopfzeile wuerde beweisen, dass meine Erkennung meine Bastelei
versteht. Was hier hochgeht, hat dagegen MediaRecorder erzeugt --
dasselbe Stueck Programm, das auf Filipes Handy laeuft. */
const browser = await pw.chromium.launch({
args: ["--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream"],
});
async function alsRolle(rolle, code, breite = 1280) {
const ctx = await browser.newContext({ viewport: { width: breite, height: 900 }, hasTouch: breite <= 860 });
const seite = await ctx.newPage();
const konsole = [];
const rauschen = [];
/* WARUM ZWEI TÖPFE.
Diese Prüfung provoziert absichtlich Absagen: fünf abgewiesene
Uploads, ein 413, drei 404 auf gesperrte Anhänge. Jede davon
schreibt der Browser als "Failed to load resource" in die Konsole
-- und jede davon ist der BEWEIS, dass die Abwehr gegriffen hat.
Erster Anlauf war, sie nach Statusnummer herauszufiltern (415, 413,
404). Das ist bequem und falsch: Ein fehlendes Bild in der
Oberfläche wäre ab da dauerhaft unsichtbar, weil es dieselbe Nummer
trägt. Gefiltert wird deshalb nach ZEITPUNKT -- was während eines
absichtlichen Versuchs entsteht, kommt in den zweiten Topf. Alles
ausserhalb bleibt ein Befund, egal welche Nummer es hat. */
const zustand = { stumm: false };
seite.on("pageerror", (f) => konsole.push("Absturz auf " + seite.url() + ": " + f.message
+ " || " + String(f.stack || "").split(/\r?\n/).slice(1, 3).join(" / ")));
seite.on("console", (m) => {
if (m.type() !== "error") return;
(zustand.stumm ? rauschen : konsole).push("Konsole: " + m.text());
});
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/chat.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
return { ctx, seite, konsole, rauschen, zustand };
}
/** Alles, was hier drin an Konsolenfehlern entsteht, ist gewollt.
* Der kurze Nachlauf ist nötig, weil Konsolenmeldungen nicht im selben
* Atemzug ankommen wie die Antwort. */
async function absichtlich(leute, tun) {
for (const l of leute) l.zustand.stumm = true;
try { return await tun(); } finally {
await new Promise((r) => setTimeout(r, 400));
for (const l of leute) l.zustand.stumm = false;
}
}
const luna = await alsRolle("creator", "CODE-CREA-0001");
const pat = await alsRolle("scout", "CODE-SCOU-0001");
const dogi = await alsRolle("admin", "CODE-DOGI-0001");
async function direkt(s, mit) {
return s.evaluate(async (m) => {
const a = await fetch("/workspace/api/chat/direkt", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mit: m }) });
return (await a.json()).raum_id;
}, mit);
}
const raumId = await direkt(luna.seite, idPat); /* Luna <-> Patrick */
const raumDogi = await direkt(luna.seite, idDogi); /* Luna <-> DogFather */
async function schicke(s, raum, text) {
return s.evaluate(async ([r, t]) => {
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: t }) });
return a.ok;
}, [raum, text]);
}
async function oeffne(s, id) {
await s.goto(`${BASIS}/workspace/chat.html?raum=${id}`, { waitUntil: "networkidle" });
await s.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
await s.waitForTimeout(500);
}
/* Ein ECHTES PNG mit bekannten Maßen -- gezeichnet im Browser, nicht
von Hand zusammengebaut. Eine selbst gebastelte Bytefolge würde
beweisen, dass meine Erkennung meine Bastelei versteht; ein echtes
PNG beweist, dass sie PNG versteht. */
async function hochladen(s, raum, { inhalt, typ, name, text = "" }) {
return s.evaluate(async ([r, i, t, nm, tx]) => {
let koerper;
if (i.art === "png" || i.art === "jpeg") {
const c = document.createElement("canvas");
c.width = i.breite; c.height = i.hoehe;
const g = c.getContext("2d");
g.fillStyle = "#2b6cb0"; g.fillRect(0, 0, c.width, c.height);
g.fillStyle = "#ffd166"; g.fillRect(4, 4, Math.max(2, c.width - 8), Math.max(2, c.height - 8));
koerper = await new Promise((res) => c.toBlob(res,
i.art === "png" ? "image/png" : "image/jpeg", 0.9));
} else if (i.art === "roh") {
koerper = new Blob([i.text], { type: t });
} else if (i.art === "gross") {
koerper = new Blob([new Uint8Array(i.bytes)], { type: t });
}
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": t, "X-Dateiname": encodeURIComponent(nm), "X-Text": encodeURIComponent(tx) },
body: koerper,
});
let leib = null;
try { leib = await a.json(); } catch { /* kein JSON */ }
return { status: a.status, leib };
}, [raum, inhalt, typ, name, text]);
}
/* Ein winziges, gültiges PDF -- fünf Zeichen Kopf reichen der
Erkennung, der Rest macht daraus eine Datei, die ein Betrachter
auch öffnen würde. */
const PDF_TEXT = "%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
+ "2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
+ "3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
+ "trailer<</Root 1 0 R>>\n%%EOF";
/* =======================================================================
1. EIN FOTO SCHICKEN
======================================================================= */
melde("\n=== 1. Ein Foto schicken ===");
let bildNachrichtId = 0;
{
await oeffne(luna.seite, raumId);
await oeffne(pat.seite, raumId);
const a = await hochladen(luna.seite, raumId, {
inhalt: { art: "png", breite: 240, hoehe: 150 },
typ: "image/png", name: "startcheck.png", text: "Schau mal, so sieht es aus",
});
ok(a.status === 201, `angenommen (HTTP ${a.status})`);
const n = a.leib?.nachricht;
bildNachrichtId = n?.id || 0;
ok(n?.anhang?.art === "bild" && n?.anhang?.typ === "image/png",
`erkannt als ${n?.anhang?.art} / ${n?.anhang?.typ}`);
ok(n?.anhang?.breite === 240 && n?.anhang?.hoehe === 150,
`die Maße wurden aus der Datei gelesen (${n?.anhang?.breite}×${n?.anhang?.hoehe})`);
ok(n?.anhang?.name === "startcheck.png", `der Name bleibt erhalten ("${n?.anhang?.name}")`);
ok(n?.text === "Schau mal, so sieht es aus", "der Begleitsatz kommt mit");
/* UND ER KOMMT BEIM ANDEREN AN, ohne dass der etwas tut. */
const beiPat = await pat.seite.waitForFunction(
() => document.querySelector(".chat-nachricht:last-of-type .chat-bild img"),
{ timeout: 8000 }).then(() => true).catch(() => false);
ok(beiPat, beiPat ? "Patrick sieht das Foto von selbst — kein Neuladen"
: "das Foto kam bei Patrick NICHT an");
/* Es wird auch wirklich GEZEIGT, nicht nur eingehaengt.
ERST WARTEN, DANN MESSEN. Der erste Anlauf mass sofort, nachdem das
<img> im Baum stand -- und las 0x0. Das war kein Befund am Chat,
sondern die Frage zu frueh gestellt: Zwischen "das Element ist da"
und "das Bild ist geladen" liegt eine Anfrage ans Netz. Eine
Pruefung, die diesen Unterschied nicht kennt, meldet Fehler, die es
nicht gibt -- und das ist teurer als gar nicht zu pruefen. */
const geladen = await pat.seite.waitForFunction(() => {
const i = document.querySelector(".chat-nachricht:last-of-type .chat-bild img");
return !!(i && i.complete && i.naturalWidth > 0);
}, { timeout: 8000 }).then(() => true).catch(() => false);
ok(geladen, geladen ? "und es laedt auch wirklich" : "das Bild kam nicht durch die Leitung");
const bild = await pat.seite.evaluate(() => {
const i = document.querySelector(".chat-nachricht:last-of-type .chat-bild img");
if (!i) return null;
const r = i.getBoundingClientRect();
return { geladen: i.complete && i.naturalWidth > 0, nw: i.naturalWidth, nh: i.naturalHeight,
w: Math.round(r.width), h: Math.round(r.height),
attrW: i.getAttribute("width"), attrH: i.getAttribute("height") };
});
ok(bild?.geladen && bild.nw === 240 && bild.nh === 150,
`in Originalgroesse angekommen (${bild?.nw}×${bild?.nh} Punkte)`);
ok(bild?.w > 40 && bild?.h > 20, `und ist sichtbar groß (${bild?.w}×${bild?.h} px)`);
/* Die Maße stehen am Bild, damit der Verlauf beim Laden nicht
springt -- das ist der Zweck, nicht Zierrat. */
ok(bild?.attrW === "240" && bild?.attrH === "150",
`Breite und Höhe stehen am Bild (${bild?.attrW}×${bild?.attrH}) — der Verlauf springt nicht`);
/* ---- ÜBER DEN ECHTEN KNOPF, NICHT ÜBER DIE SCHNITTSTELLE ---------
================================================================
WARUM DAS HIER STEHT: EIN STILLER FEHLER, EINE LÜCKE IN DIESER
PRÜFUNG (23.09.2026)
================================================================
Alles oben schickt mit `fetch` an die Route -- der bequeme Weg,
und er prüft den Server gründlich. Er prüft aber NICHT den Weg,
den ein Mensch geht: Büroklammer, Datei wählen, warten.
In genau diesem Weg stand seit dem 19.09. `const { nachricht } =
e.daten;` -- ein `e`, das es dort nicht gibt. Die Zeile warf, der
`catch` daneben fing, und der Absender las: „Keine Verbindung --
der Anhang wurde nicht geschickt." Das Foto war aber da. Es kam
Sekunden später über den Ereignisstrom, mit einer roten Meldung
darüber, die das Gegenteil behauptete, und der getippte
Begleitsatz blieb im Feld stehen -- also schickt man es noch
einmal.
Diese Prüfung war die ganze Zeit grün. Sie hat den Server
geprüft, nicht die Bedienung. Deshalb geht dieser Block jetzt
durch das Dateifeld selbst und sieht auf das, was der Mensch
danach sieht: steht dort eine Fehlermeldung, und ist das Feld
leer? */
await luna.seite.fill("#text", "über den Knopf geschickt");
await luna.seite.setInputFiles("#anhang-feld", {
name: "ueber-den-knopf.png",
mimeType: "image/png",
/* Das kleinste gültige PNG: 1×1, ein Pixel. Es geht hier nicht um
das Bild, sondern um den Weg. */
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64"),
});
await luna.seite.waitForTimeout(2500);
const danach = await luna.seite.evaluate(() => ({
fehlertext: document.getElementById("fehler")?.textContent.trim() || "",
feld: document.getElementById("text")?.value || "",
letzterName: document.querySelector(".chat-nachricht:last-of-type .chat-bild")
?.getAttribute("title") || "",
}));
ok(danach.letzterName.includes("ueber-den-knopf.png"),
`über die Büroklammer geschickt: das Foto steht im Verlauf (${danach.letzterName.slice(0, 40)})`);
/* DIE EIGENTLICHE AUSSAGE. Eine Fehlermeldung über einem Foto, das
angekommen ist, ist schlimmer als gar keine Rückmeldung. */
ok(danach.fehlertext === "",
` und darüber steht KEINE Fehlermeldung ("${danach.fehlertext}")`);
/* UND DAS FELD IST LEER. Bleibt der Begleitsatz stehen, schickt man
dasselbe Foto ein zweites Mal -- der Satz stand vorher drin. */
ok(danach.feld === "",
` das Schreibfeld ist leer, der Begleitsatz ging mit ("${danach.feld}")`);
}
/* =======================================================================
1b. DER VERLAUF STEHT TROTZDEM UNTEN
Ein Bild wird SPÄTER fertig als der Rest der Seite. Wer beim Öffnen
ans Ende springt und danach ein Foto nachlädt, steht plötzlich
mittendrin -- und die letzten Nachrichten stehen unter dem
Bildschirmrand, ohne dass jemand etwas angeklickt hätte.
Genau das war auf dem ersten Bildschirmfoto zu sehen. Deshalb steht
die Messung jetzt hier und nicht im Auge.
======================================================================= */
melde("\n=== 1b. Der Verlauf steht unten ===");
{
/* Ein GROSSES Bild -- eines, das an den Deckel von 340 px stösst.
Ein kleines würde die Frage gar nicht stellen. */
await hochladen(luna.seite, raumId, {
inhalt: { art: "png", breite: 900, hoehe: 600 }, typ: "image/png", name: "gross.png",
});
for (let i = 1; i <= 4; i++) await schicke(pat.seite, raumId, `Danach kommt noch Zeile ${i}.`);
/* IN EINEM FRISCHEN BROWSER, und das ist der Kern dieser Prüfung.
Der erste Anlauf mass in Lunas offenem Fenster -- dort lag das Bild
längst im Zwischenspeicher, war also sofort da, und die Messung
ergab tadellose 0 px. Der Fehler zeigte sich erst beim ERSTEN
Laden. Eine Prüfung, die nur den warmen Fall kennt, prüft genau den
Fall nicht, um den es geht. */
const frisch = await alsRolle("creator", "CODE-CREA-0001");
await frisch.seite.goto(`${BASIS}/workspace/chat.html?raum=${raumId}`, { waitUntil: "networkidle" });
await frisch.seite.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
await frisch.seite.waitForFunction(() => {
const b = [...document.querySelectorAll(".chat-bild img")];
return b.length > 0 && b.every((i) => i.complete && i.naturalWidth > 0);
}, { timeout: 10000 }).then(() => true).catch(() => false);
await frisch.seite.waitForTimeout(700);
const lage = await frisch.seite.evaluate(() => {
const v = document.getElementById("verlauf");
const gross = [...document.querySelectorAll(".chat-bild")]
.map((k) => Math.round(k.getBoundingClientRect().height));
return { rest: Math.round(v.scrollHeight - v.scrollTop - v.clientHeight),
knopf: !document.getElementById("zum-neuesten").hidden, gross };
});
ok(lage.rest < 40,
`beim ERSTEN Laden steht der Verlauf am Ende (${lage.rest} px Rest, Bildhöhen ${lage.gross.join("/")})`);
ok(!lage.knopf, "und der Knopf „Zum neuesten“ erscheint nicht ohne Anlass");
ok(lage.gross.every((h) => h <= 342),
`kein Bild sprengt den Deckel von 340 px (${lage.gross.join("/")})`);
await frisch.ctx.close();
}
/* =======================================================================
2. EIN PDF SCHICKEN
======================================================================= */
melde("\n=== 2. Ein PDF schicken ===");
let pdfNachrichtId = 0;
{
const a = await hochladen(luna.seite, raumId, {
inhalt: { art: "roh", text: PDF_TEXT },
typ: "application/pdf", name: "Vertrag Größe.pdf",
});
ok(a.status === 201, `angenommen (HTTP ${a.status})`);
pdfNachrichtId = a.leib?.nachricht?.id || 0;
ok(a.leib?.nachricht?.anhang?.art === "pdf", `erkannt als ${a.leib?.nachricht?.anhang?.art}`);
ok(a.leib?.nachricht?.anhang?.name === "Vertrag Größe.pdf",
`Umlaute überleben die Kopfzeile ("${a.leib?.nachricht?.anhang?.name}")`);
await oeffne(luna.seite, raumId);
const karte = await luna.seite.evaluate(() => {
const k = document.querySelector(".chat-nachricht:last-of-type .chat-datei");
if (!k) return null;
return { name: k.querySelector(".chat-datei__name")?.textContent,
mass: k.querySelector(".chat-datei__mass")?.textContent,
ziel: k.target, rel: k.rel, breit: Math.round(k.getBoundingClientRect().width) };
});
ok(karte?.name === "Vertrag Größe.pdf", `die Karte zeigt den Namen ("${karte?.name}")`);
ok(/\d/.test(karte?.mass || ""), `und die Größe ("${karte?.mass}")`);
ok(karte?.ziel === "_blank" && /noopener/.test(karte?.rel || ""),
"sie öffnet in einem neuen Tab, mit noopener");
/* KEIN Bild-Kasten für ein PDF -- eine graue Fläche, die so tut, als
könnte man etwas lesen, wäre schlechter als eine ehrliche Karte. */
const kaeme = await luna.seite.$$eval(".chat-nachricht:last-of-type .chat-bild", (b) => b.length);
ok(kaeme === 0, `für ein PDF wird kein Bildkasten gebaut (${kaeme})`);
}
/* =======================================================================
2c. DIE BILDSCHAU (25.09.2026)
=======================================================================
Filipe: „wenn man bilder aufmacht, will ich dass die in der mitte
sind. beim hochformat soll links der husky sein und rechts der hase
und beim querformat soll oben dogfather und unten team dogi stehen
da wo platz ist."
VORHER OEFFNETE DER KLICK DIE DATEI in einem neuen Tab -- und was man
sah, war die eingebaute Bildanzeige des Browsers: schwarzer Grund,
Bild oben links. Das ist kein Fehler des Browsers; eine Datei hat
keine Gestaltung.
GEMESSEN WIRD, WAS ER GESAGT HAT, und jedes einzeln:
1. Der Klick oeffnet die Schau (und nicht mehr den Tab).
2. Das Bild steht in der MITTE -- gemessen als Abstand seines
Mittelpunkts zur Fenstermitte, nicht als „es ist da".
3. Bei einem breiten Bild stehen die zwei Schriftzuege da.
4. Escape bringt einen zurueck.
5. GEGENPROBE: Mit Strg gedrueckt oeffnet sie NICHT -- der Weg in
den neuen Tab bleibt, wie er war. Ohne diese Zeile haette
„die Schau geht auf" auch dann gestimmt, wenn sie IMMER aufgeht
und der alte Weg verloren ist.
======================================================================= */
{
await oeffne(luna.seite, raumId);
/* Das Foto aus Abschnitt 2 ist 240x150 -- also quer. */
const bildKasten = ".chat-nachricht .chat-bild";
await luna.seite.waitForSelector(bildKasten, { timeout: 8000 });
/* GEGENPROBE ZUERST: mit Strg bleibt der alte Weg. */
await luna.seite.click(bildKasten, { modifiers: ["Control"] });
await luna.seite.waitForTimeout(400);
const mitStrg = await luna.seite.evaluate(() =>
!!document.querySelector("dialog.bildschau[open]"));
ok(mitStrg === false,
`Gegenprobe: Strg+Klick öffnet die Schau NICHT (${mitStrg}) — der Weg in den Tab bleibt`);
await luna.seite.click(bildKasten);
await blendeSteht(luna.seite);
const schau = await luna.seite.evaluate(() => {
const d = document.querySelector("dialog.bildschau[open]");
if (!d) return { offen: false };
const b = d.querySelector(".bildschau__bild");
const r = b.getBoundingClientRect();
const worte = [...d.querySelectorAll(".bildschau__wort")]
.filter((e) => Number(getComputedStyle(e).opacity) > .5)
.map((e) => e.textContent.trim());
return {
offen: true,
lage: d.dataset.lage,
/* Wie weit ist die Bildmitte von der Fenstermitte weg? Bei
„mittig" sind das ein paar Pixel, bei „oben links" Hunderte. */
abX: Math.round(Math.abs((r.left + r.width / 2) - window.innerWidth / 2)),
abY: Math.round(Math.abs((r.top + r.height / 2) - window.innerHeight / 2)),
worte,
figuren: [...d.querySelectorAll(".bildschau__figur")]
.filter((e) => Number(getComputedStyle(e).opacity) > .5).length,
grund: getComputedStyle(d.querySelector(".bildschau__grund")).backgroundImage.length,
};
});
ok(schau.offen === true, `ein Klick öffnet die Bildschau (${schau.offen})`);
/* 40 px Spielraum: Die Buehne hat oben mehr Polsterung als unten
(dort steht die Leiste), der Mittelpunkt liegt also nie exakt auf
dem Pixel. Bei „oben links in der Ecke" waeren es Hunderte. */
ok(schau.abX <= 40 && schau.abY <= 60,
`und das Bild steht in der Mitte (${schau.abX} px seitlich, ${schau.abY} px hoch daneben)`);
ok(schau.lage === "quer",
`ein breites Bild bekommt die Querformat-Fassung ("${schau.lage}")`);
ok(schau.worte.length === 2
&& /DogFather/i.test(schau.worte[0]) && /Team Dogi/i.test(schau.worte[1]),
`mit „DogFather" oben und „Team Dogi" unten (${JSON.stringify(schau.worte)})`);
ok(schau.figuren === 0,
`und OHNE die zwei Figuren — die gehören ins Hochformat (${schau.figuren})`);
ok(schau.grund > 80,
`der Grund ist gestaltet und nicht schwarz (${schau.grund} Zeichen Verlauf)`);
/* EIN BILD FUER DEN MENSCHEN. Andere Pruefungen in diesem Haus legen
ebenso eines ab (pruef-handy.png, pruef-crew-wand-handy.png) --
eine Zahl sagt „mittig", ein Bild sagt, ob es gut aussieht. */
await luna.seite.screenshot({ path: "server/pruef-bildschau.png" });
await luna.seite.keyboard.press("Escape");
await luna.seite.waitForTimeout(400);
const zu = await luna.seite.evaluate(() =>
!!document.querySelector("dialog.bildschau[open]"));
ok(zu === false, `Escape bringt einen zurück (${zu})`);
/* ---- UND DAS HOCHFORMAT -----------------------------------------
Der andere Fall aus Filipes Satz -- „beim hochformat soll links
der husky sein und rechts der hase". Ohne ein zweites, HOHES Bild
waere dieser halbe Auftrag nie gemessen worden; das quere Bild
oben kann ihn nicht beantworten. */
const hoch = await hochladen(luna.seite, raumId, {
inhalt: { art: "png", breite: 300, hoehe: 800 },
typ: "image/png", name: "hochformat.png", text: "Und hochkant",
});
ok(hoch.status === 201, `ein hohes Foto kommt an (HTTP ${hoch.status})`);
await oeffne(luna.seite, raumId);
await luna.seite.waitForSelector(".chat-nachricht:last-of-type .chat-bild", { timeout: 8000 });
await luna.seite.click(".chat-nachricht:last-of-type .chat-bild");
await blendeSteht(luna.seite);
const hochSchau = await luna.seite.evaluate(() => {
const d = document.querySelector("dialog.bildschau[open]");
if (!d) return { offen: false };
const b = d.querySelector(".bildschau__bild");
const r = b.getBoundingClientRect();
const sicht = (s) => [...d.querySelectorAll(s)]
.filter((e) => Number(getComputedStyle(e).opacity) > .5);
const figuren = sicht(".bildschau__figur");
return {
offen: true, lage: d.dataset.lage,
abX: Math.round(Math.abs((r.left + r.width / 2) - window.innerWidth / 2)),
figuren: figuren.length,
/* LIEGT EINE DAVON UEBER DEM BILD? Das waere der Schaden, den
man erst sieht, wenn ein Gesicht halb verdeckt ist. */
drueber: figuren.filter((e) => {
const f = e.getBoundingClientRect();
return Math.min(f.right, r.right) - Math.max(f.left, r.left) > 4;
}).length,
worte: sicht(".bildschau__wort").length,
};
});
ok(hochSchau.lage === "hoch", `ein hohes Bild bekommt die Hochformat-Fassung ("${hochSchau.lage}")`);
ok(hochSchau.figuren === 2, `Husky und Hase stehen daneben (${hochSchau.figuren})`);
ok(hochSchau.drueber === 0,
`und KEINE davon liegt über dem Foto (${hochSchau.drueber})`);
ok(hochSchau.worte === 0,
`die Schriftzüge bleiben weg — die gehören ins Querformat (${hochSchau.worte})`);
ok(hochSchau.abX <= 40, `auch hier steht das Bild mittig (${hochSchau.abX} px daneben)`);
await luna.seite.screenshot({ path: "server/pruef-bildschau-hoch.png" });
await luna.seite.keyboard.press("Escape");
await luna.seite.waitForTimeout(300);
}
/* =======================================================================
3. WAS NICHT HEREINDARF — die eigentliche Prüfung
Alles hier MUSS scheitern. Ein "ok" in diesem Abschnitt heisst: der
Angriff wurde abgewiesen.
======================================================================= */
melde("\n=== 3. Was nicht hereindarf ===");
{
const versuche = [
["HTML, das sich png nennt",
{ art: "roh", text: '<html><body><script>alert(document.cookie)</script></body></html>' },
"image/png", "harmlos.png"],
["SVG, das sich png nennt",
{ art: "roh", text: '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect/></svg>' },
"image/png", "logo.png"],
["SVG als svg",
{ art: "roh", text: '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>' },
"image/svg+xml", "logo.svg"],
["eine Textdatei",
{ art: "roh", text: "nur Text, kein Bild, kein PDF" }, "text/plain", "notiz.txt"],
["ein PNG-Kopf ohne Inhalt",
{ art: "roh", text: "\x89PNG\r\n\x1a\n" + "x".repeat(40) }, "image/png", "fast.png"],
];
await absichtlich([luna], async () => {
for (const [was, inhalt, typ, name] of versuche) {
const a = await hochladen(luna.seite, raumId, { inhalt, typ, name });
ok(a.status === 415,
`${was}: abgewiesen (HTTP ${a.status}${a.status === 415 ? "" : " — KAM DURCH"})`);
}
});
/* GEGENPROBE ZUR GEGENPROBE: Ein echtes JPEG geht durch. Ohne das
bewiese der Block oben nur, dass gar nichts angenommen wird. */
const echt = await hochladen(luna.seite, raumId, {
inhalt: { art: "jpeg", breite: 120, hoehe: 90 }, typ: "image/jpeg", name: "foto.jpg",
});
ok(echt.status === 201 && echt.leib?.nachricht?.anhang?.typ === "image/jpeg",
`ein echtes JPEG geht durch (HTTP ${echt.status}, ${echt.leib?.nachricht?.anhang?.typ})`);
ok(echt.leib?.nachricht?.anhang?.breite === 120 && echt.leib?.nachricht?.anhang?.hoehe === 90,
`auch beim JPEG stimmen die Maße (${echt.leib?.nachricht?.anhang?.breite}×${echt.leib?.nachricht?.anhang?.hoehe})`);
/* Zu gross: Das muss eine LESBARE Absage sein, kein 500er. */
const zuGross = await absichtlich([luna], () => hochladen(luna.seite, raumId, {
inhalt: { art: "gross", bytes: 13 * 1024 * 1024 }, typ: "image/png", name: "riesig.png",
}));
ok(zuGross.status === 413, `zu groß wird mit 413 abgelehnt, nicht mit 500 (${zuGross.status})`);
ok(/MB/.test(zuGross.leib?.fehler || ""),
`und die Meldung sagt, was gilt: "${zuGross.leib?.fehler}"`);
}
/* =======================================================================
4. WIE DER ANHANG AUSGELIEFERT WIRD
======================================================================= */
melde("\n=== 4. Ausliefern ===");
{
const kopf = await luna.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
return { status: a.status,
typ: a.headers.get("content-type"),
lage: a.headers.get("content-disposition"),
sniff: a.headers.get("x-content-type-options"),
csp: a.headers.get("content-security-policy") };
}, bildNachrichtId);
ok(kopf.status === 200, `das Foto kommt (HTTP ${kopf.status})`);
ok(kopf.typ === "image/png", `mit dem ERKANNTEN Typ (${kopf.typ})`);
ok(/^inline/.test(kopf.lage || ""), `zum Ansehen, nicht als Download (${kopf.lage})`);
ok(kopf.sniff === "nosniff", "der Browser darf sich keinen anderen Typ ausdenken");
ok(/sandbox/.test(kopf.csp || ""), `und die Richtlinie nimmt jede Ausführung (${kopf.csp})`);
const alsDownload = await luna.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/anhang/${id}?laden=1`, { cache: "no-store" });
return a.headers.get("content-disposition");
}, bildNachrichtId);
ok(/^attachment/.test(alsDownload || ""), `mit ?laden=1 wird es ein Download (${alsDownload})`);
/* ===================================================================
TEILANFRAGEN (02.10.2026)
WARUM DAS HIER STEHT: Miss, eine Modi, konnte keine
Sprachnachrichten hoeren. An ihrer Rolle lag es nicht -- der Weg
hier kennt gar keine. Sie ist die Einzige im Haus mit Safari
(iPhone, iOS 18.7), und Safari verlangt fuer <audio> und <video>
zwingend Teilanfragen. Der Weg antwortete auf `Range: bytes=0-1`
mit einer vollen 200, ohne `Accept-Ranges`, ohne
`Content-Length`, als `chunked`. Chrome und Firefox nehmen das
klaglos -- deshalb fiel es sieben Tage lang nur ihr auf.
GEPRUEFT WIRD NICHT NUR DER STATUS, SONDERN DER INHALT: Eine 206
mit den falschen Bytes waere schlimmer als gar keine. Jeder
Abschnitt wird deshalb gegen die volle Datei gehalten.
=================================================================== */
const holen = (id, kopfzeilen) => luna.seite.evaluate(async ([i, h]) => {
const a = await fetch(`/workspace/api/chat/anhang/${i}`, { cache: "no-store", headers: h });
const b = new Uint8Array(await a.arrayBuffer());
return {
status: a.status,
bereiche: a.headers.get("accept-ranges"),
laenge: a.headers.get("content-length"),
abschnitt: a.headers.get("content-range"),
bytes: [...b],
};
}, [id, kopfzeilen || {}]);
const ganz = await holen(bildNachrichtId);
const GROESSE = ganz.bytes.length;
ok(ganz.status === 200 && GROESSE > 100, `die ganze Datei kommt (${GROESSE} Bytes)`);
ok(ganz.bereiche === "bytes",
`der Server sagt, dass er Abschnitte kann: Accept-Ranges: ${ganz.bereiche}`);
ok(Number(ganz.laenge) === GROESSE,
`und wie lang sie ist (Content-Length ${ganz.laenge}) -- ohne das sucht Safari gar nicht erst`);
/** Stimmt eine Teilantwort? Status, beide Kopfzeilen UND die Bytes.
* Als eigene Funktion, damit die Gegenprobe weiter unten dieselbe
* Messlatte an eine erfundene 200-Antwort legen kann. */
const abschnittStimmt = (a, von, bis) =>
a.status === 206
&& a.abschnitt === `bytes ${von}-${bis}/${GROESSE}`
&& Number(a.laenge) === bis - von + 1
&& a.bytes.length === bis - von + 1
&& a.bytes.every((w, i) => w === ganz.bytes[von + i]);
const vorn = await holen(bildNachrichtId, { Range: "bytes=0-9" });
ok(abschnittStimmt(vorn, 0, 9),
`bytes=0-9 -> HTTP ${vorn.status}, ${vorn.abschnitt}, ${vorn.bytes.length} Bytes`
+ " -- und es sind die richtigen");
const hinten = await holen(bildNachrichtId, { Range: "bytes=-8" });
ok(abschnittStimmt(hinten, GROESSE - 8, GROESSE - 1),
`bytes=-8 holt die LETZTEN acht (${hinten.abschnitt})`);
const bisEnde = await holen(bildNachrichtId, { Range: "bytes=5-" });
ok(abschnittStimmt(bisEnde, 5, GROESSE - 1),
`bytes=5- geht bis zum Ende (${bisEnde.abschnitt})`);
/* ZU VIEL VERLANGEN IST ERLAUBT und endet am Dateiende -- so fragt
ein Abspielgeraet, das die Laenge noch nicht kennt. */
const zuViel = await holen(bildNachrichtId, { Range: `bytes=0-${GROESSE + 500}` });
ok(abschnittStimmt(zuViel, 0, GROESSE - 1),
`wer ueber das Ende hinaus fragt, bekommt bis zum Ende (${zuViel.abschnitt})`);
/* AUSSERHALB IST ETWAS ANDERES ALS ZU VIEL: Dafuer gibt es 416, und
die Groesse MUSS dabeistehen, sonst weiss niemand, was er
stattdessen fragen soll. */
const daneben = await absichtlich([luna],
() => holen(bildNachrichtId, { Range: `bytes=${GROESSE + 10}-` }));
ok(daneben.status === 416 && daneben.abschnitt === `bytes */${GROESSE}`,
`ein Bereich hinter dem Ende: HTTP ${daneben.status}, ${daneben.abschnitt}`);
/* MEHRERE BEREICHE AUF EINMAL werden absichtlich nicht bedient --
das darf ein Server. Die Antwort muss dann die GANZE Datei sein,
nie eine falsche Teilmenge. */
const mehrere = await holen(bildNachrichtId, { Range: "bytes=0-9,20-29" });
ok(mehrere.status === 200 && mehrere.bytes.length === GROESSE,
`mehrere Bereiche -> die ganze Datei (HTTP ${mehrere.status}, ${mehrere.bytes.length} Bytes)`);
const unsinn = await holen(bildNachrichtId, { Range: "haeppchen=0-9" });
ok(unsinn.status === 200 && unsinn.bytes.length === GROESSE,
`ein Kopf, den niemand versteht -> ebenfalls die ganze Datei (${unsinn.status})`);
/* ---- GEGENPROBE: Kann diese Messlatte ueberhaupt "nein" sagen? ----
Genau das war am 01.09. der Fehler: `every()` auf einem leeren
Feld meldete gruen, ohne ein einziges Bild angesehen zu haben.
Hier bekommt dieselbe Funktion drei erfundene Antworten -- die
ALTE (volle 200), eine mit falschem Abschnitt und eine mit
richtigem Kopf, aber falschen Bytes. */
ok(!abschnittStimmt({ status: 200, abschnitt: null, laenge: String(GROESSE),
bytes: ganz.bytes }, 0, 9),
"Gegenprobe: die alte Antwort (volle 200) faellt durch");
ok(!abschnittStimmt({ status: 206, abschnitt: `bytes 0-9/${GROESSE + 1}`, laenge: "10",
bytes: ganz.bytes.slice(0, 10) }, 0, 9),
" eine 206 mit falscher Gesamtgroesse auch");
ok(!abschnittStimmt({ status: 206, abschnitt: `bytes 0-9/${GROESSE}`, laenge: "10",
bytes: ganz.bytes.slice(5, 15) }, 0, 9),
" und eine 206 mit den falschen Bytes ebenfalls");
/* WER NICHT IM RAUM IST, BEKOMMT NICHTS. DogFather sieht sonst
überall alles -- hier ausdrücklich nicht: Ein Gespräch zwischen
zwei anderen ist kein Bereich, den man verwaltet. */
const fremd = await absichtlich([dogi], () => dogi.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
return a.status;
}, bildNachrichtId));
ok(fremd === 404, `wer nicht im Gespräch ist, bekommt 404 (${fremd})`);
/* Und die Datei liegt AUSSERHALB des Web-Verzeichnisses. */
ok(existsSync(ANHANG_ORDNER) && readdirSync(ANHANG_ORDNER).length >= 3,
`die Dateien liegen im Datenordner (${existsSync(ANHANG_ORDNER) ? readdirSync(ANHANG_ORDNER).length : 0} Stück)`);
const namen = readdirSync(ANHANG_ORDNER);
ok(!namen.includes("startcheck.png") && namen.every((x) => /^[a-z0-9]+-[a-f0-9]{16}\.[a-z]+$/.test(x)),
`auf der Platte steht ein erzeugter Name, nie der eingeschickte (${namen[0]})`);
}
/* =======================================================================
5. ZURÜCKNEHMEN NIMMT AUCH DIE DATEI
======================================================================= */
melde("\n=== 5. Zurücknehmen ===");
{
const vorher = readdirSync(ANHANG_ORDNER).length;
const weg = await luna.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/nachrichten/${id}`, { method: "DELETE" });
return a.status;
}, pdfNachrichtId);
ok(weg === 200, `die Nachricht wird zurückgenommen (${weg})`);
const nachher = readdirSync(ANHANG_ORDNER).length;
ok(nachher === vorher - 1, `die Datei ist von der Platte weg (${vorher} → ${nachher})`);
const nochDa = await absichtlich([luna], () => luna.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
return a.status;
}, pdfNachrichtId));
ok(nochDa === 404,
`und über die Adresse kommt niemand mehr heran (${nochDa}) — sonst wäre es nur der Anschein einer Rücknahme`);
/* GEGENPROBE: Das Foto von vorhin ist NICHT mitgelöscht worden. */
const anderes = await luna.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
return a.status;
}, bildNachrichtId);
ok(anderes === 200, `das andere Foto liegt unberührt da (${anderes})`);
}
/* =======================================================================
6. EIN GESPRÄCH WEGRÄUMEN — NUR BEI MIR
======================================================================= */
melde("\n=== 6. Wegräumen ===");
{
await schicke(pat.seite, raumId, "Das ist ein Satz, den es nur hier gibt: Zaunkönig.");
await oeffne(luna.seite, raumId);
await oeffne(pat.seite, raumId);
const lunaVorher = await luna.seite.$$eval(".chat-nachricht", (n) => n.length);
const patVorher = await pat.seite.$$eval(".chat-nachricht", (n) => n.length);
ok(lunaVorher > 3 && patVorher > 3, `beide sehen den Verlauf (${lunaVorher} / ${patVorher})`);
/* Seit 19.09.2026 ist das ein eigener <dialog>, kein confirm() --
der Helfer kommt mit beidem zurecht. */
await bestaetige(luna.seite, () => luna.seite.click("#gespraech-weg"));
await luna.seite.waitForTimeout(900);
const inListe = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
ok(!inListe.some((t) => /Patrick/.test(t)),
`es ist aus Lunas Liste verschwunden (${inListe.length} übrig: ${inListe.map((x) => x.trim().split("\n")[0]).join(", ")})`);
/* DAS ENTSCHEIDENDE: Bei Patrick ist alles noch da. */
await oeffne(pat.seite, raumId);
const patNachher = await pat.seite.$$eval(".chat-nachricht", (n) => n.length);
ok(patNachher === patVorher,
`Patrick hat weiterhin alle ${patNachher} Nachrichten — niemand kann einem anderen den Verlauf nehmen`);
/* Auch beim direkten Aufruf sieht Luna nichts mehr davon. */
const beiLuna = await luna.seite.evaluate(async (r) => {
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
return (await a.json()).nachrichten.length;
}, raumId);
ok(beiLuna === 0, `auch direkt abgefragt ist der Verlauf für Luna leer (${beiLuna})`);
/* UND DIE SUCHE. Das ist die Stelle, an der so etwas typischerweise
durchrutscht: aus der Liste weg, über die Suche noch da. */
const findetLuna = await luna.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
return (await a.json()).treffer.length;
});
const findetPat = await pat.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
return (await a.json()).treffer.length;
});
ok(findetLuna === 0, `die Suche findet es bei Luna nicht mehr (${findetLuna})`);
ok(findetPat === 1, `bei Patrick schon (${findetPat}) — sonst wäre es doch für beide gelöscht`);
/* Und an den Anhang kommt Luna auch nicht mehr. */
const anhangLuna = await absichtlich([luna], () => luna.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
return a.status;
}, bildNachrichtId));
const anhangPat = await pat.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
return a.status;
}, bildNachrichtId);
ok(anhangLuna === 404 && anhangPat === 200,
`der Anhang ist für Luna zu (${anhangLuna}) und für Patrick offen (${anhangPat})`);
/* Die Zahl in der Kopfleiste darf davon nicht steigen. */
const offen1 = await luna.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/ungelesen");
return (await a.json()).ungelesen;
});
ok(offen1 === 0, `nichts zählt mehr als ungelesen (${offen1})`);
}
/* =======================================================================
7. UND ES KOMMT ZURÜCK, WENN JEMAND SCHREIBT
======================================================================= */
melde("\n=== 7. Es kommt zurück ===");
{
await schicke(pat.seite, raumId, "Hallo? Bist du noch da?");
await luna.seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
await luna.seite.waitForTimeout(900);
const liste = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
ok(liste.some((t) => /Patrick/.test(t)), "das Gespräch ist wieder in der Liste");
await oeffne(luna.seite, raumId);
const texte = await luna.seite.$$eval(".chat-nachricht__text", (t) => t.map((x) => x.textContent));
ok(texte.length === 1 && /Bist du noch da/.test(texte[0]),
`aber NUR mit dem Neuen (${texte.length} Nachricht: "${texte[0]}")`);
ok(!texte.some((t) => /Zaunkönig/.test(t)), "das Alte bleibt weg");
/* Auch die Suche bringt das Alte nicht zurück. */
const wieder = await luna.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/suche?q=Zaunk%C3%B6nig");
return (await a.json()).treffer.length;
});
ok(wieder === 0, `und die Suche auch nicht (${wieder})`);
}
/* =======================================================================
8. EIN GESPRÄCH OHNE JEDE NACHRICHT
Der Randfall, für den es die zweite Spalte gibt: Hier ist die
höchste Nachrichtennummer 0 -- und 0 heisst sonst "nie gelöscht".
======================================================================= */
melde("\n=== 8. Das leere Gespräch ===");
{
await oeffne(luna.seite, raumDogi);
const leer = await luna.seite.$$eval(".chat-nachricht", (n) => n.length);
ok(leer === 0, `das Gespräch mit DogFather ist leer (${leer})`);
await bestaetige(luna.seite, () => luna.seite.click("#gespraech-weg"));
await luna.seite.waitForTimeout(900);
const liste = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
ok(!liste.some((t) => /Filipe/.test(t)),
`auch ein leeres Gespräch lässt sich wegräumen (${liste.length} übrig)`);
/* GEGENPROBE: Schreibt DogFather, ist es wieder da. */
await schicke(dogi.seite, raumDogi, "Kurze Frage noch.");
await luna.seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" });
await luna.seite.waitForTimeout(900);
const liste2 = await luna.seite.$$eval(".chat-raum__knopf", (b) => b.map((x) => x.textContent));
ok(liste2.some((t) => /Filipe/.test(t)), "und es kommt zurück, sobald er schreibt");
}
/* =======================================================================
9. KEINE STILLEN FEHLER
======================================================================= */
/* =======================================================================
SPRACHNACHRICHTEN (23.09.2026)
Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
und gifts reinschicken kann. also nur die modis rechte linke hand
und dogfather."
ZWEI FRAGEN, UND DIE ZWEITE IST DIE, DIE ER AUSDRUECKLICH GESTELLT
HAT:
1. Geht es ueberhaupt -- aufnehmen, hochladen, abspielen?
2. Geht es fuer die anderen NICHT? Ohne diesen Teil stuende sein
„also nur" bloss im Kommentar.
AUFGENOMMEN WIRD MIT MediaRecorder, im echten Browser, ueber den
echten Knopf. Damit prueft dieser Abschnitt nebenbei etwas, das
keine gebastelte Datei pruefen koennte: ob die Erkennung im Server
das versteht, was ein Browser tatsaechlich erzeugt.
======================================================================= */
melde("\n=== 9. Eine Sprachnachricht ===");
/** Im Browser aufnehmen und die Bytes als Base64 zurueckgeben.
* Fuer die Gegenprobe gebraucht: Patrick soll GENAU DIESELBE Art
* Datei schicken wollen wie DogFather -- sonst bewiese eine Absage
* nur, dass seine Datei kaputt war. */
async function tonAufnehmen(seite, ms) {
return seite.evaluate(async (dauer) => {
const spur = await navigator.mediaDevices.getUserMedia({ audio: true });
const typ = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg", "audio/mp4"]
.find((t) => window.MediaRecorder.isTypeSupported(t)) || "";
const g = new MediaRecorder(spur, typ ? { mimeType: typ } : undefined);
const stuecke = [];
g.addEventListener("dataavailable", (e) => { if (e.data?.size) stuecke.push(e.data); });
const fertig = new Promise((r) => g.addEventListener("stop", r));
g.start(200);
await new Promise((r) => setTimeout(r, dauer));
g.stop();
await fertig;
spur.getTracks().forEach((t) => t.stop());
const brocken = new Blob(stuecke, { type: g.mimeType || typ });
const puffer = new Uint8Array(await brocken.arrayBuffer());
let roh = "";
for (const byte of puffer) roh += String.fromCharCode(byte);
return { b64: btoa(roh), typ: brocken.type, bytes: puffer.length };
}, ms);
}
{
await oeffne(dogi.seite, raumDogi);
/* ---- Der Knopf ist da -- und bei Patrick nicht ------------------ */
const knopfDogi = await dogi.seite.locator("#ton-auf").isVisible();
ok(knopfDogi, "DogFather sieht den Mikrofonknopf");
await oeffne(pat.seite, raumId);
const knopfPat = await pat.seite.locator("#ton-auf").isVisible();
/* DIE GEGENPROBE AM BILDSCHIRM. Ein Knopf, der da ist und dann
abgelehnt wird, ist schlimmer als keiner. */
ok(!knopfPat, "der Scout sieht ihn nicht — „also nur die modis rechte linke hand und dogfather“");
/* ---- Aufnehmen ueber den echten Knopf --------------------------- */
const vorher = await dogi.seite.locator(".chat-nachricht").count();
await dogi.seite.click("#ton-auf");
await dogi.seite.waitForSelector("#tonleiste:not([hidden])", { timeout: 8000 });
ok(true, "der Knopf startet die Aufnahme, die Leiste erscheint");
await dogi.seite.waitForTimeout(2600);
const zeit = (await dogi.seite.locator("#ton-zeit").innerText()).trim();
/* DIE ZEIT MUSS LAUFEN. Stuende sie still, wuesste niemand, ob noch
aufgenommen wird -- und man redet in eine Aufnahme, die steht. */
ok(/^0:0[2-9]$/.test(zeit), `die Zeit läuft mit (${zeit})`);
await dogi.seite.click("#ton-senden");
await dogi.seite.waitForTimeout(3000);
const nachher = await dogi.seite.locator(".chat-nachricht").count();
ok(nachher === vorher + 1, `die Sprachnachricht steht im Verlauf (${vorher} -> ${nachher})`);
ok(!(await dogi.seite.locator("#tonleiste").isVisible()),
" und die Aufnahmeleiste ist wieder zu");
const blase = await dogi.seite.evaluate(() => {
const k = document.querySelector(".chat-ton");
if (!k) return null;
const g = k.querySelector("audio");
return {
marke: k.querySelector(".chat-ton__marke")?.textContent.trim() || "",
dauer: k.querySelector(".chat-ton__dauer")?.textContent.trim() || "",
quelle: g?.getAttribute("src") || "",
bedienbar: !!g?.controls,
vorladen: g?.getAttribute("preload") || "",
vorlesewort: g?.getAttribute("aria-label") || "",
breite: Math.round(k.getBoundingClientRect().width),
};
});
ok(!!blase, "sie ist als Sprachnachricht gezeichnet");
ok(blase?.marke === "Sprachnachricht", ` beschriftet: ${blase?.marke}`);
ok(/^0:0[2-9]$/.test(blase?.dauer || ""), ` mit ihrer Länge (${blase?.dauer})`);
ok(blase?.bedienbar === true, " mit dem Abspielgerät des Browsers, nicht nachgebaut");
ok(blase?.vorladen === "metadata",
" das nur den Kopf lädt — zehn Sprachnachrichten kosten so Kilobyte statt Megabyte");
ok(/Sprachnachricht/.test(blase?.vorlesewort || ""),
` und ein Vorleseprogramm sagt mehr als „Audio" (${blase?.vorlesewort})`);
ok((blase?.breite || 0) >= 200,
` breit genug, um den Schieber zu treffen (${blase?.breite} px)`);
/* ---- Was steht in der Datenbank? -------------------------------- */
const db2 = new DatabaseSync(process.env.WORKSPACE_DB);
const zeile = db2.prepare(`SELECT anhang_art, anhang_typ, anhang_groesse, anhang_dauer
FROM chat_nachrichten WHERE anhang_art = 'ton' ORDER BY id DESC LIMIT 1`).get();
db2.close();
ok(zeile?.anhang_art === "ton", `erkannt als ${zeile?.anhang_art}`);
/* DER TYP KOMMT AUS DEN ERSTEN BYTES, nicht aus dem Content-Type --
und das ist hier die eigentliche Aussage: Die Erkennung versteht,
was ein echter Browser erzeugt. */
ok(/^audio\/(webm|ogg|mp4)$/.test(zeile?.anhang_typ || ""),
` und der Typ kommt aus dem Inhalt (${zeile?.anhang_typ})`);
ok((zeile?.anhang_groesse || 0) > 500, ` ${zeile?.anhang_groesse} Bytes sind angekommen`);
ok((zeile?.anhang_dauer || 0) >= 2000 && (zeile?.anhang_dauer || 0) < 20000,
` und die Länge steht dabei (${zeile?.anhang_dauer} ms)`);
/* ==== DIE ZWEITE FASSUNG, DIE JEDES GERAET ABSPIELT (03.10.2026)
==================================================================
Miss im Support, Meldung #12, fuenf Runden: „Bei mir laesst sich
die Nachricht nicht abspielen." Zuletzt: „Jetzt laedt es die
ganze Zeit." Alle sechs Sprachnachrichten im Haus waren WebM.
AUSDRUECKLICH EINE WEBM, und das ist hier der Punkt. Die
Aufnahme ueber den Knopf weiter oben liefert seit dem 02.10.
eine MP4 -- die braucht keine zweite Fassung, und der erste
Anlauf dieser Pruefung mass deshalb NICHTS und wurde rot. Die
Pruefung hatte recht, die Erwartung war falsch: Gemessen werden
muss der Fall, den es bei Miss gibt, und das ist die WebM von
frueher (oder von einem Firefox, der nichts anderes kann).
WAS GEMESSEN WIRD, IST NICHT „die Datei ist da". Das waere ein
gruener Haken ueber etwas, das niemand abspielen kann. Mit
ffprobe wird gefragt, WAS drinsteckt: AAC, in einem MP4, so lang
wie das Original -- und mit der Inhaltsuebersicht VORNE.
DER DRITTE AUSGANG: Ohne ffmpeg gibt es keine zweite Fassung.
Das ist kein Fehler des Programms und wird auch nicht
stillschweigend uebersprungen -- es steht als eigene Zeile da,
mit der Anleitung dazu.
================================================================== */
{
const { ffmpegPfad, ffprobePfad, zweitfassungPfad } =
await import("./helfer-ffmpeg.mjs");
const { execFileSync } = await import("node:child_process");
const { readFileSync: lies, statSync: wiegt } = await import("node:fs");
if (!ffmpegPfad()) {
melde(" -- KONNTE NICHT NACHSEHEN: ffmpeg fehlt auf diesem Rechner.");
melde(" Ohne ffmpeg bekommt keine Sprachnachricht eine zweite");
melde(" Fassung -- die Pruefung kann nicht sagen, ob sie");
melde(" richtig waere. Auf Debian: sudo apt-get install -y ffmpeg");
nichtNachsehbar++;
} else {
/* EINE ECHTE WEBM, vom Browser aufgenommen -- nicht
zusammengebaut. `tonAufnehmen` fragt der Reihe nach und nimmt
das erste Format, das geht; die Liste beginnt mit WebM. */
const webmAuf = await dogi.seite.evaluate(async (ms) => {
const spur = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) {
spur.getTracks().forEach((t) => t.stop());
return { typ: "", b64: "", bytes: 0 };
}
const r = new MediaRecorder(spur, { mimeType: "audio/webm;codecs=opus" });
const teile = [];
r.ondataavailable = (e) => { if (e.data.size) teile.push(e.data); };
const fertig = new Promise((f) => { r.onstop = f; });
r.start();
await new Promise((f) => setTimeout(f, ms));
r.stop();
await fertig;
spur.getTracks().forEach((t) => t.stop());
const blob = new Blob(teile, { type: "audio/webm" });
const puffer = new Uint8Array(await blob.arrayBuffer());
let roh = "";
for (const b of puffer) roh += String.fromCharCode(b);
return { typ: "audio/webm", b64: btoa(roh), bytes: puffer.length };
}, 2500);
ok(webmAuf.bytes > 500,
`eine echte WebM-Sprachnachricht aufgenommen (${webmAuf.bytes} Bytes)`);
const geschickt = await dogi.seite.evaluate(async ([r, b64]) => {
const roh = atob(b64);
const puffer = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i);
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": "audio/webm",
"X-Dateiname": "Sprachnachricht.webm", "X-Dauer": "2500" },
body: puffer,
});
const d = await a.json().catch(() => ({}));
return { status: a.status, id: d?.nachricht?.id || 0 };
}, [raumDogi, webmAuf.b64]);
ok(geschickt.status === 201 && geschickt.id > 0,
`sie geht durch (${geschickt.status}, #${geschickt.id})`);
/* AUF DIE UMWANDLUNG WARTEN. Sie laeuft absichtlich NEBEN dem
Hochladen -- die Nachricht soll nicht auf ffmpeg warten.
Deshalb wird hier nachgesehen, statt es anzunehmen, und zwar
mit einer Frist: Ein festes `waitForTimeout` waere entweder
zu kurz (rot ohne Grund) oder zu lang (jeder Lauf zahlt). */
let weg2 = null;
for (let i = 0; i < 40 && !weg2; i++) {
// eslint-disable-next-line no-await-in-loop
weg2 = await dogi.seite.evaluate(async ([r, id]) => {
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
const d = await a.json();
const n = (d.nachrichten || []).find((x) => x.id === id);
return n?.anhang?.weg_auch || null;
}, [raumDogi, geschickt.id]);
// eslint-disable-next-line no-await-in-loop
if (!weg2) await new Promise((r) => setTimeout(r, 500));
}
ok(!!weg2, `die zweite Fassung entsteht von selbst (${weg2 || "kam nicht"})`);
ok(/\?form=mp4$/.test(weg2 || ""), ` und sie hat einen eigenen Weg (${weg2})`);
if (weg2) {
/* ---- Was kommt ueber die Leitung? ------------------------- */
const kopf = await dogi.seite.evaluate(async (w) => {
const a = await fetch(w);
const b = new Uint8Array(await a.arrayBuffer());
return {
code: a.status,
typ: a.headers.get("content-type"),
bereiche: a.headers.get("accept-ranges"),
bytes: b.length,
/* Die ersten zwoelf Bytes einer MP4: vier Byte Laenge,
dann „ftyp". Daran erkennt man den Behaelter, ohne ihn
zu glauben. */
marke: String.fromCharCode(...b.slice(4, 8)),
};
}, weg2);
ok(kopf.code === 200 && kopf.typ === "audio/mp4",
`sie kommt als audio/mp4 heraus (${kopf.code}, ${kopf.typ})`);
ok(kopf.marke === "ftyp",
` und ist wirklich ein MP4-Behaelter (Marke „${kopf.marke}")`);
ok(kopf.bereiche === "bytes",
` ueber denselben Ausliefer-Weg wie alles andere (${kopf.bereiche})`);
ok(kopf.bytes > 2000, ` mit Inhalt (${kopf.bytes} Bytes)`);
/* ---- DAS ORIGINAL BLEIBT ABRUFBAR -------------------------
Ohne `?form=mp4` kommt weiter die WebM. Sie ist das, was
hochgeladen wurde; sie zu ersetzen hiesse, die Aufnahme
durch eine Umrechnung davon auszutauschen. */
const original = await dogi.seite.evaluate(async (w) => {
const a = await fetch(w.replace("?form=mp4", ""));
return { code: a.status, typ: a.headers.get("content-type") };
}, weg2);
ok(original.code === 200 && original.typ === "audio/webm",
`das Original bleibt daneben liegen (${original.typ})`);
/* ---- UND WAS STECKT WIRKLICH DRIN? ------------------------ */
const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
const tonZeile = db4.prepare(
"SELECT anhang_datei FROM chat_nachrichten WHERE id = ?").get(geschickt.id);
db4.close();
const { DATEN_ORDNER } = await import("./workspace.js");
const quelle = join(DATEN_ORDNER, "chat-anhaenge", tonZeile.anhang_datei);
const ziel = zweitfassungPfad(quelle);
const probe = ffprobePfad();
/* `frag` UND `spawnSyncFuerProbe` STEHEN HIER DRAUSSEN, weil
der Abschnitt „untaugliche Fassung" weiter unten sie auch
braucht. Innerhalb des else-Zweigs waeren sie dort nicht
sichtbar -- und das faellt erst beim Laufen auf. */
const frag = (datei, was) => {
if (!probe) return "";
try {
return execFileSync(probe, ["-v", "error", "-show_entries", was,
"-of", "default=noprint_wrappers=1:nokey=1", datei],
{ encoding: "utf8" }).trim();
} catch { return ""; }
};
const { spawnSync: spawnSyncFuerProbe } = await import("node:child_process");
if (!probe) {
melde(" -- KONNTE NICHT NACHSEHEN: ffprobe fehlt neben ffmpeg.");
nichtNachsehbar++;
} else {
const codec = frag(ziel, "stream=codec_name");
const codecAlt = frag(quelle, "stream=codec_name");
const dauerNeu = Number(frag(ziel, "format=duration"));
const dauerAlt = Number(frag(quelle, "format=duration"));
ok(codecAlt === "opus",
`das Original ist Opus -- also genau der Fall von Miss (${codecAlt})`);
ok(codec === "aac", `drinnen steckt AAC, nicht nur eine neue Endung (${codec})`);
/* ==== DIE LAENGE IST DER TON, NICHT DIE ZEITACHSE ======
Verglichen wird NICHT `format=duration` beider Dateien.
An einer echten Sprachnachricht aus dem Haus gemessen:
Das Original hat 2,76 s Ton auf einer Zeitachse von
63,8 s -- `MediaRecorder` setzt das erste Paket auf 0
und das zweite auf 61,1. Wer die Zeitachsen
vergleicht, prueft dann, ob das Loch mitgewandert ist.
Gefragt wird deshalb nach dem TON: wie viele Bytes
herauskommen, wenn man beide dekodiert. Die muessen
gleich sein -- dann ist nichts abgeschnitten und nichts
dazuerfunden. */
/* UEBER stderr UND NICHT ueber die Rueckgabe: ffmpeg
schreibt seine Zusammenfassung („audio:260KiB") dorthin,
nicht auf die Standardausgabe. Mein erster Anlauf las
die Rueckgabe von `execFileSync` und bekam bei beiden
Dateien 0 KiB -- die Pruefung wurde rot und hatte recht:
Sie hat nichts gemessen. */
const tonBytes = (datei) => {
const r = spawnSyncFuerProbe(ffmpegPfad(),
["-nostdin", "-hide_banner", "-i", datei, "-f", "null", "-"],
{ encoding: "utf8" });
return String(r.stderr || "");
};
const kib = (t) => Number((t.match(/audio:(\d+)KiB/) || [])[1] || 0);
const tonAlt = kib(tonBytes(quelle));
const tonNeu = kib(tonBytes(ziel));
ok(tonAlt > 0 && Math.abs(tonNeu - tonAlt) <= 2,
`der Toninhalt bleibt derselbe (${tonAlt} KiB → ${tonNeu} KiB dekodiert)`);
/* UND DAS LOCH IST WEG. Die Zeitachse der Zweitfassung darf
nicht laenger sein als der Ton, den sie traegt. 48 kHz,
zwei Byte, ein Kanal: 1 KiB sind rund 10,7 ms. */
const tonSekunden = (tonNeu * 1024) / (48000 * 2);
ok(dauerNeu > 0 && dauerNeu < tonSekunden + 1.5,
`und die Zeitachse passt zum Ton (${dauerNeu.toFixed(2)} s fuer `
+ `${tonSekunden.toFixed(2)} s Ton) — das Loch am Anfang ist weg`);
/* GEGENPROBE: Mit dem Loch waere es aufgefallen. Die
Originaldatei aus diesem Lauf hat keines (Chromium
erzeugt es nicht immer) -- deshalb wird hier gerechnet,
was gewesen waere, statt zu behaupten, es koenne nicht
vorkommen. */
ok(dauerAlt === 0 || dauerAlt < tonSekunden + 1.5 || dauerNeu < dauerAlt,
` Gegenprobe: eine Zeitachse von ${dauerAlt.toFixed(2)} s fuer `
+ `${tonSekunden.toFixed(2)} s Ton waere aufgefallen`);
}
/* ---- DIE INHALTSUEBERSICHT STEHT VORNE --------------------
Ohne `+faststart` liegt `moov` am Dateiende. Das
Abspielgeraet muss dann erst bis ans Ende lesen, bevor es
anfangen kann -- genau das „laedt die ganze Zeit", das Miss
in Runde 5 beschrieben hat. Gemessen wird die Reihenfolge
der Kaesten in der Datei, nicht die Zeile im Aufruf. */
const roh = lies(ziel);
const wo = (name) => roh.indexOf(Buffer.from(name, "latin1"));
const moov = wo("moov");
const mdat = wo("mdat");
ok(moov > 0 && mdat > 0 && moov < mdat,
`die Inhaltsuebersicht steht VOR den Tondaten (moov ${moov}, mdat ${mdat})`);
ok(wiegt(ziel).size === kopf.bytes,
` und was auf der Platte liegt, kommt auch heraus (${wiegt(ziel).size} Bytes)`);
/* ---- EINE UNTAUGLICHE FASSUNG WIRD ERSETZT ---------------
Die ersten sechs Zweitfassungen im Haus sind entstanden,
BEVOR das Loch am Anfang bekannt war -- vier davon tragen
es. Mit „gibt es schon, dann weiter" waeren sie fuer immer
so geblieben. Geprueft wird deshalb, dass der Nachruestlauf
eine Fassung, deren Zeitachse nicht zum Ton passt, ERSETZT.
NACHGESTELLT MIT EINER ECHTEN: Dieselbe Aufnahme wird von
Hand mit dem alten Aufruf (ohne `asetpts`) und einem
kuenstlichen Versatz gebaut -- so sieht sie aus wie die
vier auf dem Server. */
const { toeneNachruesten } = await import("./workspace-chat.js");
const { zweitfassungTaugt } = await import("./helfer-ffmpeg.mjs");
/* NICHT `weg2` NENNEN -- so hiess oben schon die Adresse der
Zweitfassung, und `const` im selben Block macht daraus
einen Absturz, BEVOR die obere Zeile ueberhaupt gelesen
wird. Gefunden von der Pruefung selbst. */
const { unlinkSync: dateiLoeschen } = await import("node:fs");
dateiLoeschen(ziel);
/* WIE MAN EINE DATEI MIT LOCH BAUT -- und wie nicht.
`-itsoffset 40` war mein erster Versuch und ergab 2,5 s:
Der MP4-Baukasten rechnet einen reinen Anfangsversatz
wieder heraus. Was bleibt, ist eine Zeitachse, die laenger
ist als der Ton, den sie traegt -- und genau das stellt
eine stumme Bildspur von 40 Sekunden her. Der Weg dorthin
ist ein anderer als bei `MediaRecorder`; die ZAHLEN, die
`zweitfassungTaugt` liest, sind dieselben, und darum geht
es hier. */
spawnSyncFuerProbe(ffmpegPfad(), ["-nostdin", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "color=black:s=32x32:r=1:d=40", "-i", quelle,
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-b:a", "96k",
"-f", "mp4", "-y", ziel]);
const kaputtDauer = Number(frag(ziel, "format=duration"));
ok(kaputtDauer > 30,
`eine Fassung mit zu langer Zeitachse nachgestellt `
+ `(${kaputtDauer.toFixed(1)} s)`);
ok(zweitfassungTaugt(ziel) === false,
" und sie wird als untauglich erkannt");
await toeneNachruesten();
const heilDauer = Number(frag(ziel, "format=duration"));
ok(heilDauer > 0 && heilDauer < 10,
`der Nachruestlauf ersetzt sie (${kaputtDauer.toFixed(1)} s → `
+ `${heilDauer.toFixed(1)} s)`);
/* GEGENPROBE: Die GUTE Fassung laesst er in Ruhe. Ohne sie
koennte der Lauf bei jedem Durchgang alles neu wandeln --
eine Warnung, die immer kommt. */
ok(zweitfassungTaugt(ziel) === true,
" und die neue gilt als tauglich — er wandelt sie nicht ewig weiter");
/* ---- UND BEIM LOESCHEN GEHEN BEIDE ------------------------
Sonst bliebe bei jeder zurueckgenommenen Sprachnachricht
eine m4a liegen -- ohne Zeile, die auf sie zeigt, und damit
fuer immer unzuordenbar. Genau diese Luecke gab es gestern
bei den Supportbildern. */
const weggenommen = await dogi.seite.evaluate(async (id) => {
const a = await fetch(`/workspace/api/chat/nachrichten/${id}`, { method: "DELETE" });
return a.status;
}, geschickt.id);
await new Promise((r) => setTimeout(r, 600));
const nochDa = [quelle, ziel].filter((f) => { try { wiegt(f); return true; } catch { return false; } });
ok(weggenommen < 400 && nochDa.length === 0,
`beim Loeschen gehen BEIDE Dateien (${weggenommen}, ${nochDa.length} liegen noch)`);
}
/* ---- GEGENPROBE: EIN BILD BEKOMMT KEINE ZWEITE FASSUNG -----
Sonst liefe ffmpeg ueber jedes Foto und jedes PDF -- Arbeit
fuer nichts, und eine Tuer, nach der niemand gefragt hat.
Das Bild wird hier selbst geschickt und nicht im Verlauf
gesucht: Mein erster Anlauf suchte eines, fand keines (an
dieser Stelle gab es noch keins) und meldete `undefined`
statt `null` -- gruen waere das nie gewesen, aber es hat
auch nichts geprueft. */
const einBild = await dogi.seite.evaluate(async (r) => {
const png = Uint8Array.from(atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="),
(c) => c.charCodeAt(0));
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": "image/png", "X-Dateiname": "punkt.png" },
body: png,
});
const d = await a.json().catch(() => ({}));
return { status: a.status, auch: d?.nachricht?.anhang?.weg_auch };
}, raumDogi);
ok(einBild.status === 201 && einBild.auch === null,
`Gegenprobe: ein Bild bekommt keine zweite Fassung `
+ `(${einBild.status}, ${JSON.stringify(einBild.auch)})`);
/* ==== DIESER BLOCK HINTERLAESST NICHTS (03.10.2026) =========
Die Zeilen weiter unten fragen die Gespraechsliste und
erwarten dort das Wort „Sprachnachricht" -- also das, was
ZULETZT in diesem Raum stand. Mein Probebild liess sie rot
werden, und zwar zu Recht: Nicht die Liste war kaputt,
sondern diese Pruefung hatte den Raum veraendert.
Eine Pruefung, die den Stand fuer die naechste verschiebt,
ist schlimmer als keine: Der Fehler taucht woanders auf, und
dort sucht man ihn dann. Also wird aufgeraeumt. */
await dogi.seite.evaluate(async (r) => {
const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
const d = await a.json();
const bild = (d.nachrichten || []).filter((n) => n.anhang?.art === "bild").pop();
if (bild) {
await fetch(`/workspace/api/chat/nachrichten/${bild.id}`, { method: "DELETE" });
}
}, raumDogi);
await new Promise((r) => setTimeout(r, 400));
}
}
/* ---- In der Gesprächsliste steht ein Wort dafür ------------------ */
const vorschau = await dogi.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/raeume");
const j = await a.json();
return (j.raeume || []).map((r) => r.letzte?.text || "");
});
ok(vorschau.includes("Sprachnachricht"),
`in der Gesprächsliste steht „Sprachnachricht" statt einer leeren Zeile`);
/* ---- DIE GEGENPROBE: derselbe Ton, ein anderer Absender ---------- */
const aufnahme = await tonAufnehmen(pat.seite, 1500);
ok(aufnahme.bytes > 500, `Patrick nimmt dieselbe Art Datei auf (${aufnahme.bytes} Bytes)`);
const absage = await absichtlich([pat], () => pat.seite.evaluate(async ([r, b64, typ]) => {
const roh = atob(b64);
const puffer = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i);
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": typ, "X-Dateiname": "Sprachnachricht.webm", "X-Dauer": "1500" },
body: puffer,
});
return { status: a.status, text: (await a.json())?.fehler || "" };
}, [raumId, aufnahme.b64, aufnahme.typ]));
/* ER GEHT AN DER OBERFLAECHE VORBEI -- genau darum geht es. Der
ausgeblendete Knopf ist die Hoeflichkeit, diese Antwort ist die
Schranke. */
ok(absage.status === 403,
`am Knopf vorbei geschickt: abgelehnt (HTTP ${absage.status})`);
ok(/Team Dogi/.test(absage.text),
` mit einem Satz, der sagt warum ("${absage.text}")`);
const db3 = new DatabaseSync(process.env.WORKSPACE_DB);
const vonPat = db3.prepare(
"SELECT COUNT(*) AS n FROM chat_nachrichten WHERE anhang_art = 'ton' AND person_id = ?")
.get(idPat).n;
db3.close();
ok(vonPat === 0, ` und nichts davon ist gespeichert (${vonPat})`);
/* ---- Und ein Video im Tonbehälter geht auch nicht ---------------- */
const alsVideo = await absichtlich([dogi], () => dogi.seite.evaluate(async (r) => {
/* Eine WebM-Kopfzeile mit einer VIDEO-Spur. Hier ist Basteln
ausnahmsweise richtig: Es geht nicht darum, ob echtes Video
erkannt wird, sondern ob die Spurkennung ueberhaupt angesehen
wird -- und das laesst sich nur mit einer Datei pruefen, die
genau daran scheitern muss. */
const kopf = [0x1a, 0x45, 0xdf, 0xa3];
const wort = [..."V_VP9"].map((c) => c.charCodeAt(0));
const puffer = new Uint8Array([...kopf, ...new Array(20).fill(0x42), ...wort,
...new Array(200).fill(0x11)]);
const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
method: "POST",
headers: { "Content-Type": "audio/webm", "X-Dateiname": "nicht-ton.webm" },
body: puffer,
});
return a.status;
}, raumDogi));
ok(alsVideo === 415,
`ein Video im Ton-Behälter wird abgelehnt (HTTP ${alsVideo}) — sonst liefe es ohne Bild und niemand wüsste es`);
/* ===================================================================
DIE SPRACHNACHRICHT SELBST, AUF DEM WEG ZUM OHR (02.10.2026)
Abschnitt 4 prüft die Teilanfragen am Foto. Hier wird dasselbe
noch einmal an einer SPRACHNACHRICHT gefragt -- nicht aus
Doppelung, sondern weil genau das der Fall war, der sieben Tage
lang kaputt war: Ein Bild braucht keine Teilanfragen, ein
<audio> schon.
=================================================================== */
const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
const tonId = db4.prepare(
"SELECT id FROM chat_nachrichten WHERE anhang_art = 'ton' ORDER BY id DESC LIMIT 1").get()?.id;
db4.close();
ok(Number.isInteger(tonId),
`die Sprachnachricht steht in der Datenbank (#${tonId}) — ohne sie misst unten nichts`);
const tonWeg = await dogi.seite.evaluate(async (id) => {
const voll = await fetch(`/workspace/api/chat/anhang/${id}`, { cache: "no-store" });
const alles = new Uint8Array(await voll.arrayBuffer());
const teil = await fetch(`/workspace/api/chat/anhang/${id}`,
{ cache: "no-store", headers: { Range: "bytes=0-99" } });
const stueck = new Uint8Array(await teil.arrayBuffer());
return {
bereiche: voll.headers.get("accept-ranges"),
laenge: Number(voll.headers.get("content-length")),
groesse: alles.length,
status: teil.status,
abschnitt: teil.headers.get("content-range"),
teilBytes: stueck.length,
gleich: [...stueck].every((w, i) => w === alles[i]),
typ: voll.headers.get("content-type"),
};
}, tonId);
ok(tonWeg.bereiche === "bytes" && tonWeg.laenge === tonWeg.groesse,
`die Sprachnachricht kommt mit Accept-Ranges und Länge`
+ ` (${tonWeg.bereiche}, ${tonWeg.laenge} von ${tonWeg.groesse})`);
ok(tonWeg.status === 206 && tonWeg.teilBytes === 100 && tonWeg.gleich
&& tonWeg.abschnitt === `bytes 0-99/${tonWeg.groesse}`,
` und ein Abschnitt kommt als 206 mit genau diesen Bytes (${tonWeg.abschnitt})`
+ " — ohne das spielt Safari gar nicht erst");
/* ---- DER AUSWEG, WENN DAS GERÄT DEN BEHÄLTER NICHT KANN ----------
Dass ein iPhone WebM nicht abspielt, lässt sich hier nicht
herstellen -- Chromium kann es. Was sich herstellen lässt, ist
GENAU DER ZUSTAND, in dem sich das Abspielgerät dann meldet:
`error` mit Code 4 (MEDIA_ERR_SRC_NOT_SUPPORTED). Geprüft wird
also der Rückfallweg, nicht das iPhone -- und das ist auch das,
was hier kaputtgehen kann.
DIE ERSATZQUELLE IST EINE ECHTE DATEI VON DIESER SEITE, keine
`data:`-Adresse: Die hat beim ersten Versuch die Inhaltsregel
der Seite verletzt (`default-src 'self'`), und dann hätte ich
gemessen, dass CSP greift -- nicht, dass der Behälter nicht
passt. Zwei verschiedene Gründe für denselben Fehlercode. */
const ausweg = await absichtlich([dogi], () => dogi.seite.evaluate(async () => {
const g = document.querySelector(".chat-ton audio");
if (!g) return { fehlt: "kein Abspielgerät in der Blase" };
const kasten = g.closest(".chat-ton");
g.src = "/workspace/assets/css/chat.css";
g.load();
await new Promise((r) => {
g.addEventListener("error", r, { once: true });
setTimeout(r, 4000);
});
await new Promise((r) => setTimeout(r, 50));
const a = kasten.querySelector(".chat-ton__ausweg");
return {
kaputt: kasten.dataset.kaputt,
code: g.error?.code,
marke: kasten.querySelector(".chat-ton__marke")?.textContent.trim() || "",
weg: a?.getAttribute("href") || "",
wort: a?.textContent.trim() || "",
vorlesewort: a?.getAttribute("aria-label") || "",
hoch: a ? Math.round(a.getBoundingClientRect().height) : 0,
geraetWeg: getComputedStyle(g).display === "none",
};
}));
ok(ausweg.kaputt === "ja" && ausweg.code === 4,
`ein Behälter, den der Browser nicht kann, meldet sich (Code ${ausweg.code})`);
ok(/nicht abspielen/.test(ausweg.marke),
` und der Satz sagt, woran es liegt: „${ausweg.marke}"`);
/* „lässt sich GERADE nicht laden" hieße „gleich nochmal versuchen".
Bei einem fremden Behälter hilft kein Versuch mehr. */
ok(!/gerade/.test(ausweg.marke),
" nicht mehr „gerade nicht laden“ — das war eine Vertröstung");
ok(/\/workspace\/api\/chat\/anhang\/\d+\?laden=1$/.test(ausweg.weg),
` daneben steht ein Weg zum Herunterladen (${ausweg.weg})`);
ok(ausweg.wort === "Herunterladen" && /herunterladen/i.test(ausweg.vorlesewort),
` beschriftet „${ausweg.wort}", für Vorleseprogramme „${ausweg.vorlesewort}"`);
ok(ausweg.hoch >= 44, ` und mit ${ausweg.hoch} px hoch genug für einen Finger`);
ok(ausweg.geraetWeg === true,
" das stumme Abspielgerät ist weg — sonst stünde ein Schieber da, der nichts tut");
/* Die Seite wird wiederhergestellt: Oben wurde absichtlich eine
kaputte Quelle eingesetzt, und ein nachfolgender Abschnitt soll
keinen Zustand erben, den diese Messung hinterlassen hat. */
await dogi.seite.reload({ waitUntil: "domcontentloaded" });
}
/* =======================================================================
DIE GIF-KISTE (23.09.2026)
Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
und gifts reinschicken kann. also nur die modis rechte linke hand
und dogfather."
„gifts" ist hier als GIFs gelesen -- die bewegten Bildchen.
DIE WICHTIGSTE PRUEFUNG IN DIESEM ABSCHNITT IST DIE VORLETZTE: Ein
GIF aus der Kiste zu verschicken KOPIERT die Datei. Verwiese die
Nachricht auf dieselbe Datei, dann naehme das Zuruecknehmen EINER
Nachricht das GIF fuer alle anderen mit -- und in drei Gespraechen
stuende ab da ein kaputtes Bild. Das ist die Sorte Fehler, die erst
Wochen spaeter auffaellt und dann nicht mehr zu reparieren ist.
======================================================================= */
melde("\n=== 10. Die GIF-Kiste ===");
/* EIN ECHTES GIF, kein gebasteltes Muster: Diese 43 Bytes sind das
bekannte durchsichtige 1x1-GIF89a -- jedes Programm der Welt oeffnet
es. Es geht hier nicht um das Bild, sondern um den Weg. */
const GIF_B64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
{
await oeffne(dogi.seite, raumDogi);
/* ---- Der Knopf ist da -- und bei Patrick nicht ------------------ */
ok(await dogi.seite.locator("#gif-auf").isVisible(), "DogFather sieht den GIF-Knopf");
await oeffne(pat.seite, raumId);
ok(!(await pat.seite.locator("#gif-auf").isVisible()),
"der Scout sieht ihn nicht — dieselbe Regel wie bei den Sprachnachrichten");
/* ---- Die Kiste ist leer, und sie sagt das auch ------------------- */
await dogi.seite.click("#gif-auf");
await dogi.seite.waitForSelector("#gif-tafel:not([hidden])", { timeout: 8000 });
const leerSatz = (await dogi.seite.locator("#gif-leer").innerText()).trim();
ok(/noch leer/.test(leerSatz), `die leere Kiste sagt, was zu tun ist ("${leerSatz.slice(0, 48)}…")`);
/* ---- Ein GIF hineinlegen, ueber den echten Knopf ----------------- */
await dogi.seite.setInputFiles("#gif-feld", {
name: "daumen-hoch.gif", mimeType: "image/gif", buffer: Buffer.from(GIF_B64, "base64"),
});
await dogi.seite.waitForTimeout(2200);
const kacheln = await dogi.seite.locator(".gif-kachel").count();
ok(kacheln === 1, `es liegt ein GIF in der Kiste (${kacheln})`);
const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
const inKiste = db4.prepare(
"SELECT id, datei, name, typ, breite, hoehe, von_id FROM chat_gifs ORDER BY id DESC LIMIT 1").get();
db4.close();
ok(inKiste?.typ === "image/gif", `erkannt als ${inKiste?.typ}`);
ok(inKiste?.name === "daumen-hoch", `der Name bleibt ohne Endung ("${inKiste?.name}")`);
ok(inKiste?.breite === 1 && inKiste?.hoehe === 1,
`die Maße kommen aus der Datei (${inKiste?.breite}×${inKiste?.hoehe})`);
ok(existsSync(join(DATEN_ORDNER_P, "chat-gifs", inKiste?.datei || "-")),
"die Datei liegt in der eigenen Kiste, nicht bei den Anhängen");
/* ---- DASSELBE NOCH EINMAL IST KEIN FEHLER ------------------------ */
await dogi.seite.setInputFiles("#gif-feld", {
/* ANDERER NAME, GLEICHER INHALT -- genau der Fall, der über den
Namen nicht zu erkennen wäre: "giphy.gif", "giphy(1).gif",
"download.gif" sind fast immer dasselbe Bild. */
name: "giphy(1).gif", mimeType: "image/gif", buffer: Buffer.from(GIF_B64, "base64"),
});
await dogi.seite.waitForTimeout(1800);
const nachZweitem = await dogi.seite.locator(".gif-kachel").count();
ok(nachZweitem === 1,
`dasselbe GIF unter anderem Namen ergibt KEINE zweite Kachel (${nachZweitem}) — verglichen wird der Inhalt`);
const hinweis = (await dogi.seite.locator("#fehler").innerText()).trim();
ok(/schon in der Kiste/.test(hinweis), ` und es steht als Hinweis da, nicht als Fehler ("${hinweis}")`);
/* ---- Verschicken ------------------------------------------------- */
const vorher = await dogi.seite.locator(".chat-nachricht").count();
await dogi.seite.click(".gif-kachel__knopf");
await dogi.seite.waitForTimeout(2200);
const nachher = await dogi.seite.locator(".chat-nachricht").count();
ok(nachher === vorher + 1, `das GIF steht im Verlauf (${vorher} -> ${nachher})`);
ok(!(await dogi.seite.locator("#gif-tafel").isVisible()),
" und die Kiste geht zu — sonst steht sie vor dem, was man gerade geschickt hat");
const db5 = new DatabaseSync(process.env.WORKSPACE_DB);
const alsNachricht = db5.prepare(`SELECT anhang_art, anhang_typ, anhang_datei, anhang_name
FROM chat_nachrichten WHERE anhang_typ = 'image/gif' ORDER BY id DESC LIMIT 1`).get();
db5.close();
ok(alsNachricht?.anhang_art === "bild",
"es ist ein ganz normaler Anhang geworden — keine zweite Sorte Nachricht");
ok(alsNachricht?.anhang_name === "daumen-hoch.gif",
`mit seinem Namen (${alsNachricht?.anhang_name})`);
/* ---- DIE EIGENTLICHE AUSSAGE: ES WURDE KOPIERT ------------------- */
ok(alsNachricht?.anhang_datei !== inKiste?.datei,
"die verschickte Datei ist eine KOPIE, kein Verweis auf die Kiste");
ok(existsSync(join(DATEN_ORDNER_P, "chat-anhaenge", alsNachricht?.anhang_datei || "-")),
" sie liegt bei den Anhängen");
/* ---- Aus der Kiste nehmen: das Verschickte bleibt ---------------- */
/* DIE RUECKFRAGE WIRD BEANTWORTET, NICHT UMGANGEN.
Hier stand `window.confirm = () => true` -- und das war richtig,
solange die Kiste einen nativen confirm() benutzte. Am 23.09.2026
ist er durch den Hausdialog ersetzt worden; seither klickte die
Pruefung den Knopf, die Seite fragte, und niemand antwortete.
Ergebnis: zwei rote Zeilen an einer Stelle, an der nichts kaputt
war -- genau der Fall, vor dem der Kopf von helfer-nachfrage.mjs
seit dem 19.09.2026 warnt. Zum zweiten Mal, an einer neuen Stelle.
`bestaetige` kennt beide Wege und beantwortet den, der wirklich
kommt. Damit prueft diese Zeile ab jetzt auch mit, DASS ueberhaupt
gefragt wird: Verschwindet die Rueckfrage eines Tages, laeuft der
Helfer in seine Frist und die Zeile wird rot. */
await dogi.seite.click("#gif-auf");
await dogi.seite.waitForTimeout(600);
await bestaetige(dogi.seite, () => dogi.seite.click(".gif-kachel__weg"));
await dogi.seite.waitForTimeout(1500);
const restKacheln = await dogi.seite.locator(".gif-kachel").count();
ok(restKacheln === 0, `aus der Kiste genommen (${restKacheln} übrig)`);
ok(!existsSync(join(DATEN_ORDNER_P, "chat-gifs", inKiste?.datei || "-")),
" die Datei der Kiste ist weg");
/* UND DAS IST DER PUNKT: Das verschickte GIF steht weiter im
Gespräch. Wäre es ein Verweis gewesen, stünde dort jetzt ein
kaputtes Bild -- in jedem Gespräch, in das es je geschickt wurde. */
ok(existsSync(join(DATEN_ORDNER_P, "chat-anhaenge", alsNachricht?.anhang_datei || "-")),
" das bereits verschickte GIF bleibt unberührt");
/* ---- Die Gegenprobe: der Scout kommt an die Kiste nicht heran ---- */
const absagen = await absichtlich([pat], () => pat.seite.evaluate(async ([r, b64]) => {
const roh = atob(b64);
const puffer = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i);
const liste = await fetch("/workspace/api/chat/gifs");
const rein = await fetch("/workspace/api/chat/gifs", {
method: "POST", headers: { "Content-Type": "image/gif", "X-Dateiname": "x.gif" },
body: puffer });
const raus = await fetch(`/workspace/api/chat/raeume/${r}/gif`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gif_id: 1 }) });
return { liste: liste.status, rein: rein.status, raus: raus.status };
}, [raumId, GIF_B64]));
ok(absagen.liste === 403 && absagen.rein === 403 && absagen.raus === 403,
`am Knopf vorbei: ansehen ${absagen.liste}, hineinlegen ${absagen.rein}, schicken ${absagen.raus}`);
/* ---- Nur GIFs kommen hinein -------------------------------------- */
const keinGif = await absichtlich([dogi], () => dogi.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/gifs", {
method: "POST",
headers: { "Content-Type": "image/gif", "X-Dateiname": "eigentlich.png" },
body: new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1]),
});
return { status: a.status, text: (await a.json())?.fehler || "" };
}));
ok(keinGif.status === 415,
`ein PNG mit dem Etikett "image/gif" kommt nicht hinein (HTTP ${keinGif.status}) — entschieden wird am Inhalt`);
/* ---- Augenschonend: bei Bewegungsverzicht stehen sie still ------- */
const ruhig = await browser.newContext({
viewport: { width: 1280, height: 900 }, reducedMotion: "reduce",
});
const rs = await ruhig.newPage();
await rs.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
await rs.click('.rolle[data-rolle="admin"]');
await rs.fill("#code", "CODE-DOGI-0001");
await rs.click("button[type=submit]");
await rs.waitForURL("**/start.html", { timeout: 25000 });
/* Noch ein GIF in die Kiste, sonst ist nichts zu sehen. */
await rs.goto(`${BASIS}/workspace/chat.html?raum=${raumDogi}`, { waitUntil: "networkidle" });
await rs.waitForSelector("#schreiben:not([hidden])", { timeout: 8000 });
await rs.click("#gif-auf");
await rs.waitForTimeout(500);
await rs.setInputFiles("#gif-feld", {
name: "ruhig.gif", mimeType: "image/gif", buffer: Buffer.from(GIF_B64, "base64"),
});
await rs.waitForTimeout(2200);
const ruhigeKachel = await rs.evaluate(() => {
const k = document.querySelector(".gif-kachel__knopf");
return { tag: k?.firstElementChild?.tagName || "", da: !!k };
});
ok(ruhigeKachel.da && ruhigeKachel.tag === "CANVAS",
`bei „Bewegung reduzieren" steht das GIF still (${ruhigeKachel.tag}) — zwölf laufende Bildchen sind das Gegenteil von ruhig`);
/* GEGENPROBE: Im normalen Fall LAEUFT es. Sonst bewiese die Zeile
darüber nur, dass die Kachel immer stillsteht -- und dann sähe
niemand, was er verschickt. */
/* NEU LADEN, WEIL DIE KISTE SICH GEAENDERT HAT. Dieses Fenster hat
die Liste zuletzt geholt, als sie leer war -- ohne das Neuladen
zeigte die Gegenprobe auf ein leeres Gitter und wuerde rot, ohne
dass irgendetwas kaputt waere. (Beim Bauen genau so passiert.) */
await oeffne(dogi.seite, raumDogi);
await dogi.seite.click("#gif-auf");
await dogi.seite.waitForTimeout(1200);
const normaleKachel = await dogi.seite.evaluate(() => ({
tag: document.querySelector(".gif-kachel__knopf")?.firstElementChild?.tagName || "",
kacheln: document.querySelectorAll(".gif-kachel").length,
}));
ok(normaleKachel.kacheln === 1 && normaleKachel.tag === "IMG",
`Gegenprobe: ohne diese Einstellung läuft es (${normaleKachel.kacheln} Kachel, ${normaleKachel.tag})`);
await ruhig.close();
}
melde("\n=== 11. Stille Fehler ===");
{
/* ABSICHTLICH PROVOZIERTE ABSAGEN ZÄHLEN NICHT.
Abschnitt 3 lädt fünfmal Unfug hoch und einmal etwas zu Grosses.
Jede dieser Absagen schreibt der Browser als "Failed to load
resource … 415" in die Konsole -- das ist der BEWEIS, dass die
Abwehr gegriffen hat, nicht ein Mangel.
Der Filter ist eng gefasst: genau diese Zeile, genau diese beiden
Nummern. Ein weiter gefasster ("alles mit 'Failed'") wäre bequem
und würde ab morgen echte Ausfälle mit verschlucken. */
const echte = [...luna.konsole, ...pat.konsole, ...dogi.konsole];
const gewollt = [...luna.rauschen, ...pat.rauschen, ...dogi.rauschen];
ok(echte.length === 0, echte.length ? "Fehler in der Konsole: " + echte.slice(0, 4).join(" | ")
: `keine unerwarteten Fehler in drei Browsern (${gewollt.length} provozierte Absagen sauber getrennt)`);
/* GEGENPROBE ZUR TRENNUNG: Der zweite Topf darf nicht leer sein.
Wäre er es, hätte die Trennung nichts getan -- und der Haken
darüber wäre grün, ohne je etwas auseinandergehalten zu haben.
Neun absichtliche Absagen: fünf Uploads, ein 413, drei 404. */
ok(gewollt.length >= 6,
`die absichtlichen Absagen landen wirklich im zweiten Topf (${gewollt.length})`);
await luna.seite.evaluate(() => { setTimeout(() => { throw new Error("Absicht"); }, 0); });
await luna.seite.waitForTimeout(500);
ok(luna.konsole.some((z) => /Absicht/.test(z)),
`ein absichtlicher Fehler wird bemerkt (${luna.konsole.length})`);
}
await luna.ctx.close();
await pat.ctx.close();
await dogi.ctx.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
melde(`\n${geprueft} Prüfungen`
+ (nichtNachsehbar ? `, ${nichtNachsehbar} konnten nicht nachsehen` : ""));
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);