Files
Webklar-Kundenbereich/test/ui/termine-fragebogen.ui.mjs

740 lines
44 KiB
JavaScript

// Playwright-Checks Terminplaner TP2, Verkauf: vom Fragebogen bis zum Bestellklick (public/funktionen.js Kachel, termine.js Huelle,
// termine-fragebogen.js Schritte, funktion-bestellen.js Bestellseite und Statuskarte). Nicht Teil von npm test.
// Aufruf: NODE_OPTIONS=--max-old-space-size=1024 nice node test/ui/termine-fragebogen.ui.mjs (vorher free -m; es laeuft nur ein Browser)
// Screenshots: /root/reports/terminplaner-ui/fragebogen-*.png (bei Fehlern fehler-fragebogen-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Alles laeuft gegen die ECHTEN Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite und Stripe (test/ui/termineTestServer.mjs);
// nur die uebrigen Portal-Schnittstellen (/api/auth/me usw.) mockt page.route, und die Zahlungsseite (https://checkout.stripe.test/...) faengt
// page.route ab: dorthin geht kein Netzverkehr. Hosts ausser 127.0.0.1, localhost und checkout.stripe.test sind Fehler, ebenso Seitenfehler und
// Browser-Dialoge. Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
// NUR=<Teil des Namens> fuehrt nur passende Pruefungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import { startTermineUiServer, salonEingabe, FEATURE, KUNDE, PROJEKT } 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 (/^(fragebogen|fehler-fragebogen)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startTermineUiServer()
// erst nach dem Start des Testservers importieren (config.js liest die Umgebung beim ersten Import)
const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js')
const { aboMeldung } = await import('../../server/services/featureOrder.js')
const { leseZustimmung, nachweisTexte, texteHash, zustimmungsfassungen } = await import('../../server/services/featureConsents.js')
const REGISTER = featureConfig('termine')
const JETZT = new Date('2026-10-05T08:00:00.000Z')
const ERLAUBTE_HOSTS = new Set(['127.0.0.1', 'localhost', 'checkout.stripe.test'])
const STRIPE_SEITE = /^https:\/\/checkout\.stripe\.test\/c\/pay\/(cs_test_\w+)$/
// ---------------------------------------------------------------- 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 pause = (ms) => new Promise((fertig) => { setTimeout(fertig, ms) })
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhoehe (hoechstens 3200 px). */
async function shot(page, name) {
// Einblend-Animationen der Seite (kb-fade-in, kb-view-in) abwarten, sonst ist der Inhalt noch durchsichtig
await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))))
const fenster = page.viewportSize()
const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200))
await page.setViewportSize({ width: fenster.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/fragebogen-${name}.png` })
await page.setViewportSize(fenster)
}
/** Kein Streifen rechts: die Seite passt in die Breite. */
const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/termine-portal.ui.mjs). */
function portalHilfe(p, json) {
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Silke Müller', email: 'silke@salon-mueller.example' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Salon Müller', status: 'live' }] })
if (p === '/api/features') return json({ features: [] })
if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' })
if (p === '/api/chat/unread-count') return json({ unread: 0 })
return json({})
}
// ---------------------------------------------------------------- Ablauf der Pruefungen
const results = []
let browser = null
const hosts = new Set()
/**
* Eine Pruefung in eigenem Browser-Kontext, ausgehend von einem Kunden vor der ersten Branchenwahl (srv.zuruecksetzen({ entwurf: true })).
* optionen: context (Playwright), abo (srv.abo: 'wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'), cookie ('kunde' | 'kundenansicht').
* fn(page, t, ctx): t.anfragen sind alle Aufrufe der Seite an /api/ (Methode, Pfad, Koerper), t.stripe die Aufrufe der Zahlungsseite.
*/
async function check(name, fn, { context = {}, abo = 'wartung', cookie = 'kunde' } = {}) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
await srv.zuruecksetzen({ entwurf: true })
if (abo !== 'wartung') await srv.abo(abo)
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context })
await ctx.clock.setFixedTime(JETZT)
await ctx.addCookies([cookie === 'kundenansicht' ? await srv.kundenansichtCookie() : await srv.portalCookie()])
ctx.setDefaultTimeout(8000)
const fehler = []
const t = { anfragen: [], stripe: [] }
// jede Seite des Kontexts wird beobachtet
ctx.on('page', (seite) => {
seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`))
seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) })
seite.on('request', (r) => {
let u
try { u = new URL(r.url()) } catch { return } // data: u. a.
if (!u.protocol.startsWith('http')) return
hosts.add(u.hostname)
if (!ERLAUBTE_HOSTS.has(u.hostname)) fehler.push(`Anfrage an fremden Host: ${u.host}`)
if (u.pathname.startsWith('/api/')) {
let body = null
try { body = r.postDataJSON() } catch { body = r.postData() }
t.anfragen.push({ methode: r.method(), pfad: u.pathname, body })
}
})
})
await ctx.route('**/api/**', async (route) => {
const p = new URL(route.request().url()).pathname
const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return route.continue()
return portalHilfe(p, json)
})
// die Zahlungsseite: nur eine Attrappe; festgehalten wird, wann sie aufgerufen wurde und wie viele Nachweise zu diesem Zeitpunkt schon lagen
await ctx.route('https://checkout.stripe.test/**', async (route) => {
t.stripe.push({ url: route.request().url(), at: Date.now(), nachweise: srv.aw.docs('featureConsents').size })
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '<!doctype html><meta charset="utf-8"><title>Zahlungsseite (Attrappe)</title><p>Zahlungsseite</p>' })
})
const page = await ctx.newPage()
try {
await fn(page, t, ctx)
assert(fehler.length === 0, 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]}`)
if (process.env.STACK) console.log(e.stack)
await page.screenshot({ path: `${SHOTS}/fehler-fragebogen-${name}.png` }).catch(() => {})
} finally {
await ctx.close()
}
}
// ---- Seite und Fragebogen
/** Das Portal frisch laden: ein Wechsel nur der Adresse nach # laedt die Daten nicht neu, darum erst eine leere Seite. */
async function oeffne(page, hash) {
if (page.url().startsWith(srv.url)) await page.goto('about:blank')
await page.goto(`${srv.url}/dashboard.html${hash}`)
}
const warteFragebogen = (page) => page.locator('#view-termine.active .tm-fragebogen').waitFor()
const markierung = (page, key) => page.locator(`.tm-schritt[aria-current="step"][data-schritt="${key}"]`)
const warteSchritt = (page, key) => markierung(page, key).waitFor()
/** „Schritt n von 8“ (Text steht immer im DOM, sichtbar ist er nur am Handy). */
const standText = (page) => page.locator('.tm-schritt-stand').textContent()
const schrittKnopf = (page, key) => page.locator(`.tm-schritt[data-schritt="${key}"] button`)
const weiterKnopf = (page) => page.locator('.tm-fb-form [data-aktion="weiter"]')
const feldIn = (page, name) => page.locator(`[data-feld="${name}"]`)
/** Der Kunde mit Entwurf kommt ueber die Kachel in Funktionen in die Ansicht (die Kachel merkt sich das Projekt). */
async function ueberKachelStarten(page, knopfText) {
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
await kachel.getByRole('button', { name: knopfText, exact: true }).click()
await warteFragebogen(page)
}
const GEBUCHT = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Auf der Seite Kontakt unter dem Text', gestaltung: '', bemerkung: '' }
/** Alle acht Schritte bestaetigt und alles ausgefuellt: der Entwurf, der direkt auf der Bestellseite landet. */
const vollstaendig = (extra = {}) => ({ bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true, preiseGeprueft: true, einbau: GEBUCHT, ...extra })
// Zaehler fuer "die Version steigt nur durch PATCH": diese Aufrufe der Seite schreiben die Einstellung (und erhoehen deren Version)
const schreibt = (a) => (a.methode === 'PATCH' && a.pfad === '/api/termine/einstellungen') || (a.methode === 'POST' && a.pfad === '/api/termine/einstellungen/vorlage')
const zaehleSchreiber = (t) => t.anfragen.filter(schreibt).length
const zaehlePuts = (t) => t.anfragen.filter((a) => a.methode === 'PUT' && a.pfad === '/api/termine/fragebogen').length
const schreibaufrufe = (t) => t.anfragen.filter((a) => a.methode !== 'GET')
// Eingaben der acht Schritte (ohne „Weiter“) und was danach gespeichert sein muss
const FELD_NAME = 'Salon Müller'
const FELD_ADRESSE = 'Hauptstr. 1, 67722 Winnweiler'
const FELD_TELEFON = '06301 1234'
const IMPRESSUM = 'https://salon-mueller.example/impressum'
const DATENSCHUTZ = 'https://salon-mueller.example/datenschutz'
const EINGABE = {
async wer(page) {
await page.locator('input[name="termine-wer-art"][value="team"]').check()
await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').fill('Anna\nBen')
},
async leistungen(page) {
await feldIn(page, 'preiseGeprueft').check()
},
async zeiten(page) {
await feldIn(page, 'bundesland').selectOption({ label: 'Rheinland-Pfalz' })
},
async regeln(page) {
await page.getByRole('button', { name: 'Alle sofort buchbar', exact: true }).click()
},
async gastfelder() {},
async benachrichtigung() {},
async website(page) {
await feldIn(page, 'betrieb.telefon').fill(FELD_TELEFON)
await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM)
await feldIn(page, 'betrieb.datenschutzUrl').fill(DATENSCHUTZ)
await page.locator('input[name="termine-einbau-modus"][value="knopf"]').check()
await feldIn(page, 'einbau.knopfText').fill('Termin buchen')
},
}
const NACH_SCHRITT = {
wer: () => eq(srv.einstellung().ressourcen.map((r) => r.name), ['Anna', 'Ben'], 'Team mit zwei Namen gespeichert'),
leistungen: () => eq(srv.fragebogen().preiseGeprueft, true, 'Preise geprueft gespeichert'),
zeiten: () => {
eq(srv.fragebogen().bundeslandGewaehlt, true, 'Bundesland gewaehlt gespeichert')
eq(srv.einstellung().bundesland, 'RP', 'Bundesland Rheinland-Pfalz')
},
regeln: () => assert(srv.einstellung().leistungen.every((l) => l.buchungsart === 'sofort'), 'alle Leistungen sofort buchbar'),
gastfelder: () => {},
benachrichtigung: () => {},
website: () => {
eq(srv.einstellung().betrieb.telefon, FELD_TELEFON, 'Telefon gespeichert')
eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum gespeichert')
eq(srv.einstellung().betrieb.datenschutzUrl, DATENSCHUTZ, 'Datenschutz gespeichert')
const e = srv.fragebogen().einbau
eq([e.modus, e.knopfText], ['knopf', 'Termin buchen'], 'Einbauwunsch gespeichert')
},
}
const FOLGE = srv.SCHRITTE
/**
* Schritt 1 ohne Entwurf: Branche „Friseur und Salon“ waehlen, Name und Anschrift stehen aus dem Konto da, „Mit dieser Branche starten“.
* Danach gibt es Funktionsdokument (off) und Einstellung, und der Schritt ist bestaetigt.
*/
async function brancheStarten(page, t) {
await warteSchritt(page, 'branche')
eq(await standText(page), 'Schritt 1 von 8', 'Fortschritt vor dem Start')
eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Name aus dem Konto')
eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Anschrift aus dem Konto')
eq(srv.aw.docs('portalFeatures').size, 0, 'vor dem Start kein Funktionsdokument')
await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check()
const s0 = zaehleSchreiber(t)
await page.getByRole('button', { name: 'Mit dieser Branche starten', exact: true }).click()
await warteSchritt(page, 'wer')
const feature = srv.aw.docs('portalFeatures').get(FEATURE)
assert(feature, 'Funktionsdokument angelegt')
eq([feature.state, feature.featureKey, feature.customerId, feature.projectId], ['off', 'termine', KUNDE, PROJEKT], 'Funktionsdokument im Zustand off')
assert(/^[a-z2-7]{16}$/.test(feature.siteKey), 'Funktionsdokument hat einen siteKey')
assert(srv.einstellung(), 'Einstellung angelegt')
eq(srv.einstellung().vorlage, 'salon', 'Vorlage der Branche')
eq(srv.einstellung().betrieb.name, FELD_NAME, 'Name in der Einstellung')
eq(srv.einstellung().betrieb.adresse, FELD_ADRESSE, 'Anschrift in der Einstellung')
eq(srv.fragebogen().branche, 'salon', 'Branche im Fragebogen')
eq(srv.fragebogen().bestaetigt, ['branche'], 'Schritt 1 bestaetigt')
// die Version steigt durch die Vorlage (POST) und hoechstens einen Nachtrag (PATCH), nie durch den PUT
eq(srv.version(), zaehleSchreiber(t) - s0, 'Version = Zahl der schreibenden Aufrufe (POST vorlage, PATCH)')
eq(zaehlePuts(t), 1, 'ein PUT bestaetigt Schritt 1')
}
/**
* „Weiter“ in einem Schritt mit Feldern: danach steht der Schritt in fragebogen.bestaetigt (aus termineConfig gelesen), der naechste Schritt
* ist da, es gab genau einen PUT und die Version der Einstellung ist nur um die PATCH-Aufrufe gestiegen.
*/
async function weiter(page, t, key, naechster) {
const v0 = srv.version()
const s0 = zaehleSchreiber(t)
const p0 = zaehlePuts(t)
await weiterKnopf(page).click()
await warteSchritt(page, naechster)
assert(srv.fragebogen().bestaetigt.includes(key), `${key} steht in bestaetigt: ${JSON.stringify(srv.fragebogen().bestaetigt)}`)
eq(zaehlePuts(t) - p0, 1, `${key}: genau ein PUT`)
const patches = zaehleSchreiber(t) - s0
assert(patches <= 1, `${key}: hoechstens ein PATCH (${patches})`)
eq(srv.version() - v0, patches, `${key}: die Version steigt nur durch PATCH`)
}
/** Schritte 2 bis bis ausfuellen und bestaetigen (Schritt 1 ist schon gestartet). */
async function schritteAusfuellen(page, t, bis) {
const ende = FOLGE.indexOf(bis)
for (let i = 1; i <= ende; i += 1) {
const key = FOLGE[i]
await warteSchritt(page, key)
eq(await standText(page), `Schritt ${i + 1} von 8`, `Fortschritt in ${key}`)
await EINGABE[key](page)
await weiter(page, t, key, FOLGE[i + 1] ?? 'bestellen')
NACH_SCHRITT[key]()
}
}
/** Bestellseite: alle Haken. */
const hakenSetzen = async (page) => {
const haken = page.locator('.tm-bestellen [data-haken]')
const n = await haken.count()
assert(n > 0, 'die Bestellseite hat Haken')
for (let i = 0; i < n; i += 1) await haken.nth(i).check()
return n
}
const bestellKnopf = (page) => page.locator('.tm-bestellen [data-aktion="bestellen"]')
async function pruefungen() {
// ============================================================ ohne Abo
await check('ohne_abo', async (page, t) => {
const aus = [['hosting', 'kein_abo'], ['keins', 'kein_abo'], ['gekuendigt', 'gekuendigt'], ['nicht_aktiv', 'nicht_aktiv']]
for (const [i, [abo, grund]] of aus.entries()) {
if (i > 0) await srv.abo(abo)
const soll = aboMeldung(REGISTER, grund)
assert(soll.includes('Wartungs-Abo'), `Hinweis des Servers nennt das Wartungs-Abo: ${soll}`)
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
const kachelText = await kachel.innerText()
assert(kachelText.includes(soll), `Kachel zeigt den Hinweis (${abo}): ${kachelText}`)
eq(await kachel.locator('.fn-tile-cta').count(), 0, `keine Knoepfe in der Kachel (${abo})`)
eq(await kachel.locator('.fn-pill').innerText(), 'Aus', `Pille Aus (${abo})`)
if (grund === 'kein_abo') assert(kachelText.includes('Zu Verträge'), 'Link zu den Vertraegen bei kein_abo')
// die Ansicht zeigt denselben Hinweis, keinen Fragebogen und keine Vorlagenauswahl
await oeffne(page, '#termine')
const hinweis = page.locator('#view-termine.active [data-hinweis="abo"]')
await hinweis.waitFor()
assert((await hinweis.innerText()).includes(soll), `Ansicht zeigt den Hinweis (${abo}): ${await hinweis.innerText()}`)
eq(await page.locator('.tm-fragebogen, .tm-vorlagen, .tm-bestellen, .tm-reiterleiste').count(), 0, `kein Fragebogen (${abo})`)
if (i === 0) await shot(page, 'ohne-abo')
}
eq(srv.aw.docs('portalFeatures').size, 0, 'portalFeatures bleibt leer')
eq(srv.aw.docs('termineConfig').size, 0, 'termineConfig bleibt leer')
eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf')
}, { abo: 'hosting' })
// ============================================================ Gesundheitsbereich blockt
await check('gesundheit_blockt', async (page, t) => {
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
await warteSchritt(page, 'branche')
await page.locator('label.tm-vorlage', { hasText: 'Gesundheitsbereich' }).locator('input').check()
const hinweis = page.locator('[data-hinweis="gesundheit"]')
await hinweis.waitFor()
const text = await hinweis.innerText()
assert(text.includes('Gesundheitsbereich') && text.includes('nicht verfügbar'), `Erklaertext: ${text}`)
eq(await page.getByRole('button', { name: /Weiter/ }).count(), 0, 'kein „Weiter“')
eq(await page.locator('[data-aktion="branche-starten"], .tm-fb-form [type="submit"]').count(), 0, 'kein Knopf zum Starten')
await shot(page, 'gesundheit')
// auch ein erzwungenes Absenden (Enter im Feld, requestSubmit) sendet nichts
await feldIn(page, 'betrieb.name').press('Enter')
await page.evaluate(() => document.querySelector('.tm-fb-form').requestSubmit())
await pause(500)
eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf')
for (const sammlung of ['portalFeatures', 'termineConfig', 'featureConsents']) eq(srv.aw.docs(sammlung).size, 0, `kein Dokument in ${sammlung}`)
// ein anderer Branchenwunsch hebt die Sperre wieder auf: der Knopf kommt zurueck (nichts gesendet)
await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check()
await page.locator('[data-aktion="branche-starten"]').waitFor()
eq(await hinweis.count(), 0, 'Erklaertext weg bei anderer Branche')
eq(schreibaufrufe(t), [], 'weiterhin kein Schreibaufruf')
})
// ============================================================ komplett bis zum Bestellklick
await check('fragebogen_komplett_bis_bestellklick', async (page, t) => {
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
await shot(page, 'schritt-1-start')
await brancheStarten(page, t)
await schritteAusfuellen(page, t, 'website') // 2 bis 8; nach dem letzten „Weiter“ steht die Bestellseite da
await warteSchritt(page, 'bestellen')
eq(srv.fragebogen().bestaetigt, FOLGE, 'alle acht Schritte bestaetigt')
eq(await standText(page), 'Prüfen und bestellen', 'Kopfzeile der Bestellseite')
// ---- Bestellseite
const seite = page.locator('.tm-bestellen')
await seite.waitFor()
eq(await seite.locator('.tm-bestellen-offen').count(), 0, 'keine offenen Punkte')
const knopf = bestellKnopf(page)
assert(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen')
const knopfText = (await knopf.innerText()).trim()
eq(knopfText, REGISTER.consent.knopf, 'Knopf traegt die Beschriftung des Registers')
const belehrung = await featureHaken('termine').belehrung()
const region = seite.locator('.tm-bestellen-belehrung .tm-rechtstext')
assert(await region.isVisible(), 'Widerrufsbelehrung ist sichtbar (nicht eingeklappt)')
const belehrungImDom = (await region.textContent()).trim()
eq(belehrungImDom, belehrung.trim(), 'Widerrufsbelehrung mit Muster-Formular wie im Register (bis F7: Platzhalter)')
const reihenfolge = await page.evaluate(() => {
const b = document.querySelector('.tm-bestellen-belehrung .tm-rechtstext')
const k = document.querySelector('.tm-bestellen [data-aktion="bestellen"]')
return { vorKnopf: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_FOLLOWING), umgekehrt: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_PRECEDING) }
})
eq(reihenfolge, { vorKnopf: true, umgekehrt: false }, 'Belehrung steht im DOM vor dem Knopf')
// die gezeigten Texte der Zustimmungen: genau sie muessen im Nachweis stehen
const gezeigt = await page.evaluate(() => [...document.querySelectorAll('.tm-bestellen-haken > *')].map((el) => ({
haken: el.matches('label') ? el.querySelector('input').getAttribute('data-haken') : null,
text: el.textContent.trim(),
})))
assert(gezeigt.length > 0, 'Zustimmungen sind zu sehen')
await shot(page, 'bestellseite-gesperrt')
const hakenZahl = await hakenSetzen(page)
assert(hakenZahl > 0 && (await knopf.isEnabled()), 'Knopf aktiv nach allen Haken')
await shot(page, 'bestellseite-bereit')
// ein einzelner Haken weniger sperrt wieder
await page.locator('.tm-bestellen [data-haken]').first().uncheck()
assert(await knopf.isDisabled(), 'Knopf wieder gesperrt bei fehlendem Haken')
await page.locator('.tm-bestellen [data-haken]').first().check()
// ---- Klick
const vorKlick = Date.now()
const checkoutAnfrage = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
await knopf.click()
const body = (await checkoutAnfrage).postDataJSON()
await page.waitForURL(STRIPE_SEITE)
const sitzungId = STRIPE_SEITE.exec(page.url())[1]
eq(t.stripe.length, 1, 'die Zahlungsseite wurde genau einmal aufgerufen')
const aufruf = t.stripe[0]
// der Koerper der Bestellung: Projekt, Fassung und die IDs der Haken (nie Texte)
eq(Object.keys(body).sort(), ['projectId', 'zustimmung'], 'Koerper der Bestellung')
eq(body.projectId, PROJEKT, 'Projekt der Bestellung')
eq(body.zustimmung.version, REGISTER.consent.version, 'Fassung der Zustimmung')
eq(body.zustimmung.haken, REGISTER.consent.items.filter((i) => i.haken).map((i) => i.id), 'IDs der Haken in Registerreihenfolge')
// Funktionsdokument ordered mit der Sitzung
const feature = srv.aw.docs('portalFeatures').get(FEATURE)
eq([feature.state, feature.stripeSessionId], ['ordered', sitzungId], 'Funktionsdokument ordered mit stripeSessionId')
// genau eine Checkout-Sitzung bei Stripe, fuer den Terminplaner und das Projekt
const sitzungen = srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
eq(sitzungen.length, 1, 'genau ein POST /v1/checkout/sessions')
eq([sitzungen[0].body['metadata[featureKey]'], sitzungen[0].body['metadata[projectId]']], ['termine', PROJEKT], 'Metadaten der Sitzung')
// genau ein Nachweis mit den gezeigten Texten
const nachweise = [...srv.aw.docs('featureConsents').values()]
eq(nachweise.length, 1, 'genau ein featureConsents-Dokument')
const n = leseZustimmung(nachweise[0])
eq([n.customerId, n.projectId, n.featureKey, n.stripeSessionId], [KUNDE, PROJEKT, 'termine', sitzungId], 'Nachweis gehoert zu dieser Sitzung')
eq(n.fassungen, zustimmungsfassungen(REGISTER), 'Fassungen der Unterlagen im Nachweis')
for (const eintrag of n.texte.filter((x) => x.haken !== undefined && x.id !== 'belehrung' && x.id !== 'knopf')) {
const sichtbar = gezeigt.find((g) => (eintrag.haken ? g.haken === eintrag.id : g.haken === null) && g.text === eintrag.text)
assert(sichtbar, `Text im Nachweis wurde so gezeigt: ${eintrag.id}`)
}
eq(n.texte, nachweisTexte(REGISTER, { belehrung }), 'Texte im Nachweis = Texte des Registers samt Belehrung und Knopf, in Anzeigereihenfolge')
eq(n.texte.find((x) => x.id === 'belehrung').text.trim(), belehrungImDom, 'Belehrung im Nachweis = die gezeigte Belehrung')
const gespeicherterKnopf = n.texte.find((x) => x.id === 'knopf').text
eq([gespeicherterKnopf, REGISTER.consent.knopf, knopfText], [knopfText, knopfText, knopfText], 'Knopf: gespeichert = Register = Beschriftung des geklickten Knopfes')
eq(nachweise[0].textHash, texteHash(n.texte), 'Pruefsumme der Texte')
// Zeit des Klicks: nach dem Mausklick des Skripts, und gespeichert, BEVOR die Zahlungsseite aufgerufen wurde
const zeit = Date.parse(n.acceptedAt)
assert(Number.isFinite(zeit) && zeit >= vorKlick - 1000, `Zeit des Klicks liegt nach dem Start des Skripts: ${n.acceptedAt}`)
assert(zeit < aufruf.at, `Nachweis (${n.acceptedAt}) kleiner als der Aufruf der Zahlungsseite (${new Date(aufruf.at).toISOString()})`)
assert(Date.parse(nachweise[0].$createdAt) <= aufruf.at, 'Nachweis war angelegt, bevor die Zahlungsseite aufgerufen wurde')
eq(aufruf.nachweise, 1, 'beim Aufruf der Zahlungsseite lag der Nachweis schon in der Datenbank')
})
// ============================================================ offene Punkte sperren
await check('offene_punkte_sperren', async (page) => {
// (a) Impressum fehlt
await srv.entwurf({
eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, impressumUrl: '' } }),
fragebogen: vollstaendig(),
})
await oeffne(page, '#termine')
await warteFragebogen(page)
await warteSchritt(page, 'bestellen')
const offen = page.locator('.tm-bestellen-offen')
await offen.waitFor()
const punkt = offen.locator('li', { hasText: 'Impressum' })
eq(await punkt.count(), 1, 'genau ein Punkt zum Impressum')
eq(await offen.locator('li').count(), 1, 'sonst nichts offen')
eq(await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).innerText(), 'Zum Schritt', 'Knopf „Zum Schritt“')
await hakenSetzen(page)
assert(await bestellKnopf(page).isDisabled(), 'Knopf bleibt trotz aller Haken gesperrt')
assert((await page.locator('[data-hinweis="bestellen"]').innerText()).includes('offenen Punkte'), 'Hinweis am Knopf nennt die offenen Punkte')
await shot(page, 'offen-impressum')
await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).click()
await warteSchritt(page, 'website')
eq(await page.locator('.tm-fb-schritt').getAttribute('data-schritt'), 'website', 'Schritt 8 geoeffnet')
eq(await standText(page), 'Schritt 8 von 8', 'Fortschritt Schritt 8')
eq(await feldIn(page, 'betrieb.impressumUrl').inputValue(), '', 'Impressum-Feld ist leer')
// nachtragen: danach ist der Punkt weg, der Knopf nach den Haken aktiv
await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM)
await weiterKnopf(page).click()
await warteSchritt(page, 'bestellen')
eq(await page.locator('.tm-bestellen-offen').count(), 0, 'Punkt erledigt')
await hakenSetzen(page)
assert(await bestellKnopf(page).isEnabled(), 'Knopf aktiv, wenn nichts mehr offen ist')
// (b) Bundesland nie gewaehlt, obwohl der Server RP gespeichert hat
await srv.entwurf({ fragebogen: vollstaendig({ bundeslandGewaehlt: false }) })
eq(srv.einstellung().bundesland, 'RP', 'der Serverstandard RP ist gespeichert')
await oeffne(page, '#termine')
await warteFragebogen(page)
await warteSchritt(page, 'bestellen')
const offenLand = page.locator('.tm-bestellen-offen')
await offenLand.waitFor()
eq(await offenLand.locator('li').count(), 1, 'nur ein Punkt offen')
const landPunkt = offenLand.locator('li', { hasText: 'Bundesland' })
eq(await landPunkt.count(), 1, 'Punkt zum Bundesland')
await hakenSetzen(page)
assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt ohne Bundesland')
await shot(page, 'offen-bundesland')
await landPunkt.getByRole('button', { name: 'Zum Schritt „Zeiten“' }).click()
await warteSchritt(page, 'zeiten')
eq(await standText(page), 'Schritt 4 von 8', 'Punkt fuehrt in den Schritt Zeiten')
eq(await feldIn(page, 'bundesland').inputValue(), '', 'Bundesland ist nicht vorgewaehlt (RP ist keine Wahl)')
})
// ============================================================ Rueckkehr und Zahlung fortsetzen
await check('rueckkehr_und_zahlung_fortsetzen', async (page, t) => {
await srv.entwurf({ fragebogen: vollstaendig() })
const sitzung = await srv.bestellt()
const sitzungsUrl = `https://checkout.stripe.test/c/pay/${sitzung}`
// ---- Rueckkehr nach der Zahlung: Statuskarte mit „Danke!“, Adresse bereinigt, Einstellungen gesperrt
await oeffne(page, '#termine?bestellt=1&projekt=p1')
const karte = page.locator('.tm-statuskarte')
await karte.waitFor()
eq(await karte.getAttribute('data-state'), 'ordered', 'Statuskarte im Zustand ordered')
assert((await karte.innerText()).includes('Zahlung offen'), `Karte: ${await karte.innerText()}`)
assert((await karte.locator('[data-danke]').innerText()).startsWith('Danke!'), 'Danke! nach der Rueckkehr')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
const reiter = await page.locator('[data-reiter]').evaluateAll((els) => els.map((el) => el.dataset.reiter))
eq(reiter, ['leistungen', 'team', 'einstellungen'], 'Reiter ohne „Termine“')
await page.locator('[data-reiter="einstellungen"]').click()
await page.locator('#tm-panel .tm-abschnitt').first().waitFor()
const felder = await page.locator('#tm-panel').evaluate((panel) => {
const alle = [...panel.querySelectorAll('input, select, textarea')]
return { alle: alle.length, offen: alle.filter((el) => !el.disabled && !el.matches(':disabled')).length }
})
assert(felder.alle > 0, 'Einstellungen zeigen Felder')
eq(felder.offen, 0, 'alle Felder der Einstellungen sind gesperrt')
await shot(page, 'rueckkehr-zahlung-offen')
// ---- Zahlung fortsetzen: dieselbe Sitzung, kein zweiter Nachweis
const fortsetzen = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click()
eq((await fortsetzen).postDataJSON(), { projectId: PROJEKT, fortsetzen: true }, 'Koerper von „Zahlung fortsetzen“')
await page.waitForURL(sitzungsUrl)
eq(t.stripe.map((s) => s.url), [sitzungsUrl], 'Navigation zur selben Sitzung')
eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung')
eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine neue Sitzung angelegt')
eq(srv.aw.docs('featureConsents').size, 1, 'weiterhin genau ein featureConsents-Dokument')
// ---- Abbruch bei Stripe: Rueckkehr ohne „Danke!“
await oeffne(page, '#termine?projekt=p1')
await karte.waitFor()
assert((await karte.innerText()).includes('Zahlung offen'), 'Abbruch: Karte Zahlung offen')
eq(await page.locator('[data-danke]').count(), 0, 'Abbruch: kein „Danke!“')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Abbruch: Adresse bereinigt')
// ---- ein PATCH direkt am Router ist gesperrt
const patch = await page.evaluate(async (version) => {
const res = await fetch('/api/termine/einstellungen', { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', version, einstellung: {} }) })
return { status: res.status, code: (await res.json()).code }
}, srv.version())
eq(patch, { status: 409, code: 'gesperrt' }, 'PATCH bei ordered ist 409 gesperrt')
// ---- ohne Nachweis gibt es keine Weiterleitung: das Bestellformular kommt wieder
await srv.loescheZustimmungen()
await oeffne(page, '#termine')
await karte.waitFor()
const antwort = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout')
const naviVorher = t.stripe.length
await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click()
const res = await antwort
eq(res.status(), 409, 'ohne Nachweis: 409')
eq((await res.json()).code, 'zustimmung_fehlt', 'ohne Nachweis: zustimmung_fehlt')
await page.locator('.tm-bestellen').waitFor()
eq(await page.locator('.tm-statuskarte').count(), 0, 'die Karte ist durch das Bestellformular ersetzt')
assert((await page.locator('#view-termine').innerText()).includes('Bitte stimme der Erklärung zu'), 'Meldung zur fehlenden Zustimmung')
assert(page.url().startsWith(srv.url), `keine Navigation: ${page.url()}`)
eq(t.stripe.length, naviVorher, 'die Zahlungsseite wurde nicht aufgerufen')
eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung')
eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis ohne Zustimmung des Kunden')
await shot(page, 'zahlung-fortsetzen-ohne-nachweis')
// ---- Zustand setup: Karte mit Ticketnummer und Rechnungslink
await srv.einrichtung()
await oeffne(page, '#termine')
await karte.waitFor()
eq(await karte.getAttribute('data-state'), 'setup', 'Karte im Zustand setup')
const text = await karte.innerText()
assert(text.includes('In Einrichtung') && text.includes('#100342'), `Karte mit Ticketnummer: ${text}`)
const rechnung = karte.getByRole('link', { name: 'Rechnung ansehen' })
eq(await rechnung.getAttribute('href'), 'https://invoice.stripe.test/i/in_ui1', 'Rechnungslink')
eq(await rechnung.getAttribute('rel'), 'noopener noreferrer', 'Rechnungslink mit rel')
eq(await page.getByRole('button', { name: 'Zahlung fortsetzen' }).count(), 0, 'in der Einrichtung gibt es kein „Zahlung fortsetzen“')
await shot(page, 'in-einrichtung')
})
// ============================================================ Entwurf bleibt beim Neuladen
await check('entwurf_bleibt_beim_neuladen', async (page, t) => {
await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €')
await brancheStarten(page, t)
await schritteAusfuellen(page, t, 'zeiten') // Schritte 2 bis 4
await warteSchritt(page, 'regeln')
// ---- neu laden: Start bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern
await page.reload()
await warteFragebogen(page)
await warteSchritt(page, 'regeln')
eq(await standText(page), 'Schritt 5 von 8', 'nach dem Neuladen Schritt 5')
assert((await page.locator('.tm-schritt[data-erledigt="true"]').count()) === 4, 'vier Schritte als erledigt markiert')
await schrittKnopf(page, 'branche').click()
await warteSchritt(page, 'branche')
eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Schritt 1: Name')
eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Schritt 1: Anschrift')
assert((await page.locator('.tm-fb-branche').innerText()).includes('Friseur und Salon'), 'Schritt 1: Branche')
await schrittKnopf(page, 'wer').click()
await warteSchritt(page, 'wer')
assert(await page.locator('input[name="termine-wer-art"][value="team"]').isChecked(), 'Schritt 2: Team gewaehlt')
eq(await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').inputValue(), 'Anna\nBen', 'Schritt 2: Namen')
await schrittKnopf(page, 'leistungen').click()
await warteSchritt(page, 'leistungen')
assert(await feldIn(page, 'preiseGeprueft').isChecked(), 'Schritt 3: Preise geprueft')
await schrittKnopf(page, 'zeiten').click()
await warteSchritt(page, 'zeiten')
eq(await feldIn(page, 'bundesland').inputValue(), 'RP', 'Schritt 4: Bundesland')
await shot(page, 'nach-neuladen')
// ---- Schritt 8 vorab ausfuellen und bestaetigen: der Einbauwunsch muss den Wechsel der Branche ueberstehen
await schrittKnopf(page, 'website').click()
await warteSchritt(page, 'website')
await EINGABE.website(page)
await feldIn(page, 'einbau.stelle').fill('Auf der Seite Kontakt unter dem Text')
await weiter(page, t, 'website', 'bestellen')
NACH_SCHRITT.website()
eq(srv.fragebogen().bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten', 'website'], 'fuenf Schritte bestaetigt')
const einbau = srv.fragebogen().einbau
eq(einbau.stelle, 'Auf der Seite Kontakt unter dem Text', 'Einbauwunsch mit Stelle gespeichert')
// ---- Branche wechseln mit Rueckfrage
await schrittKnopf(page, 'branche').click()
await warteSchritt(page, 'branche')
await page.locator('[data-aktion="branche-wechseln"]').click()
const rueckfrage = page.locator('.tm-frage-box')
await rueckfrage.waitFor()
assert((await rueckfrage.innerText()).includes('Alle Bestätigungen gehen zurück'), 'Rueckfrage warnt vor dem Zuruecksetzen')
eq(srv.fragebogen().branche, 'salon', 'vor der Bestaetigung ist nichts geaendert')
await rueckfrage.locator('label.tm-vorlage', { hasText: 'Kosmetik und Nagelstudio' }).locator('input').check()
await shot(page, 'branche-wechseln-rueckfrage')
const v0 = srv.version()
await rueckfrage.getByRole('button', { name: 'Ja, Branche wechseln', exact: true }).click()
await page.locator('.tm-fb-branche', { hasText: 'Kosmetik und Nagelstudio' }).waitFor()
eq(srv.fragebogen().branche, 'kosmetik', 'neue Branche gespeichert')
eq(srv.fragebogen().bestaetigt, [], 'Bestaetigungen zurueckgesetzt')
eq(srv.fragebogen().bundeslandGewaehlt, false, 'Bundesland muss neu gewaehlt werden')
eq(srv.fragebogen().preiseGeprueft, false, 'Preise muessen neu geprueft werden')
eq(srv.fragebogen().einbau, einbau, 'der Einbauwunsch bleibt')
eq(srv.einstellung().vorlage, 'kosmetik', 'Einstellung aus der neuen Vorlage')
assert(srv.version() > v0, 'Version der Einstellung gestiegen (Ersetzen ist ein Schreiben der Einstellung)')
eq(srv.einstellung().betrieb.name, FELD_NAME, 'Betriebsname bleibt')
eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum bleibt')
eq(await page.locator('.tm-schritt[data-erledigt="true"]').count(), 0, 'kein Schritt mehr als erledigt markiert')
await shot(page, 'nach-branchenwechsel')
})
// ============================================================ Kundenansicht
await check('kundenansicht', async (page, t) => {
await srv.entwurf({ fragebogen: vollstaendig({ bestaetigt: ['branche'] }) })
await oeffne(page, '#termine')
await warteFragebogen(page)
await warteSchritt(page, 'wer')
const hinweis = page.locator('.tm-fragebogen .tm-box-info')
assert((await hinweis.innerText()).includes('Kundenansicht'), 'Hinweis auf die Kundenansicht')
// alle Schritte lassen sich lesen und mit „Weiter“ durchklicken, bestaetigt wird nichts
for (const [i, key] of FOLGE.slice(1).entries()) {
await warteSchritt(page, key)
eq(await standText(page), `Schritt ${i + 2} von 8`, `Fortschritt in ${key}`)
await weiterKnopf(page).click()
await warteSchritt(page, FOLGE[i + 2] ?? 'bestellen')
eq(srv.fragebogen().bestaetigt, ['branche'], `nach „Weiter“ in ${key} bleibt bestaetigt unveraendert`)
}
eq(zaehlePuts(t), 0, 'die Seite sendet in der Kundenansicht keinen PUT')
// Bestellseite: gesperrt mit Hinweis (hier zusaetzlich offene Punkte, weil nichts bestaetigt wurde)
assert((await page.locator('.tm-bestellen-offen li').count()) >= 7, 'sieben unbestaetigte Schritte stehen als offene Punkte da')
await hakenSetzen(page)
assert(await bestellKnopf(page).isDisabled(), 'Bestellknopf gesperrt')
eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf')
await shot(page, 'kundenansicht-bestellseite')
// ---- nichts offen, trotzdem gesperrt: nur die Kundenansicht haelt den Knopf zu
await srv.entwurf({ fragebogen: vollstaendig() })
await page.reload()
await warteFragebogen(page)
await warteSchritt(page, 'bestellen')
eq(await page.locator('.tm-bestellen-offen').count(), 0, 'nichts offen')
await hakenSetzen(page)
assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt, obwohl nichts offen ist und alle Haken gesetzt sind')
eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf (nichts offen)')
// ---- die Router selbst: PUT /fragebogen und der Checkout sind 403
const vorher = srv.fragebogen()
const direkt = await page.evaluate(async ({ fragebogen }) => {
const sende = async (url, method, body) => {
const res = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
return { status: res.status, body: await res.json() }
}
return {
put: await sende('/api/termine/fragebogen', 'PUT', { projectId: 'p1', fragebogen: { ...fragebogen, bestaetigt: [] } }),
checkout: await sende('/api/funktionen/termine/checkout', 'POST', { projectId: 'p1', zustimmung: { version: 'x', haken: [] } }),
}
}, { fragebogen: vorher })
eq(direkt.put.status, 403, 'PUT /fragebogen in der Kundenansicht')
eq(direkt.checkout.status, 403, 'Checkout in der Kundenansicht')
eq(srv.fragebogen(), vorher, 'der Fragebogen blieb unveraendert')
eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine Stripe-Sitzung')
eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis')
}, { cookie: 'kundenansicht' })
// ============================================================ Handy 390 px
await check('handy_390', async (page) => {
const pruefe = async (name, soll) => {
assert(await passtInBreite(page), `${name}: seitliche Rollleiste (scrollWidth ${await page.evaluate(() => document.documentElement.scrollWidth)})`)
const stand = page.locator('.tm-schritt-stand')
assert(await stand.isVisible(), `${name}: Fortschritt ist am Handy sichtbar`)
eq((await stand.innerText()).trim(), soll, `${name}: Fortschritt`)
}
// Schritt 1 ohne Entwurf
await oeffne(page, '#termine')
await warteFragebogen(page)
await warteSchritt(page, 'branche')
await pruefe('Schritt 1 (Start)', 'Schritt 1 von 8')
await shot(page, '390-1-start')
// mit Entwurf: Bestellseite, Schritt 4, Schritt 8, Schritt 1
await srv.entwurf({ fragebogen: vollstaendig() })
await page.reload()
await warteFragebogen(page)
await warteSchritt(page, 'bestellen')
assert(await passtInBreite(page), 'Bestellseite: seitliche Rollleiste')
await hakenSetzen(page)
assert(await passtInBreite(page), 'Bestellseite mit Haken: seitliche Rollleiste')
await shot(page, '390-2-bestellseite')
await schrittKnopf(page, 'zeiten').click()
await warteSchritt(page, 'zeiten')
await pruefe('Schritt 4', 'Schritt 4 von 8')
await shot(page, '390-3-zeiten')
await schrittKnopf(page, 'website').click()
await warteSchritt(page, 'website')
await pruefe('Schritt 8', 'Schritt 8 von 8')
await shot(page, '390-4-website')
await schrittKnopf(page, 'branche').click()
await warteSchritt(page, 'branche')
await pruefe('Schritt 1 (Entwurf)', 'Schritt 1 von 8')
await shot(page, '390-5-branche-entwurf')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
}
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch()
await pruefungen()
const fremd = [...hosts].filter((h) => !ERLAUBTE_HOSTS.has(h))
results.push(['nur_eigene_herkunft', fremd.length === 0])
console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft')
} 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)