Files
Webklar-Kundenbereich/test/ui/zeiten-embed.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

602 lines
39 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 des Website-Skripts public/embed/zeiten.js (Oeffnungszeiten live, Aufgabe 26: Hinweis-Leiste, Kontakt-Leiste,
// JSON-LD, Platzhalter) gegen den UI-Testserver (test/ui/zeitenTestServer.mjs): echte oeffentliche Schnittstelle, Nachbau von Appwrite.
// Nicht Teil von npm test.
// Aufruf: free -m && nice node test/ui/zeiten-embed.ui.mjs (es laeuft nur ein Browser)
// Screenshots: /root/reports/oeffnungszeiten-ui/embed-*.png (bei Fehlern fehler-embed-<Pruefung>.png). Exit-Code 1, wenn eine Pruefung scheitert.
//
// Seiten: Sie laufen auf srv.pageOrigin (http://localhost:PORT) und sprechen die Schnittstelle unter srv.url (http://127.0.0.1:PORT)
// an: echte Cross-Origin-Anfragen wie auf Kundenseiten. Server-Uhr: Montag 2026-10-05 10:00 Berliner Zeit (fest, srv.setzeUhr);
// Pruefungen mit Zeitsprung installieren dieselbe Zeit im Browser (context.clock.install) und spulen mit fastForward vor.
import { mkdir, readdir, rm } from 'node:fs/promises'
import path from 'node:path'
import { startZeitenUiServer, zeitenEingabe, hinweis, FIRMA, ADRESSE } from './zeitenTestServer.mjs'
const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
const { chromium } = await import(PW)
const SHOTS = '/root/reports/oeffnungszeiten-ui'
await mkdir(SHOTS, { recursive: true })
for (const datei of await readdir(SHOTS)) if (/^(embed|fehler-embed)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe
const srv = await startZeitenUiServer()
const KEY = srv.SITE_KEY
const API = `${srv.url}/api/public/zeiten/v1/${KEY}`
// ---------------------------------------------------------------- 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 nahe = (a, b, tol = 1) => Math.abs(a - b) <= tol
const fmt = (z) => (typeof z === 'number' ? z.toFixed(1) : String(z))
const SKRIPT = `<script src="/embed/zeiten.js" data-site="${KEY}" data-api="${srv.url}" defer></script>`
const STATUS = '<p>Status: <span id="status" data-webklar-zeiten data-modus="status">Ersatz-Status</span></p>'
const lang = (n = 40) => Array.from({ length: n }, (_, i) => `<p>Absatz ${i + 1}: Brot, Brötchen und Kuchen aus eigener Backstube.</p>`).join('\n')
/** Testseite: kopf = <head>-Zusatz, stil = CSS, anfang = Anfang von <body> (z. B. Kopfzeile), inhalt = <main>. */
function seitenHtml({ kopf = '', stil = '', anfang = '', inhalt = `${STATUS}${lang()}<p id="ende">Ende der Seite</p>`, skript = SKRIPT } = {}) {
return `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Bäckerei Sonnenschein</title>
<style>body{margin:0;font-family:system-ui,sans-serif;background:#faf7f2;color:#1f2937}main{max-width:760px;margin:0 auto;padding:16px}${stil}</style>${kopf}</head>
<body>${anfang}<main>${inhalt}</main>${skript}</body></html>`
}
const KOPF_FEST = '<header id="kopf" style="position:fixed;top:0;left:0;right:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
const KOPF_KLEBT = '<header id="kopf" style="position:sticky;top:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
const HOST = (art) => `[data-wkz-leiste="${art}"]`
const anzahl = (page, sel) => page.locator(sel).count()
const hoehe = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().height, sel)
const oben = (page, sel) => page.evaluate((s) => document.querySelector(s).getBoundingClientRect().top, sel)
const scrolle = (page, y) => page.evaluate((v) => window.scrollTo(0, v), y)
const shot = async (page, name) => {
await page.waitForTimeout(450) // die Einblendung der Leiste (0,3 s) ist fertig
await page.screenshot({ path: `${SHOTS}/embed-${name}.png` })
}
/** Wartet, bis das Skript die Platzhalter gefuellt hat (und damit in derselben Runde die Leisten gezeichnet). */
const warteBereit = (page) => page.waitForFunction(() => { const s = document.getElementById('status'); return s && !s.textContent.includes('Ersatz') })
/** Wartet auf die Antwort der Schnittstelle und ein paar Bilder danach (fuer Seiten, auf denen nichts gefuellt wird). */
async function warteAufSchnittstelle(page, aktion) {
const antwort = page.waitForResponse((r) => r.url().includes('/api/public/zeiten/v1/'))
await aktion()
await antwort
await page.waitForTimeout(400)
}
const STATUS_OFFEN = 'Jetzt geöffnet · schließt um 18 Uhr'
const STATUS_ZU = 'Geschlossen · öffnet morgen um 9 Uhr'
const statusText = (page) => page.locator('#status').innerText()
const results = []
let browser = null
const hosts = new Set()
async function check(name, fn, { eingabe, team, viewport = { width: 1280, height: 800 }, uhr, context = {}, initScript } = {}) {
await srv.zuruecksetzen({ eingabe, team })
if (uhr) srv.setzeUhr(uhr)
const ctx = await browser.newContext({ viewport, ...context })
if (uhr) await ctx.clock.install({ time: new Date(uhr) })
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const fehler = []
page.on('pageerror', (e) => fehler.push(e.message))
page.on('request', (r) => { try { const u = new URL(r.url()); if (u.protocol.startsWith('http')) hosts.add(u.host) } catch { /* data: u. a. */ } })
if (initScript) await page.addInitScript(initScript)
try {
await fn(page, ctx)
assert(fehler.length === 0, `JavaScript-Fehler auf der Seite: ${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]}`)
await page.screenshot({ path: `${SHOTS}/fehler-embed-${name}.png`, fullPage: true }).catch(() => {})
} finally {
await ctx.close()
}
}
const INFO = hinweis('info', 'Heute frische Brezeln aus dem Ofen.')
const HANDY = { width: 390, height: 844 }
// ---------------------------------------------------------------- Pruefungen
async function pruefungen() {
await check('hinweisleiste_zeigt_gueltigen_hinweis', async (page) => {
await page.goto(srv.seite('hinweis.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('hinweis'))
await host.waitFor({ state: 'attached' })
const region = page.getByRole('region', { name: 'Hinweis' })
assert(await region.isVisible(), 'Region "Hinweis" nicht sichtbar')
assert((await region.innerText()).includes('Heute frische Brezeln aus dem Ofen.'), 'Text fehlt')
eq(await region.locator('button, [role=button]').count(), 0, 'Schliessen-Knopf vorhanden')
assert(!(await region.innerText()).includes('Laden schließt'), 'der Hinweis von morgen gilt noch nicht')
const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
eq(farben, ['rgb(224, 242, 254)', 'rgb(12, 74, 110)'], 'Farben der Art Info')
// erstes Kind von body, im Fluss (schiebt den Inhalt)
assert(await page.evaluate(() => document.body.firstElementChild.hasAttribute('data-wkz-leiste')), 'Host ist nicht das erste Kind von body')
assert(await page.evaluate(() => ['static', 'relative'].includes(getComputedStyle(document.querySelector('[data-wkz-leiste="hinweis"]')).position)), 'Host muss im Fluss stehen')
const h = await hoehe(page, HOST('hinweis'))
assert(h > 20, `Hoehe der Leiste ${fmt(h)}`)
assert(nahe(await oben(page, 'main'), h, 1.5), `main steht bei ${fmt(await oben(page, 'main'))} statt unter der Leiste (${fmt(h)})`)
await shot(page, 'hinweis-desktop')
}, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Nur heute: Laden schließt um 14 Uhr.', { von: '2026-10-06', bis: '2026-10-06' })] }) })
await check('hinweis_wichtig_mit_knopf', async (page) => {
await page.goto(srv.seite('hinweis-wichtig.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.innerText()).includes('Heute Betriebsfeier'), 'der Hinweis mit dem hoechsten Rang gewinnt')
const farben = await region.evaluate((e) => { const c = getComputedStyle(e); return [c.backgroundColor, c.color] })
eq(farben, ['rgb(185, 28, 28)', 'rgb(255, 255, 255)'], 'Farben der Art Wichtig')
const knopf = region.getByRole('link', { name: 'Zur Aktion' })
eq(await knopf.getAttribute('href'), 'https://baeckerei.example/aktion?x=1', 'href')
eq(await knopf.getAttribute('rel'), 'noopener noreferrer', 'rel')
const box = await knopf.boundingBox()
assert(box.height >= 44 && box.width >= 44, `Tippflaeche ${box.width}x${box.height}`)
// sichtbarer Fokus
await knopf.focus()
const fokus = await knopf.evaluate((e) => getComputedStyle(e).outlineStyle)
assert(fokus !== 'none', 'kein sichtbarer Fokus')
await shot(page, 'hinweis-wichtig')
}, { eingabe: zeitenEingabe({ hinweise: [INFO, hinweis('wichtig', 'Heute Betriebsfeier: wir schließen um 14 Uhr.', { knopf: { art: 'link', ziel: 'https://baeckerei.example/aktion?x=1', text: 'Zur Aktion' } })] }) })
await check('hinweisleiste_aus', async (page) => {
await page.goto(srv.seite('hinweis-aus.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page) // das Skript hat gelaufen und die Platzhalter gefuellt ...
eq(await anzahl(page, HOST('hinweis')), 0, 'Leiste trotz Team-Schalter')
// ... und nichts an der Seite veraendert
eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Kopf angefasst')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top angefasst')
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nicht bei 0')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { hinweisleisteAus: true } })
await check('fester_header_rueckt', async (page) => {
await page.goto(srv.seite('fest.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `<div style="height:60px"></div>${STATUS}${lang()}` })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), `Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), 'important', 'Prioritaet des Inline-top')
// scroll-padding-top um die Hoehe erhoeht
const sp = await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0)
assert(nahe(sp, h, 1), `scroll-padding-top ${fmt(sp)} statt ${fmt(h)}`)
// halb gescrollt: der Kopf folgt dem unteren Rand der Leiste
const halb = Math.round(h / 2)
await scrolle(page, halb)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
// weiter gescrollt: wieder bei 0
await scrolle(page, 400)
await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
// zurueck nach oben: wieder unter der Leiste
await scrolle(page, 0)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
await shot(page, 'fester-header')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('fester_header_mit_polster_bleibt', async (page) => {
// Die Seite haelt Platz fuer ihren festen Kopf per body-Polster frei: die Leiste steht erst darunter, der Kopf muss bleiben, wo er ist
await page.goto(srv.seite('polster.html', seitenHtml({ anfang: KOPF_FEST, stil: 'body{padding-top:60px}' })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
assert(nahe(await oben(page, HOST('hinweis')), 60, 1), `Leiste steht bei ${fmt(await oben(page, HOST('hinweis')))} statt unter dem Polster`)
assert(nahe(await oben(page, '#kopf'), 0, 0.5), `der Kopf wurde gerueckt (${fmt(await oben(page, '#kopf'))})`)
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Inline-top des Kopfes angefasst')
await shot(page, 'fester-header-polster')
await scrolle(page, 200)
await page.waitForTimeout(100)
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf nach dem Scrollen nicht bei 0')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('klebender_header', async (page) => {
await page.goto(srv.seite('klebt.html', seitenHtml({ anfang: KOPF_KLEBT })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), `klebender Kopf steht bei ${fmt(await oben(page, '#kopf'))}, die Leiste ist ${fmt(h)} hoch`)
const halb = Math.round(h / 2)
await scrolle(page, halb)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h - halb])
await scrolle(page, 500)
await page.waitForFunction(() => Math.abs(document.getElementById('kopf').getBoundingClientRect().top) < 0.5)
await scrolle(page, 0)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('fester_header_versteckt_beim_scrollen', async (page) => {
// Kopf, der per Klasse beim Scrollen wegfaehrt (top: -100px), mit dem eigenen Scroll-Skript der Seite. Solange die Hinweis-Leiste sichtbar ist,
// rueckt das Skript ihn; ist sie weggescrollt, muss die Klasse der Seite wieder gelten (kein dauerhaftes top: 0 !important).
const kopf = '<header id="kopf" style="position:fixed;left:0;right:0;height:60px;background:#222;color:#fff;z-index:100;padding:0 16px;line-height:60px">Bäckerei Sonnenschein</header>'
+ '<script>addEventListener("scroll",function(){document.getElementById("kopf").classList.toggle("weg",scrollY>300)},{passive:true})</script>'
await page.goto(srv.seite('kopf-weg.html', seitenHtml({ anfang: kopf, stil: '#kopf{top:0}#kopf.weg{top:-100px}' })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), `Kopf steht bei ${fmt(await oben(page, '#kopf'))} statt unter der Leiste (${fmt(h)})`)
// weit gescrollt: die Seite blendet den Kopf aus, das Skript haelt ihn nicht fest
await scrolle(page, 700)
await page.waitForFunction(() => document.getElementById('kopf').getBoundingClientRect().top < -99)
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyValue('top')), '', 'Inline-top des Kopfes nach dem Wegscrollen der Leiste')
// zurueck nach oben: die Klasse faellt ab, der Kopf steht wieder unter der Leiste
await scrolle(page, 0)
await page.waitForFunction(([erwartet]) => Math.abs(document.getElementById('kopf').getBoundingClientRect().top - erwartet) < 1, [h])
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('team_navselektor', async (page) => {
// der Schwebende hat top: 10px (nicht top: 0): nur der Selektor des Teams findet ihn
const seite = seitenHtml({
stil: '.schweber{position:fixed;top:10px;left:16px;z-index:100;background:#222;color:#fff;padding:8px 12px;border-radius:8px}',
anfang: '<nav class="schweber">Menü</nav>',
})
await page.goto(srv.seite('nav.html', seite))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '.schweber'), 10 + h, 1), `Schweber steht bei ${fmt(await oben(page, '.schweber'))} statt ${fmt(10 + h)}`)
await scrolle(page, 400)
await page.waitForFunction(() => Math.abs(document.querySelector('.schweber').getBoundingClientRect().top - 10) < 0.5)
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '.schweber' } })
await check('ungueltiger_selektor', async (page) => {
await page.goto(srv.seite('selektor.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leiste ist trotzdem da
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), 'der feste Kopf wird auch ohne gueltigen Selektor erkannt und gerueckt')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), team: { navSelektor: '[[kaputt' } })
await check('hinweis_endet_und_stellt_wieder_her', async (page, ctx) => {
await page.goto(srv.seite('ende.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
const h = await hoehe(page, HOST('hinweis'))
assert(nahe(await oben(page, '#kopf'), h, 1), 'Kopf unter der Leiste')
// 23:55 Berlin: der Hinweis gilt bis 24:00 und verschwindet dann von selbst
await ctx.clock.fastForward(6 * 60 * 1000)
await page.locator(HOST('hinweis')).waitFor({ state: 'detached' })
eq(await page.evaluate(() => document.getElementById('kopf').style.top), '0px', 'Inline-top des Kopfes nicht zurueckgesetzt')
eq(await page.evaluate(() => document.getElementById('kopf').style.getPropertyPriority('top')), '', 'Prioritaet nicht zurueckgesetzt')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingTop), '', 'scroll-padding-top nicht zurueckgesetzt')
assert(nahe(await oben(page, '#kopf'), 0, 0.5), 'Kopf wieder bei 0')
assert(nahe(await oben(page, 'main'), 0, 0.5), 'Inhalt rueckt wieder nach oben')
}, { eingabe: zeitenEingabe({ hinweise: [hinweis('info', 'Nur heute: Kuchen zum halben Preis.', { von: '2026-10-05', bis: '2026-10-05' })] }), uhr: '2026-10-05T21:55:00.000Z' })
await check('kontaktleiste_nur_handy', async (page) => {
await page.goto(srv.seite('handy.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
const stil = await host.evaluate((e) => { const c = getComputedStyle(e); return [c.position, c.zIndex, c.bottom] })
eq(stil, ['fixed', '40', '0px'], 'position, z-index, bottom der Kontakt-Leiste')
const knoepfe = host.locator('a')
eq(await knoepfe.count(), 3, 'Anzahl Knoepfe')
eq(await knoepfe.allInnerTexts(), ['Anrufen', 'Route', 'WhatsApp'], 'Beschriftungen')
const hrefs = await knoepfe.evaluateAll((l) => l.map((a) => a.getAttribute('href')))
eq(hrefs[0], 'tel:+491711234567', 'tel:')
assert(hrefs[1].startsWith('https://www.google.com/maps/dir/'), `Route: ${hrefs[1]}`)
assert(hrefs[2].startsWith('https://wa.me/491711234567'), `WhatsApp: ${hrefs[2]}`)
for (const a of await knoepfe.elementHandles()) {
const b = await a.boundingBox()
assert(b.height >= 44 && b.width >= 44, `Tippflaeche ${b.width}x${b.height}`)
eq(await a.getAttribute('rel'), 'noopener noreferrer', 'rel')
}
// Abstandhalter am Ende von body in Hoehe der Leiste, scroll-padding-bottom
const h = await hoehe(page, HOST('kontakt'))
eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter')
assert(await page.evaluate(() => document.body.lastElementChild.hasAttribute('data-wkz-abstand')), 'Abstandhalter nicht am Ende von body')
assert(nahe(await hoehe(page, '[data-wkz-abstand]'), h, 1), 'Abstandhalter nicht so hoch wie die Leiste')
assert(nahe(await page.evaluate(() => parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom) || 0), h, 1), 'scroll-padding-bottom')
// ganz unten: der letzte Absatz liegt ueber der Leiste
await scrolle(page, 1e6)
await page.waitForFunction(() => window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 1)
const unten = await page.evaluate(() => document.getElementById('ende').getBoundingClientRect().bottom)
const leisteOben = await oben(page, HOST('kontakt'))
assert(unten <= leisteOben + 0.5, `der letzte Absatz endet bei ${fmt(unten)}, die Leiste beginnt bei ${fmt(leisteOben)}`)
await shot(page, 'kontakt-handy')
// auf breitem Schirm weg (und wieder da: die Abfrage wird beobachtet)
await page.setViewportSize({ width: 1280, height: 800 })
await host.waitFor({ state: 'detached' })
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter blieb stehen')
eq(await page.evaluate(() => document.documentElement.style.scrollPaddingBottom), '', 'scroll-padding-bottom nicht zurueckgesetzt')
await page.setViewportSize(HANDY)
await host.waitFor({ state: 'attached' })
eq(await anzahl(page, '[data-wkz-abstand]'), 1, 'Abstandhalter nach der Rueckkehr')
}, { viewport: HANDY })
await check('kontaktleiste_nicht_auf_dem_desktop', async (page) => {
await page.goto(srv.seite('desktop.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste auf dem Desktop')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter auf dem Desktop')
})
await check('kontaktleiste_aus_per_team', async (page) => {
await page.goto(srv.seite('kontakt-aus.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste trotz Team-Schalter')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter trotz Team-Schalter')
}, { viewport: HANDY, team: { kontaktleisteAus: true } })
await check('kontaktleiste_nur_mit_daten', async (page) => {
await page.goto(srv.seite('kontakt-nur-route.html', seitenHtml()))
await warteBereit(page)
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
eq(await host.locator('a').allInnerTexts(), ['Route'], 'nur Route (kein Telefon, kein WhatsApp)')
}, { viewport: HANDY, eingabe: zeitenEingabe({ kontakt: { an: true, telefon: '', adresse: ADRESSE } }) })
await check('kontaktleiste_ohne_daten', async (page) => {
srv.ueberschreibe((a) => ({ ...a, kontakt: null })) // der Browser hat die Antwort eines frueheren Abrufs sonst 60 Sekunden im Zwischenspeicher
await page.goto(srv.seite('kontakt-leer.html', seitenHtml()))
await warteBereit(page)
eq(await anzahl(page, HOST('kontakt')), 0, 'Leiste ohne Daten')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter ohne Daten')
}, { viewport: HANDY })
await check('eigene_anrufleiste', async (page) => {
const eigene = '<div id="eigene" style="position:fixed;bottom:0;left:0;right:0;height:56px;background:#fff;border-top:1px solid #ccc;z-index:50"><a href="tel:+496301123456" style="display:block;line-height:56px;text-align:center">Jetzt anrufen</a></div>'
await page.goto(srv.seite('eigene-leiste.html', seitenHtml({ anfang: eigene })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // das Skript hat gezeichnet ...
eq(await anzahl(page, HOST('kontakt')), 0, 'Kontakt-Leiste neben einer eigenen Anruf-Leiste')
eq(await anzahl(page, '[data-wkz-abstand]'), 0, 'Abstandhalter neben einer eigenen Anruf-Leiste')
}, { viewport: HANDY, eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('anruf_im_kopf_stoert_nicht', async (page) => {
// ein tel:-Link in einem festen Kopf OBEN ist keine Anruf-Leiste unten
const kopf = '<header style="position:fixed;top:0;left:0;right:0;height:48px;background:#222;z-index:100"><a href="tel:+496301123456" style="color:#fff;line-height:48px">06301 123456</a></header>'
await page.goto(srv.seite('anruf-kopf.html', seitenHtml({ anfang: kopf })))
await warteBereit(page)
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
}, { viewport: HANDY })
await check('unsichere_daten_werden_nicht_verwendet', async (page) => {
srv.ueberschreibe((a) => ({
...a,
hinweise: [{ art: 'wichtig', text: '<img src=x onerror="window.__x=1">Achtung', knopf: { art: 'link', ziel: 'javascript:window.__y=1', text: 'Los' }, ab: 0, bis: 8.64e15, rang: 4 }],
kontakt: { telefon: '491711234567', routeUrl: 'javascript:window.__z=1', whatsappUrl: 'http://wa.me/491711234567' },
}))
await page.goto(srv.seite('boese.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.innerText()).includes('<img src=x onerror="window.__x=1">Achtung'), 'Text nicht als Text gezeigt')
eq(await region.locator('img').count(), 0, 'img eingeschleust')
eq(await region.locator('a').count(), 0, 'javascript:-Knopf wurde ein Link')
const host = page.locator(HOST('kontakt'))
await host.waitFor({ state: 'attached' })
eq(await host.locator('a').allInnerTexts(), ['Anrufen'], 'nur der Anruf-Knopf bleibt (Route: javascript:, WhatsApp: http:)')
eq(await page.evaluate(() => [window.__x, window.__y, window.__z]), [undefined, undefined, undefined], 'Skript ausgefuehrt')
}, { viewport: HANDY })
await check('jsonld_ergaenzt', async (page) => {
const bekannt = { '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, telephone: '+49 171 1234567', address: ADRESSE, openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '08:00', closes: '10:00' }] }
const web = { '@context': 'https://schema.org', '@type': 'WebSite', name: FIRMA, url: 'https://baeckerei.example/' }
const kaputt = '{ "@type": "Bakery", kaputt'
const kopf = `<script type="application/ld+json">${JSON.stringify(web)}</script><script type="application/ld+json">${kaputt}</script><script type="application/ld+json">${JSON.stringify(bekannt)}</script>`
await page.goto(srv.seite('jsonld.html', seitenHtml({ kopf })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte.length, 3, 'Anzahl der JSON-LD-Bloecke (kein zweiter Block fuer ein vorhandenes Unternehmen)')
eq(JSON.parse(texte[0]), web, 'WebSite-Block veraendert')
eq(texte[1], kaputt, 'unparsbarer Block veraendert')
const api = await (await fetch(API)).json()
const neu = JSON.parse(texte[2])
eq(neu.openingHoursSpecification, api.jsonld.openingHoursSpecification, 'Zeiten nicht aus der Schnittstelle')
eq(neu.specialOpeningHoursSpecification ?? [], api.jsonld.specialOpeningHoursSpecification, 'Sonderzeiten')
assert(neu.openingHoursSpecification.length === 6, `Mo-Sa = 6 Eintraege (statt ${neu.openingHoursSpecification.length})`)
assert(!JSON.stringify(neu).includes('"Sunday"'), 'alter Sonntag-Eintrag blieb stehen')
eq([neu['@type'], neu.name, neu.telephone, neu.address], ['Bakery', FIRMA, '+49 171 1234567', ADRESSE], 'uebrige Felder des Blocks')
})
await check('jsonld_neuer_block', async (page) => {
const web = { '@context': 'https://schema.org', '@type': 'WebSite', name: FIRMA }
await page.goto(srv.seite('jsonld-neu.html', seitenHtml({ kopf: `<script type="application/ld+json">${JSON.stringify(web)}</script>` })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte.length, 2, 'Anzahl der Bloecke (WebSite + neuer Block)')
eq(JSON.parse(texte[0]), web, 'WebSite-Block veraendert')
const api = await (await fetch(API)).json()
const neu = JSON.parse(texte[1])
eq(neu['@type'], 'LocalBusiness', '@type')
eq(neu['@context'], api.jsonld.neuerBlock['@context'], '@context kommt vom Server')
eq([neu.name, neu.address], [FIRMA, ADRESSE], 'name/address')
eq(neu.url, `${srv.pageOrigin}/`, 'url')
eq(neu.openingHoursSpecification, api.jsonld.openingHoursSpecification, 'Zeiten')
})
await check('jsonld_ohne_zeiten_bleibt_unberuehrt', async (page) => {
srv.ueberschreibe((a) => ({ ...a, jsonld: null }))
const bekannt = { '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, openingHoursSpecification: [{ '@type': 'OpeningHoursSpecification', dayOfWeek: 'Sunday', opens: '08:00', closes: '10:00' }] }
const text = JSON.stringify(bekannt)
await page.goto(srv.seite('jsonld-null.html', seitenHtml({ kopf: `<script type="application/ld+json">${text}</script>` })))
await warteBereit(page)
const texte = await page.evaluate(() => [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent))
eq(texte, [text], 'ohne Zeiten bleibt das JSON-LD wie es ist (und es kommt kein Block dazu)')
})
await check('aktiv_false_ersatz_bleibt', async (page) => {
srv.setzeFunktion('disabled')
const ld = JSON.stringify({ '@context': 'https://schema.org', '@type': 'Bakery', name: FIRMA, openingHoursSpecification: [] })
const html = seitenHtml({
kopf: `<script type="application/ld+json">${ld}</script>`,
inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="tabelle"><table><caption>Öffnungszeiten</caption><tr><th>Montag</th><td>09:00&#8211;18:00</td></tr></table></div>${lang(5)}`,
})
await warteAufSchnittstelle(page, () => page.goto(srv.seite('aus.html', html)))
assert((await page.locator('#ersatz').innerText()).includes('09:00–18:00'), 'Ersatz-HTML ist weg')
eq(await anzahl(page, '#ersatz table'), 1, 'Ersatz-Tabelle')
eq(await anzahl(page, '#ersatz .wkz-tabelle'), 0, 'Tabelle des Skripts trotz aktiv: false')
eq(await anzahl(page, '[data-wkz-leiste]'), 0, 'Leiste trotz aktiv: false')
eq(await page.evaluate(() => document.querySelector('script[type="application/ld+json"]').textContent), ld, 'JSON-LD veraendert')
eq(await anzahl(page, 'script[type="application/ld+json"]'), 1, 'neuer JSON-LD-Block trotz aktiv: false')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('spaeter_platzhalter_wird_gefuellt', async (page) => {
// Einzelseiten-App: der Platzhalter kommt erst nach einem Seitenwechsel auf die Seite (der erste Abruf ist dann laengst durch)
const html = seitenHtml({ inhalt: `<div id="spa"></div>${lang(5)}`, skript: SKRIPT })
await page.goto(srv.seite('spa.html', html))
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // das Skript hat gelaufen, es gibt keinen Platzhalter
await page.evaluate(() => {
const d = document.createElement('div')
d.id = 'neu'
d.setAttribute('data-webklar-zeiten', '')
d.setAttribute('data-modus', 'status')
d.textContent = 'Ersatz-Status'
document.getElementById('spa').appendChild(d)
})
await page.waitForFunction(() => { const s = document.getElementById('neu'); return s && !s.textContent.includes('Ersatz') }, null, { timeout: 3000 })
assert((await page.locator('#neu').innerText()).includes('geöffnet'), `Status: ${await page.locator('#neu').innerText()}`)
// ein zweiter Seitenwechsel: der alte Knoten geht, ein neuer kommt
await page.evaluate(() => {
document.getElementById('neu').remove()
const d = document.createElement('span')
d.id = 'neu2'
d.setAttribute('data-webklar-zeiten', '')
d.setAttribute('data-modus', 'badge')
d.textContent = 'Ersatz-Badge'
document.getElementById('spa').appendChild(d)
})
await page.waitForFunction(() => { const s = document.getElementById('neu2'); return s && !s.textContent.includes('Ersatz') }, null, { timeout: 3000 })
eq(await page.locator('#neu2').innerText(), 'Geöffnet', 'Badge')
eq(await anzahl(page, 'style#wkz-style'), 1, 'Stile')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('wieder_eingehaengter_platzhalter_wird_neu_gezeichnet', async (page) => {
await page.goto(srv.seite('spa-zurueck.html', seitenHtml({ inhalt: `<div id="spa"></div>${STATUS}${lang(5)}` })))
await warteBereit(page)
// der Knoten verlaesst die Seite, ein anderer Platzhalter loest ein Zeichnen aus (der alte wird dabei vergessen) ...
await page.evaluate(() => { window.__alt = document.getElementById('status'); window.__alt.remove() })
await page.evaluate(() => {
const d = document.createElement('div')
d.id = 'anderer'
d.setAttribute('data-webklar-zeiten', '')
d.setAttribute('data-modus', 'status')
d.textContent = 'Ersatz-Status'
document.getElementById('spa').appendChild(d)
})
await page.waitForFunction(() => { const s = document.getElementById('anderer'); return s && !s.textContent.includes('Ersatz') }, null, { timeout: 3000 })
// ... und kommt zurueck, mit dem Inhalt, den die App ihm gegeben hat
await page.evaluate(() => { window.__alt.textContent = 'Ersatz-Status'; document.getElementById('spa').appendChild(window.__alt) })
await page.waitForFunction(() => !document.getElementById('status').textContent.includes('Ersatz'), null, { timeout: 3000 })
assert((await statusText(page)).includes('geöffnet'), `Status: ${await statusText(page)}`)
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('unbekannter_plan', async (page) => {
const meldungen = []
page.on('console', (m) => { if (m.type() === 'warning') meldungen.push(m.text()) })
const html = seitenHtml({ inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="status" data-plan="gibtsnicht">Ersatz-Status</div>${lang(5)}` })
await page.goto(srv.seite('plan.html', html))
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' }) // die Leisten haengen nicht am Plan
eq(await page.locator('#ersatz').innerText(), 'Ersatz-Status', 'Ersatz-HTML bei unbekanntem Plan')
assert(meldungen.some((m) => m.includes('gibtsnicht')), `Konsole-Warnung fehlt: ${JSON.stringify(meldungen)}`)
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('kein_storage', async (page) => {
await page.goto(srv.seite('storage.html', seitenHtml({ anfang: KOPF_FEST })))
await warteBereit(page)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
const s = await page.evaluate(async () => ({ c: document.cookie, l: localStorage.length, s: sessionStorage.length, db: (await indexedDB.databases()).length }))
eq(s, { c: '', l: 0, s: 0, db: 0 }, 'Cookies/Storage')
eq(await page.context().cookies(), [], 'Cookies im Browser')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), viewport: HANDY })
await check('doppelt_geladen', async (page) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/zeiten/v1/')) anfragen.push(r.url()) })
await page.goto(srv.seite('doppelt.html', seitenHtml({ skript: `${SKRIPT}${SKRIPT}` })))
await warteBereit(page)
await page.waitForTimeout(300)
eq(await anzahl(page, HOST('hinweis')), 1, 'Hinweis-Leisten')
eq(await anzahl(page, 'style#wkz-style'), 1, 'Stile')
eq(anfragen.length, 1, 'Abrufe der Schnittstelle')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }) })
await check('reduzierte_bewegung', async (page) => {
await page.goto(srv.seite('ruhig.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
eq(await region.evaluate((e) => getComputedStyle(e).animationName), 'none', 'Animation trotz prefers-reduced-motion')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), context: { reducedMotion: 'reduce' } })
await check('normale_bewegung_hat_einblendung', async (page) => {
await page.goto(srv.seite('bewegt.html', seitenHtml()))
await warteBereit(page)
const region = page.getByRole('region', { name: 'Hinweis' })
await region.waitFor()
assert((await region.evaluate((e) => getComputedStyle(e).animationName)) !== 'none', 'ohne Praeferenz soll die Leiste einblenden')
}, { eingabe: zeitenEingabe({ hinweise: [INFO] }), context: { reducedMotion: 'no-preference' } })
await check('status_wechselt_um_18_uhr', async (page, ctx) => {
await page.goto(srv.seite('status.html', seitenHtml()))
await warteBereit(page)
eq(await statusText(page), STATUS_OFFEN, 'Status um 17:55')
await ctx.clock.fastForward(6 * 60 * 1000) // 18:01
await page.waitForFunction((text) => document.getElementById('status').textContent.trim() === text, STATUS_ZU)
eq(await statusText(page), STATUS_ZU, 'Status nach 18 Uhr')
}, { uhr: '2026-10-05T15:55:00.000Z' })
await check('neu_laden_nach_zehn_minuten', async (page, ctx) => {
const anfragen = []
page.on('request', (r) => { if (r.url().includes('/api/public/zeiten/v1/')) anfragen.push(r.url()) })
await page.goto(srv.seite('neu-laden.html', seitenHtml()))
await warteBereit(page)
eq(anfragen.length, 1, 'ein Abruf beim Laden')
eq(await anzahl(page, HOST('hinweis')), 0, 'noch kein Hinweis')
// das Team stellt einen Hinweis ein; die Server-Uhr steht auf dem neuen Zeitpunkt
await srv.speichere(zeitenEingabe({ hinweise: [INFO] }))
srv.setzeUhr('2026-10-05T16:06:00.000Z')
await ctx.clock.fastForward(11 * 60 * 1000)
await page.locator(HOST('hinweis')).waitFor({ state: 'attached' })
assert(anfragen.length === 2, `zweiter Abruf nach 10 Minuten (Abrufe: ${anfragen.length})`)
}, { uhr: '2026-10-05T15:55:00.000Z' })
await check('fetch_wirft_sofort', async (page) => {
const html = seitenHtml({ inhalt: `<div id="ersatz" data-webklar-zeiten data-modus="status">Ersatz-Status</div>${lang(3)}` })
await page.goto(srv.seite('fetch-wirft.html', html))
await page.waitForTimeout(500)
eq(await page.locator('#ersatz').innerText(), 'Ersatz-Status', 'Ersatz-HTML')
eq(await anzahl(page, '[data-wkz-leiste]'), 0, 'Leiste ohne Daten')
}, { initScript: () => { window.fetch = () => { throw new TypeError('fetch kaputt') } } })
await check('screenshots_handy', async (page) => {
await page.goto(srv.seite('shot.html', seitenHtml({ anfang: KOPF_FEST, inhalt: `<div style="height:60px"></div>${STATUS}${lang(12)}` })))
await warteBereit(page)
await page.locator(HOST('kontakt')).waitFor({ state: 'attached' })
await page.waitForTimeout(400)
await shot(page, 'handy-hinweis-und-kontakt')
}, { viewport: HANDY, eingabe: zeitenEingabe({ hinweise: [hinweis('urlaub', 'Betriebsurlaub vom 12. bis 16. Oktober – ab dem 17. sind wir wieder für Euch da.', { knopf: { art: 'anruf', ziel: '06301 1234', text: 'Anrufen' } })] }) })
}
browser = await chromium.launch()
try {
await pruefungen()
} finally {
await browser.close()
await srv.stop()
}
// Nur der Testserver (und kein Dritter) wurde angefragt
const erlaubt = new Set([new URL(srv.url).host, new URL(srv.pageOrigin).host])
const fremd = [...hosts].filter((h) => !erlaubt.has(h))
if (fremd.length) {
results.push(['kein_fremder_host', false])
console.log(`FAIL kein_fremder_host: ${fremd.join(', ')}`)
} else {
results.push(['kein_fremder_host', true])
console.log('ok kein_fremder_host')
}
const failed = results.filter(([, ok]) => !ok)
console.log(`${results.length - failed.length}/${results.length} Pruefungen bestanden`)
process.exit(failed.length ? 1 : 0)