Filipe: "dan will ich auch dass man im chat auch sprachnachrichten und
gifts reinschicken kann. also nur die modis rechte linke hand und
dogfather."
ICH LESE "gifts" ALS GIFs -- die bewegten Bildchen -- und sage das
ausdruecklich, statt es stillschweigend anzunehmen. Im Zusammenhang
("im chat reinschicken", direkt neben Sprachnachrichten) passt nichts
anderes. Sollte er Geschenke gemeint haben, sagt er es, und dann baue
ich das.
EINE EIGENE KISTE STATT GIPHY ODER TENOR
----------------------------------------
Beide brauchen ein Konto und einen Schluessel und bekommen bei jedem
Tastendruck mit, wonach hier gesucht wird -- an eine fremde Firma, aus
einem Arbeitsplatz heraus, in dem sonst nichts nach aussen geht. Dazu
kosten sie ab einer Menge Geld. Beides steht quer zu dem, wie dieses
Haus gebaut ist.
Die Kiste laeuft auf demselben Server wie alles andere, kostet nichts
und wird mit der Zeit besser statt schlechter: Was darin liegt, hat das
Team ausgesucht. Zehn gute GIFs, die alle kennen, sind im Alltag mehr
wert als zehn Millionen fremde -- ein Katalog mit allem ist nicht mehr
Auswahl, sondern weniger.
HABEN WIR DAS SCHON? WIRD AM INHALT BEANTWORTET
-----------------------------------------------
Der Schluessel ist der SHA-256 der Datei. Ueber den NAMEN zu
vergleichen waere die naheliegende Abkuerzung -- und sie versagt genau
dort, wo Dateien "giphy.gif", "giphy(1).gif" und "download.gif"
heissen, also fast immer. Dasselbe Bild waere dreimal dieselbe Kachel.
Dasselbe GIF zweimal hineinzulegen ist deshalb KEIN Fehler: Es ist
drin, und genau das wollte man. Eine Absage waere formal richtig und im
Erleben falsch.
DAS WICHTIGSTE STUECK: ES WIRD KOPIERT, NICHT VERWIESEN
-------------------------------------------------------
Eine Nachricht zurueckzunehmen entfernt ihre Datei von der Platte. Laege
ein Kachel-GIF in demselben Ordner und mehrere Nachrichten zeigten
darauf, 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.
Deshalb liegt die Kiste in einem eigenen Ordner (chat-gifs/), und beim
Verschicken wird kopiert. Danach ist es ein ganz normaler Anhang: im
Verlauf, in der Suche, zuruecknehmbar, unter derselben Nachtruhe. Keine
zweite Sorte Nachricht mit eigenen Rechten und eigenem Loeschen.
Geprueft wird genau das: GIF hineinlegen, verschicken, aus der Kiste
nehmen -- und danach steht das verschickte immer noch im Gespraech.
"ALSO NUR" GILT AUCH HIER
-------------------------
Ansehen, hineinlegen, verschicken: alle drei nur fuer Team Dogi und
DogFather (istTeamDogi). Der Satz stand im selben Atemzug wie die
Sprachnachrichten; es gibt keinen Grund, warum er fuer das eine gelten
sollte und fuer das andere nicht. Gemessen auch am Knopf vorbei:
403/403/403.
AUGENSCHONEND -- HIER EINE ECHTE FRAGE, KEINE FORMSACHE
--------------------------------------------------------
Zwoelf gleichzeitig laufende Bildchen sind das Gegenteil von ruhig. Wer
"Bewegung reduzieren" gesetzt hat, bekommt deshalb STEHENDE Bilder: das
erste Einzelbild, im Browser auf eine Zeichenflaeche gemalt (kostet
keine zweite Datei), und es laeuft erst, wenn man es anfasst oder mit
der Tastatur darauf steht.
Fuer alle anderen laufen sie. Eine GIF-Auswahl, in der sich nichts
bewegt, ist eine, in der man nicht sieht, was man verschickt -- deshalb
steht dazu eine Gegenprobe in der Pruefung.
NEBENBEFUND, UND ER WIEGT SCHWERER ALS DIE GIFS
-----------------------------------------------
Weil die Kiste einen neuen Datenordner braucht, lief
tools/wiederherstellung-proben.mjs -- und meldete, dass der Ordner
"material" seit dem 22.09. NICHT gesichert wird. 2,4 MB
Materialbibliothek auf dem Server, nach einem Plattenausfall weg, ohne
dass die taegliche Meldung je etwas gesagt haette. Genau die Luecke,
wegen der diese Probe ueberhaupt gebaut wurde -- und sie hat sie
gefunden, weil sie die Ordner aus dem QUELLTEXT liest statt aus einer
gepflegten Liste.
Eingetragen. Danach frisch geholt und zurueckgespielt: 18 von 18 gruen,
"DIE SICHERUNG LAESST SICH ZURUECKSPIELEN" (vorher 3 Fehler).
Dabei war die Gegenprobe daneben selbst kaputt: Sie prueft, ob ein
erfundener Ordner als fehlend erkannt wird, und verlangte dafuer
`=== 1`. Das setzt voraus, dass sonst nichts fehlt. An dem Tag, an dem
wirklich etwas fehlte, wurde sie rot und zeigte damit auf sich selbst
statt auf den Fund -- zwei Meldungen, eine Ursache, und die zweite
lenkt von der ersten ab. Jetzt zaehlt sie die Differenz.
GEPRUEFT
--------
pruef-chat-anhaenge: 109 Pruefungen, 0 Fehler (vorher 86, davor 60).
pruef-chat, pruef-chat-aufloesen (126), pruef-chat-optik: gruen.
tools/wiederherstellung-proben: 18 von 18.
Co-Authored-By: Claude Opus 5 <[email protected]>
1065 lines
53 KiB
JavaScript
1065 lines
53 KiB
JavaScript
/* 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");
|
||
|
||
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;
|
||
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 } });
|
||
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})`);
|
||
}
|
||
|
||
/* =======================================================================
|
||
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})`);
|
||
|
||
/* 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)`);
|
||
|
||
/* ---- 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 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 ---------------- */
|
||
await dogi.seite.evaluate(() => { window.confirm = () => true; });
|
||
await dogi.seite.click("#gif-auf");
|
||
await dogi.seite.waitForTimeout(600);
|
||
await 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`);
|
||
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
|
||
process.exit(fehler ? 1 : 0);
|