Terminplaner: Einbau-Skript – Nachbesserungen

429 ohne Schleife (Meldung und Erneut-versuchen-Knopf, Formulare bleiben stehen), Schriftfarbe
auf der Betriebsfarbe mit mindestens 4,5 : 1, AGB-Link aus der oeffentlichen Konfiguration
(betrieb.agbUrl), Haken starten bei geaenderter Auswahl ohne Haken, Fehler ohne Feld nicht
verschluckt, Konfiguration fail closed, ladeNeu fuer Neuladen und neu_laden, Tastatur und
Fokus (Modifikatoren, Monatspfeile, Belehrungstext), Aufraeumen. UI-Testserver: Timer,
offene Verbindungen, Hinweis auf sessionCookie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:44 +00:00
1 parent 5fef7e47f0
commit 4465ac7b94
9 files changed
+501 -88

No files matched your search

+242 -1
View File
@@ -12,7 +12,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
startTermineUiServer, salonEingabe,
R_ANNA, S_DAMEN, S_HERREN, S_KENNEN,
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'
@@ -165,10 +165,17 @@ async function pruefungen() {
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')
@@ -366,6 +373,8 @@ async function pruefungen() {
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')
@@ -797,6 +806,238 @@ async function pruefungen() {
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')
})
// 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')
+11 -1
View File
@@ -12,6 +12,11 @@
// damit Tage und Uhrzeiten in Screenshots gleich bleiben und Zwischenspeicher, Haltezeiten und Formular-Token trotzdem
// ablaufen. srv.schiebeUhr(ms) verschiebt sie (z. B. +3 h: Formular-Token abgelaufen), srv.setzeUhr(date) setzt sie neu.
//
// Cookies fuer das Portal (Aufgaben 21a/21b): sessionCookie/customerCookie aus test/support/testApp.js und fixtures.js erst NACH dem
// Start dieses Servers dynamisch importieren (testApp.js prueft beim Laden, dass die Umgebung auf die Nachbauten zeigt):
// const srv = await startTermineUiServer()
// const { sessionCookie } = await import('../support/testApp.js')
//
// Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host).
// Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit
// data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt.
@@ -64,6 +69,7 @@ export function salonEingabe(ueberschreibe = {}) {
websiteUrl: 'https://salon-mueller.example',
impressumUrl: 'https://salon-mueller.example/impressum',
datenschutzUrl: 'https://salon-mueller.example/datenschutz',
agbUrl: 'https://salon-mueller.example/agb',
},
anrede: 'sie',
farbe: '#0f766e',
@@ -259,7 +265,11 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false,
async beendeApp() {
if (srv.appBeendet) return
srv.appBeendet = true
await Promise.race([app.close(), new Promise((resolve) => setTimeout(resolve, 3000))])
let frist = null
const geschlossen = app.close()
app.server.closeAllConnections?.() // offene Verbindungen des Browsers (Keep-Alive) halten close() sonst auf
await Promise.race([geschlossen, new Promise((resolve) => { frist = setTimeout(resolve, 3000) })])
clearTimeout(frist)
},
appBeendet: false,
}