Files
Webklar-Kundenbereich/test/ui/zeiten-portal.ui.mjs
T
ClaudeandClaude Opus 5.5 aa9ee35ab6 Öffnungszeiten: Fixrunde Website-Skript und Editor (JSON-LD, Kopf beim Scrollen, späte Platzhalter, Links, Bestätigung)
- JSON-LD wird ohne "<" geschrieben (<), ein </script> im Firmennamen bricht keinen Block mehr
- verschiebe(): ist die Hinweis-Leiste weggescrollt, bekommen feste Elemente ihren eigenen Inline-top zurück
  (Köpfe, die per Klasse beim Scrollen wegfahren, funktionieren wieder)
- Späte Platzhalter (Einzelseiten-Apps): entprellter MutationObserver auf body zeichnet neue oder wieder
  eingehängte Platzhalter nach
- sichererLink lehnt Zugangsdaten in der Adresse ab; die Adresse der Schnittstelle bestimmt nur das eigene
  Script-Tag, kein Platzhalter mehr (der siteKey am Platzhalter bleibt laut Spec 4.2)
- Editor: die Bestätigung klebt unter der Kopfleiste und bleibt nach dem Speichern eines unteren Abschnitts sichtbar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:10:02 +00:00

527 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Playwright-Checks der Portal-Ansicht Öffnungszeiten live (public/zeiten*.js, Kachel in funktionen.js, zeiten.css) – Aufgabe 30:
// Kachel, Einschalten, leerer Editor, Speichern mit Konflikt, Hinweis-Vorschlag, Handy (390 px) und Tastatur. Nicht Teil von npm test.
// Aufruf: free -m; nice node test/ui/zeiten-portal.ui.mjs (es läuft nur ein Browser)
// Screenshots: /root/reports/zeiten-portal-ui/portal-*.png (bei Fehlern fehler-portal-<Prüfung>.png). Exit-Code 1, wenn eine Prüfung scheitert.
//
// Alle Prüfungen sind "fx_*": Fixture. Alle /api/-Anfragen beantwortet page.route mit festen Daten (wie test/ui/termine-portal.ui.mjs und
// funktionen.ui.mjs); der Server liefert nur dashboard.html und die Dateien aus public/. Nichts verlässt den Rechner.
// NUR=<Teil des Namens> führt nur passende Prüfungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg.
// Uhr: Browser steht auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime).
import http from 'node:http'
import { mkdir, readdir, readFile, rm } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
const SHOTS = '/root/reports/zeiten-portal-ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/^(portal|fehler-portal)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Läufe
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
try {
const datei = path.join(PUBLIC, url.pathname === '/' ? 'dashboard.html' : url.pathname)
if (!datei.startsWith(PUBLIC)) throw new Error('außerhalb')
const daten = await readFile(datei)
res.writeHead(200, { 'content-type': TYPES[path.extname(datei)] || 'application/octet-stream' })
res.end(daten)
} catch {
res.writeHead(404)
res.end('nicht gefunden')
}
})
await new Promise((fertig) => { server.listen(0, '127.0.0.1', fertig) })
const BASE = `http://127.0.0.1:${server.address().port}`
const JETZT = new Date('2026-10-05T08:00:00.000Z') // Montag, 10:00 Berlin (MESZ)
const HEUTE = '2026-10-05'
// ---------------------------------------------------------------- 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 tagPlus = (tag, n) => new Date(Date.parse(`${tag}T00:00:00Z`) + n * 86400000).toISOString().slice(0, 10)
/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhöhe (höchstens 3600 px). */
async function shot(page, name) {
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), 3600))
await page.setViewportSize({ width: fenster.width, height: hoehe })
await page.screenshot({ path: `${SHOTS}/portal-${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)
// ---------------------------------------------------------------- Fixture
const WOCHENTAGE = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
const woche = (zeiten = {}) => Object.fromEntries([1, 2, 3, 4, 5, 6, 7].map((n) => [n, zeiten[n] || { modus: 'zu', fenster: [] }]))
const werktage = () => woche({
1: { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] },
2: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
3: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
4: { modus: 'zeiten', fenster: [['09:00', '18:00']] },
5: { modus: 'zeiten', fenster: [['09:00', '16:00']] },
6: { modus: 'zeiten', fenster: [['09:00', '12:00']] },
})
const PLAN = 'p_haupt001'
const KONTAKT = { an: true, telefon: '496301123456', adresse: 'Hauptstraße 1, 67722 Winnweiler', whatsapp: { nummer: '4917112345678', starttext: 'Hallo, ich habe eine Frage zu euren Öffnungszeiten.', bestaetigtAt: '2026-10-01T08:00:00.000Z' } }
const FEIERTAGE = [
{ tag: '2026-11-01', name: 'Allerheiligen', art: 'feiertag' },
{ tag: '2026-12-24', name: 'Heiligabend', art: 'pruefen' },
{ tag: '2026-12-25', name: 'Erster Weihnachtstag', art: 'feiertag' },
{ tag: '2026-12-31', name: 'Silvester', art: 'pruefen' },
]
const gespeicherteDaten = (extra = {}) => ({
land: 'RP',
plaene: [{ id: PLAN, kurz: 'oeffnungszeiten', name: 'Öffnungszeiten', haupt: true, tage: werktage() }],
feiertage: {},
ausnahmen: [],
hinweise: [],
kontakt: KONTAKT,
...extra,
})
const googleTextAus = (daten) => {
const tage = daten.plaene[0].tage
const text = (t) => (t.modus === 'zeiten' ? t.fenster.map((f) => `${f[0]}–${f[1]}`).join(', ') : t.modus === 'rund' ? 'rund um die Uhr' : t.modus === 'vereinbarung' ? 'nach Vereinbarung' : 'geschlossen')
return WOCHENTAGE.map((name, i) => `${name}: ${text(tage[i + 1])}`).join('\n')
}
const FX_CONSENT = { version: 'fx-1', knopf: 'Einschalten', items: [{ id: 'leistung', text: 'Ich habe die Leistungsbeschreibung „Öffnungszeiten live“ gelesen.', haken: true }] }
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/zeiten liefert. */
function neueFixture(o = {}) {
const fx = {
state: 'active',
inklusive: true,
grund: null,
zeitenLeer: false,
daten: gespeicherteDaten(),
version: 3,
konflikt: false, // das nächste PUT antwortet 409 (jemand hat inzwischen gespeichert)
aufrufe: [],
...o,
}
fx.angebot = () => ({
verfuegbar: true,
inklusive: fx.inklusive,
grund: fx.grund,
preis: '30 €',
einschalten: { consent: FX_CONSENT, leistungsbeschreibung: { version: 'fx-1', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } },
belehrung: 'Widerrufsbelehrung der Fixture.',
})
fx.feature = () => ({
state: fx.state, siteKey: 'abcdabcdabcdabcd', ticketWoid: fx.state === 'off' ? null : '1234', orderedAt: null, activatedAt: null, invoiceUrl: null,
disabledReason: null, disabledAt: null, loeschungAm: null, unlockedBy: fx.state === 'off' ? null : 'abo', aboEndeAt: null, einbau: fx.state === 'setup' ? 'angestossen' : null,
})
fx.vorschau = () => (fx.daten ? { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' } : null)
fx.liste = () => ({
projects: [{
projectId: 'p1', projectName: 'Bäckerei Sonnenschein', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' }, zeiten: fx.feature() },
zeitenStatus: fx.state === 'active' && fx.daten ? fx.vorschau() : null, zeitenLeer: fx.zeitenLeer,
}],
features: { zeiten: { consent: FX_CONSENT, priceLabel: '30 €', setupEstimate: 'meist innerhalb von 1–2 Wochen', leistungsbeschreibung: { version: 'fx-1', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } } },
ustHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
zeiten: fx.angebot(),
})
fx.ansicht = () => ({
state: fx.state, daten: fx.daten, version: fx.version, kundenansicht: false, vorschau: fx.vorschau(), feiertage: FEIERTAGE, googleText: fx.daten ? googleTextAus(fx.daten) : '',
})
return fx
}
/** Die übrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/funktionen.ui.mjs). */
function portalHilfe(p, json) {
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'berta@baeckerei.example' } })
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Sonnenschein', 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({})
}
/** Antworten der Fixture auf /api/funktionen und /api/zeiten (Teil von page.route). */
function fixtureAntwort(fx, req, url, json) {
const p = url.pathname
const m = req.method()
if (p === '/api/funktionen') return json(fx.liste())
if (p === '/api/funktionen/zeiten/einschalten' && m === 'POST') {
fx.aufrufe.push(['einschalten', req.postDataJSON()])
fx.state = 'setup'
fx.zeitenLeer = true
fx.daten = null
fx.version = 0
return json({ feature: { state: 'setup' }, einbau: 'angestossen' })
}
if (p === '/api/zeiten/p1' && m === 'GET') {
fx.aufrufe.push(['get'])
return json(fx.ansicht())
}
if (p === '/api/zeiten/p1' && m === 'PUT') {
const body = req.postDataJSON()
fx.aufrufe.push(['put', body])
if (fx.konflikt) {
// jemand anders hat inzwischen gespeichert: neuer Stand, neue Version
fx.konflikt = false
fx.version += 1
fx.daten = { ...fx.daten, plaene: [{ ...fx.daten.plaene[0], name: 'Öffnungszeiten (inzwischen geändert)' }] }
return json({ error: 'Inzwischen geändert – bitte neu laden.', code: 'ZEITEN_VERALTET' }, 409)
}
if (body.version !== fx.version) return json({ error: 'Inzwischen geändert – bitte neu laden.', code: 'ZEITEN_VERALTET' }, 409)
const erstes = fx.daten === null
fx.daten = { ...body.daten, kontakt: body.daten.kontakt || { an: false, telefon: '', adresse: '', whatsapp: null } }
fx.version += 1
if (erstes) fx.zeitenLeer = false
return json(fx.ansicht())
}
if (p === '/api/zeiten/p1/vorschau' && m === 'GET') {
const zeit = url.searchParams.get('zeit') || ''
fx.aufrufe.push(['vorschau', zeit])
if (!fx.daten) return json({ error: 'Bitte speichere zuerst deine Öffnungszeiten.', code: 'keine_zeiten' }, 409)
const wann = new Date(`${zeit}:00Z`)
const stunde = wann.getUTCHours()
const sonntag = wann.getUTCDay() === 0
if (sonntag) return json({ offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' })
return json(stunde >= 9 && stunde < 18 ? { offen: true, text: 'Jetzt geöffnet · schließt um 18 Uhr', kurz: 'Geöffnet' } : { offen: false, text: 'Geschlossen · öffnet morgen um 9 Uhr', kurz: 'Geschlossen' })
}
return json({})
}
// ---------------------------------------------------------------- Ablauf der Prüfungen
const results = []
let browser = null
const hosts = new Set()
/** Eine Prüfung in eigenem Browser-Kontext. optionen: viewport/context (Playwright), fixture (Objekt aus neueFixture()). */
async function check(name, fn, { context = {}, fixture = neueFixture() } = {}) {
if (process.env.NUR && !name.includes(process.env.NUR)) return
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, locale: 'de-DE', timezoneId: 'Europe/Berlin', ...context })
await ctx.clock.setFixedTime(JETZT)
ctx.setDefaultTimeout(8000)
const fehler = []
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) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
})
await ctx.route('**/api/**', 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/funktionen') || url.pathname.startsWith('/api/zeiten')) return fixtureAntwort(fixture, req, url, json)
return portalHilfe(url.pathname, json)
})
const page = await ctx.newPage()
page.fx = fixture
try {
await fn(page, ctx, fixture)
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-portal-${name}.png` }).catch(() => {})
} finally {
await ctx.close()
}
}
const oeffne = async (page, hash) => {
await page.goto(`${BASE}/dashboard.html${hash}`)
await page.waitForFunction(() => document.getElementById('customer-name')?.textContent)
}
/** Ansicht #zeiten öffnen und warten, bis der Editor (mit Abschnitten) da ist. */
const oeffneEditor = async (page) => {
await oeffne(page, '#zeiten')
await page.locator('#view-zeiten.active').waitFor()
await page.locator('#zeiten-body .zt-editor [data-abschnitt="plaene"]').waitFor()
}
const editor = (page) => page.locator('#zeiten-body .zt-editor')
const abschnitt = (page, key) => page.locator(`#zeiten-body [data-abschnitt="${key}"]`)
const knopf = (loc, name) => loc.getByRole('button', { name }) // Teiltreffer: wie die Relaunch-Station („Hinweis-Leiste anlegen“)
const meldung = (page) => page.locator('#zeiten-body [data-meldung="editor"]')
async function pruefungen() {
// ---------------------------------------------------------------- Kachel in Funktionen
await check('fx_kachel_zustaende', async (page, _ctx, fx) => {
const faelle = [
['off, im Abo enthalten', { state: 'off', inklusive: true, zeitenLeer: true, daten: null }, 'Aus', /Im Wartungs-Abo enthalten\./, 'Einschalten'],
['off, 30 €', { state: 'off', inklusive: false, grund: 'kein_abo', zeitenLeer: true, daten: null }, 'Aus', /30 € einmalig/, 'Öffnungszeiten einrichten · 30 €'],
['off, Abo noch nicht aktiv', { state: 'off', inklusive: false, grund: 'nicht_aktiv', zeitenLeer: true, daten: null }, 'Aus', /einschaltbar, sobald dein Abo aktiv ist/, null],
['off, nicht prüfbar', { state: 'off', inklusive: null, grund: null, zeitenLeer: true, daten: null }, 'Aus', /Gerade nicht prüfbar – bitte später erneut\./, null],
['Zahlung offen', { state: 'ordered', inklusive: false, grund: 'kein_abo', zeitenLeer: true, daten: null }, 'Zahlung offen', /Zahlung wird bestätigt/, 'Status ansehen'],
['In Einrichtung, leer', { state: 'setup', zeitenLeer: true, daten: null }, 'In Einrichtung', /Trag jetzt deine Zeiten ein/, 'Zeiten bearbeiten'],
['Aktiv mit Status', { state: 'active' }, 'Aktiv', /Jetzt geöffnet · schließt um 18 Uhr/, 'Zeiten bearbeiten'],
['Abgeschaltet', { state: 'disabled', zeitenLeer: true, daten: null }, 'Abgeschaltet', /abgeschaltet/, 'Chat öffnen'],
]
for (const [name, zustand, pille, text, cta] of faelle) {
Object.assign(fx, { state: 'active', inklusive: true, grund: null, zeitenLeer: false, daten: gespeicherteDaten() }, zustand)
await page.goto('about:blank')
await oeffne(page, '#funktionen')
const kachel = page.locator('[data-fn-tile="zeiten"]')
await kachel.waitFor()
eq((await kachel.locator('.fn-pill').innerText()).trim(), pille, `${name}: Pille`)
assert(text.test(await kachel.innerText()), `${name}: Text fehlt: ${text} in „${(await kachel.innerText()).replace(/\n/g, ' | ')}“`)
if (cta) eq((await kachel.locator('.fn-tile-cta').first().innerText()).trim(), cta, `${name}: Knopf`)
else eq(await kachel.locator('.fn-tile-cta').count(), 0, `${name}: kein Knopf`)
assert(await passtInBreite(page), `${name}: waagerechter Streifen`)
}
await shot(page, 'kachel-abgeschaltet')
})
// ---------------------------------------------------------------- Einschalten
await check('fx_einschalten', async (page, _ctx, fx) => {
await oeffne(page, '#funktionen')
await knopf(page.locator('[data-fn-tile="zeiten"]'), 'Einschalten').click()
const seite = page.locator('[data-variante="einschalten"]')
await seite.waitFor()
const einschalten = knopf(seite, 'Einschalten')
assert(await einschalten.isDisabled(), 'ohne Haken gesperrt')
await shot(page, 'einschalten')
await seite.locator('input[type="checkbox"]').check()
assert(await einschalten.isEnabled(), 'der Haken gibt den Knopf frei')
await einschalten.click()
await page.locator('.zt-status[data-state="setup"]').waitFor()
eq(fx.aufrufe.filter((a) => a[0] === 'einschalten'), [['einschalten', { projectId: 'p1', zustimmung: { version: 'fx-1', haken: ['leistung'] } }]], 'POST Einschalten')
assert((await page.locator('.zt-status [data-danke]').innerText()).includes('Öffnungszeiten live ist eingeschaltet.'), 'Danke-Satz')
await page.locator('#zeiten-body .zt-editor [data-hinweis="leer"]').waitFor()
assert((await page.locator('#zeiten-body .zt-editor [data-hinweis="leer"]').innerText()).includes('Trag deine Öffnungszeiten ein'), 'der Editor wartet auf die ersten Zeiten')
eq(await page.locator('#zeiten-body details[data-plan]').count(), 1, 'ein leerer Hauptplan')
assert(await passtInBreite(page), 'waagerechter Streifen')
await shot(page, 'einschalten-danach')
}, { fixture: neueFixture({ state: 'off', inklusive: true, zeitenLeer: true, daten: null, version: 0 }) })
// ---------------------------------------------------------------- Leerer Editor, erstes Speichern
await check('fx_editor_leer', async (page, _ctx, fx) => {
await oeffneEditor(page)
assert((await page.locator('.zt-status').innerText()).includes('Trag jetzt deine Zeiten ein'), 'Statuskarte fordert die Zeiten')
assert((await editor(page).innerText()).includes('Trag deine Öffnungszeiten ein'), 'Editor: Trag deine Öffnungszeiten ein')
eq(await page.locator('#zeiten-body details[data-plan]').count(), 1, 'ein leerer Hauptplan')
assert((await page.locator('#zeiten-body [data-kurz]').innerText()).trim() === 'oeffnungszeiten', 'Kurzname oeffnungszeiten')
for (const key of ['feiertage', 'ausnahmen', 'hinweise', 'kontakt', 'google']) eq(await abschnitt(page, key).count(), 0, `${key}: erst nach dem ersten Speichern`)
assert((await page.locator('[data-vorschau="jetzt"]').innerText()).toLowerCase().includes('noch keine öffnungszeiten gespeichert'), 'Vorschau: noch nichts gespeichert')
// Regler vor dem ersten Speichern: ein Hinweis, kein Fehler
await page.locator('[data-regler="zeit"]').fill('11:00')
await page.locator('[data-vorschau="test"]', { hasText: 'Speichere zuerst deine Öffnungszeiten' }).waitFor()
eq(await page.locator('[data-vorschau="test"]').getAttribute('role'), null, 'der Hinweis ist kein Alarm')
await shot(page, 'editor-leer')
// Montag eintragen und speichern
const montag = page.locator('details[data-plan] [data-tag="1"]')
await montag.locator('input[type="checkbox"]').check()
const zeiten = montag.locator('input[type="time"]')
await zeiten.nth(0).fill('09:00')
await zeiten.nth(1).fill('12:30')
await zeiten.nth(2).fill('13:30')
await zeiten.nth(3).fill('18:00')
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
await abschnitt(page, 'feiertage').waitFor()
for (const key of ['ausnahmen', 'hinweise', 'kontakt', 'google']) await abschnitt(page, key).waitFor()
const put = fx.aufrufe.find((a) => a[0] === 'put')[1]
eq(put.version, 0, 'erstes PUT mit version 0')
eq(put.daten.plaene[0].tage['1'], { modus: 'zeiten', fenster: [['09:00', '12:30'], ['13:30', '18:00']] }, 'Montag gespeichert')
assert(!(await page.locator('.zt-status').innerText()).includes('Trag jetzt deine Zeiten ein'), 'der Kopf kennt die Zeiten jetzt')
assert((await page.locator('[data-vorschau="jetzt"]').innerText()).includes('Jetzt geöffnet'), 'Vorschau aus der Antwort')
await shot(page, 'editor-gespeichert')
}, { fixture: neueFixture({ state: 'active', zeitenLeer: true, daten: null, version: 0 }) })
// ---------------------------------------------------------------- Speichern mit Konflikt
await check('fx_speichern_konflikt', async (page, _ctx, fx) => {
await oeffneEditor(page)
fx.konflikt = true
const name = page.locator('details[data-plan] input[type="text"]').first()
await name.fill('Öffnungszeiten neu')
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
const box = page.locator('#zeiten-body .zt-editor [role="alert"]', { hasText: 'Inzwischen geändert – bitte neu laden.' })
await box.waitFor()
assert(await knopf(box, 'Neu laden').isVisible(), 'Knopf „Neu laden“')
await shot(page, 'konflikt')
await knopf(box, 'Neu laden').click()
await page.waitForFunction(() => !document.querySelector('#zeiten-body .zt-editor [role="alert"]:not([hidden])') || [...document.querySelectorAll('#zeiten-body .zt-editor .tm-box[role="alert"]')].every((e) => e.hidden))
eq(await page.locator('details[data-plan] input[type="text"]').first().inputValue(), 'Öffnungszeiten (inzwischen geändert)', 'der Stand vom Server steht da')
// jetzt geht Speichern mit der neuen Version
await knopf(page.locator('details[data-plan]'), 'Speichern').click()
await meldung(page).filter({ hasText: 'gespeichert' }).waitFor()
const puts = fx.aufrufe.filter((a) => a[0] === 'put').map((a) => a[1].version)
eq(puts, [3, 4], 'zweites PUT mit der neuen Version')
})
// ---------------------------------------------------------------- Bestätigung bleibt sichtbar
// Die Meldung ist das erste Kind des Editors; nach dem Speichern eines unteren Abschnitts (Kontakt) springt der Fokus auf den Knopf. Ohne
// klebende Meldung stünde „gespeichert“ dann ausserhalb des Fensters.
await check('fx_meldung_bleibt_sichtbar', async (page) => {
await oeffneEditor(page)
const form = page.locator('form[aria-label="Kontakt-Leiste"]')
await form.scrollIntoViewIfNeeded()
await form.locator('input[type="tel"]').first().fill('06301 999888')
await knopf(form, 'Kontakt-Leiste speichern').click()
await meldung(page).filter({ hasText: 'Kontakt-Leiste gespeichert' }).waitFor()
await page.waitForTimeout(150)
const lage = await meldung(page).evaluate((e) => {
const r = e.getBoundingClientRect()
return { oben: r.top, unten: r.bottom, hoehe: r.height, fenster: window.innerHeight, pos: getComputedStyle(e).position, scroll: window.scrollY }
})
assert(lage.scroll > 300, `die Prüfung braucht eine gescrollte Seite (scrollY ${lage.scroll})`)
assert(lage.hoehe > 0 && lage.oben >= -0.5 && lage.unten <= lage.fenster + 0.5, `Meldung liegt ausserhalb des Fensters: ${JSON.stringify(lage)}`)
eq(lage.pos, 'sticky', 'Meldung klebt unter der Kopfleiste')
// leer klebt sie nicht (kein leerer Streifen)
await meldung(page).evaluate((e) => { e.textContent = '' })
eq(await meldung(page).evaluate((e) => getComputedStyle(e).position), 'static', 'leere Meldung')
await shot(page, 'meldung-sichtbar')
}, { context: { viewport: { width: 1280, height: 600 } } })
// ---------------------------------------------------------------- Urlaub ⇒ Hinweis-Vorschlag, „Hinweis-Leiste anlegen“
await check('fx_hinweis_vorschlag', async (page, _ctx, fx) => {
await oeffneEditor(page)
const von = tagPlus(HEUTE, 20)
const bis = tagPlus(HEUTE, 25)
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Betriebsurlaub eintragen' }).click()
const form = page.getByRole('form', { name: 'Betriebsurlaub eintragen' })
await form.locator('input[type="date"]').nth(0).fill(von)
await form.locator('input[type="date"]').nth(1).fill(bis)
await form.locator('input[type="text"]').fill('Sommerpause')
await form.getByRole('button', { name: 'Betriebsurlaub eintragen' }).click()
const frage = page.getByRole('form', { name: 'Hinweis-Leiste dazu anlegen?' })
await frage.waitFor()
const kurz = (t) => `${t.slice(8)}.${t.slice(5, 7)}.`
assert((await frage.innerText()).includes(`Betriebsurlaub vom ${kurz(von)} bis ${kurz(bis)}`), 'Vorschlag mit Zeitraum')
await shot(page, 'hinweis-vorschlag')
await knopf(frage, 'Ja, anlegen').click()
const liste = abschnitt(page, 'hinweise')
await liste.locator('li', { hasText: `Betriebsurlaub vom ${kurz(von)} bis ${kurz(bis)}` }).waitFor()
const hinweise = fx.aufrufe.filter((a) => a[0] === 'put').at(-1)[1].daten.hinweise
eq(hinweise.map((h) => [h.art, h.knopf, h.von, h.bis]), [['urlaub', null, tagPlus(von, -7), bis]], 'Hinweis im zweiten PUT')
// „Hinweis-Leiste anlegen“: der Knopf, den die Relaunch-Station antippt
const anlegen = page.getByRole('button', { name: 'Hinweis-Leiste anlegen', exact: true })
assert(await anlegen.isVisible(), 'Knopf „Hinweis-Leiste anlegen“ ist sichtbar')
await anlegen.click()
const neu = page.getByRole('form', { name: 'Neuer Hinweis' })
await neu.waitFor()
await neu.locator('input[type="text"]').first().fill('Heute 20 % auf alle Torten')
await neu.locator('[data-hinweis="preis"]').waitFor({ state: 'visible' })
await shot(page, 'hinweis-formular')
await neu.locator('select').first().selectOption('aktion')
await knopf(neu, 'Hinweis speichern').click()
await neu.locator('[data-fehler$=".bis"]', { hasText: 'Eine Aktion braucht ein Enddatum.' }).waitFor()
await neu.locator('input[type="date"]').nth(1).fill(tagPlus(HEUTE, 3))
await knopf(neu, 'Hinweis speichern').click()
await liste.locator('li', { hasText: 'Heute 20 % auf alle Torten' }).waitFor()
eq(fx.aufrufe.filter((a) => a[0] === 'put').length, 3, 'drei Speichervorgänge')
})
// ---------------------------------------------------------------- Handy 390 px
await check('fx_handy_390', async (page, _ctx, fx) => {
fx.daten = gespeicherteDaten({
hinweise: [{ id: 'h_aktion001', art: 'aktion', text: '20 % auf alle Torten – nur diese Woche, solange der Vorrat reicht, bei Abholung im Laden', knopf: { art: 'link', ziel: 'https://baeckerei.example/torten/aktion/herbst', text: 'Mehr erfahren' }, von: HEUTE, bis: tagPlus(HEUTE, 4) }],
})
await oeffneEditor(page)
await page.locator('details[data-plan] summary').first().scrollIntoViewIfNeeded()
// alles aufklappen, was aufklappt: Hinweis-Formular, WhatsApp-Felder, Regler
await abschnitt(page, 'hinweise').locator('li[data-id="h_aktion001"] [data-aktion="bearbeiten"]').click()
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Betriebsurlaub eintragen' }).click()
await page.locator('[data-regler="zeit"]').fill('19:30')
await page.locator('[data-vorschau="test"]', { hasText: 'Geschlossen' }).waitFor()
assert(await passtInBreite(page), 'waagerechter Streifen')
// nichts im Editor ragt über den rechten Rand hinaus
const ueberstehend = await page.evaluate(() => {
const breite = window.innerWidth
return [...document.querySelectorAll('#zeiten-body *')].filter((el) => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.height > 0 && (r.right > breite + 0.5 || r.left < -0.5)
}).slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${el.className}`)
})
eq(ueberstehend, [], 'nichts ragt über den Rand')
// Tippflächen mindestens 44 px (Knöpfe, Auswahllisten, Eingabefelder; Haken über ihre Beschriftungszeile)
const zuKlein = await page.evaluate(() => [...document.querySelectorAll('#zeiten-body button, #zeiten-body select, #zeiten-body input:not([type="checkbox"]), #zeiten-body summary, #zeiten-body a[href]')]
.filter((el) => el.getClientRects().length && !el.matches(':disabled'))
.map((el) => ({ el, h: el.getBoundingClientRect().height }))
.filter(({ h }) => h < 43.5)
.slice(0, 6)
.map(({ el, h }) => `${el.tagName.toLowerCase()}[${el.getAttribute('data-aktion') || el.getAttribute('data-feld') || el.className}] ${Math.round(h)}px`))
eq(zuKlein, [], 'Tippflächen ≥ 44 px')
await shot(page, 'handy-editor')
// Kontakt und Google unten
await abschnitt(page, 'kontakt').scrollIntoViewIfNeeded()
await shot(page, 'handy-kontakt')
assert(await passtInBreite(page), 'waagerechter Streifen (unten)')
}, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } })
// ---------------------------------------------------------------- Tastatur
await check('fx_tastatur', async (page, _ctx, fx) => {
fx.daten = gespeicherteDaten({
hinweise: [{ id: 'h_aktion001', art: 'aktion', text: '20 % auf alle Torten', knopf: { art: 'link', ziel: 'https://baeckerei.example/torten', text: 'Mehr erfahren' }, von: HEUTE, bis: tagPlus(HEUTE, 4) }],
})
await oeffneEditor(page)
await abschnitt(page, 'hinweise').getByRole('button', { name: 'Hinweis-Leiste anlegen' }).click()
await abschnitt(page, 'ausnahmen').getByRole('button', { name: '+ Sondertag eintragen' }).click()
await page.locator('details[data-plan] summary').first().scrollIntoViewIfNeeded()
// alles, was sichtbar und bedienbar ist, bekommt eine Nummer
const erwartet = await page.evaluate(() => {
const fokus = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])'
const sichtbar = (el) => !el.matches(':disabled') && el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden'
const liste = [...document.querySelectorAll(`#zeiten-body ${fokus.split(', ').join(', #zeiten-body ')}`)].filter(sichtbar)
liste.forEach((el, i) => el.setAttribute('data-tabidx', String(i)))
liste[0].focus()
return liste.map((el) => `${el.tagName.toLowerCase()}:${el.getAttribute('data-aktion') || el.getAttribute('data-feld') || el.getAttribute('aria-label') || el.id || el.className}`)
})
assert(erwartet.length > 40, `genug Bedienelemente (${erwartet.length})`)
// Zeit- und Datumsfelder haben in Chromium mehrere Tab-Stopps (Stunde, Minute, Tag …): bleibt der Fokus im selben Element, zählt es nicht als nächstes
const erreicht = new Set([0])
const ohneFokusring = []
let aktuell = 0
for (let versuch = 0; aktuell < erwartet.length - 1; versuch += 1) {
if (versuch > erwartet.length * 8) throw new Error(`Tab: nach ${versuch} Tastendrücken erst bei ${aktuell + 1} von ${erwartet.length}`)
await page.keyboard.press('Tab')
const stand = await page.evaluate(() => {
const el = document.activeElement
const stil = el ? getComputedStyle(el) : null
return el && el.hasAttribute('data-tabidx')
? { idx: Number(el.getAttribute('data-tabidx')), ring: stil.outlineStyle !== 'none' && parseFloat(stil.outlineWidth) > 0, schatten: stil.boxShadow !== 'none' }
: { idx: -1 }
})
if (stand.idx < 0) throw new Error(`Tab: Fokus außerhalb des Editors (erwartet: ${erwartet[aktuell + 1]})`)
if (stand.idx === aktuell) continue
if (stand.idx !== aktuell + 1) throw new Error(`Tab: Fokus auf ${erwartet[stand.idx]} statt ${erwartet[aktuell + 1]} (Reihenfolge oder übersprungen)`)
aktuell = stand.idx
erreicht.add(aktuell)
if (!stand.ring && !stand.schatten) ohneFokusring.push(erwartet[aktuell])
}
eq([...erreicht].length, erwartet.length, 'alle Felder per Tab erreichbar')
eq(ohneFokusring, [], 'Fokus überall sichtbar')
await shot(page, 'tastatur-ende')
})
}
// ---------------------------------------------------------------- Lauf
let abbruch = null
try {
browser = await chromium.launch()
await pruefungen()
const fremd = [...hosts].filter((h) => h !== new URL(BASE).host)
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(() => {})
server.close()
}
const fehlgeschlagen = results.filter(([, ok]) => !ok)
console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`)
process.exit(abbruch || fehlgeschlagen.length ? 1 : 0)