Files
Webklar-Kundenbereich/test/ui/termine-widget.ui.mjs
T

1352 lines
84 KiB
JavaScript

// 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-<Pruefung>.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 = '<p id="ausweich">Termine telefonisch unter 06301 1234.</p>', key = KEY) =>
`<div data-webklar-termine="${key}" data-api="${srv.url}" ${attrs}>${fallback}</div>`
const seitenHtml = (widgets, { head = '', style = '', skript = '<script src="/embed/termine.js" defer></script>' } = {}) => `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Testseite</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#f6f7f9;color:#1f2937}main{max-width:760px;margin:0 auto;padding:24px 16px}${style}</style>${head}</head>
<body><main><h1>Testseite</h1>${widgets}</main>${skript}</body></html>`
/** 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, '<style id="wkt-style"> genau einmal')
assert((await page.locator('#wkt-style').textContent()).includes('@layer wkt'), 'Stile stehen nicht in @layer wkt')
const knopf = page.getByRole('button', { name: 'Termin online buchen' })
assert(await knopf.isVisible(), 'Knopf der Demo fehlt')
const bg = await knopf.evaluate((b) => getComputedStyle(b).backgroundColor)
assert(bg !== 'rgba(0, 0, 0, 0)', `Knopf ohne Hintergrund (${bg})`)
const wd = w(page, 0)
await karte(wd, 'Kinderschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-13').click()
await zeit(wd, '09:00').click()
await warteTitel(wd, 'Ihre Angaben')
// Honigtopf: unsichtbar, nicht anwaehlbar, ohne Autovervollstaendigung, vor Screenreadern versteckt
const hp = wd.locator('input[name=kontakt_url]')
eq(await hp.getAttribute('autocomplete'), 'off', 'autocomplete')
eq(await hp.getAttribute('tabindex'), '-1', 'tabindex')
eq(await hp.evaluate((i) => i.closest('[aria-hidden="true"]') !== null), true, 'aria-hidden')
const box = await hp.evaluate((i) => { const r = i.closest('[aria-hidden]').getBoundingClientRect(); return { x: r.x + r.width, w: r.width } })
assert(box.x <= 0 || box.w <= 2, `Honigtopf im sichtbaren Bereich: ${JSON.stringify(box)}`)
// aria-live polite fuer Meldungen
assert(await page.locator('[aria-live="polite"]').count() >= 1, 'aria-live="polite" fehlt')
})
// ---------------------------------------------------------------- 3. Knopfmodus mit <dialog>
await check('knopf_dialog', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/termine/')) anfragen.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin online buchen"')))
const knopf = page.getByRole('button', { name: 'Termin online buchen' })
await knopf.waitFor()
await page.waitForTimeout(400)
// R54: beim Laden der Seite genau ein Abruf, die Konfiguration (fuer den Knopf "Vertrag widerrufen"), sonst nichts
eq(anfragen.map((u) => new URL(u).pathname.replace(/^.*\/v1\/[^/]+/, '')), ['/config'], 'Vor dem Klick nur die Konfiguration')
assert(await page.locator('#ausweich').count() === 0 || !(await page.locator('#ausweich').isVisible()), 'Ausweichtext darf neben dem Knopf nicht stehen')
eq(await page.locator('dialog.wkt-dialog').count(), 1, 'ein <dialog>')
assert(!(await page.locator('dialog.wkt-dialog').evaluate((d) => d.open)), 'Dialog ist zu')
await knopf.click()
const dlg = page.locator('dialog.wkt-dialog')
await karte(dlg, 'Herrenschnitt').waitFor()
eq(anfragen.filter((u) => u.includes('/config')).length, 1, 'der Klick laedt die Konfiguration nicht noch einmal')
assert(await dlg.evaluate((d) => d.open && d.matches(':modal')), 'Dialog muss modal offen sein')
eq(await dlg.getAttribute('aria-label'), 'Termin online buchen', 'Name des Dialogs')
await shot(page, 'knopf-dialog')
// Esc schliesst, Fokus zurueck auf den Knopf, Wiederoeffnen ohne neuen Abruf der Konfiguration
await page.keyboard.press('Escape')
assert(!(await dlg.evaluate((d) => d.open)), 'Esc schliesst den Dialog nicht')
assert(await knopf.evaluate((b) => document.activeElement === b), 'Fokus nicht zurueck auf dem Knopf')
await knopf.click()
await karte(dlg, 'Herrenschnitt').waitFor()
eq(anfragen.filter((u) => u.includes('/config')).length, 1, 'Wiederoeffnen laedt die Konfiguration nicht noch einmal')
// Klick auf den Hintergrund schliesst ebenfalls
await page.mouse.click(4, 4)
assert(!(await dlg.evaluate((d) => d.open)), 'Klick auf den Hintergrund schliesst nicht')
await knopf.click()
// Komplett durchbuchen im Dialog (kostenlose Leistung: "Termin buchen", ohne Haken fuer vorzeitigen Beginn)
await karte(dlg, 'Kennenlern-Beratung').click()
await karte(dlg, 'Ben').click()
await tag(dlg, '2026-10-14').click()
await shot(page, 'knopf-dialog-termin')
await zeit(dlg, '15:00').click()
await dlg.locator('input[name=name]').fill('Erika Muster')
await dlg.locator('input[name=email]').fill('erika@example.com')
await dlg.getByRole('button', { name: 'Weiter' }).click()
await dlg.locator('.wkt-zusammen').waitFor()
eq(await dlg.locator('input[name=vorzeitig]').count(), 0, 'kein Haken fuer vorzeitigen Beginn ohne Preis')
eq(await dlg.locator('.wkt-form button[type=submit]').innerText(), 'Termin buchen', 'Knopftext ohne Preis')
eq(await dlg.locator('input[name=storno]').count(), 0, 'kein Storno-Haken ohne Preis (F11)')
eq(await dlg.locator('.wkt-box h3', { hasText: 'Stornobedingungen' }).count(), 0, 'keine Stornobox ohne Preis (F11)')
await dlg.getByRole('button', { name: 'Termin buchen' }).click()
await dlg.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
await srv.warteAufVersand()
eq(srv.buchungen().map((b) => [b.ressourceId, b.gastName]).sort(), [['r_ben00002', 'Erika Muster']], 'Buchung mit gewaehlter Person')
})
await check('knopf_mit_vorgewaehlter_leistung', async (page) => {
await oeffne(page, seitenHtml(eingebettet(`data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="${S_DAMEN}"`)))
await page.getByRole('button', { name: 'Damenschnitt buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await warteTitel(dlg, 'Bei wem')
eq(await dlg.locator('.wkt-pfad').innerText(), 'Schritt 1 von 4', 'ohne Leistungsschritt')
eq(await karte(dlg, 'Herrenschnitt').count(), 0, 'Leistungsliste darf nicht erscheinen')
await karte(dlg, 'Egal').click()
assert((await dlg.locator('.wkt-hinweis').first().innerText()).startsWith('Damenschnitt'), 'vorgewaehlte Leistung steht im Termin-Schritt')
})
// Ruling R54: die Widerrufsfunktion steht im Knopfmodus dauerhaft neben dem Buchungsknopf und oeffnet den Dialog gleich beim Formular
await check('knopf_widerruf_knopf_dauerhaft', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/termine/')) anfragen.push(new URL(r.url()).pathname.replace(/^.*\/v1\/[^/]+/, '')) })
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin online buchen"')))
const haupt = page.getByRole('button', { name: 'Termin online buchen' })
const widerruf = page.locator('.wkt-knopf-box > button', { hasText: 'Vertrag widerrufen' })
await widerruf.waitFor()
await page.waitForTimeout(300)
eq(anfragen, ['/config'], 'nur ein Abruf beim Laden der Seite')
assert(!(await page.locator('dialog.wkt-dialog').evaluate((d) => d.open)), 'Dialog ist zu')
const boxen = await page.evaluate(() => {
const [a, b] = [...document.querySelectorAll('.wkt-knopf-box > button')].map((e) => e.getBoundingClientRect())
return { gleicheZeile: Math.abs(a.top - b.top) < 4, rechtsDavon: b.left >= a.right - 1, hoeheB: b.height, hoeheA: a.height }
})
assert(boxen.gleicheZeile && boxen.rechtsDavon, `Widerrufsknopf neben dem Buchungsknopf: ${JSON.stringify(boxen)}`)
assert(boxen.hoeheB >= 44, `Widerrufsknopf mindestens 44 px hoch: ${boxen.hoeheB}`)
const stil = await widerruf.evaluate((b) => ({ grund: getComputedStyle(b).backgroundColor, rand: getComputedStyle(b).borderTopColor }))
assert(stil.grund === 'rgba(0, 0, 0, 0)' || stil.grund === 'transparent', `sekundaer (ohne Fuellung): ${stil.grund}`)
await shot(page, 'knopf-mit-widerruf')
await widerruf.click()
const dlg = page.locator('dialog.wkt-dialog')
await warteTitel(dlg, 'Vertrag widerrufen')
assert(await dlg.evaluate((d) => d.open && d.matches(':modal')), 'Dialog modal offen')
eq(await dlg.locator('input[name=name]').count(), 1, 'Widerrufsformular')
eq(anfragen, ['/config'], 'auch das Oeffnen laedt nichts nach')
eq(await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).count(), 1, 'Weg zurueck zur Buchung')
// Fuss im Dialog: ebenfalls ein Knopf (aber auf der Widerrufsseite zeigt er dieselbe Seite)
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await karte(dlg, 'Herrenschnitt').waitFor()
eq(anfragen, ['/config'], 'Zurueck zur Buchung laedt nichts nach')
// Esc: der Fokus kehrt zu dem Knopf zurueck, der den Dialog geoeffnet hat
await page.keyboard.press('Escape')
assert(await widerruf.evaluate((b) => document.activeElement === b), 'Fokus nicht zurueck auf dem Widerrufsknopf')
// Der Buchungsknopf zeigt wieder die Buchung, nicht das Widerrufsformular
await widerruf.click()
await warteTitel(dlg, 'Vertrag widerrufen')
await page.keyboard.press('Escape')
await haupt.click()
await karte(dlg, 'Herrenschnitt').waitFor()
eq(await dlg.locator('input[name=name]').count(), 0, 'Buchungsknopf: kein Widerrufsformular')
assert(await haupt.evaluate((b) => b !== document.activeElement), 'Dialog offen: Fokus im Dialog')
// Widerruf aus dem Fuss des laufenden Dialogs, dann zurueck (der Stand bleibt)
await karte(dlg, 'Herrenschnitt').click()
await dlg.locator('.wkt-fuss').getByRole('button', { name: 'Vertrag widerrufen' }).click()
await warteTitel(dlg, 'Vertrag widerrufen')
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await karte(dlg, 'Egal').waitFor()
})
// Widerrufsformular gleich beim Oeffnen, danach "Zurueck zur Terminbuchung" beginnt die Buchung mit der vorgewaehlten Leistung
await check('knopf_widerruf_mit_vorgewaehlter_leistung', async (page) => {
await oeffne(page, seitenHtml(eingebettet(`data-modus="knopf" data-knopf="Damenschnitt buchen" data-leistung="${S_DAMEN}"`)))
await page.locator('.wkt-knopf-box > button', { hasText: 'Vertrag widerrufen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await warteTitel(dlg, 'Vertrag widerrufen')
await dlg.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await warteTitel(dlg, 'Bei wem')
eq(await karte(dlg, 'Herrenschnitt').count(), 0, 'Leistungsliste darf nicht erscheinen')
})
await check('knopf_ohne_widerrufsrecht_kein_widerrufsknopf', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/termine/')) anfragen.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')))
await page.getByRole('button', { name: 'Termin buchen' }).waitFor()
await page.waitForTimeout(500)
eq(await page.locator('.wkt-knopf-box > button').count(), 1, 'nur der Buchungsknopf (Gaeste sind Unternehmen)')
eq(anfragen.length, 1, 'ein Abruf beim Laden')
await page.getByRole('button', { name: 'Termin buchen' }).click()
await karte(page.locator('dialog.wkt-dialog'), 'Herrenschnitt').waitFor()
eq(await page.locator('dialog.wkt-dialog .wkt-fuss').getByRole('button', { name: 'Vertrag widerrufen' }).count(), 0, 'auch im Fuss kein Widerruf')
}, { eingabe: salonEingabe({ recht: { gaesteSind: 'unternehmer', freizeit: false } }) })
// ---------------------------------------------------------------- 4. Nur Tastatur
await check('nur_tastatur', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
await karte(w(page), 'Herrenschnitt').waitFor()
const aktiv = (fn, arg) => page.evaluate(fn, arg)
const tabBis = async (bedingung, max = 25) => {
for (let i = 0; i < max; i += 1) {
await page.keyboard.press('Tab')
if (await page.evaluate(bedingung)) return
}
throw new Error(`Fokus nicht erreicht (${bedingung})`)
}
await tabBis(() => document.activeElement?.matches('[data-webklar-termine] .wkt-karte'))
eq(await aktiv(() => document.activeElement.querySelector('.wkt-karte-name').textContent), 'Herrenschnitt', 'erste Karte')
await page.keyboard.press('Enter')
await warteTitel(w(page), 'Bei wem')
assert(await aktiv(() => document.activeElement?.matches('h2.wkt-titel')), 'Fokus muss auf der neuen Ueberschrift liegen')
await page.keyboard.press('Tab')
assert((await aktiv(() => document.activeElement.textContent)).startsWith('Egal'), 'Tab von der Ueberschrift: erste Option')
await page.keyboard.press('Enter')
await warteTitel(w(page), 'Wann passt es Ihnen?')
await w(page).locator('td.wkt-tag').first().waitFor()
await tabBis(() => document.activeElement?.matches('td.wkt-tag'))
const start = await aktiv(() => document.activeElement.dataset.tag)
eq(start, '2026-10-05', 'Raster startet beim ersten freien Tag')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
eq(await aktiv(() => document.activeElement.dataset.tag), '2026-10-19', 'zwei Wochen weiter')
await page.keyboard.press('Enter')
await w(page).locator('.wkt-zeit').first().waitFor()
eq(await tag(w(page), '2026-10-19').getAttribute('aria-selected'), 'true', 'Auswahl mit Enter')
await tabBis(() => document.activeElement?.matches('.wkt-zeit'))
eq(await aktiv(() => document.activeElement.textContent), '09:00', 'erste Uhrzeit')
await page.keyboard.press('Enter')
await warteTitel(w(page), 'Ihre Angaben')
await tabBis(() => document.activeElement?.matches('input[name=name]'))
await page.keyboard.type('Max Mustermann')
await page.keyboard.press('Tab')
await page.keyboard.type('max@example.com')
await page.keyboard.press('Enter') // Absenden des Formulars per Enter
await warteTitel(w(page), 'Bitte prüfen Sie Ihre Buchung')
for (let i = 0; i < 20; i += 1) {
await page.keyboard.press('Tab')
const a = await page.evaluate(() => ({ name: document.activeElement.name || '', submit: document.activeElement.matches('.wkt-form button[type=submit]'), text: document.activeElement.textContent }))
if (a.name === 'storno' || a.name === 'vorzeitig') await page.keyboard.press('Space')
if (a.submit) {
eq(a.text, 'Zahlungspflichtig buchen', 'Knopf per Tastatur erreicht')
await page.keyboard.press('Enter')
break
}
}
await w(page).getByRole('heading', { name: 'Fast geschafft' }).waitFor()
assert(await aktiv(() => document.activeElement?.matches('h2.wkt-titel') && document.activeElement.textContent === 'Fast geschafft'), 'Fokus auf "Fast geschafft"')
await srv.warteAufVersand()
eq(srv.buchungen().map((b) => [b.leistungId, b.gastName, b.status]), [[S_HERREN, 'Max Mustermann', 'gehalten']], 'Buchung per Tastatur')
})
await check('monatsraster_tasten', async (page) => {
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`)))
const wd = w(page)
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
const grid = wd.locator('table[role=grid]')
eq(await grid.getAttribute('aria-labelledby') !== null, true, 'Raster ist benannt')
eq(await wd.locator('td[role=gridcell][tabindex="0"]').count(), 1, 'genau eine Zelle mit tabindex 0 (Roving)')
await wd.locator('td[tabindex="0"]').focus()
const aktTag = () => page.evaluate(() => document.activeElement.dataset.tag)
const monat = () => wd.locator('.wkt-monat').innerText()
eq(await aktTag(), '2026-10-05', 'Start')
await page.keyboard.press('Control+ArrowRight'); eq(await aktTag(), '2026-10-05', 'Strg+Pfeil gehoert dem Browser, nicht dem Raster')
await page.keyboard.press('Meta+ArrowDown'); eq(await aktTag(), '2026-10-05', 'Meta+Pfeil gehoert dem System, nicht dem Raster')
await page.keyboard.press('ArrowRight'); eq(await aktTag(), '2026-10-06', 'Pfeil rechts')
await page.keyboard.press('ArrowDown'); eq(await aktTag(), '2026-10-13', 'Pfeil runter = +7')
await page.keyboard.press('ArrowLeft'); eq(await aktTag(), '2026-10-12', 'Pfeil links')
await page.keyboard.press('ArrowUp'); eq(await aktTag(), '2026-10-05', 'Pfeil hoch = -7')
await page.keyboard.press('End'); eq(await aktTag(), '2026-10-11', 'Ende = Sonntag der Woche')
eq(await tag(wd, '2026-10-11').getAttribute('aria-disabled'), 'true', 'Sonntag ist aria-disabled, aber anwaehlbar')
await page.keyboard.press('Enter')
eq(await wd.locator('.wkt-zeit').count(), 0, 'Enter auf gesperrtem Tag waehlt nichts')
eq(await tag(wd, '2026-10-11').getAttribute('aria-selected'), 'false', 'gesperrter Tag bleibt unausgewaehlt')
await page.keyboard.press('Home'); eq(await aktTag(), '2026-10-05', 'Pos1 = Montag der Woche')
await page.keyboard.press('PageDown')
await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
eq(await aktTag(), '2026-11-05', 'Bild runter = naechster Monat, gleicher Tag')
await page.keyboard.press('Home'); eq(await aktTag(), '2026-11-02', 'Pos1 im November')
await page.keyboard.press('ArrowUp')
await wd.locator('.wkt-monat', { hasText: 'Oktober 2026' }).waitFor()
eq(await aktTag(), '2026-10-26', 'Pfeil hoch ueber die Monatsgrenze')
await page.keyboard.press('ArrowDown'); await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
eq(await aktTag(), '2026-11-02', 'Pfeil runter zurueck in den November')
await page.keyboard.press('ArrowLeft'); eq(await aktTag(), '2026-11-01', 'Pfeil links')
await page.keyboard.press('ArrowLeft'); await wd.locator('.wkt-monat', { hasText: 'Oktober 2026' }).waitFor()
eq(await aktTag(), '2026-10-31', 'Pfeil links vom 1. in den Vormonat')
await page.keyboard.press('PageUp')
eq(await monat(), 'Oktober 2026', 'vor dem aktuellen Monat gibt es kein Zurueck')
eq(await wd.getByRole('button', { name: 'Vorheriger Monat' }).isDisabled(), true, 'Pfeil zurueck im aktuellen Monat gesperrt')
await page.keyboard.press('PageDown'); await wd.locator('.wkt-monat', { hasText: 'November 2026' }).waitFor()
await page.keyboard.press('Enter')
await wd.locator('.wkt-zeit').first().waitFor()
eq(await tag(wd, '2026-11-30').count(), 1, 'Tage des Novembers stehen im Raster')
assert(await wd.locator('td[aria-selected="true"]').count() === 1, 'genau ein Tag ausgewaehlt')
})
// ---------------------------------------------------------------- 5. Handy, 390 px
await check('handy_390', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').waitFor()
const pruefe = async (schritt) => {
assert(await passtInBreite(page), `${schritt}: seitliches Scrollen`)
const klein = await page.evaluate(() => [...document.querySelectorAll('[data-webklar-termine] :is(button,input:not([name=kontakt_url]),textarea,select,summary,td.wkt-tag,a)')]
.filter((e) => e.getClientRects().length && !e.closest('[aria-hidden="true"]:not(td)') && !e.closest('.wkt-datenschutz'))
.map((e) => { const r = e.getBoundingClientRect(); return { t: e.tagName + '.' + e.className, w: Math.round(r.width), h: Math.round(r.height) } })
.filter((r) => r.w < 24 || r.h < 24))
assert(klein.length === 0, `${schritt}: Zielgroesse unter 24 px: ${JSON.stringify(klein)}`)
}
await pruefe('Leistung')
await shot(page, '390-1-leistung')
await karte(wd, 'Damenschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-zeit').first().waitFor()
await pruefe('Termin')
await shot(page, '390-2-termin')
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
await pruefe('Angaben')
await shot(page, '390-3-daten')
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
await pruefe('Zusammenfassung')
await shot(page, '390-4-pruefen')
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen')
await pruefe('Ergebnis')
await shot(page, '390-5-fast-geschafft')
const breite = await wd.evaluate((e) => e.getBoundingClientRect().width)
assert(breite <= 390, `Widget breiter als der Bildschirm: ${breite}`)
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
await check('handy_390_dialog', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')))
await page.getByRole('button', { name: 'Termin buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await karte(dlg, 'Herrenschnitt').waitFor()
const maße = await dlg.evaluate((d) => { const r = d.getBoundingClientRect(); return { l: r.left, r: r.right, t: r.top, b: r.bottom, w: window.innerWidth, h: window.innerHeight, scroll: d.scrollHeight > d.clientHeight } })
assert(maße.l >= 0 && maße.r <= maße.w + 0.5 && maße.t >= 0 && maße.b <= maße.h + 0.5, `Dialog ragt aus dem Bildschirm: ${JSON.stringify(maße)}`)
assert(maße.scroll, 'Der lange Inhalt muss im Dialog scrollen')
await page.screenshot({ path: `${SHOTS}/widget-390-dialog.png` })
assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'seitliches Scrollen mit offenem Dialog')
}, { context: { viewport: { width: 390, height: 700 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ---------------------------------------------------------------- 6. Zeitzone: New York zeigt dasselbe wie Berlin
const erfasse = async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Damenschnitt').click()
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
const monat = await wd.locator('.wkt-monat').innerText()
const frei = await wd.locator('td.wkt-tag[aria-disabled="false"]').evaluateAll((l) => l.map((e) => e.dataset.tag))
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-zeit').first().waitFor()
const lang = await wd.locator('.wkt-zeiten-titel').innerText()
const zeiten = await wd.locator('.wkt-zeit').allInnerTexts()
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.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
const zus = (await wd.locator('.wkt-zusammen').innerText()).replace(/\s+/g, ' ')
return { monat, frei, lang, zeiten, zus }
}
const datumZaehler = () => {
// zaehlt jede Benutzung von Date und Intl im Seitenskript (das Widget darf keine brauchen)
window.__datum = 0
const D = Date
window.Date = new Proxy(D, {
construct(t, a, nt) { window.__datum += 1; return Reflect.construct(t, a, nt) },
apply(t, th, a) { window.__datum += 1; return Reflect.apply(t, th, a) },
get(t, p, r) { if (p === 'now') window.__datum += 1; return Reflect.get(t, p, r) },
})
const I = Intl.DateTimeFormat
Intl.DateTimeFormat = new Proxy(I, { construct(t, a, nt) { window.__datum += 1; return Reflect.construct(t, a, nt) }, apply(t, th, a) { window.__datum += 1; return Reflect.apply(t, th, a) } })
}
let berlin = null
await check('zeitzone_berlin_referenz', async (page) => {
berlin = await erfasse(page)
assert(berlin.zeiten[0] === '09:00' && berlin.zus.includes('10:00 Uhr'), `Berliner Uhrzeiten: ${berlin.zeiten[0]}`)
eq(await page.evaluate(() => window.__datum), 0, 'Date/Intl im Berliner Browser benutzt')
}, { context: { timezoneId: 'Europe/Berlin', locale: 'de-DE' }, initScript: datumZaehler })
await check('zeitzone_new_york_gleiche_zeiten', async (page) => {
const ny = await erfasse(page)
eq(await page.evaluate(() => window.__datum), 0, 'Date/Intl in New York benutzt')
assert(await page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone) === 'America/New_York', 'Browser steht nicht auf New York')
eq(ny, berlin, 'Tage, Wochentag und Uhrzeiten in New York weichen von Berlin ab')
eq(ny.zeiten.slice(0, 2), ['09:00', '09:15'], 'Uhrzeiten sind Berliner Wanduhr')
}, { context: { timezoneId: 'America/New_York', locale: 'en-US' }, initScript: datumZaehler })
// ---------------------------------------------------------------- 7. Anrede Du
await check('anrede_du', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').waitFor()
eq(await titel(wd).innerText(), 'Welche Leistung möchtest du buchen?', 'Titel Du')
await karte(wd, 'Herrenschnitt').click()
eq(await titel(wd).innerText(), 'Bei wem möchtest du den Termin?', 'Titel Person Du')
await karte(wd, 'Egal').click()
eq(await titel(wd).innerText(), 'Wann passt es dir?', 'Titel Termin Du')
await tag(wd, '2026-10-26').click()
await zeit(wd, '11:00').click()
eq(await titel(wd).innerText(), 'Deine Angaben', 'Titel Angaben Du')
// Fehlermeldungen Du
await wd.getByRole('button', { name: 'Weiter' }).click()
eq(await wd.locator('#' + await wd.locator('input[name=name]').getAttribute('id') + '-e').innerText(), 'Bitte gib deinen Namen an.', 'Fehlertext Du')
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
eq(await titel(wd).innerText(), 'Bitte prüfe deine Buchung', 'Titel Pruefen Du')
assert((await wd.locator('.wkt-datenschutz').innerText()).startsWith('Datenschutz. Wir verwenden deine Angaben'), 'Datenschutz-Hinweis in Du')
// Zustimmungstexte bleiben Rechtstext in der Sie-Form (26.10. liegt nach der Widerrufsfrist: nur Storno)
eq(await wd.locator('label[for$="h-storno"]').innerText(), 'Ich habe die Stornobedingungen gelesen.', 'Haken Storno')
eq(await wd.locator('input[name=vorzeitig]').count(), 0, 'nach der Widerrufsfrist kein Haken fuer vorzeitigen Beginn')
await shot(page, 'du-pruefen')
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen')
eq(await wd.locator('.wkt-ergebnis-text').innerText(),
'Bitte bestätige den Termin über den Link, den wir an m***@example.com geschickt haben. Der Termin ist 30 Minuten für dich reserviert.', 'Ergebnistext Du')
}, { eingabe: salonEingabe({ anrede: 'du' }) })
// ---------------------------------------------------------------- 8. Zwei Widgets
await check('zwei_widgets_stoeren_sich_nicht', async (page) => {
const zwei = eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`, '<p id="a1">Ausweich 1</p>') + eingebettet(`data-modus="inline" data-leistung="${S_DAMEN}"`, '<p id="a2">Ausweich 2</p>')
await oeffne(page, seitenHtml(zwei))
const w0 = w(page, 0)
const w1 = w(page, 1)
await warteTitel(w0, 'Bei wem')
await warteTitel(w1, 'Bei wem')
eq(await page.locator('#wkt-style').count(), 1, 'Stile genau einmal')
const ids = await page.evaluate(() => [...document.querySelectorAll('[id]')].map((e) => e.id))
eq(new Set(ids).size, ids.length, `doppelte IDs: ${ids.filter((x, i) => ids.indexOf(x) !== i)}`)
await karte(w0, 'Egal').click()
await warteTitel(w0, 'Wann passt')
eq(await karte(w1, 'Egal').count(), 1, 'Widget 2 steht noch bei der Personenwahl')
await tag(w0, '2026-10-12').click()
await w0.locator('.wkt-zeit').first().waitFor()
await karte(w1, 'Ben').click()
await w1.locator('td.wkt-tag').first().waitFor()
await tag(w1, '2026-10-13').click()
await w1.locator('.wkt-zeit').first().waitFor()
eq(await w0.locator('td[aria-selected="true"]').getAttribute('data-tag'), '2026-10-12', 'Widget 1 behaelt seinen Tag')
eq(await w1.locator('td[aria-selected="true"]').getAttribute('data-tag'), '2026-10-13', 'Widget 2 hat seinen Tag')
assert((await w0.locator('.wkt-hinweis').first().innerText()).startsWith('Herrenschnitt'), 'Widget 1: Herrenschnitt')
assert((await w1.locator('.wkt-hinweis').first().innerText()).startsWith('Damenschnitt'), 'Widget 2: Damenschnitt')
// Beschriftungen und ARIA-Bezuege zeigen innerhalb des eigenen Widgets
await zeit(w0, '10:00').click()
await warteTitel(w0, 'Ihre Angaben')
const bezuege = await page.evaluate(() => [...document.querySelectorAll('[data-webklar-termine]')].map((root) => ({
labels: [...root.querySelectorAll('label[for]')].every((l) => root.contains(document.getElementById(l.htmlFor))),
aria: [...root.querySelectorAll('[aria-labelledby],[aria-describedby]')].every((e) => (e.getAttribute('aria-labelledby') || e.getAttribute('aria-describedby')).split(' ').every((id) => root.contains(document.getElementById(id)))),
})))
eq(bezuege, [{ labels: true, aria: true }, { labels: true, aria: true }], 'Bezuege innerhalb des Widgets')
await w0.locator('input[name=name]').fill('Max Mustermann')
eq(await w1.locator('input[name=name]').count(), 0, 'Widget 2 hat kein Namensfeld')
})
// ---------------------------------------------------------------- 9. Eingaben und Fehler
await check('pflichtfelder_und_haken', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Damenschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-12').click()
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
await wd.getByRole('button', { name: 'Weiter' }).click()
const name = wd.locator('input[name=name]')
const mail = wd.locator('input[name=email]')
eq(await name.getAttribute('aria-invalid'), 'true', 'Name ungueltig markiert')
eq(await mail.getAttribute('aria-invalid'), 'true', 'E-Mail ungueltig markiert')
assert(await name.evaluate((i) => document.activeElement === i), 'Fokus auf dem ersten Fehlerfeld')
const fehlerId = await name.getAttribute('aria-describedby')
eq(await wd.locator(`#${fehlerId}`).innerText(), 'Bitte geben Sie Ihren Namen an.', 'Fehlertext am Feld')
assert((await page.locator('[aria-live="polite"]').first().textContent()).includes('Bitte prüfen Sie Ihre Eingaben'), 'Fehler werden per aria-live angesagt')
await name.fill('Max Mustermann')
await mail.fill('keine-adresse')
await wd.locator('input[name=telefon]').fill('abc')
await wd.getByRole('button', { name: 'Weiter' }).click()
assert((await wd.locator('#' + await mail.getAttribute('aria-describedby')).innerText()).includes('gültige E-Mail-Adresse'), 'E-Mail-Fehler')
assert((await wd.locator('#' + await wd.locator('input[name=telefon]').getAttribute('aria-describedby')).innerText()).includes('gültige Telefonnummer'), 'Telefon-Fehler')
await mail.fill('max@example.com')
await wd.locator('input[name=telefon]').fill('')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
// ohne Haken: nichts wird gesendet, Fehler stehen an den Haken
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
eq(await wd.locator('input[name=storno]').getAttribute('aria-invalid'), 'true', 'Storno-Haken markiert')
eq(await wd.locator('input[name=vorzeitig]').getAttribute('aria-invalid'), 'true', 'Haken vorzeitiger Beginn markiert')
eq(srv.buchungen().length, 0, 'ohne Haken wird nichts gebucht')
// Zurueck behaelt die Eingaben
await wd.getByRole('button', { name: 'Zurück' }).click()
eq(await wd.locator('input[name=name]').inputValue(), 'Max Mustermann', 'Eingabe bleibt nach Zurueck')
})
await check('zeit_inzwischen_vergeben', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { leistung: 'Damenschnitt', person: 'Anna', tag: '2026-10-26', zeit: '10:00' })
// ein anderer Gast bucht Anna zur selben Zeit
const conf = await (await fetch(`${srv.url}/api/public/termine/v1/${KEY}/config`)).json()
const res = await fetch(`${srv.url}/api/public/termine/v1/${KEY}/buchungen`, {
method: 'POST',
headers: { 'content-type': 'application/json', origin: srv.url },
body: JSON.stringify({ leistung: S_DAMEN, ressource: R_ANNA, tag: '2026-10-26', zeit: '10:00', name: 'Anderer Gast', email: 'anderer@example.com', telefon: '', nachricht: '', zusatz: {}, erinnerung: false, zustimmungen: { storno: true, vorzeitig: false }, kontakt_url: '', formToken: conf.formToken }),
})
eq(res.status, 201, 'zweiter Gast kann buchen')
await wd.locator('input[name=storno]').check()
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await warteTitel(wd, 'Wann passt es Ihnen?')
eq(await wd.locator('.wkt-fehlertext').first().innerText(), 'Diese Zeit ist leider nicht mehr frei. Bitte eine andere Zeit wählen.', 'Hinweis auf die vergebene Zeit')
eq(srv.buchungen().filter((b) => b.gastEmail === 'max@example.com').length, 0, 'keine zweite Buchung')
await shot(page, 'fehler-zeit-vergeben')
})
await check('honigtopf_gefuellt_keine_buchung', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
await wd.locator('input[name=kontakt_url]').evaluate((i) => { i.value = 'http://spam.example'; i.dispatchEvent(new Event('input', { bubbles: true })) })
await hakenUndBuchen(wd, 'Zahlungspflichtig buchen') // der Server antwortet gleich (kein Hinweis fuer Automaten) ...
await srv.warteAufVersand()
eq(srv.buchungen().length, 0, '... speichert aber nichts')
eq(srv.mails().length, 0, '... und schickt nichts')
})
await check('formular_token_abgelaufen_neu_laden', async (page) => {
const konfig = []
page.on('request', (r) => { if (r.url().includes('/config')) konfig.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
eq(konfig.length, 1, 'Konfiguration einmal geladen')
await wd.locator('input[name=storno]').check()
srv.schiebeUhr(3 * 60 * 60 * 1000) // Formular-Token ist beim Server jetzt aelter als 2 Stunden, im Browser sind erst Sekunden vergangen
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.locator('.wkt-meldung').waitFor()
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.', 'Hinweis nach neu_laden')
eq(konfig.length, 2, 'Konfiguration neu geladen (neues Formular-Token)')
eq(srv.buchungen().length, 0, 'noch nichts gebucht')
eq(await wd.locator('input[name=storno]').isChecked(), true, 'Eingaben und Haken bleiben')
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
eq(srv.buchungen().length, 1, 'zweiter Versuch bucht')
})
await check('konfiguration_nach_100_minuten_neu', async (page) => {
const konfig = []
page.on('request', (r) => { if (r.url().includes('/config')) konfig.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-26').click()
await wd.locator('.wkt-zeit').first().waitFor()
eq(konfig.length, 1, 'bisher eine Konfiguration')
await page.evaluate(() => { window.__vorsprung = 99 * 60 * 1000 })
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
eq(konfig.length, 1, 'nach 99 Minuten wird noch nicht neu geladen')
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
eq(konfig.length, 2, 'nach 101 Minuten wird neu geladen')
}, { initScript: () => { const echt = performance.now.bind(performance); window.__vorsprung = 0; performance.now = () => echt() + window.__vorsprung } })
// Ruling R53: ein frisch geladenes Formular-Token ist dem Server zu jung (formMinSekunden): das Widget wartet, statt eine Buchung
// abzusenden, die der Server mit der normalen Erfolgsantwort verwirft
const MIN_SEKUNDEN = 3
const mitVorsprung = () => { const echt = performance.now.bind(performance); window.__vorsprung = 0; performance.now = () => echt() + window.__vorsprung }
await check('formular_token_nach_neuladen_wartet_und_bucht', async (page) => {
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
const konfig = []
page.on('request', (r) => { if (r.url().includes('/config')) konfig.push(r.url()) })
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
eq(konfig.length, 1, 'bisher eine Konfiguration')
// die Konfiguration ist im Widget aelter als 100 Minuten: sende() laedt sie neu und das Token ist beim Absenden nur Millisekunden alt
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
await wd.locator('input[name=storno]').check()
const knopf = wd.getByRole('button', { name: 'Zahlungspflichtig buchen' })
const start = Date.now()
await knopf.click()
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
eq(konfig.length, 2, 'Konfiguration neu geladen')
eq(await wd.getByRole('button', { name: 'Einen Moment …' }).isDisabled(), true, 'Knopf bleibt gesperrt')
eq(srv.buchungen().length, 0, 'waehrend des Wartens noch nichts gesendet')
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
const dauer = Date.now() - start
assert(dauer >= (MIN_SEKUNDEN + 0.4) * 1000, `das Widget wartete nur ${dauer} ms`)
await srv.warteAufVersand()
eq(srv.buchungen().map((b) => [b.gastEmail, b.status]), [['max@example.com', 'gehalten']], 'die Buchung wurde wirklich angelegt (kein stilles 201)')
eq(srv.mails().map((m) => m.to.map((t) => t.address).join()), ['max@example.com'], 'G01 an den Gast')
}, { initScript: mitVorsprung })
await check('formular_token_neu_laden_und_gleich_nochmal_bucht', async (page) => {
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
await wd.locator('input[name=storno]').check()
srv.schiebeUhr(3 * 60 * 60 * 1000) // beim Server ist das Token jetzt aelter als 2 Stunden
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.locator('.wkt-meldung').waitFor()
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.', 'Hinweis nach neu_laden')
eq(srv.buchungen().length, 0, 'noch nichts gebucht')
// sofort noch einmal: das neue Token ist erst Sekundenbruchteile alt
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
await wd.getByRole('heading', { name: 'Fast geschafft' }).waitFor()
await srv.warteAufVersand()
eq(srv.buchungen().length, 1, 'zweiter Versuch bucht trotz junger Token')
eq(srv.mails().map((m) => m.to.map((t) => t.address).join()), ['max@example.com'], 'G01 an den Gast')
})
await check('formular_token_wartezeit_zurueck_bricht_ab', async (page) => {
srv.config.termine.formMinSekunden = MIN_SEKUNDEN
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { tag: '2026-10-26' })
await wd.locator('input[name=storno]').check()
await page.evaluate(() => { window.__vorsprung = 101 * 60 * 1000 })
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.getByRole('button', { name: 'Einen Moment …' }).waitFor()
await wd.getByRole('button', { name: 'Zurück' }).click() // der Gast geht waehrend des Wartens einen Schritt zurueck
await warteTitel(wd, 'Ihre Angaben')
await page.waitForTimeout((MIN_SEKUNDEN + 1.2) * 1000)
eq(srv.buchungen().length, 0, 'es wird nichts mehr gesendet, wenn die Ansicht gewechselt hat')
eq(await titel(wd).innerText(), 'Ihre Angaben', 'die Ansicht bleibt, wo der Gast ist')
}, { initScript: mitVorsprung })
// ---------------------------------------------------------------- 10. Widerruf ohne Link
await check('vertrag_widerrufen_im_fuss', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').waitFor()
eq((await wd.locator('.wkt-fuss').innerText()).replace(/\s+/g, ' '), 'Vertrag widerrufen Datenschutz Terminbuchung über WEBklar', 'Fuss')
// Ruling R54: ein zweiter Knopf (wkt-btn wkt-btn-sek), kein Textlink; mindestens 44 px hoch
const fussKnopf = wd.locator('.wkt-fuss button')
eq(await fussKnopf.getAttribute('class'), 'wkt-btn wkt-btn-sek', 'Klassen des Widerrufsknopfs')
const fussBox = await fussKnopf.boundingBox()
assert(fussBox.height >= 44, `Widerrufsknopf im Fuss mindestens 44 px hoch: ${fussBox.height}`)
eq(await fussKnopf.evaluate((b) => getComputedStyle(b).textDecorationLine), 'none', 'kein unterstrichener Textlink')
const ds = wd.locator('.wkt-fuss a')
eq([await ds.getAttribute('href'), await ds.getAttribute('rel'), await ds.getAttribute('target')], ['https://salon-mueller.example/datenschutz', 'noopener noreferrer', '_blank'], 'Datenschutz-Link')
await wd.getByRole('button', { name: 'Vertrag widerrufen' }).click()
await warteTitel(wd, 'Vertrag widerrufen')
await wd.getByRole('button', { name: 'Widerruf bestätigen' }).click() // leer: Fehler
eq(await wd.locator('input[name=name]').getAttribute('aria-invalid'), 'true', 'Name Pflicht')
await wd.locator('input[name=name]').fill('Gerda Gast')
await wd.locator('input[name=email]').fill('gerda@example.com')
await wd.locator('input[name=nummer]').fill('ABCD1234')
await shot(page, 'widerruf')
await wd.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await wd.locator('.wkt-ergebnis-text').waitFor()
eq(await wd.locator('.wkt-ergebnis-text').innerText(), 'Ihre Erklärung ist eingegangen.', 'Eingangsmeldung')
assert(/Eingang: \d{2}\.\d{2}\.\d{4}, \d{2}:\d{2} Uhr/.test(await wd.innerText()), 'Datum und Uhrzeit des Eingangs')
await srv.warteAufVersand()
const an = srv.mails().map((m) => m.to.map((t) => t.address).join())
assert(an.includes('gerda@example.com'), `Eingangsbestaetigung (G08) fehlt: ${an}`)
assert(an.includes('info@salon-mueller.example'), `Weiterleitung an den Betrieb (K21) fehlt: ${an}`)
await wd.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await karte(wd, 'Herrenschnitt').waitFor()
})
// Ruling R55: auch ein Betrieb mit nur Anfrage-Leistungen (Privatpersonen) zeigt Belehrung und Widerrufsfunktion
await check('nur_anfragen_zeigen_belehrung_und_widerruf', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Brautfrisur').waitFor()
eq(await wd.locator('.wkt-karte').count(), 1, 'nur die Anfrage-Leistung')
eq((await wd.locator('.wkt-fuss').innerText()).replace(/\s+/g, ' '), 'Vertrag widerrufen Datenschutz Terminbuchung über WEBklar', 'Fuss mit Widerruf')
await bisZusammenfassung(page, { leistung: 'Brautfrisur', tag: '2026-10-26' })
await wd.locator('details.wkt-belehrung').waitFor()
assert((await wd.locator('details.wkt-belehrung').innerText()).includes('Widerrufsbelehrung'), 'Belehrung vor dem Absenden')
eq(await wd.locator('input[name=storno]').count(), 0, 'Anfrage ohne Preis: kein Storno-Haken (F11)')
await hakenUndBuchen(wd, 'Termin anfragen')
await srv.warteAufVersand()
const buchungen = srv.buchungen()
eq(buchungen.map((b) => [b.buchungsart, b.verbraucher]), [['anfrage', true]], 'Anfrage von einer Privatperson')
assert(/^[0-9a-f]{64}$/.test(JSON.parse(buchungen[0].nachweis).widerruf?.hash || ''), 'Nachweis haelt die gezeigte Belehrung fest')
}, { eingabe: (() => { const e = salonEingabe(); e.leistungen = e.leistungen.filter((l) => l.buchungsart === 'anfrage'); return e })() })
// Telefonnummer im Ausfalltext: Nummer wird zum Link, freier Text bleibt Text (kein Link auf eine falsche Nummer)
const ausfallOeffnen = async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').click()
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
await page.route(API, (route) => route.abort())
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-ausfall').waitFor()
return wd.locator('.wkt-ausfall')
}
const mitTelefon = (telefon) => { const e = salonEingabe(); e.betrieb.telefon = telefon; return e }
await check('ausfall_telefon_freitext_bleibt_text', async (page) => {
const meldung = await ausfallOeffnen(page)
eq(await meldung.innerText(), 'Gerade nicht erreichbar. Bitte rufen Sie an: 06301 1234 (ab 9 Uhr)', 'Text mit Nummer und Zusatz')
eq(await meldung.locator('a').count(), 0, 'kein tel:-Link fuer freien Text')
}, { eingabe: mitTelefon('06301 1234 (ab 9 Uhr)') })
await check('ausfall_telefon_landesvorwahl_ohne_null', async (page) => {
const meldung = await ausfallOeffnen(page)
eq(await meldung.locator('a').getAttribute('href'), 'tel:+4963011234', '(0) hinter der Landesvorwahl entfaellt')
eq(await meldung.locator('a').innerText(), '+49 (0) 6301-1234', 'angezeigt wird, was der Betrieb eingetragen hat')
}, { eingabe: mitTelefon('+49 (0) 6301-1234') })
// ---------------------------------------------------------------- 11. Stile der Seite
await check('seitenstile_und_betriebsfarbe', async (page, ctx) => {
const leer = await ctx.newPage()
await oeffne(leer, seitenHtml(eingebettet('data-modus="inline" data-leistung="' + S_HERREN + '"')), 'standard.html')
const wdL = w(leer)
await karte(wdL, 'Egal').click()
await wdL.locator('td.wkt-tag[aria-disabled="false"]').first().waitFor()
const standard = await wdL.locator('td.wkt-tag[aria-disabled="false"]').first().evaluate((e) => getComputedStyle(e).borderTopColor)
eq(standard, 'rgb(15, 118, 110)', 'ohne Variable gilt die Betriebsfarbe als Akzent')
await leer.close()
const eigen = 'style="--wkt-akzent:rgb(10, 20, 30);--wkt-akzent-text:rgb(250, 250, 250);--wkt-radius:2px"'
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}" ${eigen}`), { style: '.wkt-titel{color:rgb(1, 2, 3)}' }))
const wd = w(page)
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag[aria-disabled="false"]').first().waitFor()
const werte = await page.evaluate(() => ({
akzent: getComputedStyle(document.querySelector('td.wkt-tag[aria-disabled="false"]')).borderTopColor,
radius: getComputedStyle(document.querySelector('.wkt-pfeil')).borderTopLeftRadius,
titel: getComputedStyle(document.querySelector('.wkt-titel')).color,
}))
eq(werte.akzent, 'rgb(10, 20, 30)', 'CSS-Variable --wkt-akzent der Seite gewinnt')
eq(werte.radius, '2px', 'CSS-Variable --wkt-radius der Seite gewinnt')
eq(werte.titel, 'rgb(1, 2, 3)', 'Regel der Seite (ohne Layer) schlaegt @layer wkt')
await tag(wd, '2026-10-12').click()
await zeit(wd, '10:00').click()
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
const knopfFarbe = await wd.locator('.wkt-form button[type=submit]').evaluate((b) => [getComputedStyle(b).backgroundColor, getComputedStyle(b).color])
eq(knopfFarbe, ['rgb(10, 20, 30)', 'rgb(250, 250, 250)'], 'Akzent und Akzent-Text am Knopf')
})
// ---------------------------------------------------------------- 12. Fehler und Ausweichtext
await check('ohne_javascript_bleibt_ausweichtext', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext fehlt')
}, { context: { javaScriptEnabled: false } })
await check('funktion_aus_oder_unbekannt_ausweichtext', async (page) => {
await srv.setzeFunktion('disabled')
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"') + eingebettet('data-modus="knopf" data-knopf="Termin buchen"', '<p id="ausweich-knopf">Termine telefonisch unter 06301 1234.</p>') + eingebettet('data-modus="inline"', '<p id="ausweich-unbekannt">Unbekannter Schluessel</p>', 'zzzzzzzzzzzzzzzz')))
await page.waitForLoadState('networkidle')
await page.waitForTimeout(300)
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext (Funktion aus) fehlt')
assert(await page.locator('#ausweich-unbekannt').isVisible(), 'Ausweichtext (unbekannter Schluessel) fehlt')
eq(await page.locator('[data-modus="inline"].wkt-root').count(), 0, 'kein Widget (inline) bei abgeschalteter Funktion')
eq(await page.locator('#wkt-style').count(), 1, 'nur der Knopf bringt Stile mit (inline: keine)')
// Knopf: erst beim Klick bemerkt das Widget, dass nichts buchbar ist; im Dialog steht der Ausweichtext
await page.getByRole('button', { name: 'Termin buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await dlg.locator('#ausweich-knopf').waitFor()
assert(await dlg.locator('#ausweich-knopf').isVisible(), 'Ausweichtext im Dialog')
eq(await dlg.locator('.wkt-karte').count(), 0, 'keine Leistungen')
await shot(page, 'fehler-funktion-aus')
})
await check('netzfehler_mitten_in_der_strecke', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').click()
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
await page.route(API, (route) => route.abort())
await tag(wd, '2026-10-12').click()
const meldung = wd.locator('.wkt-ausfall')
await meldung.waitFor()
eq(await meldung.innerText(), 'Gerade nicht erreichbar. Bitte rufen Sie an: 06301 1234', 'Ausfalltext mit Telefonnummer')
eq(await meldung.locator('a').getAttribute('href'), 'tel:063011234', 'Telefonlink')
assert(await wd.locator('.wkt-fuss').isVisible(), 'Fuss bleibt')
await shot(page, 'fehler-nicht-erreichbar')
await page.unroute(API)
await wd.getByRole('button', { name: 'Erneut versuchen' }).click()
await wd.locator('td.wkt-tag').first().waitFor()
await tag(wd, '2026-10-12').click()
await wd.locator('.wkt-zeit').first().waitFor()
})
await check('keine_fremden_anfragen_keine_cookies', async (page, ctx) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
await bisZusammenfassung(page, { tag: '2026-10-26' })
eq((await ctx.cookies()).length, 0, 'Cookies')
const erlaubt = new Set([new URL(srv.url).host, new URL(SEITE).host])
const fremd = [...hosts].filter((h) => !erlaubt.has(h))
eq(fremd, [], 'Anfragen an fremde Hosts')
})
// ---------------------------------------------------------------- 13. Nachbesserungen (Fix-Runde 1)
const ACAO = { 'access-control-allow-origin': '*' }
const TEXT_429 = 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.'
const json = (route, status, body) => route.fulfill({ status, contentType: 'application/json', headers: ACAO, body: JSON.stringify(body) })
await check('grenze_429_keine_schleife', async (page) => {
// lesend (Tage): Meldung und Knopf, kein neuer Abruf von selbst
let tage = 0
await page.route(/\/tage\?/, (route) => { tage += 1; return json(route, 429, { fehler: { _: TEXT_429 } }) })
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
await karte(wd, 'Herrenschnitt').click()
await karte(wd, 'Egal').click()
const meldung = wd.locator('.wkt-ausfall')
await meldung.waitFor()
await page.waitForTimeout(1500)
assert(tage >= 1 && tage <= 2, `bei 429 darf es hoechstens 2 Abrufe von /tage geben, es waren ${tage}`)
eq(await meldung.innerText(), TEXT_429, 'Meldung der Grenze')
assert(await wd.getByRole('button', { name: 'Erneut versuchen' }).isVisible(), 'Knopf "Erneut versuchen" fehlt')
await shot(page, 'fehler-429-lesen')
await page.unroute(/\/tage\?/)
await wd.getByRole('button', { name: 'Erneut versuchen' }).click()
await wd.locator('td.wkt-tag').first().waitFor()
// schreibend (Buchung): die Zusammenfassung bleibt mit der Meldung stehen
await tag(wd, '2026-10-26').click()
await zeit(wd, '10:00').click()
await wd.locator('input[name=name]').fill('Max Mustermann')
await wd.locator('input[name=email]').fill('max@example.com')
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
await wd.locator('input[name=storno]').check()
let buchungen = 0
await page.route(/\/buchungen$/, (route) => { buchungen += 1; return json(route, 429, { fehler: { _: TEXT_429 } }) })
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.locator('.wkt-fehlerbox .wkt-fehlertext').waitFor()
await page.waitForTimeout(800)
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').innerText(), TEXT_429, 'Meldung in der Zusammenfassung')
eq(await titel(wd).innerText(), 'Bitte prüfen Sie Ihre Buchung', 'bleibt in der Zusammenfassung')
eq(buchungen, 1, 'genau ein Versuch')
assert(await wd.locator('input[name=storno]').isChecked(), 'Haken bleibt')
await page.unroute(/\/buchungen$/)
// Widerrufsformular (auch eine 429 ohne Text): bleibt in der Widerrufsansicht
let widerrufe = 0
await page.route(/\/widerruf$/, (route) => { widerrufe += 1; return json(route, 429, {}) })
await wd.getByRole('button', { name: 'Vertrag widerrufen' }).click()
await warteTitel(wd, 'Vertrag widerrufen')
await wd.locator('input[name=name]').fill('Gerda Gast')
await wd.locator('input[name=email]').fill('gerda@example.com')
await wd.getByRole('button', { name: 'Widerruf bestätigen' }).click()
await wd.locator('.wkt-fehlerbox .wkt-fehlertext').waitFor()
await page.waitForTimeout(800)
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').innerText(), TEXT_429, 'Meldung im Widerrufsformular')
eq(await titel(wd).innerText(), 'Vertrag widerrufen', 'bleibt in der Widerrufsansicht')
eq(widerrufe, 1, 'genau ein Versuch')
assert(await wd.getByRole('button', { name: 'Widerruf bestätigen' }).isEnabled(), 'Knopf wieder frei')
assert(await wd.locator('input[name=name]').inputValue() === 'Gerda Gast', 'Eingaben bleiben')
await shot(page, 'fehler-429-widerruf')
await wd.getByRole('button', { name: 'Zurück zur Terminbuchung' }).click()
await wd.locator('.wkt-zusammen').waitFor()
})
await check('betriebsfarbe_orange_schrift_lesbar', async (page) => {
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`)))
const wd = await bisZusammenfassung(page, { leistung: null, tag: '2026-10-26' })
const farben = await wd.locator('.wkt-form button[type=submit]').evaluate((b) => [getComputedStyle(b).backgroundColor, getComputedStyle(b).color])
eq(farben, ['rgb(234, 88, 12)', 'rgb(17, 24, 39)'], 'Orange bekommt dunkle Schrift (Weiss waere nur 3,6 : 1)')
await shot(page, 'orange-pruefen')
}, { eingabe: salonEingabe({ farbe: '#ea580c' }) })
await check('haken_beginnen_bei_aenderung_ohne_haken', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { leistung: 'Herrenschnitt', tag: '2026-10-12', zeit: '10:00' })
const haken = () => wd.locator('.wkt-haken input').evaluateAll((l) => l.map((i) => i.name + ':' + i.checked))
const alleAn = async () => { for (const n of ['storno', 'vorzeitig', 'erinnerung']) await wd.locator(`input[name=${n}]`).check() }
const zurueck = async (n) => { for (let i = 0; i < n; i += 1) await wd.getByRole('button', { name: 'Zurück' }).click() }
const weiterBisZusammenfassung = async () => { await wd.getByRole('button', { name: 'Weiter' }).click(); await wd.locator('.wkt-zusammen').waitFor() }
await alleAn()
eq(await haken(), ['storno:true', 'vorzeitig:true', 'erinnerung:true'], 'alle drei angekreuzt')
// dieselbe Uhrzeit noch einmal gewaehlt: nichts hat sich geaendert, die Haken bleiben
await zurueck(2)
await wd.locator('.wkt-zeit').first().waitFor()
await zeit(wd, '10:00').click()
await warteTitel(wd, 'Ihre Angaben')
await weiterBisZusammenfassung()
eq(await haken(), ['storno:true', 'vorzeitig:true', 'erinnerung:true'], 'gleiche Uhrzeit: Haken bleiben')
// andere Uhrzeit
await zurueck(2)
await wd.locator('.wkt-zeit').first().waitFor()
await zeit(wd, '10:15').click()
await warteTitel(wd, 'Ihre Angaben')
await weiterBisZusammenfassung()
eq(await haken(), ['storno:false', 'vorzeitig:false', 'erinnerung:false'], 'andere Uhrzeit: ohne Haken')
// anderer Tag
await alleAn()
await zurueck(2)
await tag(wd, '2026-10-13').click()
await zeit(wd, '10:15').click()
await warteTitel(wd, 'Ihre Angaben')
await weiterBisZusammenfassung()
eq(await haken(), ['storno:false', 'vorzeitig:false', 'erinnerung:false'], 'anderer Tag: ohne Haken')
// andere Leistung
await alleAn()
await zurueck(4)
await warteTitel(wd, 'Welche Leistung')
await karte(wd, 'Kinderschnitt').click()
await karte(wd, 'Egal').click()
await tag(wd, '2026-10-13').click()
await zeit(wd, '10:15').click()
await warteTitel(wd, 'Ihre Angaben')
await weiterBisZusammenfassung()
eq(await haken(), ['storno:false', 'vorzeitig:false', 'erinnerung:false'], 'andere Leistung: ohne Haken')
})
await check('fehler_ohne_feld_nicht_verschluckt', async (page) => {
let fehler = {}
await page.route(/\/buchungen$/, (route) => json(route, 400, { fehler }))
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { leistung: 'Herrenschnitt', tag: '2026-10-26' })
await wd.locator('input[name=storno]').check()
eq(await wd.locator('input[name=vorzeitig]').count(), 0, 'Voraussetzung: der Haken wird nicht gezeigt')
// 1) Schluessel ohne Feld in der Zusammenfassung: Fehlerkasten
fehler = { vorzeitig: 'Bitte der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zustimmen, sonst ist die Buchung nicht möglich.', irgendwas: 'Unbekannter Fehler vom Server.' }
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.locator('.wkt-fehlerbox .wkt-fehlertext').first().waitFor()
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').allInnerTexts(), Object.values(fehler), 'beide Meldungen sichtbar')
eq(await titel(wd).innerText(), 'Bitte prüfen Sie Ihre Buchung', 'bleibt in der Zusammenfassung')
// 2) Feld im Schritt "Angaben" plus Schluessel ohne Feld
fehler = { name: 'Bitte einen Namen angeben.', foo: 'Weitere Meldung.' }
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await warteTitel(wd, 'Ihre Angaben')
eq(await wd.locator('input[name=name]').getAttribute('aria-invalid'), 'true', 'Namensfeld markiert')
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').allInnerTexts(), ['Weitere Meldung.'], 'Meldung ohne Feld im Kasten')
// 3) Leistung: der Leistungsschritt zeigt die Meldung
await wd.getByRole('button', { name: 'Weiter' }).click()
await wd.locator('.wkt-zusammen').waitFor()
fehler = { leistung: 'Bitte eine Leistung auswählen.' }
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await warteTitel(wd, 'Welche Leistung')
eq(await wd.locator('.wkt-fehlerbox .wkt-fehlertext').allInnerTexts(), ['Bitte eine Leistung auswählen.'], 'Meldung im Leistungsschritt')
})
await check('angebot_geaendert_beginnt_von_vorn', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { leistung: 'Damenschnitt', tag: '2026-10-26' })
await wd.locator('input[name=storno]').check()
// der Betrieb nimmt die Leistung heraus; das Formular-Token ist inzwischen abgelaufen (neu_laden)
const eingabe = salonEingabe()
eingabe.leistungen = eingabe.leistungen.filter((l) => l.id !== S_DAMEN)
await srv.speichere(eingabe)
srv.schiebeUhr(3 * 60 * 60 * 1000)
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.locator('.wkt-meldung').waitFor()
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Angebot hat sich geändert. Bitte wählen Sie neu.', 'Hinweis')
eq(await titel(wd).innerText(), 'Welche Leistung möchten Sie buchen?', 'zurueck zur Leistungswahl')
eq(await karte(wd, 'Damenschnitt').count(), 0, 'die entfernte Leistung ist weg')
eq(srv.buchungen().length, 0, 'nichts gebucht')
})
await check('konfiguration_unvollstaendig_ausweichtext', async (page, ctx) => {
const varianten = {
'Leistung ohne Knopftext': (j) => { delete j.leistungen[0].knopf },
'ohne Text fuer den Storno-Haken': (j) => { delete j.texte.storno },
'ohne Text fuer den Haken vorzeitiger Beginn': (j) => { delete j.texte.vorzeitig },
'Widerruf ohne Belehrung': (j) => { delete j.texte.widerrufsbelehrung },
'ohne Formular-Token': (j) => { delete j.formToken },
}
for (const [name, aendere] of [['unveraendert', () => {}], ...Object.entries(varianten)]) {
const p = await ctx.newPage()
p.setDefaultTimeout(8000)
await p.route(/\/config$/, async (route) => {
const antwort = await route.fetch()
const daten = await antwort.json()
aendere(daten)
await route.fulfill({ response: antwort, json: daten })
})
await p.goto(srv.seite('unvollstaendig.html', seitenHtml(eingebettet('data-modus="inline"'))))
if (name === 'unveraendert') {
await karte(w(p), 'Herrenschnitt').waitFor()
} else {
await p.waitForLoadState('networkidle')
await p.waitForTimeout(300)
assert(await p.locator('#ausweich').isVisible(), `${name}: Ausweichtext muss stehen`)
eq(await p.locator('.wkt-root').count(), 0, `${name}: kein Widget`)
}
await p.close()
}
})
await check('render_fehler_zeigt_ausweichtext_wieder', async (page) => {
// Der Aufbau der Leistungsliste scheitert (<ul> laesst sich nicht erzeugen), nachdem der Ausweichtext schon beiseite ist
const kaputt = await page.context().newPage()
await kaputt.addInitScript(() => {
const echt = document.createElement.bind(document)
document.createElement = (tag, opt) => { if (String(tag).toLowerCase() === 'ul') throw new Error('Test: Aufbau scheitert'); return echt(tag, opt) }
})
const fehler = []
kaputt.on('pageerror', (e) => fehler.push(e.message))
await kaputt.goto(srv.seite('kaputt.html', seitenHtml(eingebettet('data-modus="inline"'))))
await kaputt.waitForLoadState('networkidle')
await kaputt.waitForTimeout(500)
assert(await kaputt.locator('#ausweich').isVisible(), 'Ausweichtext muss nach dem Fehler wieder da sein')
eq(await kaputt.locator('.wkt-root').count(), 0, 'kein halbes Widget')
eq(fehler, [], 'der Fehler wird abgefangen')
await kaputt.close()
})
await check('monatspfeil_behaelt_fokus', async (page) => {
await oeffne(page, seitenHtml(eingebettet(`data-modus="inline" data-leistung="${S_HERREN}"`)))
const wd = w(page)
await karte(wd, 'Egal').click()
await wd.locator('td.wkt-tag').first().waitFor()
const vor = wd.getByRole('button', { name: 'Nächster Monat' })
const zur = wd.getByRole('button', { name: 'Vorheriger Monat' })
const fokus = () => page.evaluate(() => { const a = document.activeElement; return { body: a === document.body, label: a.getAttribute('aria-label') || '', imWidget: !!a.closest('[data-webklar-termine]') } })
const klickeBisGesperrt = async (knopf) => {
for (let i = 0; i < 8 && await knopf.isEnabled(); i += 1) {
const vorher = await wd.locator('.wkt-monat').innerText()
await knopf.click()
await page.waitForFunction((v) => document.querySelector('.wkt-monat').textContent !== v, vorher)
}
assert(await knopf.isDisabled(), 'Pfeil wurde nie gesperrt')
}
await klickeBisGesperrt(vor)
eq(await fokus(), { body: false, label: 'Vorheriger Monat', imWidget: true }, 'Fokus nach dem gesperrten "Naechster Monat" beim anderen Pfeil')
await klickeBisGesperrt(zur)
eq(await fokus(), { body: false, label: 'Nächster Monat', imWidget: true }, 'Fokus nach dem gesperrten "Vorheriger Monat" beim anderen Pfeil')
})
// ---------------------------------------------------------------- 14. Fix-Runde 2: veraltetes Angebot, Aufbaufehler im Knopfmodus
// Die Auswahl wird gewaehlt, der Betrieb aendert das Angebot, dann antwortet der Server auf /tage mit 400 (leistung/ressource):
// das Widget muss frisch laden und von vorn beginnen (Hinweis, neue Liste), nicht "Gerade nicht erreichbar" zeigen.
const veraltetVorTage = (wahl) => async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = w(page)
let freigeben = null
const tor = new Promise((resolve) => { freigeben = resolve })
await page.route(/\/tage\?/, async (route) => { await tor; await route.continue() })
await karte(wd, 'Damenschnitt').click()
await warteTitel(wd, 'Bei wem')
const eingabe = salonEingabe()
if (wahl === 'leistung') {
eingabe.leistungen = eingabe.leistungen.filter((l) => l.id !== S_DAMEN)
await karte(wd, 'Egal').click() // /tage ist unterwegs und wird festgehalten ...
} else {
eingabe.ressourcen = eingabe.ressourcen.map((r) => (r.id === R_ANNA ? { ...r, aktiv: false } : r))
await karte(wd, 'Anna').click()
}
await srv.speichere(eingabe) // ... der Betrieb aendert das Angebot ...
freigeben() // ... und der Abruf erreicht den Server erst jetzt
await wd.locator('.wkt-meldung').waitFor()
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Angebot hat sich geändert. Bitte wählen Sie neu.', 'Hinweis')
eq(await titel(wd).innerText(), 'Welche Leistung möchten Sie buchen?', 'Neubeginn bei der Leistungswahl')
eq(await wd.locator('.wkt-ausfall').count(), 0, 'kein "Gerade nicht erreichbar"')
eq(await karte(wd, 'Herrenschnitt').count(), 1, 'frische Liste')
if (wahl === 'leistung') {
eq(await karte(wd, 'Damenschnitt').count(), 0, 'entfernte Leistung nicht mehr in der Liste')
} else {
// mit Anna ausser Betrieb bleibt nur Ben: kein Personenschritt mehr, die Strecke geht gleich zum Termin
await karte(wd, 'Damenschnitt').click()
await warteTitel(wd, 'Wann passt es Ihnen?')
eq(await wd.locator('.wkt-pfad').innerText(), 'Schritt 2 von 4', 'ohne Personenschritt')
await wd.locator('td.wkt-tag[aria-disabled="false"]').first().waitFor()
}
await shot(page, `angebot-geaendert-${wahl}`)
}
await check('angebot_geaendert_leistung_vor_tage', veraltetVorTage('leistung'))
await check('angebot_geaendert_person_vor_tage', veraltetVorTage('person'))
await check('angebot_geaendert_400_beim_absenden', async (page) => {
await oeffne(page, seitenHtml(eingebettet('data-modus="inline"')))
const wd = await bisZusammenfassung(page, { leistung: 'Damenschnitt', tag: '2026-10-26' })
await wd.locator('input[name=storno]').check()
const eingabe = salonEingabe()
eingabe.leistungen = eingabe.leistungen.filter((l) => l.id !== S_DAMEN)
await srv.speichere(eingabe) // das Token ist noch gueltig: der Server antwortet mit 400 fuer die Leistung, nicht mit neu_laden
await wd.getByRole('button', { name: 'Zahlungspflichtig buchen' }).click()
await wd.locator('.wkt-meldung').waitFor()
eq(await wd.locator('.wkt-meldung').innerText(), 'Das Angebot hat sich geändert. Bitte wählen Sie neu.', 'Hinweis')
eq(await titel(wd).innerText(), 'Welche Leistung möchten Sie buchen?', 'Neubeginn')
eq(await karte(wd, 'Damenschnitt').count(), 0, 'entfernte Leistung nicht mehr in der Liste')
eq(srv.buchungen().length, 0, 'nichts gebucht')
})
await check('knopf_render_fehler_zeigt_ausweichtext', async (page) => {
await page.addInitScript(() => {
const echt = document.createElement.bind(document)
document.createElement = (tag, opt) => { if (String(tag).toLowerCase() === 'ul') throw new Error('Test: Aufbau scheitert'); return echt(tag, opt) }
})
await oeffne(page, seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')))
await page.getByRole('button', { name: 'Termin buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await dlg.locator('#ausweich').waitFor()
assert(await dlg.locator('#ausweich').isVisible(), 'Ausweichtext im Dialog')
assert(!(await dlg.innerText()).includes('Wird geladen'), '"Wird geladen" darf nicht stehen bleiben')
eq(await dlg.locator('.wkt-karte').count(), 0, 'kein halbes Widget')
})
// Zum Schluss: Server gestoppt. Seite und Skript liefert ein eigener kleiner Server, die Schnittstelle ist weg (Verbindung abgelehnt).
await check('server_gestoppt_ausweichtext', async (page) => {
const skript = await readFile(WIDGET_JS, 'utf8')
const seiten = new Map()
const kleiner = http.createServer((req, res) => {
const pfad = new URL(req.url, 'http://x').pathname
if (pfad === '/embed/termine.js') return res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }).end(skript)
if (seiten.has(pfad)) return res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(seiten.get(pfad))
return res.writeHead(404).end()
})
await new Promise((resolve) => kleiner.listen(0, '127.0.0.1', resolve))
const eigen = `http://localhost:${kleiner.address().port}`
const oeffneEigen = async (html, name) => { seiten.set(`/${name}`, html); await page.goto(`${eigen}/${name}`) }
await srv.beendeApp()
try {
await oeffneEigen(seitenHtml(eingebettet('data-modus="inline"')), 'gestoppt.html')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(500)
assert(await page.locator('#ausweich').isVisible(), 'Ausweichtext fehlt bei gestopptem Server')
eq(await page.locator('.wkt-root').count(), 0, 'kein halbes Widget')
eq(await page.locator('#wkt-style').count(), 0, 'keine Stile ohne Widget')
await shot(page, 'fehler-ausweichtext')
// Knopfmodus: der Dialog zeigt "Gerade nicht erreichbar." und den Ausweichtext
await oeffneEigen(seitenHtml(eingebettet('data-modus="knopf" data-knopf="Termin buchen"')), 'gestoppt2.html')
await page.getByRole('button', { name: 'Termin buchen' }).click()
const dlg = page.locator('dialog.wkt-dialog')
await dlg.locator('#ausweich').waitFor()
assert((await dlg.innerText()).includes('Gerade nicht erreichbar.'), 'Meldung im Dialog')
await shot(page, 'fehler-ausweichtext-knopf')
} finally {
kleiner.closeAllConnections?.()
await new Promise((resolve) => kleiner.close(resolve))
}
})
}
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch()
await pruefungen()
} catch (e) {
abbruch = e
console.log(`ABBRUCH: ${e.stack || e.message}`)
} finally {
if (browser) await browser.close().catch(() => {})
await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`))
}
const fehlgeschlagen = results.filter(([, ok]) => !ok)
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)