// Playwright-Checks des Terminplaner-Widgets public/embed/termine.js gegen den UI-Testserver (test/ui/termineTestServer.mjs): // echte Router, Nachbau von Appwrite/Stripe, Mail-Senke. Nicht Teil von npm test. // Aufruf: nice node test/ui/termine-widget.ui.mjs (vorher free -m; es laeuft nur ein Browser) // Screenshots: /root/reports/terminplaner-ui/widget-*.png (bei Fehlern fehler-widget-.png). Exit-Code 1, wenn eine Pruefung scheitert. // // Seiten: Das Widget laeuft auf Seiten von srv.pageOrigin (http://localhost:PORT) und spricht die Schnittstelle unter // srv.url (http://127.0.0.1:PORT) an: echte Cross-Origin-Anfragen mit Preflight. Die Demo-Seite (/embed/termine-demo.html) // laeuft ohne data-api (gleiche Herkunft). Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (laeuft in Echtzeit weiter). import http from 'node:http' import { mkdir, readdir, readFile, rm } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { startTermineUiServer, salonEingabe, R_ANNA, S_DAMEN, S_HERREN, } from './termineTestServer.mjs' const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs' const { chromium } = await import(PW) const SHOTS = '/root/reports/terminplaner-ui' await mkdir(SHOTS, { recursive: true }) for (const datei of await readdir(SHOTS)) if (/^(widget|fehler-widget)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe const WIDGET_JS = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public/embed/termine.js') const srv = await startTermineUiServer({ crossOrigin: true }) const KEY = srv.SITE_KEY const SEITE = srv.pageOrigin const API = '**/api/public/termine/v1/**' // ---------------------------------------------------------------- Helfer function assert(cond, msg) { if (!cond) throw new Error(msg) } function eq(ist, soll, msg) { const a = JSON.stringify(ist) const b = JSON.stringify(soll) if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`) } const eingebettet = (attrs = '', fallback = '

Termine telefonisch unter 06301 1234.

', key = KEY) => `
${fallback}
` const seitenHtml = (widgets, { head = '', style = '', skript = '' } = {}) => ` Testseite ${head}

Testseite

${widgets}
${skript}` /** Seite von srv.pageOrigin mit eigenem Inhalt (liefert der Testserver selbst unter /ui-seiten/). */ async function oeffne(page, html, name = 'testseite.html') { await page.goto(srv.seite(name, html)) } const shot = (page, name) => page.screenshot({ path: `${SHOTS}/widget-${name}.png`, fullPage: true }) const w = (page, i = 0) => page.locator('[data-webklar-termine]').nth(i) const titel = (wd) => wd.locator('h2.wkt-titel').first() const karte = (wd, text) => wd.locator('.wkt-karte', { hasText: text }) const tag = (wd, t) => wd.locator(`td[data-tag="${t}"]`) const zeit = (wd, z) => wd.locator('.wkt-zeit', { hasText: new RegExp(`^${z}$`) }) const warteTitel = (wd, text) => wd.locator('h2.wkt-titel', { hasText: text }).waitFor() /** Leistung, Person, Tag, Uhrzeit, Angaben ausfuellen bis zur Zusammenfassung. Fehlt ein Wert (null), entfaellt der Schritt. */ async function bisZusammenfassung(page, o = {}) { const wd = w(page, o.widget ?? 0) const v = { leistung: 'Damenschnitt', person: 'Egal', tag: '2026-10-12', zeit: '10:00', name: 'Max Mustermann', email: 'max@example.com', ...o } if (v.leistung) await karte(wd, v.leistung).click() if (v.person) await karte(wd, v.person).click() await tag(wd, v.tag).click() await zeit(wd, v.zeit).click() await wd.locator('input[name=name]').fill(v.name) await wd.locator('input[name=email]').fill(v.email) if (v.telefon) await wd.locator('input[name=telefon]').fill(v.telefon) await wd.getByRole('button', { name: 'Weiter' }).click() await wd.locator('.wkt-zusammen').waitFor() return wd } async function hakenUndBuchen(wd, knopf) { for (const n of ['storno', 'vorzeitig']) { const cb = wd.locator(`input[name=${n}]`) if (await cb.count()) await cb.check() } await wd.getByRole('button', { name: knopf }).click() await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor() } /** Kein Streifen rechts: Seite und Widget passen in die Breite. */ const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) // ---------------------------------------------------------------- Ablauf der Pruefungen const results = [] let browser = null const hosts = new Set() async function check(name, fn, { eingabe, context = {}, initScript } = {}) { await srv.zuruecksetzen({ eingabe }) const ctx = await browser.newContext({ viewport: { width: 1000, height: 900 }, ...context }) const page = await ctx.newPage() page.setDefaultTimeout(8000) const fehler = [] page.on('pageerror', (e) => fehler.push(e.message)) page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } }) if (initScript) await page.addInitScript(initScript) try { await fn(page, ctx) assert(fehler.length === 0, `JavaScript-Fehler auf der Seite: ${fehler.join(' | ')}`) results.push([name, true]) console.log(`ok ${name}`) } catch (e) { results.push([name, false]) console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`) await page.screenshot({ path: `${SHOTS}/fehler-widget-${name}.png`, fullPage: true }).catch(() => {}) } finally { await ctx.close() } } // ---------------------------------------------------------------- 1. Inline bis "Fast geschafft" async function pruefungen() { await check('inline_bis_fast_geschafft', async (page, ctx) => { await oeffne(page, seitenHtml(eingebettet('data-modus="inline"'))) const wd = w(page) await karte(wd, 'Herrenschnitt').waitFor() assert(await page.locator('#ausweich').count() === 0, 'Ausweichtext blieb stehen, obwohl das Widget lief') eq(await titel(wd).innerText(), 'Welche Leistung möchten Sie buchen?', 'Titel Schritt 1') assert((await wd.innerText()).includes('Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'), 'Zahlungsart fehlt im ersten Schritt') assert((await karte(wd, 'Damenschnitt').innerText()).includes('ab 45,00 €'), 'Preis "ab" fehlt') assert((await karte(wd, 'Damenschnitt').innerText()).includes('45 Minuten'), 'Dauer fehlt') await shot(page, 'inline-1-leistung') await karte(wd, 'Damenschnitt').click() await warteTitel(wd, 'Bei wem') assert(await karte(wd, 'Egal').count() === 1 && await karte(wd, 'Anna').count() === 1 && await karte(wd, 'Ben').count() === 1, 'Personenwahl: Egal, Anna, Ben') assert((await wd.locator('.wkt-karte').first().innerText()).startsWith('Egal'), '"Egal" muss die erste Option sein') await shot(page, 'inline-2-person') await karte(wd, 'Egal').click() await warteTitel(wd, 'Wann passt es Ihnen?') await wd.locator('td.wkt-tag').first().waitFor() eq(await wd.locator('.wkt-monat').innerText(), 'Oktober 2026', 'Monat') eq(await tag(wd, '2026-10-11').getAttribute('aria-disabled'), 'true', 'Sonntag ist gesperrt') eq(await tag(wd, '2026-10-12').getAttribute('aria-disabled'), 'false', 'Montag ist frei') eq(await tag(wd, '2026-10-12').getAttribute('aria-selected'), 'false', 'noch nichts gewaehlt') await tag(wd, '2026-10-12').click() await wd.locator('.wkt-zeit').first().waitFor() eq(await tag(wd, '2026-10-12').getAttribute('aria-selected'), 'true', 'gewaehlter Tag') eq(await wd.locator('.wkt-zeiten-titel').innerText(), 'Freie Zeiten am Montag, 12.10.2026', 'Titel der Uhrzeiten') const zeiten = await wd.locator('.wkt-zeit').allInnerTexts() eq(zeiten.slice(0, 3), ['09:00', '09:15', '09:30'], 'erste Uhrzeiten (Berliner Wanduhr)') assert(!zeiten.includes('12:30') && zeiten.includes('13:30'), 'Mittagspause (12:30 bis 13:30) muss fehlen') await shot(page, 'inline-3-termin') await zeit(wd, '10:00').click() await warteTitel(wd, 'Ihre Angaben') await wd.locator('input[name=name]').fill('Max Mustermann') await wd.locator('input[name=email]').fill('max@example.com') await wd.locator('input[name=telefon]').fill('0171 1234567') await shot(page, 'inline-4-daten') await wd.getByRole('button', { name: 'Weiter' }).click() await warteTitel(wd, 'Bitte prüfen Sie Ihre Buchung') const zus = await wd.locator('.wkt-zusammen').innerText() for (const t of ['Damenschnitt (45 Minuten)', 'Montag, 12.10.2026', '10:00 Uhr', 'ab 45,00 € (inkl. USt)', 'Salon Müller, Hauptstr. 1, 67722 Winnweiler', 'Max Mustermann', 'max@example.com']) { assert(zus.replace(/\s+/g, ' ').includes(t), `Zusammenfassung ohne "${t}": ${zus}`) } assert((await wd.locator('.wkt-box').innerText()).includes('Danach berechnen wir 50 % des Preises.'), 'Stornoregel im Klartext fehlt') assert(zus.replace(/\s+/g, ' ').includes('Beschreibung Mit Beratung zur Frisur.'), 'Beschreibung der Leistung fehlt in der Zusammenfassung') const agb = wd.locator('.wkt-box a') eq([await agb.innerText(), await agb.getAttribute('href'), await agb.getAttribute('rel'), await agb.getAttribute('target')], ['AGB des Betriebs', 'https://salon-mueller.example/agb', 'noopener noreferrer', '_blank'], 'Link auf die AGB neben den Stornobedingungen') const det = wd.locator('details.wkt-belehrung') assert(await det.count() === 1 && await det.evaluate((d) => !d.open), 'Widerrufsbelehrung muss aufklappbar und zu sein') await det.locator('summary').click() assert((await det.innerText()).includes('Widerrufsbelehrung') && (await det.innerText()).includes('Muster-Widerrufsformular'), 'Belehrung nicht lesbar') const belehrung = det.locator('.wkt-belehrung-text') eq([await belehrung.getAttribute('tabindex'), await belehrung.getAttribute('role'), await belehrung.getAttribute('aria-label')], ['0', 'region', 'Widerrufsbelehrung und Muster-Widerrufsformular'], 'Belehrungstext per Tastatur scrollbar') await det.locator('summary').click() assert((await wd.locator('.wkt-datenschutz').innerText()).startsWith('Datenschutz. Wir verwenden Ihre Angaben'), 'Datenschutz-Hinweis') eq(await wd.locator('.wkt-datenschutz a').getAttribute('rel'), 'noopener noreferrer', 'rel am Datenschutz-Link') // Haken: Storno und vorzeitiger Beginn Pflicht (12.10. liegt in der Widerrufsfrist), Erinnerung freiwillig und nicht vorangekreuzt eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno') assert((await wd.locator('label[for$="h-vorzeitig"]').innerText()).startsWith('Ich verlange ausdrücklich, dass Salon Müller mit der Leistung vor Ablauf der Widerrufsfrist beginnt.'), 'Haken vorzeitig') eq(await wd.locator('label[for$="h-erinnerung"]').innerText(), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.', 'Haken Erinnerung') assert(!(await wd.locator('input[name=erinnerung]').isChecked()), 'Erinnerung darf nicht vorangekreuzt sein') assert(await wd.locator('input[name=storno]').getAttribute('required') !== null && await wd.locator('input[name=vorzeitig]').getAttribute('required') !== null, 'Pflicht-Haken') assert(await wd.locator('input[name=erinnerung]').getAttribute('required') === null, 'Erinnerung ist freiwillig') eq(await wd.locator('.wkt-form button[type=submit]').innerText(), 'Zahlungspflichtig buchen', 'Knopftext aus der Konfiguration') await shot(page, 'inline-5-pruefen') await hakenUndBuchen(wd, 'Zahlungspflichtig buchen') eq(await titel(wd).innerText(), 'Fast geschafft', 'Titel Ergebnis') eq(await wd.locator('.wkt-ergebnis-text').innerText(), 'Bitte bestätigen Sie den Termin über den Link, den wir an m***@example.com geschickt haben. Der Termin ist 30 Minuten für Sie reserviert.', 'Ergebnistext') await shot(page, 'inline-6-fast-geschafft') // Server: genau eine gehaltene Buchung, G01 an den Gast mit Bestaetigungslink await srv.warteAufVersand() const buchungen = srv.buchungen() eq(buchungen.length, 1, 'Anzahl Buchungen') eq([buchungen[0].status, buchungen[0].gastEmail, buchungen[0].leistungId, buchungen[0].gastName], ['gehalten', 'max@example.com', S_DAMEN, 'Max Mustermann'], 'Buchung') const g01 = srv.mails().filter((m) => m.to.some((t) => t.address === 'max@example.com')) eq(g01.length, 1, 'G01-Mails an den Gast') assert(/termin\.html#v=[A-Za-z0-9_-]{43}/.test(g01[0].text), 'G01 ohne Bestätigungslink') assert(!g01[0].bcc?.length, 'nie BCC') // Kein Cookie, kein Speicher const speicher = await page.evaluate(() => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length })) eq(speicher, { c: '', l: 0, s: 0 }, 'Cookies/Storage') eq((await ctx.cookies()).length, 0, 'Cookies im Browser') }) // ---------------------------------------------------------------- 2. Demo-Seite und Aufbau await check('demo_seite_stile_und_honigtopf', async (page) => { await page.goto(`${SEITE}/embed/termine-demo.html`) await karte(w(page, 0), 'Herrenschnitt').waitFor() assert(await page.locator('#ausweich-inline').count() === 0, 'Ausweichtext (inline) muss ersetzt sein') eq(await page.locator('#wkt-style').count(), 1, '