public/embed/termine.js: Buchungs-Widget fuer Kundenwebsites (inline und Knopf mit dialog, Monatsraster nach APG, Haken-Texte und Knopf aus der Konfiguration, Honigtopf kontakt_url, Widerrufsfunktion, Konfiguration nach 100 min neu, Ausweichtext bei Fehlern). Rechnet nie mit Datum oder Zeitzone des Browsers. Dazu Demo-Seite, Einbauanleitung fuer Mitarbeiter, statischer Test und Playwright-Skript mit wiederverwendbarem UI-Testserver (test/ui/termineTestServer.mjs). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
853 lines
52 KiB
JavaScript
853 lines
52 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, S_KENNEN,
|
|
} 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')
|
|
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')
|
|
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.', '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)
|
|
eq(anfragen.length, 0, 'Vor dem Klick darf nichts geladen werden')
|
|
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()
|
|
assert(anfragen.some((u) => u.includes('/config')), 'Konfiguration wird beim Klick geladen')
|
|
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')
|
|
await dlg.locator('input[name=storno]').check()
|
|
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')
|
|
})
|
|
|
|
// ---------------------------------------------------------------- 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('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 } })
|
|
|
|
// ---------------------------------------------------------------- 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')
|
|
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()
|
|
})
|
|
|
|
// ---------------------------------------------------------------- 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')
|
|
})
|
|
|
|
// 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)
|