Community: die echte Sicht messbar machen -- und die Sternchen weg

Filipe: "perfektioniere jede einzelne kategorie der community."

ERST MESSEN. tools/community-blick.mjs meldet sich als GAST an und
fotografiert alle sieben Bretter. Das war noetig, weil als DogFather
auf jedem Brett Knoepfe und Felder stehen, die ein Mitglied nie sieht
-- wer so beurteilt, beurteilt eine Seite, die es fuer die Community
nicht gibt.

Der Weg dorthin war laenger als gedacht, und jeder Umweg steht als
Kommentar im Werkzeug:
  - Die Community hat eine EIGENE Anmeldeseite (crew-index.html); auf
    der anderen gibt es die Rolle "gast" gar nicht.
  - Wer sich zum ersten Mal anmeldet, muss sein Alter bestaetigen
    (400 "alter_offen"), sonst kommt er nicht hinein.
  - Ueber die echte Adresse schickt der Server HSTS, woraufhin
    Chromium alle Unterdateien auf https umbiegt -- der Testserver
    spricht nur http. Ergebnis: eine nackte Seite ohne Stil und ohne
    Skripte, auf der kein Knopf etwas tat. Die Kopfzeile ist richtig
    so und bleibt; stattdessen wird jetzt ueber die Kopfzeile
    angemeldet und das Sitzungsplaetzchen in den Browser gelegt.
Ohne diese drei Punkte fotografiert man die Anmeldeseite und haelt
"0 Karten, kein Schreibfeld" fuer einen Befund ueber die Bretter.

ERSTER BEFUND, BEHOBEN: An zehn Stellen stand **Fettschrift** als
Sternchen im Text. Auf "Regeln & Hilfe" hiess es woertlich "Drei
Stufen: **Hinweis** ..., **Pause** ..., **Ausschluss**" -- ausgerechnet
die drei Stufen, um die es geht, sahen aus wie ein Tippfehler.

KEIN innerHTML: Gebaut wird aus einzelnen Knoten. Was nicht zwischen
zwei Sternchenpaaren steht, wird Text und kann nichts anderes werden.
Ein einzelnes Sternchen bleibt ein Sternchen -- "3 * 4" ist keine
Hervorhebung.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-18 18:33:02 +02:00
co-authored by Claude Opus 5
parent e933de3248
commit d4e05e460a
35 changed files with 653 additions and 404 deletions
+198
View File
@@ -0,0 +1,198 @@
/* =====================================================================
DIE SIEBEN COMMUNITY-BRETTER MIT DEN AUGEN EINES MITGLIEDS
Aufruf: node tools/community-blick.mjs
BREITE=412 node tools/community-blick.mjs (Handy)
WARUM ALS GAST UND NICHT ALS ADMIN. Als DogFather sieht man auf
jedem Brett Knoepfe, Vorschlaege und Felder, die ein Mitglied nie zu
Gesicht bekommt. Wer die Bretter so beurteilt, beurteilt eine Seite,
die es fuer die Community nicht gibt -- und uebersieht genau das,
was ihr fehlt.
Der Startkatalog wird vorher vom Team uebernommen, damit die Bretter
nicht leer sind. Leere Bretter zu fotografieren beantwortet nur die
Frage, ob sie leer sind.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { request as anfrage } from "node:http";
const HIER = join(dirname(fileURLToPath(import.meta.url)), "..", "server");
const PORT = Number(process.env.PORT_BLICK || 4364);
const BASIS = `http://127.0.0.1:${PORT}`;
const ordner = mkdtempSync(join(tmpdir(), "ws-comm-"));
const DB = join(ordner, "workspace.db");
const BRETTER = ["treff", "anschlag", "ansteht", "wunsch", "highlight", "regeln", "mitmachen"];
let kind = null;
async function start() {
kind = spawn(process.execPath, [join(HIER, "index.js")], {
env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT),
SITE_ACCESS_SECRET: "lokaler-test",
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
stdio: ["ignore", "pipe", "pipe"] });
let aus = "";
kind.stdout.on("data", (d) => { aus += d; });
kind.stderr.on("data", (d) => { aus += d; });
for (let i = 0; i < 80; i++) {
await new Promise((r) => setTimeout(r, 250));
if (kind.exitCode !== null) break;
try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
if (a.status < 500) return; } catch { /* noch nicht oben */ }
}
throw new Error("Server startete nicht:\n" + aus.slice(-1500));
}
function personAnlegen(name, rolle, code) {
const d = new DatabaseSync(DB);
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salz, 32768, new Date().toISOString());
d.close();
}
await start();
kind.kill();
await new Promise((r) => setTimeout(r, 600));
personAnlegen("Filipe", "admin", "CODE-DOGI-0001");
personAnlegen("Mara", "gast", "CODE-GAST-0001");
await start();
/** Meldet an -- mit Host-Kopfzeile, weil die Rolle davon abhaengt.
*
* „gast" gibt es nur im Haus der Community. Der Host entscheidet
* darueber, und ein `fetch` auf 127.0.0.1 schickt eben 127.0.0.1.
* Deshalb hier ueber node:http, wo sich der Kopf setzen laesst.
*
* ALTERSBESTAETIGUNG: Wer sich zum ersten Mal in der Community
* anmeldet, muss bestaetigen, dass er 18 ist -- sonst antwortet der
* Server mit 400 „alter_offen". */
function rohAnfrage(pfad, host, koerper) {
return new Promise((f, s2) => {
const a = anfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: "POST",
headers: { Host: host, "Content-Type": "application/json" },
}, (an) => {
let t = "";
an.on("data", (c) => { t += c; });
an.on("end", () => f({ code: an.statusCode, text: t, kopf: an.headers }));
});
a.on("error", s2);
a.write(JSON.stringify(koerper));
a.end();
});
}
async function anmelden(rolle, code, host = "127.0.0.1") {
let a = await rohAnfrage("/workspace/api/anmelden", host, { rolle, code });
if (a.code === 400 && a.text.includes("alter_offen")) {
a = await rohAnfrage("/workspace/api/anmelden", host,
{ rolle, code, alter_ok: true });
}
if (a.code !== 200) {
throw new Error(`Anmeldung ${rolle}@${host} -> ${a.code} ${a.text.slice(0, 120)}`);
}
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
/* ---- Das Team uebernimmt die Vorschlaege, damit nichts leer ist ---- */
const kAdmin = await anmelden("admin", "CODE-DOGI-0001");
console.log("=== Startkatalog uebernehmen ===");
for (const b of BRETTER) {
const a = await fetch(`${BASIS}/workspace/api/bereich/${b}/start`, {
method: "POST",
headers: { "Content-Type": "application/json", cookie: kAdmin, origin: BASIS },
body: JSON.stringify({}),
});
const r = await a.json().catch(() => ({}));
console.log(` ${b.padEnd(11)} ${a.status} ${r.angelegt ?? r.anzahl ?? ""}`);
}
/* ---- Und jetzt als Mitglied hinsehen ------------------------------ */
const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
/* DIE ECHTE ADRESSE, nicht 127.0.0.1.
Der Server entscheidet ueber den Host-Kopf, ob er im Haus der
Community steht („crew.dogfather-universe.com") oder in dem der
Agentur. Auf 127.0.0.1 ist er in der Agentur -- und dort gibt es die
Rolle „gast" nicht. Der erste Versuch hat deshalb sieben Mal die
ANMELDESEITE fotografiert, mit „0 Karten" und ohne Schreibfeld, und
das sah aus wie ein Befund ueber die Bretter.
Chromium loest den Namen auf Wunsch selbst auf. */
const browser = await pw.chromium.launch();
const BREITE = Number(process.env.BREITE || 1500);
const ctx = await browser.newContext({
viewport: { width: BREITE, height: BREITE < 700 ? 915 : 1100 },
deviceScaleFactor: 1, isMobile: BREITE < 700, hasTouch: BREITE < 700 });
const seite = await ctx.newPage();
const fehlerAufSeite = [];
seite.on("console", (m) => { if (m.type() === "error") fehlerAufSeite.push(m.text()); });
/* Welche Dateien kommen NICHT an? Ohne diese Zeile sieht man nur eine
nackte Seite und raet, woran es liegt. */
const nichtGeladen = [];
seite.on("requestfailed", (r) => nichtGeladen.push(`${r.url().slice(-60)} (${r.failure()?.errorText})`));
seite.on("response", (r) => { if (r.status() >= 400) nichtGeladen.push(`${r.url().slice(-60)} -> ${r.status()}`); });
/* DIE SITZUNG EINES GASTES IN DEN BROWSER LEGEN.
Direkt im Browser anzumelden ginge nur ueber die echte Adresse --
und dort schickt der Server `Strict-Transport-Security`, woraufhin
Chromium alle Unterdateien auf https umbiegt. Der Testserver spricht
aber nur http: Ergebnis war eine nackte Seite ohne Stil und ohne
Skripte, auf der kein Knopf mehr etwas tat. Die Kopfzeile ist
richtig so und bleibt.
Also: anmelden, wo es geht (ueber die Kopfzeile), und das
Sitzungsplaetzchen in den Browser legen. Gemessen wird danach das,
was ein Mitglied wirklich sieht. */
const kGast = await anmelden("gast", "CODE-GAST-0001", "crew.dogfather-universe.com");
await ctx.addCookies(kGast.split("; ").filter(Boolean).map((z) => {
const [name, ...rest] = z.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
console.log("");
console.log("=== Was ein Mitglied sieht ===");
for (const b of BRETTER) {
await seite.goto(`${BASIS}/workspace/bereich.html?b=${b}`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1500);
const z = await seite.evaluate(() => ({
titel: document.querySelector("h1")?.textContent?.trim() || "",
karten: document.querySelectorAll('.eintrag-karte').length,
arten: [...new Set([...document.querySelectorAll('.eintrag-art, .art')].map((e) => e.textContent.trim()))].length,
knoepfeJeKarte: document.querySelector('.eintrag-karte')
? document.querySelector('.eintrag-karte').querySelectorAll('button, a.schritt').length : 0,
schreibfeld: !!document.querySelector('#neu-form, .neu-form, form#neu'),
hoeheJeKarte: document.querySelector('.eintrag-karte')
? Math.round(document.querySelector('.eintrag-karte').getBoundingClientRect().height) : 0,
startkatalog: !!document.querySelector("#startkatalog")?.children?.length,
leerText: document.querySelector(".leer, .leer-hinweis, .hinweis")?.textContent?.trim().slice(0, 80) || "",
hoehe: document.body.scrollHeight,
/* Sichtbare Sternchenpaare -- gemeint war Fettschrift. */
sternchen: (document.body.innerText.match(/\*\*/g) || []).length,
}));
console.log(`${b.padEnd(11)} „${z.titel}" · ${z.karten} Karten`
+ ` · Schreibfeld: ${z.schreibfeld ? "ja" : "NEIN"}`
+ ` · Arten: ${z.arten} · Karte ${z.hoeheJeKarte}px/${z.knoepfeJeKarte} Knöpfe`
+ (z.sternchen ? ` · STERNCHEN: ${z.sternchen}` : "")
+ (z.leerText ? ` · „${z.leerText}"` : ""));
const pfad = `C:/Users/qciga/AppData/Local/Temp/comm-${b}${BREITE < 700 ? "-handy" : ""}.png`;
await seite.screenshot({ path: pfad, fullPage: true });
}
console.log("");
console.log(fehlerAufSeite.length
? `ACHTUNG: ${fehlerAufSeite.length} Konsolenfehler: ${fehlerAufSeite.slice(0, 3).join(" | ")}`
: "Keine Konsolenfehler.");
await browser.close();
kind.kill();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }