/* ===================================================================== pruef-textform.mjs — FETT, KURSIV, UNTERSTRICHEN, DURCHGESTRICHEN ===================================================================== Filipes Wunsch vom 21.09.2026: "dan will ich dass die leute auch immer die art von text aussuchen können, fettgedrückt, unterstrichen und so alles. das überall sei es im chat jeder für sich oder sei es wenn die modis oder rechte hand oder dogfather eine aufgabe oder sachen posten." WAS HIER GEPRUEFT WIRD -- und warum gerade das: (1) DASS ES UEBERALL IST. Nicht ueber eine gepflegte Seitenliste, sondern abgeleitet: Wessen Skript die Gestaltung benutzt, dessen Seite muss sie auch laden. Und umgekehrt -- wer sie laedt und nicht benutzt, schleppt sie umsonst mit. Eine Liste, die niemand pflegt, kann nicht veralten. (2) DIE REGELN -- im echten Browser, an der echten ausgelieferten Datei. Ein Nachbau in Node wuerde beweisen, dass mein Nachbau funktioniert, nicht dass der Chat es tut. (3) DASS ECHTE ELEMENTE ENTSTEHEN. `` im Baum, nicht "**fett**" als Text und nicht "<strong>" als Zeichenkette. Alle drei sehen im Quelltext aehnlich aus und sind voellig verschieden. (4) DIE SICHERHEITSPROBE. Wer Text zu Auszeichnung macht, ist einen Tippfehler von `innerHTML` entfernt. `textform.js` baut mit `createTextNode`, also kann nichts passieren -- aber das ist eine Eigenschaft des Codes von heute, keine des Codes von morgen. Deshalb wird es GEMESSEN: Eine Nachricht mit `` und `` muss als Text dastehen, sichtbar, unwirksam. (5) DIE GEGENPROBEN. Ohne sie wuesste niemand, ob diese Pruefung ueberhaupt "nicht in Ordnung" sagen kann. ===== Was hier ABSICHTLICH NICHT geprueft wird ===== Das Aussehen der Leiste (Farben, Abstaende) -- das macht pruef-chat-optik an echten Bildpunkten, und zwei Pruefungen fuer dieselbe Frage altern verschieden. ===================================================================== */ import { mkdtempSync, rmSync, readFileSync, readdirSync, appendFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const ordner = mkdtempSync(join(tmpdir(), "ws-textform-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); import { notbremse } from "./helfer-notbremse.mjs"; const { eigenerPort } = await import("./helfer-port.mjs"); const PORT = await eigenerPort(import.meta, "die Textgestaltungspruefung"); 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(420_000, "pruef-textform"); await new Promise((r) => setTimeout(r, 1000)); const BASIS = `http://127.0.0.1:${PORT}`; const PROTOKOLL = join(process.cwd(), "pruef-textform-lauf.txt"); try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ } const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } }; let fehler = 0; const ok = (b, t) => { melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; melde("\n===== DIE TEXTGESTALTUNG ====="); /* ======================================================================= 1. WER SIE BENUTZT, LAEDT SIE AUCH -- UND UMGEKEHRT Abgeleitet, nicht abgeschrieben. Beide Richtungen, weil nur eine davon die halbe Sicherung waere: Eine Seite ohne das Modul stuerzt ab; eine Seite mit unbenutztem Modul kostet jeden Besucher Bytes. ======================================================================= */ melde("\n=== Jede Seite, die sie braucht, hat sie ==="); { const { ohneKommentar } = await import("./helfer-ohne-kommentar.mjs"); const wHtml = new URL("../workspace/", import.meta.url); const wJs = new URL("../workspace/assets/js/", import.meta.url); /* Welche Skripte benutzen die Gestaltung wirklich? OHNE Kommentare -- in calls.js steht das Wort "Textformen" in einem Erklaertext, und eine Pruefung, die daran anschlaegt, treibt einen dazu, die Erklaerung zu loeschen. */ const nutzer = new Set(); for (const datei of readdirSync(wJs).filter((f) => f.endsWith(".js"))) { const roh = readFileSync(new URL(datei, wJs), "utf8"); if (datei === "textform.js") continue; /* sie selbst zaehlt nicht */ if (/\bTextform\b/.test(ohneKommentar(roh, ".js"))) nutzer.add(datei); } ok(nutzer.size >= 3, `${nutzer.size} Skripte benutzen die Gestaltung (${[...nutzer].sort().join(", ")})`); let mitBedarf = 0, ohneBedarf = 0, fehlend = [], ueberfluessig = []; const seiten = readdirSync(wHtml).filter((f) => f.endsWith(".html")); for (const seite of seiten) { const html = readFileSync(new URL(seite, wHtml), "utf8"); const skripte = [...html.matchAll(/]+src="[^"]*assets\/js\/([a-z0-9-]+\.js)/g)] .map((m) => m[1]); const braucht = skripte.some((s) => nutzer.has(s)); const laedt = /assets\/js\/textform\.js/.test(html); if (braucht) { mitBedarf++; if (!laedt) fehlend.push(seite); } else { ohneBedarf++; if (laedt) ueberfluessig.push(seite); } } /* DIE ZAHLEN GEHOEREN IN DIE BEDINGUNG. Faende die Ableitung keine einzige Seite mit Bedarf, waere unten alles gruen und nichts geprueft -- genau der Fehler, der in diesem Haus schon zweimal unbemerkt blieb. */ ok(mitBedarf >= 3 && ohneBedarf >= 5, `${mitBedarf} Seiten brauchen sie, ${ohneBedarf} nicht (${seiten.length} insgesamt)`); ok(fehlend.length === 0, fehlend.length === 0 ? "jede Seite mit Bedarf laedt textform.js" : `diese Seiten brauchen sie und laden sie nicht: ${fehlend.join(", ")}`); ok(ueberfluessig.length === 0, ueberfluessig.length === 0 ? "und keine schleppt sie umsonst mit" : `laden sie ohne Bedarf: ${ueberfluessig.join(", ")}`); } /* ======================================================================= 2. VORBEREITUNG FUER ALLES WEITERE ======================================================================= */ const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes } = await import("node:crypto"); await fetch(BASIS + "/workspace/api/anmelden", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); for (let i = 0; i < 60; i++) { try { const p = new DatabaseSync(process.env.WORKSPACE_DB); try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } } catch { await new Promise((r) => setTimeout(r, 250)); } } const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; } anlegen("Filipe", "admin", "CODE-DOGI-0001"); const idRieke = anlegen("Rieke", "hand", "CODE-HAND-0001"); d.close(); const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch(); /* JEDER SEITENFEHLER WIRD MITGESCHRIEBEN. Beim ersten Lauf meldete diese Pruefung "die Leiste steht nicht am Feld" -- richtig, aber zwei Schritte vom Grund entfernt. Der Grund war ein `ReferenceError: $ is not defined`, den niemand sah: Die Seite laedt, sieht normal aus und kann nur eine Sache nicht mehr. Ein Skriptfehler ist immer ein Befund, auch wenn man nicht sofort weiss, was er anrichtet. */ const seitenFehler = []; async function alsRolle(rolle, code, wohin, breite = 1280) { const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } }); const seite = await ctx.newPage(); seite.on("pageerror", (e) => seitenFehler.push(`${wohin}: ${String(e).slice(0, 120)}`)); await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" }); await seite.click(`.rolle[data-rolle="${rolle}"]`); await seite.fill("#code", code); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 25000 }); await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" }); await seite.waitForTimeout(900); return { ctx, seite }; } const chef = await alsRolle("admin", "CODE-DOGI-0001", "chat.html"); /* ======================================================================= 3. DIE REGELN -- an der echten Datei, im echten Browser Dreizehn Faelle, und die Haelfte davon sind die, die NICHT gestaltet werden duerfen. Ein Zerleger, der alles auszeichnet, ist schlimmer als keiner: Dann heisst jeder Dateiname plotzlich anders, als er heisst. ======================================================================= */ melde("\n=== Die Regeln ==="); { const proben = await chef.seite.evaluate(() => { const f = window.Textform; if (!f) return null; /* Ein Stueck wird zu "text|stile" verdichtet, damit sich der Vergleich unten in einer Zeile lesen laesst. */ const kurz = (t) => f.teile(t) .map((s) => s.text + "|" + Object.keys(s.stile).sort().join("+")) .join(" // "); return { fett: kurz("**fett**"), kursiv: kurz("ein _wort_ hier"), unter: kurz("ein __wort__ hier"), durch: kurz("~~weg~~"), dateiname: kurz("datei_name_hier"), erwaehnung: kurz("@max_muster"), verschachtelt: kurz("**fett _und_ kursiv**"), rechnung: kurz("2 * 3 * 4"), nackt: kurz("**"), gewoehnlich: kurz("ganz gewoehnlich"), zweimal: kurz("**a** und **b**"), mittendrin: kurz("a_b_c"), leer: kurz(""), }; }); ok(!!proben, "die Gestaltung ist auf der Chatseite geladen"); if (proben) { const soll = { fett: "fett|fett", kursiv: "ein |" + " // " + "wort|kursiv" + " // " + " hier|", unter: "ein |" + " // " + "wort|unter" + " // " + " hier|", durch: "weg|durch", dateiname: "datei_name_hier|", erwaehnung: "@max_muster|", verschachtelt: "fett |fett // und|fett+kursiv // kursiv|fett", rechnung: "2 * 3 * 4|", nackt: "**|", gewoehnlich: "ganz gewoehnlich|", zweimal: "a|fett // und |" + " // " + "b|fett", mittendrin: "a_b_c|", leer: "", }; let stimmt = 0; for (const name of Object.keys(soll)) { const gut = proben[name] === soll[name]; if (gut) stimmt++; ok(gut, gut ? `${name}: ${JSON.stringify(proben[name])}` : `${name}: ${JSON.stringify(proben[name])} statt ${JSON.stringify(soll[name])}`); } ok(stimmt === 13, `${stimmt} von 13 Regelfaellen stimmen`); } } /* ======================================================================= 4. EINE NACHRICHT WIRD ZU ECHTEN ELEMENTEN Der Weg, den ein Mensch geht: Nachricht abschicken, Nachricht ansehen. Nicht die Funktion aufrufen -- die habe ich oben schon geprueft. ======================================================================= */ melde("\n=== Im Chat wird daraus wirklich Auszeichnung ==="); let raumId = null; { raumId = await chef.seite.evaluate(async (id) => { const a = await fetch("/workspace/api/chat/direkt", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mit: id }), }); const j = await a.json().catch(() => ({})); return j.raum_id ?? null; }, idRieke); ok(Number.isInteger(raumId), `ein Gespraech mit Rieke steht (Raum ${raumId})`); const SATZ = "So geht das: **fett**, _kursiv_, __unterstrichen__ und " + "~~durchgestrichen~~. Und datei_name_hier bleibt normal."; await chef.seite.evaluate(async ([id, text]) => { await fetch(`/workspace/api/chat/raeume/${id}/nachrichten`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); }, [raumId, SATZ]); await chef.seite.goto(`${BASIS}/workspace/chat.html?raum=${raumId}`, { waitUntil: "networkidle" }); await chef.seite.waitForTimeout(1400); const bild = await chef.seite.evaluate(() => { const p = document.querySelector(".chat-nachricht__text"); if (!p) return null; const holen = (tag) => [...p.querySelectorAll(tag)].map((e) => e.textContent); return { sichtbar: p.textContent, fett: holen("strong"), kursiv: holen("em"), unter: holen("u"), durch: holen("s"), }; }); ok(!!bild, "die Nachricht steht auf dem Bildschirm"); if (bild) { /* DIE VIER AUSZEICHNUNGEN SIND ECHTE ELEMENTE, keine Sternchen. */ ok(bild.fett.join() === "fett", ` traegt "${bild.fett.join()}"`); ok(bild.kursiv.join() === "kursiv", ` traegt "${bild.kursiv.join()}"`); ok(bild.unter.join() === "unterstrichen", ` traegt "${bild.unter.join()}"`); ok(bild.durch.join() === "durchgestrichen", ` traegt "${bild.durch.join()}"`); /* UND DIE ZEICHEN SIND WEG. Stuenden sie noch da, waere oben trotzdem alles gruen -- die Elemente gaebe es ja. */ ok(!bild.sichtbar.includes("**") && !bild.sichtbar.includes("~~") && !bild.sichtbar.includes("__"), "und die Sternchen und Striche stehen nicht mehr im Text"); /* GEGENPROBE IM SATZ SELBST: Der Dateiname bleibt, wie er ist. */ ok(bild.sichtbar.includes("datei_name_hier"), "datei_name_hier steht unveraendert da"); ok(!bild.kursiv.includes("name"), "und ist NICHT kursiv geworden"); } } /* ======================================================================= 5. WAS WIE HTML AUSSIEHT, BLEIBT TEXT Die wichtigste Zeile dieser Datei. `textform.js` baut heute mit `createTextNode` -- das ist sicher, aber es ist eine Eigenschaft von heute. Wer morgen aus Bequemlichkeit `innerHTML` schreibt, faellt hier auf, und nicht erst, wenn jemand es ausnutzt. ======================================================================= */ melde("\n=== Sicherheitsprobe: nichts wird zu HTML ==="); { const BOES = "nicht fett " + "**aber das schon**"; await chef.seite.evaluate(async ([id, text]) => { await fetch(`/workspace/api/chat/raeume/${id}/nachrichten`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); }, [raumId, BOES]); await chef.seite.waitForTimeout(1500); const lage = await chef.seite.evaluate(() => { const alle = [...document.querySelectorAll(".chat-nachricht__text")]; const p = alle[alle.length - 1]; return { text: p ? p.textContent : null, bTags: p ? p.querySelectorAll("b").length : -1, bilder: p ? p.querySelectorAll("img").length : -1, fett: p ? [...p.querySelectorAll("strong")].map((e) => e.textContent).join() : null, geknackt: !!window.__geknackt, }; }); ok(lage.text !== null, "die zweite Nachricht ist da"); ok(lage.text.includes("nicht fett"), "der HTML-Schnipsel steht als TEXT auf dem Bildschirm"); ok(lage.bTags === 0, `kein -Element entstanden (${lage.bTags})`); ok(lage.bilder === 0, `kein -Element entstanden (${lage.bilder})`); ok(lage.geknackt === false, "und nichts davon wurde ausgefuehrt"); /* GEGENPROBE: In derselben Nachricht wird das ECHTE Zeichen sehr wohl ausgewertet -- sonst waere oben nur bewiesen, dass gar nichts gestaltet wird. */ ok(lage.fett === "aber das schon", "und die echte Auszeichnung in derselben Nachricht wirkt trotzdem"); } /* ======================================================================= 6. DIE LEISTE -- da, wo Menschen schreiben ======================================================================= */ melde("\n=== Die Leiste ueber dem Schreibfeld ==="); { const leiste = await chef.seite.evaluate(() => { const k = document.querySelector(".textform"); if (!k) return null; return { schilder: [...k.querySelectorAll("button")].map((b) => b.textContent), titel: [...k.querySelectorAll("button")].map((b) => b.title), benannt: k.getAttribute("aria-label"), }; }); ok(!!leiste, "im Chat steht eine Leiste"); if (leiste) { ok(leiste.schilder.join("") === "FKUS", `vier Knoepfe: ${leiste.schilder.join(" ")}`); ok(leiste.titel.every((t) => t && t.length > 3), "jeder sagt im Titel, was er tut"); ok(leiste.benannt === "Textgestaltung", "und die Gruppe ist fuer Vorleseprogramme benannt"); } /* SIE TUT AUCH ETWAS. Ein Knopf, der nur dasteht, ist kein Knopf. */ const umgelegt = await chef.seite.evaluate(() => { const feld = document.getElementById("text"); if (!feld) return null; feld.value = "hallo welt"; feld.setSelectionRange(0, 5); feld.focus(); document.querySelector(".textform__knopf--fett").click(); const nachher = feld.value; /* Noch einmal derselbe Knopf -- das muss es wieder wegnehmen. */ feld.setSelectionRange(2, 7); document.querySelector(".textform__knopf--fett").click(); return { einmal: nachher, zweimal: feld.value }; }); ok(umgelegt && umgelegt.einmal === "**hallo** welt", `der F-Knopf macht daraus ${JSON.stringify(umgelegt && umgelegt.einmal)}`); ok(umgelegt && umgelegt.zweimal === "hallo welt", "und noch einmal gedrueckt nimmt er es wieder weg"); } /* ======================================================================= 7. UND AUF DEN ANDEREN SEITEN Filipes Wunsch nennt ausdruecklich "wenn die modis oder rechte hand oder dogfather eine aufgabe oder sachen posten" -- das ist das Aufgabenbrett und sind die Bretter, nicht nur der Chat. ======================================================================= */ melde("\n=== Auch beim Aufgaben- und Brettschreiben ==="); { for (const [seite, oeffnen, feld] of [ ["aufgaben.html", "#neu-auf", "#f-text"], ["bereich.html?b=ideen", "#neu-auf", "#f-text"], ]) { const p = await alsRolle("admin", "CODE-DOGI-0001", seite); try { /* Das Formular aufklappen -- solange es zu ist, gibt es das Feld noch gar nicht, und die Leiste koennte gar nicht dort sein. */ const auf = await p.seite.$(oeffnen); if (auf) { await auf.click(); await p.seite.waitForTimeout(700); } const lage = await p.seite.evaluate((sel) => { const f = document.querySelector(sel); if (!f) return { feld: false }; const l = f.parentElement && f.parentElement.querySelector(".textform"); return { feld: true, leiste: !!l, knoepfe: l ? [...l.querySelectorAll("button")].map((b) => b.textContent).join("") : "", }; }, feld); ok(lage.feld, `${seite}: das Schreibfeld ist da`); ok(lage.leiste === true, `${seite}: und die Leiste steht daran`); ok(lage.knoepfe === "FKUS", `${seite}: mit denselben vier Knoepfen`); } finally { await p.seite.waitForTimeout(150); await p.ctx.close(); } } } /* ======================================================================= 8. GEGENPROBEN -- kann diese Pruefung ueberhaupt rot werden? ======================================================================= */ melde("\n=== Gegenproben ==="); { /* (a) Ohne die Gestaltung bliebe der Text roh. Statt die Datei zu entfernen (das faende nur heraus, dass dann gar nichts geht), wird der Zerleger fuer einen Augenblick abgeschaltet -- genau wie es der Rueckfall in chat.js vorsieht. */ const ohne = await chef.seite.evaluate(() => { const echt = window.Textform; window.Textform = null; try { const p = document.createElement("p"); /* der Rueckfallweg aus chat.js, woertlich */ const teilchen = window.Textform ? window.Textform.teile("**fett**") : [{ text: "**fett**", stile: {} }]; for (const t of teilchen) p.append(document.createTextNode(t.text)); return { text: p.textContent, fett: p.querySelectorAll("strong").length }; } finally { window.Textform = echt; } }); ok(ohne.text === "**fett**" && ohne.fett === 0, "ohne die Gestaltung stuenden die Sternchen da -- die Probe kann also anschlagen"); /* (b) Und die Seitenableitung: ein erfundenes Skript, das die Gestaltung benutzt, muss eine Seite ohne Modul auffliegen lassen. Sonst waere Abschnitt 1 nur zufaellig gruen. */ const { ohneKommentar } = await import("./helfer-ohne-kommentar.mjs"); const erfunden = "/* Textform steht hier nur im Kommentar */\nvar x = 1;"; const echtNutzer = "var y = window.Textform.zeichne('a');"; ok(/\bTextform\b/.test(ohneKommentar(erfunden, ".js")) === false, "ein Skript, das die Gestaltung nur im Kommentar nennt, gilt nicht als Nutzer"); ok(/\bTextform\b/.test(ohneKommentar(echtNutzer, ".js")) === true, "eines, das sie wirklich aufruft, schon"); } melde("\n=== Kein Skript ist unterwegs gestolpert ==="); ok(seitenFehler.length === 0, seitenFehler.length === 0 ? "keine der besuchten Seiten meldet einen Skriptfehler" : `Skriptfehler: ${seitenFehler.join(" | ")}`); await chef.ctx.close(); await browser.close(); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ } melde(`\n ${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); process.exit(fehler ? 1 : 0);