/* ===================================================================== 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);