/* Prüft die elektronische Widerrufsfunktion (§ 356a BGB) im Browser. Die Prüfungen orientieren sich am Gesetzeswortlaut, nicht am Gefühl: - "gut lesbar mit 'Vertrag widerrufen' beschriftet" - "während der gesamten Widerrufsfrist ständig verfügbar, hervorgehoben platziert und leicht zugänglich" - Der Verbraucher muss Name, Vertragskennzeichnung und ein Kommunikationsmittel angeben können - ZWEITE Schaltfläche "Widerruf bestätigen" - unverzügliche Eingangsbestätigung mit Zeitpunkt Dazu zwei Dinge, die aus dem Sinn der Vorschrift folgen und die man leicht falsch baut: Der Widerruf darf KEINE Begründung verlangen, und die Seite darf nicht hinter einer Anmeldung liegen. */ import { chromium } from "playwright"; import { createServer } from "node:http"; import { readFileSync, existsSync } from "node:fs"; import { join, extname } from "node:path"; const W = process.cwd(); const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" }; const srv = createServer((q, r) => { const p = join(W, decodeURIComponent(q.url.split("?")[0])); if (existsSync(p) && !p.endsWith("/")) { r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" }); return r.end(readFileSync(p)); } r.writeHead(404).end(""); }); await new Promise((r) => srv.listen(4328, r)); const ergebnisse = []; function pruefe(name, ok, zusatz) { ergebnisse.push({ name, ok }); console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : "")); } const browser = await chromium.launch(); /* ---- Beschriftung nach § 356a in allen fünf Sprachen ---- */ { const p = await browser.newPage(); await p.goto("http://127.0.0.1:4328/webdesign/widerruf.html"); await p.waitForTimeout(600); console.log("\n=== Gesetzlich vorgegebene Beschriftungen ==="); const erwartet = { de: ["Vertrag widerrufen", "Widerruf bestätigen"], "de-CH": ["Vertrag widerrüefe", "Widerrüef bestätige"], en: ["Withdraw from contract", "Confirm withdrawal"], fr: ["Rétracter le contrat", "Confirmer la rétractation"], pt: ["Rescindir o contrato", "Confirmar a rescisão"], }; for (const [sp, [h1, knopf]] of Object.entries(erwartet)) { await p.addInitScript(`localStorage.setItem("dogi-sprache", ${JSON.stringify(sp)})`); await p.goto("http://127.0.0.1:4328/webdesign/widerruf.html"); await p.waitForTimeout(350); const gefunden = await p.evaluate(() => ({ h1: document.querySelector("h1")?.textContent.trim(), knopf: document.getElementById("wr-bestaetigen")?.textContent.trim(), fuss: Array.from(document.querySelectorAll(".wd-fuss-widerruf")).map((a) => a.textContent.trim()), })); pruefe(`[${sp}] Überschrift lautet „${h1}“`, gefunden.h1 === h1, gefunden.h1); pruefe(`[${sp}] zweite Schaltfläche lautet „${knopf}“`, gefunden.knopf === knopf, gefunden.knopf); pruefe(`[${sp}] Fusszeilen-Link auf jeder Seite vorhanden`, gefunden.fuss.length === 1, gefunden.fuss.join("|")); } await p.close(); } /* ---- Ablauf ---- */ for (const [wo, breite, hoehe] of [["Computer", 1280, 900], ["Handy", 390, 844]]) { const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } }); const fehlerImProtokoll = []; seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message)); let gesendet = null; await seite.route("**/webdesign/widerruf", (route) => { gesendet = route.request().postDataJSON(); return route.fulfill({ json: { ok: true, nummer: "W-2608-0001", eingegangenAm: "2026-08-23T09:12:00.000Z", bestaetigung: { nummer: "W-2608-0001", name: gesendet.name, vertrag: gesendet.vertrag, kontakt: gesendet.kontakt, eingegangenAm: "2026-08-23T09:12:00.000Z", empfaenger: "Filipe Pereira Queiroz, 3 rue Arthur Thinnes, L-3919 Mondercange, Luxemburg", } } }); }); await seite.addInitScript(`localStorage.setItem("dogi-sprache", "de")`); await seite.goto("http://127.0.0.1:4328/webdesign/widerruf.html"); await seite.waitForTimeout(600); console.log("\n=== " + wo + " ==="); /* KEINE Anmeldung: Wer widerrufen will, darf nicht erst an einer Anmeldemaske scheitern. */ pruefe(wo + ": ohne Anmeldung nutzbar", await seite.locator("#wr-form").isVisible() && await seite.locator('input[type="password"]').count() === 0); /* Der Widerruf braucht KEINE Begründung. */ const pflicht = await seite.evaluate(() => Array.from(document.querySelectorAll(".wd-feld")).map((f) => ({ id: f.querySelector("input,textarea")?.id, pflicht: !!f.querySelector(".wd-pflicht"), })).filter((x) => x.id)); const grund = pflicht.find((x) => x.id === "wr-grund"); pruefe(wo + ": der Grund ist NICHT Pflicht", grund && !grund.pflicht); pruefe(wo + ": genau drei Pflichtfelder (Name, Vertrag, Kontakt)", pflicht.filter((x) => x.pflicht).length === 3, pflicht.filter((x) => x.pflicht).map((x) => x.id).join(", ")); /* Leeres Formular darf nicht durchgehen. */ await seite.click("#wr-weiter"); await seite.waitForTimeout(300); pruefe(wo + ": leeres Formular wird abgefangen", await seite.locator("#wr-schritt2").isVisible() === false && await seite.locator(".wd-feld--fehler").count() === 3); /* Ausfüllen und weiter — die zweite Stufe MUSS kommen. */ await seite.fill("#wr-name", "Anna Muster"); await seite.fill("#wr-vertrag", "P-2608-0001"); await seite.fill("#wr-kontakt", "anna@example.com"); await seite.click("#wr-weiter"); await seite.waitForTimeout(500); pruefe(wo + ": zweite Stufe erscheint", await seite.locator("#wr-schritt2").isVisible()); pruefe(wo + ": das Formular ist dabei ausgeblendet", await seite.locator("#wr-form").isVisible() === false); pruefe(wo + ": die Angaben werden zur Prüfung gezeigt", (await seite.locator("#wr-pruef").innerText()).includes("Anna Muster")); /* Zurück und wieder vor — Angaben dürfen nicht verloren gehen. */ await seite.click("#wr-zurueck"); await seite.waitForTimeout(300); pruefe(wo + ": „Angaben ändern“ behält das Ausgefüllte", await seite.inputValue("#wr-name") === "Anna Muster"); await seite.click("#wr-weiter"); await seite.waitForTimeout(400); /* Bestätigen. */ await seite.click("#wr-bestaetigen"); await seite.waitForTimeout(700); pruefe(wo + ": Widerruf wird abgeschickt", gesendet && gesendet.name === "Anna Muster" && gesendet.vertrag === "P-2608-0001", JSON.stringify(gesendet)); pruefe(wo + ": Eingangsbestätigung erscheint", await seite.locator("#wr-schritt3").isVisible()); const beleg = await seite.locator("#wr-beleg").innerText(); pruefe(wo + ": Bestätigung nennt eine Nummer", beleg.includes("W-2608-0001")); /* § 356a verlangt den ZEITPUNKT, nicht nur das Datum. */ pruefe(wo + ": Bestätigung nennt Datum UND Uhrzeit", /\d{2}:\d{2}/.test(beleg), beleg.match(/\d{2}[.:]\d{2}[.:]?\d*/g)?.join(" ")); pruefe(wo + ": Bestätigung nennt den Empfänger", beleg.includes("Mondercange")); pruefe(wo + ": Bestätigung enthält die Erklärung selbst", beleg.includes("widerrufe")); pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | ")); /* Druckansicht: Auf Papier muss der Beleg lesbar sein und die Bedienelemente müssen verschwinden. */ await seite.emulateMedia({ media: "print" }); await seite.waitForTimeout(250); const druck = await seite.evaluate(() => { const beleg = document.getElementById("wr-beleg"); const stil = getComputedStyle(beleg); return { hintergrundKoerper: getComputedStyle(document.body).backgroundColor, belegRand: stil.borderTopColor, kopfWeg: getComputedStyle(document.getElementById("wd-kopf")).display, knoepfeWeg: getComputedStyle(document.querySelector(".wr-nicht-drucken")).display, formularWeg: getComputedStyle(document.getElementById("wr-form")).display, }; }); pruefe(wo + ": Druck — heller Hintergrund statt dunkel", druck.hintergrundKoerper === "rgb(255, 255, 255)", druck.hintergrundKoerper); pruefe(wo + ": Druck — Kopfzeile und Knöpfe sind weg", druck.kopfWeg === "none" && druck.knoepfeWeg === "none" && druck.formularWeg === "none"); await seite.emulateMedia({ media: "screen" }); await seite.screenshot({ path: `pruef-widerruf-${wo.toLowerCase()}.png`, fullPage: true }); await seite.close(); } /* ---- Rechtsseite ---- */ { const p = await browser.newPage({ viewport: { width: 1280, height: 900 } }); const fehler = []; p.on("pageerror", (e) => fehler.push(e.message)); await p.goto("http://127.0.0.1:4328/webdesign/rechtliches.html"); await p.waitForTimeout(700); console.log("\n=== Rechtsseite ==="); const m = await p.evaluate(() => ({ abschnitte: ["impressum", "agb", "widerruf", "datenschutz"].map((id) => !!document.getElementById(id)), muster: document.body.innerText.includes("Muster-Widerrufsformular"), frist: document.body.innerText.includes("vierzehn Tagen"), ust: document.body.innerText.includes("Kleinunternehmerregelung"), erloeschen: document.body.innerText.includes("ausdrückliche Zustimmung"), linkWiderruf: document.querySelectorAll('a[href="/webdesign/widerruf.html"]').length, aufsicht: document.body.innerText.includes("CNPD"), navi: document.querySelectorAll(".rl-nav a").length, })); pruefe("alle vier Abschnitte vorhanden", m.abschnitte.every(Boolean), JSON.stringify(m.abschnitte)); pruefe("Muster-Widerrufsformular ist da", m.muster); pruefe("14-Tage-Frist ist benannt", m.frist); pruefe("Umsatzsteuer-Hinweis ist da (Preisangabenpflicht)", m.ust); pruefe("vorzeitiges Erlöschen ist erklärt (§ 356 Abs. 4)", m.erloeschen); pruefe("mehrere Wege zur Widerrufsfunktion", m.linkWiderruf >= 2, "n=" + m.linkWiderruf); pruefe("zuständige Aufsichtsbehörde genannt", m.aufsicht); pruefe("Sprungnavigation vorhanden", m.navi === 4); pruefe("keine Fehler im Protokoll", fehler.length === 0, fehler.join(" | ")); await p.screenshot({ path: "pruef-rechtliches.png", fullPage: false }); await p.close(); } await browser.close(); srv.close(); const f = ergebnisse.filter((e) => !e.ok).length; console.log(`\n===== ${ergebnisse.length - f} bestanden, ${f} fehlgeschlagen =====`); process.exitCode = f ? 1 : 0;