diff --git a/server/pruef-textform.mjs b/server/pruef-textform.mjs new file mode 100644 index 00000000..0b0eee7c --- /dev/null +++ b/server/pruef-textform.mjs @@ -0,0 +1,500 @@ +/* ===================================================================== + 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); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 72951a88..e68ac40c 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +