// End-to-end-Pruefung des Buchungskalenders im Browser (Playwright, Chromium), gegen die echten Router hinter Nachbauten von Appwrite, // Stripe (Connect) und Mail (test/ui/unterkunftTestServer.mjs). Nicht Teil von npm test. // Aufruf: nice node --max-old-space-size=1024 test/ui/unterkunft.ui.mjs (vorher free -m; es laeuft nur ein Browser) // Screenshots: /root/reports/buchungskalender-2026-10-07/ui/ (fortlaufend nummeriert, Desktop 1280 und Telefon 390). Exit-Code 1, wenn ein Schritt scheitert. // // Ein durchgehendes Szenario (jeder Schritt baut auf dem vorigen auf): // 1 Portal „Buchungen“: Einstellungen (inkl. Rechnungsauftrag), Stripe verbinden + Online-Zahlung, Unterkunft „Donau Home“ mit Preisen // 2 Widget auf der Seite des Vermieters, 1280 und 390 px: zwei Anfragen (Eva am Desktop, Paul am Telefon) // 3 Portal „Anfragen“: Vorschlag pruefen, Posten aendern, Freigeben (Buchungslink, G17) // 4 Buchungsseite: Pflichtangaben, Rechnungsadresse, „vor Ort“, „Zahlungspflichtig buchen“, Rechnung als PDF, G19 mit Anhang // 5 Portal: „Als bezahlt (vor Ort) markieren“ // 6 Zweite Anfrage freigeben, online bezahlen (Stripe-Nachbau, Webhook, Rueckkehr), G20 // 7 Kalender (Belegungen farbig, Sperre) und iCal-Export // 8 Stornieren mit Teilerstattung (G22, Erstattung im Stripe-Nachbau), Rechnungen, iCal ohne die stornierte Buchung // Stripe-Adressen (https://*.stripe.test) werden per page.route abgefangen; die Erstattung und die Zahlung des Gastes macht der Stripe-Nachbau. import { mkdir, readdir, rm } from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' import { startUnterkunftUiServer, KUNDEN_EMAIL, SITE_KEY } from './unterkunftTestServer.mjs' const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core' const { chromium } = createRequire(import.meta.url)(PW) const CHROME = process.env.CHROME_BIN || path.join(process.env.HOME || '/root', '.cache/ms-playwright/chromium-1234/chrome-linux64/chrome') const SHOTS = process.env.SHOTS || '/root/reports/buchungskalender-2026-10-07/ui' await mkdir(SHOTS, { recursive: true }) for (const datei of await readdir(SHOTS)) if (/\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe const srv = await startUnterkunftUiServer() const DESKTOP = { width: 1280, height: 900 } const PHONE = { width: 390, height: 844 } // ---------------------------------------------------------------- Daten des Szenarios const heute = new Date() const naechsterMonat = new Date(Date.UTC(heute.getFullYear(), heute.getMonth() + 1, 1)) const tag = (n) => `${naechsterMonat.getUTCFullYear()}-${String(naechsterMonat.getUTCMonth() + 1).padStart(2, '0')}-${String(n).padStart(2, '0')}` const EVA = { name: 'Eva Gast', email: 'eva@example.com', von: tag(10), bis: tag(17), personen: '2' } // 7 Naechte (Staffel 7) const PAUL = { name: 'Paul Phone', email: 'paul@example.com', von: tag(20), bis: tag(23), personen: '2' } // 3 Naechte (Staffel 1) const SPERRE = { von: tag(26), bis: tag(27) } const RESERVIERT = { von: tag(2), bis: tag(4) } const ohneStriche = (t) => t.replaceAll('-', '') // ---------------------------------------------------------------- Helfer const ergebnisse = [] let schritt = null /** Weiche Pruefung: merkt den Fehler und laeuft weiter (Schritt scheitert am Ende). */ function ok(bedingung, text) { if (!bedingung) { schritt.fehler.push(text) console.log(` x ${text}`) } else if (process.env.VERBOSE) console.log(` + ${text}`) return Boolean(bedingung) } const norm = (t) => String(t ?? '').replace(/ | /g, ' ').replace(/\s+/g, ' ').trim() const enthaelt = (text, teil) => norm(text).includes(norm(teil)) let nummer = 0 /** Screenshot der ganzen Seite in der aktuellen Fensterbreite (Name: fortlaufende Nummer, Beschreibung, Breite). */ async function shot(page, name) { nummer += 1 const vorher = page.viewportSize() const breite = vorher.width await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {})))).catch(() => {}) await page.waitForTimeout(150) // wie test/ui/shop.ui.mjs: Fenster auf Seitenhoehe stellen statt fullPage (die Portalansicht blendet sonst leer ein) const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3600)) await page.setViewportSize({ width: breite, height: hoehe }) await page.waitForTimeout(100) const datei = `${SHOTS}/${String(nummer).padStart(2, '0')}-${name}-${breite}.png` await page.screenshot({ path: datei }) await page.setViewportSize(vorher) return datei } /** Dieselbe Seite zusaetzlich in der anderen Breite aufnehmen und zurueckstellen. */ async function shotBeide(page, name) { const vorher = page.viewportSize() await shot(page, name) await page.setViewportSize(vorher.width > 600 ? PHONE : DESKTOP) await shot(page, name) await page.setViewportSize(vorher) } async function warteBis(bedingung, text, ms = 10000) { const ende = Date.now() + ms for (;;) { if (await bedingung()) return if (Date.now() > ende) throw new Error(`Zeit abgelaufen: ${text}`) await new Promise((fertig) => { setTimeout(fertig, 50) }) } } /** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/shop.ui.mjs). */ function portalHilfe(p, json) { if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Mimi Donau', email: KUNDEN_EMAIL } }) if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Donau Home', 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({}) } const ECHT = [/^\/api\/funktionen/, /^\/api\/unterkunft/, /^\/api\/public\/unterkunft/] const stripeNavigationen = [] // { art: 'connect' | 'checkout', url } const offeneSeiten = new Set() // fuer Fehler-Screenshots const fremdeHosts = new Set() /** Seite der Vermieter-Website (https://www.donau-home.example/...): Platzhalter fuer den Einbau des Widgets mit data-api (Cross-Origin). */ const kundenSeite = () => `Donau Home
Donau Home – Monteurwohnung Stetten (Testseite des Vermieters)

Jetzt anfragen

` async function neuerKontext(browser, { cookie = false, viewport = DESKTOP } = {}) { // Service Worker des Portals blockieren: ihre Anfragen umgingen page.route const ctx = await browser.newContext({ viewport, serviceWorkers: 'block', locale: 'de-DE', timezoneId: 'Europe/Berlin' }) if (cookie) await ctx.addCookies([await srv.portalCookie()]) ctx.setDefaultTimeout(10000) await ctx.route(`${srv.portalUrl}/**`, async (route) => { const req = route.request() const url = new URL(req.url()) const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) if (url.pathname.startsWith('/api/') && !ECHT.some((re) => re.test(url.pathname))) return portalHilfe(url.pathname, json) const headers = await req.allHeaders() delete headers.host const antwort = await route.fetch({ url: `${srv.url}${url.pathname}${url.search}`, headers }) return route.fulfill({ response: antwort }) }) await ctx.route(`${srv.kundenUrl}/**`, async (route) => { if (new URL(route.request().url()).pathname === '/favicon.ico') return route.fulfill({ status: 404, body: '' }) return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: kundenSeite() }) }) // Stripe: Navigation abfangen (Onboarding -> zurueck ins Portal; Kasse -> Platzhalterseite, der Test navigiert selbst zurueck) await ctx.route(/^https:\/\/[a-z]+\.stripe\.test\//, async (route) => { const url = route.request().url() const host = new URL(url).host if (host === 'connect.stripe.test') { stripeNavigationen.push({ art: 'connect', url }) const zurueck = `${srv.portalUrl}/dashboard.html#buchungen?stripe=zurueck&projekt=${srv.PROJEKT}` return route.fulfill({ status: 200, contentType: 'text/html', body: `Stripe (Nachbau)

Onboarding

` }) } stripeNavigationen.push({ art: host === 'checkout.stripe.test' ? 'checkout' : 'sonst', url }) return route.fulfill({ status: 200, contentType: 'text/html', body: 'Stripe-Kasse (Nachbau)

Stripe-Kasse (Nachbau)

' }) }) ctx.on('page', (seite) => { offeneSeiten.add(seite) seite.on('close', () => offeneSeiten.delete(seite)) seite.on('pageerror', (e) => schritt?.fehler.push(`JavaScript-Fehler: ${e.message}`)) seite.on('dialog', (d) => { schritt?.fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) }) seite.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http') && !u.host.endsWith('.stripe.test')) fremdeHosts.add(u.host) } catch { /* data: u. a. */ } }) seite.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) schritt?.hinweise.push(`Konsole: ${m.text().slice(0, 160)}`) }) }) return ctx } let browser = null async function lauf(name, fn) { if (process.env.NUR && !name.includes(process.env.NUR)) return schritt = { name, fehler: [], hinweise: [] } console.log(`--- ${name}`) try { await fn() } catch (e) { schritt.fehler.push(`Abbruch: ${String(e.message).split('\n')[0]}`) if (process.env.STACK) console.log(e.stack) } if (schritt.fehler.length) { let i = 0 for (const seite of offeneSeiten) { i += 1 await seite.screenshot({ path: `${SHOTS}/fehler-${name}-${i}.png`, fullPage: true }).catch(() => {}) } } const bestanden = schritt.fehler.length === 0 ergebnisse.push([name, bestanden, schritt.fehler, schritt.hinweise]) console.log(`${bestanden ? 'ok ' : 'FAIL'} ${name}${bestanden ? '' : `: ${schritt.fehler.join(' | ')}`}`) for (const h of schritt.hinweise) console.log(` ! ${h}`) } // ---------------------------------------------------------------- Portal-Helfer /** Sichtbarkeit wirklich pruefen (nicht nur DOM): sichtbar, Hoehe > 0, nicht durchsichtig (Einblend-Animation fertig). */ async function sichtbar(locator, name) { await warteBis(async () => { if (!(await locator.first().isVisible().catch(() => false))) return false const box = await locator.first().boundingBox() const deckend = await locator.first().evaluate((el) => { let o = 1; for (let e = el; e; e = e.parentElement) o *= Number(getComputedStyle(e).opacity); return o > 0.99 }) return Boolean(box) && box.height > 0 && box.width > 0 && deckend }, `${name} sichtbar`, 5000).catch(() => ok(false, `${name} ist nicht sichtbar (Hoehe 0, hidden oder durchsichtig)`)) } const oeffnePortal = async (page) => { await page.goto(`${srv.portalUrl}/dashboard.html#buchungen`) await page.locator('#view-buchungen.active').waitFor() await page.locator('#buchungen-body .sh-reiterleiste').waitFor() await sichtbar(page.locator('#view-buchungen'), 'Ansicht Buchungskalender') await sichtbar(page.locator('#view-buchungen h1'), 'Titel Buchungskalender') await sichtbar(page.locator('[data-reiter="anfragen"]'), 'Reiter Anfragen') } const reiter = async (page, key) => { await page.locator(`[data-reiter="${key}"]`).click() await page.locator(`#bk-tab-${key}[aria-selected="true"]`).waitFor() await sichtbar(page.locator('#bk-panel > *'), `Inhalt des Reiters ${key}`) } const warteMeldung = async (page, teil) => { await warteBis(async () => enthaelt(await page.locator('#buchungen-body .sh-meldung').innerText().catch(() => ''), teil), `Meldung „${teil}“`) } const eurotext = (cent) => `${(cent / 100).toFixed(2).replace('.', ',')} €` // ---------------------------------------------------------------- Widget-Helfer /** Sichtbarer Monatsraster des Widgets (aria-label „November 2026“). */ const sichtbareMonate = (page) => page.locator('.wku-raster:visible').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label'))) /** Klickt, bis der Tag sichtbar ist (Weiter-Knopf), und waehlt ihn. */ async function waehleTag(page, t) { for (let i = 0; i < 4; i += 1) { if (await page.locator(`[data-tag="${t}"]:visible`).count()) break await page.locator('.wku-pfeil:visible[aria-label="Nächster Monat"]').first().click() await page.waitForTimeout(250) } await page.locator(`[data-tag="${t}"]:visible`).click() } async function sendeWidgetAnfrage(page, g) { await page.goto(`${srv.kundenUrl}/anfragen`) await page.locator('[data-webklar-unterkunft] [data-tag]').first().waitFor() await page.waitForTimeout(300) return sichtbareMonate(page) } async function fuelleWidget(page, g) { await waehleTag(page, g.von) await waehleTag(page, g.bis) await page.locator('select[name="personen"]').selectOption(g.personen) await page.locator('[name="name"]').fill(g.name) await page.locator('[name="email"]').fill(g.email) } // ---------------------------------------------------------------- Szenario let portalCtx = null let portal = null // Seite des Vermieters im Portal (Cookie) const merk = {} // IDs und Texte zwischen den Schritten async function szenario() { portalCtx = await neuerKontext(browser, { cookie: true }) portal = await portalCtx.newPage() // ------------------------------------------------------------ 1 await lauf('1_portal_einstellungen_stripe_unterkunft_preise', async () => { await oeffnePortal(portal) await shot(portal, 'portal-anfragen-leer') ok(await portal.locator('#view-buchungen h1').first().innerText().then((t) => enthaelt(t, 'Buchungskalender')), 'Ansicht „Buchungskalender“ geoeffnet') // Einstellungen await reiter(portal, 'einstellungen') const form = portal.locator('form.sh-einstellungen') await form.waitFor() await form.locator('[data-feld="firma"]').fill('Donau Home') await form.locator('[data-feld="inhaber"]').fill('Mimi Donau') await form.locator('[data-feld="strasse"]').fill('Donaustraße 5') await form.locator('[data-feld="plz"]').fill('78576') await form.locator('[data-feld="ort"]').fill('Stetten') await form.locator('[data-feld="email"]').fill('buchung@donau-home.example') await form.locator('[data-feld="telefon"]').fill('07461 12345') await form.locator('[data-feld="steuernummer"]').fill('12/345/67890') await form.locator('[data-feld="iban"]').fill('DE02120300000000202051') await form.locator('[data-feld="bic"]').fill('BYLADEM1001') await form.locator('[data-feld="bank"]').fill('Beispielbank') await shot(portal, 'einstellungen-ohne-zustimmung') // ohne Zustimmung wird gespeichert, der Hinweis bleibt aber sichtbar ok(await form.locator('[data-hinweis="auftrag"]').isVisible(), 'Hinweis „Ohne diesen Auftrag …“ sichtbar, solange die Zustimmung fehlt') await form.locator('[data-feld="rechnungAuftrag"]').check() ok(!(await form.locator('[data-hinweis="auftrag"]').isVisible()), 'Hinweis verschwindet mit der Zustimmung') await form.locator('[data-aktion="einstellungen-speichern"]').click() await warteMeldung(portal, 'Die Einstellungen sind gespeichert') const e = srv.einstellungen() ok(e?.firma === 'Donau Home' && e.steuernummer === '12/345/67890', `Einstellungen gespeichert (${e?.firma})`) ok(Boolean(e?.rechnungAuftragAt), 'Zustimmung zur Rechnungsstellung gespeichert (rechnungAuftragAt)') await shot(portal, 'einstellungen-gespeichert') // Stripe verbinden -> Onboarding (abgefangen) -> zurueck ins Portal await portal.locator('[data-aktion="stripe-verbinden"]').click() await warteBis(() => stripeNavigationen.some((n) => n.art === 'connect'), 'Weiterleitung zum Stripe-Onboarding') await portal.locator('#buchungen-body .sh-reiterleiste').waitFor() ok(Boolean(srv.kontoId()), `Stripe-Konto angelegt (${srv.kontoId()})`) srv.kontoFreigeben() await reiter(portal, 'einstellungen') await portal.locator('[data-stripe-status="onboarding"]').waitFor() await portal.locator('[data-aktion="stripe-aktualisieren"]').click() await portal.locator('[data-stripe-status="aktiv"]').waitFor() await portal.locator('form.sh-einstellungen [data-feld="onlineZahlung"]').check() await portal.locator('[data-aktion="einstellungen-speichern"]').click() await warteMeldung(portal, 'Die Einstellungen sind gespeichert') ok(srv.einstellungen()?.onlineZahlung === true, 'Online-Zahlung eingeschaltet') await shot(portal, 'einstellungen-stripe-aktiv') // Unterkunft anlegen await reiter(portal, 'unterkuenfte') await portal.locator('[data-leer]').waitFor() await shot(portal, 'unterkuenfte-leer') await portal.locator('[data-aktion="neu"]').click() await portal.locator('#bk-u-name').fill('Donau Home') await portal.locator('#bk-u-beschr').fill('Monteurwohnung mit Küche, 4 Betten, Parkplatz am Haus.') await portal.locator('#bk-u-max').fill('4') await portal.locator('#bk-u-min').fill('1') await portal.locator('#bk-u-ein').fill('15:00') await portal.locator('#bk-u-aus').fill('10:00') await portal.locator('[data-aktion="speichern"]').click() await portal.locator('.bk-preise form').waitFor() ok(srv.unterkuenfte().length === 1 && srv.unterkuenfte()[0].name === 'Donau Home', 'Unterkunft „Donau Home“ angelegt') merk.unterkunftId = srv.unterkuenfte()[0]?.$id // Preise: Staffeln 1 / 7 / 28, Reinigung, Direktrabatt 10 %, Regelbesteuerung, Mustertext const staffel = (i) => portal.locator('[data-staffel-zeile]').nth(i) await staffel(0).locator('[data-staffel="preis"]').fill('90,00') await portal.locator('[data-aktion="staffel-dazu"]').click() await staffel(1).locator('[data-staffel="ab"]').fill('7') await staffel(1).locator('[data-staffel="preis"]').fill('80,00') await portal.locator('[data-aktion="staffel-dazu"]').click() await staffel(2).locator('[data-staffel="ab"]').fill('28') await staffel(2).locator('[data-staffel="preis"]').fill('70,00') await portal.locator('#bk-p-reinigung').fill('60,00') await portal.locator('#bk-p-inklusive').fill('2') await portal.locator('#bk-p-aufpreis').fill('10,00') await portal.locator('#bk-p-rabatt').fill('10') await portal.locator('#bk-p-steuerart').selectOption('regel') await portal.locator('#bk-p-ust-ue').fill('7') await portal.locator('#bk-p-ust-re').fill('19') await portal.locator('#bk-p-frist').fill('3') await portal.locator('[data-aktion="storno-muster"]').click() const muster = await portal.locator('#bk-p-storno').inputValue() ok(muster.startsWith('1. Stornierung durch den Gast.'), 'Mustertext in die Stornobedingungen übernommen') // mit den Platzhaltern wird nicht gespeichert await portal.locator('[data-aktion="preise-speichern"]').click() await warteBis(async () => enthaelt(await portal.locator('[data-fehler="stornobedingungen"]').innerText(), 'Bitte ersetze die Platzhalter in eckigen Klammern durch deine Werte.'), 'Platzhalter-Fehler am Feld') ok(srv.preise().length === 0, 'Preise mit Platzhaltern nicht gespeichert') await shot(portal, 'preise-platzhalter-fehler') // Platzhalter durch Werte ersetzen await portal.locator('#bk-p-storno').fill(muster.replace('[E-Mail-Adresse des Vermieters]', 'buchung@donau-home.example').replace(/\[(\d+)\]/g, '$1')) ok(!/\[[^\]]*\]/.test(await portal.locator('#bk-p-storno').inputValue()), 'keine eckigen Klammern mehr in den Stornobedingungen') await shot(portal, 'preise-ausgefuellt') await portal.locator('[data-aktion="preise-speichern"]').click() await warteMeldung(portal, 'Die Preise sind gespeichert') await portal.locator('[data-vorschau]').filter({ hasText: 'Beispiel:' }).waitFor() const preise = srv.preise()[0] ok(preise?.staffeln?.length === 3 && preise.staffeln.map((s) => s.abNaechten).join() === '1,7,28', `Staffeln 1/7/28 gespeichert (${JSON.stringify(preise?.staffeln)})`) ok(preise?.reinigungCent === 6000 && preise?.direktrabattProzent === 10 && preise?.steuerart === 'regel', 'Reinigung 60 €, Direktrabatt 10 %, Regelbesteuerung gespeichert') ok((preise?.stornobedingungen || '').length > 200, 'Stornobedingungen gespeichert') ok(enthaelt(await portal.locator('[data-vorschau]').innerText(), '7 Nächte, 2 Personen = '), `Beispielrechnung sichtbar (${norm(await portal.locator('[data-vorschau]').innerText())})`) await shot(portal, 'preise-gespeichert') }) // ------------------------------------------------------------ 2 await lauf('2_widget_desktop_und_telefon', async () => { // Desktop 1280: zwei Monate, Weiter-Knopf const ctxD = await neuerKontext(browser, { viewport: DESKTOP }) const d = await ctxD.newPage() const monateD = await sendeWidgetAnfrage(d) ok(monateD.length === 2, `1280 px: zwei Monate sichtbar (${monateD.join(' | ')})`) await shot(d, 'widget-leer') await d.locator('.wku-pfeil:visible[aria-label="Nächster Monat"]').last().click() await d.waitForTimeout(250) const weiter = await sichtbareMonate(d) ok(weiter.length === 2 && weiter[0] === monateD[1], `1280 px: „Weiter“ verschiebt um einen Monat (${monateD.join(' | ')} -> ${weiter.join(' | ')})`) await d.locator('.wku-pfeil:visible[aria-label="Vorheriger Monat"]').first().click() await d.waitForTimeout(250) ok((await sichtbareMonate(d))[0] === monateD[0], '1280 px: „Zurück“ geht zum Ausgangsmonat') await fuelleWidget(d, EVA) await shot(d, 'widget-ausgefuellt') await d.locator('button[type="submit"]').click() await d.locator('[data-webklar-unterkunft]').getByText('Danke!').waitFor() const dankeD = await d.locator('[data-webklar-unterkunft]').innerText() ok(/Danke! Ihre Anfrage [A-Z0-9-]+ ist eingegangen/.test(norm(dankeD)), `1280 px: Danke-Text (${norm(dankeD).slice(0, 90)})`) await shot(d, 'widget-danke') await ctxD.close() // Telefon 390: ein Monat, Weiter-Knopf const ctxT = await neuerKontext(browser, { viewport: PHONE }) const t = await ctxT.newPage() const monateT = await sendeWidgetAnfrage(t) ok(monateT.length === 1, `390 px: ein Monat sichtbar (${monateT.join(' | ')})`) ok(await t.locator('.wku-pfeil:visible[aria-label="Nächster Monat"]').count() >= 1, '390 px: „Nächster Monat“ sichtbar') ok(await t.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), '390 px: kein waagerechtes Scrollen') await shot(t, 'widget-leer') await fuelleWidget(t, PAUL) ok((await sichtbareMonate(t)).length === 1, '390 px: nach dem Weiterklicken weiter ein Monat') await shot(t, 'widget-ausgefuellt') await t.locator('button[type="submit"]').click() await t.locator('[data-webklar-unterkunft]').getByText('Danke!').waitFor() ok(/Danke! Ihre Anfrage [A-Z0-9-]+ ist eingegangen/.test(norm(await t.locator('[data-webklar-unterkunft]').innerText())), '390 px: Danke-Text') await shot(t, 'widget-danke') await ctxT.close() const a = srv.anfragen() ok(a.length === 2 && a.every((x) => x.status === 'neu'), `zwei Anfragen im Status neu (${a.map((x) => x.status).join()})`) merk.eva = a.find((x) => x.gast?.email === EVA.email)?.$id merk.paul = a.find((x) => x.gast?.email === PAUL.email)?.$id ok(Boolean(merk.eva && merk.paul), 'beide Anfragen den Gästen zugeordnet') await srv.warteAufVersand() ok(srv.gastArten().filter((x) => x === 'G16').length === 2, `G16 an beide Gäste (${srv.gastArten().join()})`) ok(srv.vermieterArten().includes('K36'), `K36 an den Vermieter (${srv.vermieterArten().join()})`) ok(srv.mailsMit().some((m) => m.to.some((x) => x.address === EVA.email)), 'G16 liegt in der Mail-Senke') }) // ------------------------------------------------------------ 3 await lauf('3_portal_anfrage_pruefen_aendern_freigeben', async () => { await oeffnePortal(portal) await reiter(portal, 'anfragen') await portal.locator(`[data-anfrage="${merk.eva}"]`).waitFor() ok((await portal.locator('[data-anfrage]').count()) === 2, 'Liste „Offen“ zeigt zwei Anfragen') await shot(portal, 'anfragen-offen') await portal.locator(`[data-anfrage="${merk.eva}"]`).click() await portal.locator('.bk-freigabe').waitFor() const posten = portal.locator('[data-zeile]') const texte = await posten.locator('[data-posten="text"]').evaluateAll((els) => els.map((e) => e.value)) ok(texte.length === 3, `Vorschlag mit drei Posten (${texte.join(' | ')})`) ok(texte.some((x) => /Reinigung/i.test(x)) && texte.some((x) => /rabatt/i.test(x)), 'Vorschlag enthält Reinigung und Direktrabatt') const summe0 = await portal.locator('[data-summen]').innerText() ok(enthaelt(summe0, eurotext(56400)), `Vorschlag: Gesamt ${eurotext(56400)} (7 × 80 + 60 − 56) – ${norm(summe0)}`) await shot(portal, 'anfrage-vorschlag') // Posten ändern: Reinigung auf 50 € const i = texte.findIndex((x) => /Reinigung/i.test(x)) await posten.nth(i).locator('[data-posten="einzel"]').fill('50,00') await posten.nth(i).locator('[data-posten="einzel"]').dispatchEvent('change') await portal.locator('#bk-a-nachricht').fill('Gern auch mit Hund. Schlüsselübergabe nach Absprache.') await warteBis(async () => enthaelt(await portal.locator('[data-summen]').innerText(), eurotext(55400)), `Summe live ${eurotext(55400)}`) await shot(portal, 'anfrage-posten-geaendert') await portal.locator('[data-aktion="freigeben"]').click() const box = portal.locator(`[data-freigegeben="${merk.eva}"]`) await box.waitFor() merk.evaLink = await box.locator('[data-link]').inputValue() ok(new RegExp(`^${srv.portalUrl}/unterkunft/${SITE_KEY}/buchung/${merk.eva}\\?t=`).test(merk.evaLink), `Buchungslink sichtbar (${merk.evaLink.slice(0, 80)}…)`) await shot(portal, 'anfrage-freigegeben-link') const a = srv.anfrage(merk.eva) ok(a.status === 'freigegeben', `Status freigegeben (${a.status})`) ok(a.summen?.bruttoCent === 55400 && a.posten.some((p) => /Reinigung/i.test(p.text) && p.einzelCent === 5000), `geänderter Posten gespeichert (brutto ${a.summen?.bruttoCent})`) await srv.warteAufVersand() ok(srv.gastArten().includes('G17'), `G17 eingereiht (${srv.gastArten().join()})`) const g17 = srv.mailsMit().find((m) => m.to.some((x) => x.address === EVA.email) && String(m.text).includes(merk.evaLink)) ok(Boolean(g17), 'G17 mit demselben Buchungslink liegt in der Mail-Senke') ok(Boolean(g17) && /Gern auch mit Hund/.test(g17.text), 'G17 trägt die Nachricht des Vermieters') }) // ------------------------------------------------------------ 4 await lauf('4_buchungsseite_vor_ort_buchen_rechnung', async () => { const ctxG = await neuerKontext(browser, { viewport: DESKTOP }) const g = await ctxG.newPage() await g.goto(merk.evaLink) await g.locator('.uk-karte h1').filter({ hasText: 'Ihre Buchung bestätigen' }).waitFor() const pflicht = g.locator('[data-tag="pflichtangaben"]') const ptext = await pflicht.innerText() for (const teil of ['Anbieter: Donau Home', 'Donau Home, ', 'Personen: 2', `Gesamtpreis: ${eurotext(55400)}`, 'Zahlarten:', 'Stornobedingungen: 1. Stornierung durch den Gast', 'Ein Widerrufsrecht besteht nicht', 'Datenschutz:', 'Reserviert bis']) { ok(enthaelt(ptext, teil), `Pflichtangaben enthalten „${teil}“`) } const knopf = g.locator('form button[type="submit"]') ok(enthaelt(await knopf.innerText(), 'Zahlungspflichtig buchen'), 'Knopf heißt „Zahlungspflichtig buchen“') const yPflicht = (await pflicht.boundingBox()).y const yKnopf = (await knopf.boundingBox()).y ok(yPflicht < yKnopf, `Pflichtangaben stehen über dem Knopf (${Math.round(yPflicht)} < ${Math.round(yKnopf)})`) ok(await g.locator('input[name="zahlart"]').count() === 2, 'zwei Zahlarten (vor Ort, online) wählbar') // Pflichtfelder: ohne Adresse kommt ein Hinweis, nichts wird gebucht await knopf.click() await g.locator('.uk-fehler:visible').waitFor() ok(srv.anfrage(merk.eva).status === 'freigegeben', 'ohne Rechnungsadresse wird nicht gebucht') await shotBeide(g, 'buchungsseite-pflichtfehler') await g.locator('input[name="strasse"]').fill('Gästeweg 1') await g.locator('input[name="plz"]').fill('10115') await g.locator('input[name="ort"]').fill('Berlin') await g.locator('input[name="zahlart"][value="vorort"]').check() await shotBeide(g, 'buchungsseite-ausgefuellt') await knopf.click() await g.locator('.uk-karte h1').filter({ hasText: 'Ihre Buchung ist bestätigt' }).waitFor() const btext = await g.locator('.uk-karte').innerText() ok(enthaelt(btext, 'Die Zahlung erfolgt vor Ort.'), 'Bestätigung: „Die Zahlung erfolgt vor Ort.“') ok(/Rechnung [A-Z0-9]+-\d{4}-\d{4}/.test(norm(btext)), `Bestätigung nennt die Rechnungsnummer (${norm(btext).match(/Rechnung [^ ]+/)?.[0]})`) await shotBeide(g, 'buchung-bestaetigt') const a = srv.anfrage(merk.eva) ok(a.status === 'gebucht' && a.zahlart === 'vorort' && a.rechnungId, `Status gebucht, Zahlart vor Ort, Rechnung ${a.rechnungId}`) // Rechnungs-PDF aus dem Browser abrufen const href = await g.locator('a[data-tag="rechnung"]').getAttribute('href') const pdf = await g.evaluate(async (h) => { const r = await fetch(h) const b = new Uint8Array(await r.arrayBuffer()) return { status: r.status, typ: r.headers.get('content-type'), kopf: String.fromCharCode(...b.slice(0, 5)), laenge: b.length } }, href) ok(pdf.status === 200 && /^application\/pdf/.test(pdf.typ) && pdf.kopf.startsWith('%PDF') && pdf.laenge > 800, `Rechnungs-PDF abrufbar (${pdf.status} ${pdf.typ} ${pdf.kopf.trim()} ${pdf.laenge} Byte)`) await ctxG.close() await srv.warteAufVersand() ok(srv.gastArten().includes('G19'), `G19 eingereiht (${srv.gastArten().join()})`) const g19 = srv.mailsMit().find((m) => m.to.some((x) => x.address === EVA.email) && (m.attachments || []).some((x) => /^Rechnung-.*\.pdf$/.test(x.filename))) ok(Boolean(g19), 'G19 mit Rechnungs-PDF im Anhang liegt in der Mail-Senke') ok(Boolean(g19) && Buffer.from(g19.attachments[0].content, 'base64').subarray(0, 4).toString() === '%PDF', 'Anhang der G19 ist ein PDF') ok(srv.vermieterArten().filter((x) => x === 'K37').length >= 1, `K37 an den Vermieter (${srv.vermieterArten().join()})`) ok(srv.rechnungen().length === 1 && srv.rechnungen()[0].art === 'rechnung', 'eine Rechnung in ukRechnungen') }) // ------------------------------------------------------------ 5 await lauf('5_portal_als_bezahlt_vor_ort', async () => { await oeffnePortal(portal) await reiter(portal, 'anfragen') await portal.locator('#bk-a-filter').selectOption('gebucht') await portal.locator(`[data-anfrage="${merk.eva}"]`).click() const detail = portal.locator(`[data-detail="${merk.eva}"]`) await detail.waitFor() await detail.locator('[data-status="gebucht"]').waitFor() ok(await detail.locator('[data-aktion="rechnung"]').isVisible(), 'Rechnungs-Link im Detail') const pdf = await portal.evaluate(async (h) => { const r = await fetch(h) const b = new Uint8Array(await r.arrayBuffer()) return { status: r.status, typ: r.headers.get('content-type'), kopf: String.fromCharCode(...b.slice(0, 5)) } }, await detail.locator('[data-aktion="rechnung"]').getAttribute('href')) ok(pdf.status === 200 && /^application\/pdf/.test(pdf.typ) && pdf.kopf.startsWith('%PDF'), `Rechnungs-PDF im Portal abrufbar (${pdf.status} ${pdf.typ})`) await shot(portal, 'anfrage-gebucht') await detail.locator('[data-aktion="bezahlt"]').click() await portal.locator(`[data-detail="${merk.eva}"] [data-status="bezahlt"]`).waitFor() const a = srv.anfrage(merk.eva) ok(a.status === 'bezahlt' && a.bezahltVia === 'vorort', `Status bezahlt, via vor Ort (${a.status}/${a.bezahltVia})`) await shot(portal, 'anfrage-bezahlt') await srv.warteAufVersand() ok(!srv.gastArten().includes('G20'), `vor Ort bezahlt: keine G20 (die Zahlungsbestaetigung gibt es nur bei Stripe) (${srv.gastArten().join()})`) }) // ------------------------------------------------------------ 6 await lauf('6_zweite_anfrage_online_bezahlen', async () => { await oeffnePortal(portal) await reiter(portal, 'anfragen') await portal.locator(`[data-anfrage="${merk.paul}"]`).click() await portal.locator('.bk-freigabe').waitFor() ok(enthaelt(await portal.locator('[data-summen]').innerText(), eurotext(30300)), `Vorschlag Paul: ${eurotext(30300)} (3 × 90 + 60 − 27)`) await portal.locator('[data-aktion="freigeben"]').click() const box = portal.locator(`[data-freigegeben="${merk.paul}"]`) await box.waitFor() merk.paulLink = await box.locator('[data-link]').inputValue() await shot(portal, 'anfrage2-freigegeben') await srv.warteAufVersand() ok(srv.mailsMit().some((m) => m.to.some((x) => x.address === PAUL.email) && String(m.text).includes(merk.paulLink)), 'G17 an Paul mit demselben Link') // Gast am Telefon: online bezahlen const ctxG = await neuerKontext(browser, { viewport: PHONE }) const g = await ctxG.newPage() await g.goto(merk.paulLink) await g.locator('.uk-karte h1').filter({ hasText: 'Ihre Buchung bestätigen' }).waitFor() ok(await g.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), '390 px: Buchungsseite ohne waagerechtes Scrollen') await g.locator('input[name="strasse"]').fill('Weg 7') await g.locator('input[name="plz"]').fill('80331') await g.locator('input[name="ort"]').fill('München') await g.locator('input[name="zahlart"][value="online"]').check() ok(enthaelt(await g.locator('.uk-zahlarten').innerText(), 'gewählt: online'), 'Pflichtangaben nennen die gewählte Zahlart online') await shot(g, 'buchungsseite-online-ausgefuellt') await g.locator('form button[type="submit"]').click() await warteBis(() => stripeNavigationen.some((n) => n.art === 'checkout'), 'Weiterleitung zur Stripe-Kasse') await g.waitForURL(/checkout\.stripe\.test/) await shot(g, 'stripe-kasse-nachbau') const sitzung = srv.kassenSitzungen().at(-1) ok(Boolean(sitzung) && sitzung.metadata?.wkUk === '1' && sitzung.metadata?.anfrageId === merk.paul, `Kassen-Sitzung mit Metadaten wkUk/anfrageId (${sitzung?.id})`) ok(srv.anfrage(merk.paul).status === 'gebucht' && srv.anfrage(merk.paul).zahlart === 'online', 'Status gebucht, Zahlart online (noch unbezahlt)') // Der Gast zahlt bei Stripe: Webhook (signiert), dann Rückkehr auf die Buchungsseite const bezahlt = srv.bezahleKasse(sitzung.id) const hook = await srv.sendeConnect('checkout.session.completed', bezahlt) ok(hook.status === 200 || hook.status === 204, `Webhook checkout.session.completed beantwortet (${hook.status})`) await warteBis(() => srv.anfrage(merk.paul).status === 'bezahlt', 'Status bezahlt nach dem Webhook') ok(srv.anfrage(merk.paul).bezahltVia === 'stripe' && Boolean(srv.anfrage(merk.paul).stripePaymentIntentId), 'bezahlt via Stripe mit Zahlungs-ID') await g.goto(sitzung.success_url) await g.locator('.uk-karte').getByText('Bezahlt – vielen Dank.').waitFor() await shot(g, 'buchung-online-bezahlt') await ctxG.close() await srv.warteAufVersand() ok(srv.gastArten().filter((x) => x === 'G20').length === 1, `G20 nach der Online-Zahlung (${srv.gastArten().join()})`) ok(srv.mailsMit().some((m) => m.to.some((x) => x.address === PAUL.email) && /bezahlt|Zahlung/i.test(m.subject) && !(m.attachments || []).length), `G20 liegt in der Mail-Senke (${srv.mailsMit().filter((m) => m.to.some((x) => x.address === PAUL.email)).map((m) => m.subject).join(' / ')})`) }) // ------------------------------------------------------------ 7 await lauf('7_kalender_und_ical', async () => { // eine reservierte Anfrage (Dienstaufruf), eine Sperre per Klick im Kalender await srv.reserviere({ unterkunftId: merk.unterkunftId, ...RESERVIERT }) await oeffnePortal(portal) await reiter(portal, 'kalender') await portal.locator('.bk-raster [data-tag]').first().waitFor() await portal.locator('[data-aktion="weiter"]').click() await portal.locator(`.bk-raster [data-tag="${tag(10)}"].ist-gebucht`).waitFor() const klasse = (t) => portal.locator(`.bk-raster [data-tag="${t}"]`).getAttribute('class') ok((await klasse(tag(10))).includes('ist-gebucht') && (await klasse(tag(16))).includes('ist-gebucht'), 'Eva (10.–17.) farbig als gebucht') ok(!(await klasse(tag(17))).includes('ist-gebucht'), 'Abreisetag (17.) ist frei') ok((await klasse(tag(20))).includes('ist-gebucht') && (await klasse(tag(22))).includes('ist-gebucht'), 'Paul (20.–23.) farbig als gebucht') ok((await klasse(tag(2))).includes('ist-reserviert') && (await klasse(tag(3))).includes('ist-reserviert'), 'Reservierung (2.–4.) farbig als reserviert') await portal.locator(`.bk-raster [data-tag="${SPERRE.von}"]`).click() await portal.locator(`.bk-raster [data-tag="${SPERRE.von}"]`).and(portal.locator('[aria-pressed="true"]')).waitFor() await portal.locator(`.bk-raster [data-tag="${tag(26)}"]`).click() // zweiter Klick auf denselben Tag: Zeitraum = 1 Nacht await portal.locator('#bk-k-notiz').fill('Eigener Aufenthalt') await portal.locator('[data-aktion="sperren-senden"]').click() await portal.locator(`.bk-raster [data-tag="${tag(26)}"].ist-manuell`).waitFor() ok(srv.sperren().length === 1 && srv.sperren()[0].von === SPERRE.von && srv.sperren()[0].bis === SPERRE.bis, `Sperre ${SPERRE.von} gespeichert (${srv.sperren().map((s) => `${s.von}…${s.bis}`)})`) const farben = await portal.evaluate((tage) => tage.map((t) => getComputedStyle(document.querySelector(`.bk-raster [data-tag="${t}"]`)).backgroundColor), [tag(10), tag(2), tag(26), tag(5)]) ok(new Set(farben).size === 4, `gebucht, reserviert, gesperrt und frei haben vier verschiedene Farben (${farben.join(' | ')})`) await shot(portal, 'kalender-farbig') await portal.setViewportSize(PHONE) await shot(portal, 'kalender-farbig') await portal.setViewportSize(DESKTOP) // iCal-Export: Link in der Unterkunft, Abruf liefert text/calendar mit den Buchungen await reiter(portal, 'unterkuenfte') await portal.locator('[data-aktion="bearbeiten"]').click() await portal.locator('.bk-preise form').waitFor() await shot(portal, 'unterkunft-ical-export') const liste = await portal.evaluate(async () => (await (await fetch('/api/unterkunft/unterkuenfte?projectId=p1')).json()).unterkuenfte) merk.icalUrl = liste[0].icalExportUrl ok(/\/unterkunft\/ical\/[A-Za-z0-9_-]{20,64}\.ics$/.test(merk.icalUrl || ''), `iCal-Export-Link vorhanden (${merk.icalUrl})`) const ics = await holeIcs() ok(ics.status === 200 && /^text\/calendar/.test(ics.typ), `iCal-Abruf: ${ics.status} ${ics.typ}`) ok(ics.text.startsWith('BEGIN:VCALENDAR') && ics.text.includes('END:VCALENDAR'), 'iCal ist ein VCALENDAR') for (const [name, von] of [['Eva', EVA.von], ['Paul', PAUL.von], ['Reservierung', RESERVIERT.von], ['Sperre', SPERRE.von]]) { ok(ics.text.includes(`DTSTART;VALUE=DATE:${ohneStriche(von)}`), `iCal enthält ${name} (${von})`) } ok((ics.text.match(/BEGIN:VEVENT/g) || []).length === 4, `iCal: vier Ereignisse (${(ics.text.match(/BEGIN:VEVENT/g) || []).length})`) merk.icsVorher = ics.text }) // ------------------------------------------------------------ 8 await lauf('8_stornieren_mit_teilerstattung', async () => { await oeffnePortal(portal) await reiter(portal, 'anfragen') await portal.locator('#bk-a-filter').selectOption('gebucht') await portal.locator(`[data-anfrage="${merk.paul}"]`).click() const detail = portal.locator(`[data-detail="${merk.paul}"]`) await detail.waitFor() await detail.locator('[data-status="bezahlt"]').waitFor() await detail.locator('[data-aktion="stornieren"]').click() await portal.locator('#bk-a-grund').fill('Wasserschaden im Bad, die Wohnung ist nicht nutzbar.') ok((await portal.locator('#bk-a-erstattung').inputValue()) === '303,00', `Erstattung ist mit dem Gesamtbetrag vorbelegt (${await portal.locator('#bk-a-erstattung').inputValue()})`) await portal.locator('#bk-a-erstattung').fill('150,00') await shot(portal, 'storno-dialog') await portal.locator('[data-aktion="storno-senden"]').click() await portal.locator(`[data-detail="${merk.paul}"] [data-status="storniert"]`).waitFor() await shot(portal, 'storno-fertig') const a = srv.anfrage(merk.paul) ok(a.status === 'storniert' && a.erstattetCent === 15000, `Status storniert, erstattet ${a.erstattetCent} Cent`) const ers = srv.erstattungen() ok(ers.length === 1 && ers[0].amount === 15000 && ers[0].payment_intent === a.stripePaymentIntentId, `Erstattung im Stripe-Nachbau: ${ers.map((r) => `${r.id} ${r.amount} ct auf ${r.payment_intent}`).join(', ')}`) ok(srv.rechnungen().some((r) => r.art === 'storno' && r.anfrageId === merk.paul), 'Storno-Rechnung angelegt') await srv.warteAufVersand() ok(srv.gastArten().includes('G22'), `G22 eingereiht (${srv.gastArten().join()})`) const g22 = srv.mailsMit().find((m) => m.to.some((x) => x.address === PAUL.email) && (m.attachments || []).some((x) => /^Stornorechnung-.*\.pdf$/.test(x.filename))) ok(Boolean(g22), 'G22 mit Storno-Rechnung als PDF im Anhang') ok(Boolean(g22) && g22.text.includes('Eine Erstattung von 150,00 € wird veranlasst.') && g22.text.includes('Wasserschaden'), 'G22 nennt die Erstattung (150,00 €) und den Grund') ok(Boolean(g22) && Buffer.from(g22.attachments[0].content, 'base64').subarray(0, 4).toString() === '%PDF', 'Anhang der G22 ist ein PDF') // Rechnungen-Reiter: Rechnung Eva, Rechnung Paul, Storno Paul await reiter(portal, 'rechnungen') await portal.locator('[data-rechnung]').first().waitFor() const arten = await portal.locator('[data-rechnung]').evaluateAll((els) => els.map((e) => e.dataset.art)) ok(arten.length === 3 && arten.filter((x) => x === 'storno').length === 1, `Rechnungen: ${arten.join(', ')}`) const stornoPdf = await portal.evaluate(async (h) => { const r = await fetch(h) const b = new Uint8Array(await r.arrayBuffer()) return { status: r.status, typ: r.headers.get('content-type'), kopf: String.fromCharCode(...b.slice(0, 5)) } }, await portal.locator('[data-art="storno"] a[data-aktion="pdf"]').getAttribute('href')) ok(stornoPdf.status === 200 && stornoPdf.kopf.startsWith('%PDF'), `Storno-Rechnung im Portal abrufbar (${stornoPdf.status} ${stornoPdf.typ})`) await shot(portal, 'rechnungen') // iCal: die stornierte Buchung ist weg, die anderen bleiben const ics = await holeIcs() ok(!ics.text.includes(`DTSTART;VALUE=DATE:${ohneStriche(PAUL.von)}`), 'iCal enthält die stornierte Buchung nicht mehr') ok(ics.text.includes(`DTSTART;VALUE=DATE:${ohneStriche(EVA.von)}`) && (ics.text.match(/BEGIN:VEVENT/g) || []).length === 3, 'iCal enthält Eva, Reservierung und Sperre weiter') // Kalender: Pauls Tage sind wieder frei await reiter(portal, 'kalender') await portal.locator('[data-aktion="weiter"]').click() await portal.locator(`.bk-raster [data-tag="${tag(10)}"].ist-gebucht`).waitFor() ok((await portal.locator(`.bk-raster [data-tag="${tag(21)}"]`).getAttribute('class')).includes('ist-frei'), 'Kalender: Pauls Tage sind nach dem Storno wieder frei') await shot(portal, 'kalender-nach-storno') }) await portalCtx.close() } /** iCal-Export abrufen (wie ein Kalenderdienst: ohne Browser, ohne Cookie). */ async function holeIcs() { const antwort = await fetch(`${srv.url}${new URL(merk.icalUrl).pathname}`) return { status: antwort.status, typ: antwort.headers.get('content-type') || '', text: await antwort.text() } } // ---------------------------------------------------------------- Lauf let abbruch = null try { browser = await chromium.launch({ executablePath: CHROME }) await szenario() const eigene = new Set([srv.url, srv.portalUrl, srv.kundenUrl].map((u) => new URL(u).host)) const fremd = [...fremdeHosts].filter((h) => !eigene.has(h)) ergebnisse.push(['nur_eigene_herkunft', fremd.length === 0, fremd.length ? [`Anfragen an fremde Hosts: ${fremd.join(', ')}`] : [], []]) console.log(fremd.length ? `FAIL nur_eigene_herkunft: ${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 gescheitert = ergebnisse.filter(([, bestanden]) => !bestanden) console.log(`\n${ergebnisse.length - gescheitert.length}/${ergebnisse.length} Schritte bestanden; ${nummer} Screenshots in ${SHOTS}`) for (const [name, , fehler] of gescheitert) console.log(` FAIL ${name}\n${fehler.map((f) => ` - ${f}`).join('\n')}`) process.exit(abbruch || gescheitert.length ? 1 : 0)