/* =====================================================================
MEHRERE BILDER IM SUPPORT — IM ECHTEN BROWSER (02.10.2026)
=====================================================================
VanVan im Support, Meldung #11 -- VIERMAL gemeldet: „Hier im
Supportbereich kann man immer nur ein Bild hinzufügen bei einer
Meldung. 2-3 wären besser." Und in der zweiten Runde der Satz, auf
den es ankommt:
„wenn man es nacheinander versucht hinzuzufügen wird das Bild
immer nur ersetzt."
WARUM `pruef-support` DAFÜR NICHT REICHT. Die dortigen Prüfungen
sprechen mit dem Server und bekommen drei Bilder zurück -- grün,
und trotzdem wäre VanVans Problem unverändert da. Denn „es wird
nur ersetzt" passiert NICHT am Server, sondern in `support.js`:
Eine neue Auswahl im Dateifeld wirft die vorige weg, und zwar auch
dann, wenn das Feld `multiple` trägt. Ein Server, der drei Bilder
annimmt, hilft niemandem, dem der Browser nur eines mitgibt.
Also wird hier das gemessen, was SIE tut: dreimal hintereinander
ein Bild wählen. Und einmal viermal, damit die Grenze nicht nur im
Quelltext steht.
DREI AUSGÄNGE. Steht Playwright nicht bereit, endet die Prüfung mit
2 und sagt, was fehlt -- „konnte nicht nachsehen" ist etwas anderes
als „in Ordnung".
Aufrufen mit: node server/pruef-support-bilder.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eigenerPort } from "./helfer-port.mjs";
const PORT = await eigenerPort(import.meta);
const ordner = mkdtempSync(join(tmpdir(), "ws-supbild-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(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 });
};
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(300_000, "pruef-support-bilder");
await new Promise((r) => setTimeout(r, 900));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
const BASIS = `http://127.0.0.1:${PORT}`;
let geprueft = 0, fehler = 0;
function ok(bedingung, text) {
geprueft++;
if (bedingung) { console.log(" ok " + text); return true; }
fehler++; console.log(" FEHL " + text); return false;
}
const melde = (t) => console.log(t);
/* ---- Zugänge ------------------------------------------------------ */
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
const d = new DatabaseSync(process.env.WORKSPACE_DB);
const jetzt = new Date().toISOString();
const sch = randomBytes(32).toString("hex");
d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
.run("code_kennung_schluessel", sch, jetzt);
const anlegen = (name, rolle, code) => {
const s = randomBytes(16).toString("hex");
const h = scryptSync(code, s, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
return d.prepare("INSERT INTO personen"
+ " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
.get(name, rolle, h, s, 32768,
createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Miss", "modi", "CODE-MODI-0001");
d.close();
async function keksFuer(rolle, code) {
const an = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }),
});
return (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
}
const keksModi = await keksFuer("modi", "CODE-MODI-0001");
const keksHand = await keksFuer("admin", "CODE-DOGI-0001");
/* ---- Playwright, oder der dritte Ausgang -------------------------- */
let pw = null;
try {
pw = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
} catch (f) {
console.log("KONNTE NICHT NACHSEHEN: Playwright fehlt (" + (f?.message || "") + ").");
console.log(" Diese Pruefung braucht einen echten Browser -- was VanVan gemeldet hat,");
console.log(" passiert im Dateifeld und ist ueber die Schnittstelle nicht zu sehen.");
console.log(" Nachinstallieren in C:/Users/qciga/Documents/Obelix/Analyse:");
console.log(" npm i -D playwright && npx playwright install chromium");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
process.exit(2);
}
const browser = await pw.chromium.launch();
/** Drei verschiedene Bilder -- verschieden LANG und verschieden GROSS.
*
* Drei gleiche waeren nicht zu unterscheiden: Wuerde die Oberflaeche
* dreimal dasselbe anzeigen oder die Reihenfolge vertauschen, faellt
* das nur auf, wenn die Bilder sich unterscheiden. */
const BILDER = [
{ name: "eins.png", typ: "image/png", b64:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" },
{ name: "zwei.gif", typ: "image/gif", b64:
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" },
{ name: "drei.webp", typ: "image/webp", b64:
"UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==" },
];
/** Eine Datei in ein `` legen -- SO WIE EIN MENSCH.
*
* `setInputFiles` von Playwright ersetzt die ganze Auswahl und
* feuert `change` -- also genau das, was auch der Dateidialog des
* Browsers tut. Mehrmals aufgerufen ist es genau VanVans Weg:
* „nacheinander versucht hinzuzufuegen". */
async function waehle(seite, wahl, bilder) {
await seite.setInputFiles(wahl, bilder.map((b) => ({
name: b.name, mimeType: b.typ, buffer: Buffer.from(b.b64, "base64"),
})));
await seite.waitForTimeout(120);
}
async function neueSeite(breite, keks) {
const kontext = await browser.newContext({
viewport: { width: breite, height: 900 }, hasTouch: breite <= 860,
});
await kontext.addCookies(keks.split("; ").map((k) => {
const [n, ...r] = k.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
seite.on("pageerror", (f) => { console.log(" JS-FEHLER: " + f.message); fehler++; });
await seite.goto(BASIS + "/workspace/support.html", { waitUntil: "networkidle" });
await seite.waitForTimeout(500);
return { kontext, seite };
}
/* =====================================================================
1. NACHEINANDER WAEHLEN LEGT DAZU — VanVans Satz, gemessen
===================================================================== */
melde("\n=== Nacheinander waehlen (VanVan, Meldung #11, Runde 2) ===");
{
const { kontext, seite } = await neueSeite(390, keksModi);
const zahl = () => seite.locator("#vorschau .s-vorschau__eins").count();
ok(await zahl() === 0, `vorher ist nichts gewaehlt (${await zahl()})`);
await waehle(seite, "#bild", [BILDER[0]]);
const n1 = await zahl();
ok(n1 === 1, `nach dem ersten Bild: eine Vorschau (${n1})`);
/* HIER WAR DER FEHLER. Vorher stand die Auswahl in einer einzigen
Variablen -- der zweite Griff warf den ersten weg, und genau das
hat VanVan beschrieben. */
await waehle(seite, "#bild", [BILDER[1]]);
const n2 = await zahl();
ok(n2 === 2, `nach dem ZWEITEN: zwei (${n2}) — vorher wurde hier ersetzt`);
await waehle(seite, "#bild", [BILDER[2]]);
const n3 = await zahl();
ok(n3 === 3, `nach dem dritten: drei (${n3})`);
/* DIE REIHENFOLGE BLEIBT, und es sind wirklich die drei. Abgelesen
am Alternativtext, in dem der Dateiname steht. */
const texte = await seite.locator("#vorschau img").evaluateAll(
(bilder) => bilder.map((b) => b.alt));
ok(texte.length === 3 && /eins\.png/.test(texte[0])
&& /zwei\.gif/.test(texte[1]) && /drei\.webp/.test(texte[2]),
`und zwar in der Reihenfolge des Waehlens (${texte.join(", ")})`);
/* ---- DIE GRENZE ----------------------------------------------- */
await waehle(seite, "#bild", [{ ...BILDER[0], name: "vier.png" }]);
const n4 = await zahl();
ok(n4 === 3, `ein viertes kommt nicht dazu (${n4})`);
const hinweis = (await seite.locator("#melden-fehler").textContent() || "").trim();
ok(/3 Bilder|drei Bilder/i.test(hinweis) && hinweis.length > 10,
`und es steht dabei, warum („${hinweis}")`);
/* ---- DASSELBE BILD ZWEIMAL IST EINES --------------------------- */
const vorher = await zahl();
await waehle(seite, "#bild", [BILDER[0]]);
ok(await zahl() === vorher,
`dasselbe Bild noch einmal zaehlt nicht doppelt (${await zahl()})`);
/* ---- EINZELN WIEDER WEGNEHMEN ---------------------------------- */
await seite.locator("#vorschau .s-vorschau__eins").nth(1)
.locator(".s-vorschau__weg").click();
await seite.waitForTimeout(120);
const nachWeg = await seite.locator("#vorschau img").evaluateAll(
(bilder) => bilder.map((b) => b.alt));
ok(nachWeg.length === 2 && /eins\.png/.test(nachWeg[0]) && /drei\.webp/.test(nachWeg[1]),
`das mittlere einzeln entfernen laesst die anderen stehen (${nachWeg.join(", ")})`);
/* ---- UND „ALLE WEG" NIMMT ALLE ---------------------------------- */
await seite.locator("#bild-weg").click();
await seite.waitForTimeout(120);
ok(await zahl() === 0, `„Alle weg" raeumt auf (${await zahl()})`);
ok(await seite.locator("#vorschau").isHidden(),
"und der leere Kasten nimmt keinen Platz mehr ein");
await kontext.close();
}
/* =====================================================================
2. DIE KREUZE SIND ZU TREFFEN — 44 px und nichts davor
===================================================================== */
melde("\n=== Die Kreuze auf 390 px ===");
{
const { kontext, seite } = await neueSeite(390, keksModi);
for (const b of BILDER) await waehle(seite, "#bild", [b]);
const mass = await seite.evaluate(() => {
const raus = [];
for (const k of document.querySelectorAll("#vorschau .s-vorschau__weg")) {
const r = k.getBoundingClientRect();
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
raus.push({
breite: Math.round(r.width), hoehe: Math.round(r.height),
frei: k.contains(oben) || k === oben,
imBild: r.right <= document.documentElement.clientWidth + 0.5 && r.left >= -0.5,
});
}
return raus;
});
ok(mass.length === 3, `drei Kreuze da (${mass.length})`);
const zuKlein = mass.filter((m) => m.breite < 44 || m.hoehe < 44);
ok(mass.length === 3 && zuKlein.length === 0,
`jedes misst mindestens 44x44 (${mass.map((m) => `${m.breite}x${m.hoehe}`).join(", ")})`);
const verdeckt = mass.filter((m) => !m.frei);
ok(mass.length === 3 && verdeckt.length === 0,
`und keines liegt unter etwas anderem (${verdeckt.length} verdeckt)`);
const draussen = mass.filter((m) => !m.imBild);
ok(mass.length === 3 && draussen.length === 0,
`alle drei stehen im sichtbaren Bereich (${draussen.length} daneben)`);
/* GEGENPROBE: Kann diese Messung ueberhaupt „verdeckt" sagen? Ein
Deckel ueber das erste Kreuz, und sie MUSS anschlagen -- sonst
waere der gruene Haken oben wertlos. */
await seite.evaluate(() => {
const k = document.querySelector("#vorschau .s-vorschau__weg");
const r = k.getBoundingClientRect();
const deckel = document.createElement("div");
deckel.id = "probe-deckel";
deckel.style.cssText = `position:fixed;left:${r.left}px;top:${r.top}px;`
+ `width:${r.width}px;height:${r.height}px;z-index:9999;`;
document.body.append(deckel);
});
const mitDeckel = await seite.evaluate(() => {
const k = document.querySelector("#vorschau .s-vorschau__weg");
const r = k.getBoundingClientRect();
const oben = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return k.contains(oben) || k === oben;
});
ok(mitDeckel === false, "Gegenprobe: ein Deckel darueber wird erkannt");
await seite.evaluate(() => document.getElementById("probe-deckel")?.remove());
/* ---- UND NICHTS RAGT AUS DER SEITE -------------------------- */
const ueber = await seite.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(ueber <= 0, `die Seite mit drei Vorschauen ist nicht zu breit (${ueber} px)`);
await kontext.close();
}
/* =====================================================================
3. ABSCHICKEN UND WIEDERSEHEN
===================================================================== */
melde("\n=== Abschicken und wiedersehen ===");
let meldungId = 0;
{
const { kontext, seite } = await neueSeite(390, keksModi);
await seite.fill("#text", "Vorher, der Klick und nachher – drei Bilder dazu.");
for (const b of BILDER) await waehle(seite, "#bild", [b]);
await seite.click("#senden");
await seite.waitForTimeout(1200);
const bilder = await seite.locator("#meine-liste .s-karte__bild img").count();
ok(bilder === 3, `die eigene Karte zeigt alle drei (${bilder})`);
/* GELADEN HEISST GELADEN. Ein mit kaputter Adresse steht
genauso im Baum wie ein gutes -- `naturalWidth` ist der
Unterschied. Ohne diese Zeile waeren drei 404er gruen. */
await seite.waitForTimeout(600);
const echt = await seite.locator("#meine-liste .s-karte__bild img").evaluateAll(
(bs) => bs.filter((b) => b.complete && b.naturalWidth > 0).length);
ok(echt === 3, `und alle drei kommen wirklich vom Server (${echt} geladen)`);
const wege = await seite.locator("#meine-liste .s-karte__bild a").evaluateAll(
(as) => as.map((a) => a.getAttribute("href")));
ok(new Set(wege).size === 3,
`drei verschiedene Adressen, nicht dreimal dieselbe (${new Set(wege).size})`);
const ueber = await seite.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(ueber <= 0, `die Karte mit drei Bildern ist nicht zu breit (${ueber} px)`);
/* Das Feld ist danach leer -- sonst haengt beim naechsten Melden
noch die alte Auswahl dran. */
ok(await seite.locator("#vorschau .s-vorschau__eins").count() === 0,
"nach dem Abschicken ist die Auswahl leer");
meldungId = await seite.evaluate(() => {
const k = document.querySelector("#meine-liste .s-karte");
return Number((k?.id || "").replace("meldung-", "")) || 0;
});
ok(meldungId > 0, `die Meldung hat eine Nummer (#${meldungId})`);
await kontext.close();
}
/* =====================================================================
4. UND IM „NOCH NICHT"-FENSTER GENAUSO
===================================================================== */
melde("\n=== Bilder in der Rueckfrage ===");
{
/* Die Leitung schickt die Meldung zum Melder zurueck -- erst dann
gibt es die zwei Knoepfe. */
await fetch(BASIS + `/workspace/api/support/${meldungId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie: keksHand },
body: JSON.stringify({ stand: "erledigt", antwort: "Sollte jetzt gehen." }),
});
const { kontext, seite } = await neueSeite(390, keksModi);
await seite.locator("#meine-liste .s-tun--geht").waitFor({ timeout: 8000 });
await seite.locator("#meine-liste .s-karte__tun .schritt:not(.s-tun--geht)").click();
await seite.locator("dialog.nachfrage").waitFor({ state: "visible", timeout: 8000 });
ok(true, "das Fenster „Geht es noch nicht?“ geht auf");
const dz = () => seite.locator("dialog.nachfrage .nachfrage__vorschau").count();
ok(await dz() === 0, `vorher keine Vorschau (${await dz()})`);
await waehle(seite, "#nachfrage-bild", [BILDER[0]]);
ok(await dz() === 1, `ein Bild (${await dz()})`);
await waehle(seite, "#nachfrage-bild", [BILDER[1]]);
ok(await dz() === 2, `nacheinander gewaehlt macht zwei (${await dz()}) — auch hier`);
await waehle(seite, "#nachfrage-bild", [BILDER[2]]);
await waehle(seite, "#nachfrage-bild", [{ ...BILDER[1], name: "vier.gif" }]);
ok(await dz() === 3, `und bei drei ist Schluss (${await dz()})`);
await seite.fill("#nachfrage-grund", "Geht noch nicht, hier drei Bildschirmfotos.");
await seite.locator("dialog.nachfrage [data-teil=\"ja\"]").click();
await seite.waitForTimeout(1500);
const imVerlauf = await seite.locator("#meine-liste .s-runde .s-karte__bild img").count();
ok(imVerlauf === 3, `im Verlauf haengen alle drei an der Runde (${imVerlauf})`);
const echt = await seite.locator("#meine-liste .s-runde .s-karte__bild img").evaluateAll(
(bs) => bs.filter((b) => b.complete && b.naturalWidth > 0).length);
ok(echt === 3, `und alle drei laden (${echt})`);
/* DIE BILDER DER MELDUNG BLEIBEN OBEN, die der Antwort unten. Sonst
waere nach drei Runden nicht mehr zu sehen, was wozu gehoert. */
const oben = await seite.locator("#meine-liste .s-karte > .s-karte__bild img").count();
ok(oben === 3, `oben stehen weiterhin die drei vom Melden (${oben})`);
const ueber = await seite.evaluate(() =>
document.documentElement.scrollWidth - document.documentElement.clientWidth);
ok(ueber <= 0, `und die Seite bleibt schmal genug (${ueber} px)`);
await kontext.close();
}
/* =====================================================================
5. AUF DEM GROSSEN BILDSCHIRM: KEINE LEERE SPALTE
=====================================================================
Ab 760 px steht das Bild der Meldung NEBEN dem Text. Seit die
Rundenbilder denselben Kasten benutzen, trifft diese Regel ohne den
Pfeil in `:has(> …)` auch auf eine Karte zu, deren einziges Bild
tief im Verlauf steckt -- die Karte bekaeme eine zweite Spalte, in
der nichts steht.
===================================================================== */
melde("\n=== Die zweite Spalte nur mit eigenem Bild ===");
{
/* Eine Meldung OHNE Bild, aber mit einem Bild in der Antwortrunde. */
const neu = await fetch(BASIS + "/workspace/api/support", {
method: "POST",
headers: { cookie: keksModi, "Content-Type": "text/plain",
"x-text": encodeURIComponent("Diese Meldung hat selbst kein Bild.") },
});
const id2 = (await neu.json())?.meldung?.id;
await fetch(BASIS + `/workspace/api/support/${id2}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie: keksHand },
body: JSON.stringify({ stand: "erledigt", antwort: "Schau mal." }),
});
await fetch(BASIS + `/workspace/api/support/${id2}/rueckmeldung`, {
method: "POST",
headers: { cookie: keksModi, "Content-Type": "application/octet-stream",
"x-geht": "nein", "x-text": encodeURIComponent("Geht noch nicht, siehe Bild.") },
body: Buffer.from(BILDER[0].b64, "base64"),
});
const { kontext, seite } = await neueSeite(1280, keksModi);
const mass = await seite.evaluate((nr) => {
const k = document.getElementById("meldung-" + nr);
if (!k) return null;
const st = getComputedStyle(k);
const text = k.querySelector(".s-karte__text");
return {
raster: st.display,
spalten: st.gridTemplateColumns,
textBreite: Math.round(text?.getBoundingClientRect().width || 0),
karteBreite: Math.round(k.getBoundingClientRect().width),
};
}, id2);
ok(!!mass, `die Karte ohne eigenes Bild ist da (#${id2})`);
/* GEMESSEN WIRD `display`, NICHT DIE TEXTBREITE. Erst stand hier
„der Text nimmt mehr als 90 % ein" -- und die Gegenprobe darunter
wurde rot, obwohl alles stimmte: Die Pruefbilder sind 1x1 Bildpunkt
gross, die Bildspalte damit wenige Pixel breit, und der Text nimmt
auch MIT zweiter Spalte 93 %. Die Zahl mass die Bildgroesse, nicht
die Regel. `display` sagt genau das, was die Regel schaltet. */
ok(mass?.raster !== "grid",
`und sie bekommt KEINE zweite Spalte (display: ${mass?.raster})`);
/* GEGENPROBE: Bei einer Karte MIT eigenem Bild soll die zweite
Spalte sehr wohl kommen -- sonst waere die Zeile darueber nur
deshalb gruen, weil die Regel gar nicht mehr wirkt. */
const mitBild = await seite.evaluate((nr) => {
const k = document.getElementById("meldung-" + nr);
if (!k) return null;
return {
display: getComputedStyle(k).display,
spalten: getComputedStyle(k).gridTemplateColumns,
eigene: k.querySelectorAll(":scope > .s-karte__bild img").length,
imVerlauf: k.querySelectorAll(".s-runde .s-karte__bild img").length,
};
}, meldungId);
ok(mitBild?.display === "grid" && mitBild.eigene === 3,
`Gegenprobe: MIT eigenem Bild gibt es die zweite Spalte `
+ `(${mitBild?.display}, ${mitBild?.eigene} eigene Bilder)`);
/* UND BEIDE KARTEN HABEN BILDER IM VERLAUF -- die eine mit, die
andere ohne eigenes. Ohne diese Zeile koennte der Unterschied
oben auch daher ruehren, dass die zweite Karte ueberhaupt kein
Bild hat. */
ok((mitBild?.imVerlauf || 0) === 3,
` und beide Karten haben Rundenbilder (${mitBild?.imVerlauf})`);
await kontext.close();
}
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
console.log("");
console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
process.exit(fehler ? 1 : 0);