// 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-.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= 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)