Hinweis-Leiste (Liste, „Hinweis-Leiste anlegen“, Art, Knopf, Zeitraum, Preis-Hinweis), Kontakt-Leiste (Telefon, Adresse, WhatsApp mit Datenschutz-Haken, R12 in der Kundenansicht), Google (Für Google kopieren, Profil-Link), Live-Vorschau mit Regler „Uhrzeit testen“ und UI-Prüfung test/ui/zeiten-portal.ui.mjs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
503 lines
30 KiB
JavaScript
503 lines
30 KiB
JavaScript
// 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')
|
||
})
|
||
|
||
// ---------------------------------------------------------------- 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)
|