// 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-.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 = `` const STATUS = '

Status: Ersatz-Status

' const lang = (n = 40) => Array.from({ length: n }, (_, i) => `

Absatz ${i + 1}: Brot, Brötchen und Kuchen aus eigener Backstube.

`).join('\n') /** Testseite: kopf = -Zusatz, stil = CSS, anfang = Anfang von (z. B. Kopfzeile), inhalt =
. */ function seitenHtml({ kopf = '', stil = '', anfang = '', inhalt = `${STATUS}${lang()}

Ende der Seite

`, skript = SKRIPT } = {}) { return ` Bäckerei Sonnenschein ${kopf} ${anfang}
${inhalt}
${skript}` } const KOPF_FEST = '
Bäckerei Sonnenschein
' const KOPF_KLEBT = '
Bäckerei Sonnenschein
' 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: `
${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 = '
Bäckerei Sonnenschein
' + '' 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: '', }) 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 = '' 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 = '
06301 123456
' 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: '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('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 = `` 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: `` }))) 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: `` }))) 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: ``, inhalt: `
Öffnungszeiten
Montag09:00–18:00
${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: `
${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: `
${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: `
Ersatz-Status
${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: `
Ersatz-Status
${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: `
${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)