Ö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>
This commit is contained in:
1 parent
ecf67d6978
commit
aa9ee35ab6
6 files changed
+221
-12
No files matched your search
+69
-9
@@ -49,6 +49,8 @@
|
||||
var RELOAD_MS = 600000 // hoechstens alle 10 Minuten ein Abruf
|
||||
var ALTER_MAX_MS = 24 * 60 * 60 * 1000 // aeltere Daten gelten nicht mehr
|
||||
var ABRUF_TIMEOUT_MS = 15000
|
||||
var BEOBACHTER_MS = 200 // Entprellung: nach Aenderungen der Seite hoechstens alle 200 ms nach neuen Platzhaltern sehen
|
||||
var GETRENNT_MAX = 20
|
||||
|
||||
// @rein-anfang (reine Funktionen: kein Zugriff auf Seite oder Uhr; test/zeitenEmbedStatic.test.js fuehrt sie einzeln in einem leeren Kontext aus)
|
||||
var UHR_TOLERANZ_MS = 5 * 60 * 1000 // groessere Abweichung der Uhr laut Date-Kopf: Serverzeit
|
||||
@@ -221,11 +223,12 @@
|
||||
'Store BikeStore BookStore ClothingStore ComputerStore ConvenienceStore DepartmentStore ElectronicsStore Florist FurnitureStore GardenStore GroceryStore HardwareStore HobbyShop HomeGoodsStore JewelryStore LiquorStore MensClothingStore MobilePhoneStore MovieRentalStore MusicStore OfficeEquipmentStore OutletStore PawnShop PetStore ShoeStore SportingGoodsStore TireShop ToyStore WholesaleStore ' +
|
||||
'TelevisionStation TouristInformationCenter TravelAgency '
|
||||
|
||||
// Eine Adresse aus der Schnittstelle: nur https mit Host, ohne Leer- und Steuerzeichen (sonst ''). Es steht keine feste Webadresse im Skript,
|
||||
// nur diese Pruefung des Schemas.
|
||||
// Eine Adresse aus der Schnittstelle: nur https mit Host, ohne Leer- und Steuerzeichen und ohne Zugangsdaten (sonst ''; wie der Server und
|
||||
// linkOk des Portals). Ein @ vor dem ersten /, ?, # oder \ gehoert zu Benutzer und Kennwort (die Rueckwaertsschraege zaehlt wie / im Browser).
|
||||
// Es steht keine feste Webadresse im Skript, nur diese Pruefung des Schemas.
|
||||
function sichererLink (url) {
|
||||
if (typeof url !== 'string' || url.length > 2000 || /[\s\u0000-\u001f\u007f]/.test(url)) return ''
|
||||
return /^https:\/\/[^\/?#]+/i.test(url) ? url : ''
|
||||
return /^https:\/\/[^\/?#\\@]+(?:[\/?#]|$)/i.test(url) ? url : ''
|
||||
}
|
||||
|
||||
// Knopf eines Hinweises { art: 'link'|'anruf', ziel, text } als { art, href, text } oder null. Links: nur https; Anruf: tel: nur ueber telefonLink.
|
||||
@@ -336,6 +339,10 @@
|
||||
}
|
||||
return erg
|
||||
}
|
||||
|
||||
// JSON-LD als Text fuer ein <script type="application/ld+json">: ohne "<" (\u003c), damit ein </script> im Firmennamen oder in einem schon
|
||||
// vorhandenen Block den Block nie beendet, wenn die Seite serialisiert wird (Suchroboter, "Seite speichern"). JSON.parse liest es zurueck.
|
||||
function jsonLdText (wert) { return JSON.stringify(wert).replace(/</g, '\\u003c') }
|
||||
// @rein-ende
|
||||
|
||||
// @telefon-anfang (reine Funktion, test/zeitenEmbedStatic.test.js fuehrt sie einzeln aus; dieselbe Regel wie in public/embed/termine.js; fuer Kontakt-Leiste und Hinweis-Knoepfe)
|
||||
@@ -385,6 +392,7 @@
|
||||
var laeuft = false
|
||||
var timerId = 0
|
||||
var platzhalter = []
|
||||
var getrennt = [] // Platzhalter, die gerade nicht mehr auf der Seite stehen (hoechstens GETRENNT_MAX): kommt derselbe Knoten zurueck, gilt er wieder
|
||||
var siteKey = ''
|
||||
var api = ''
|
||||
var HANDY_MQ = '(max-width: 768px)' // Kontakt-Leiste nur auf schmalen Schirmen
|
||||
@@ -632,9 +640,31 @@
|
||||
|
||||
function fuelleSicher (p, d, t) { sicher(function () { fuelle(p, d, t) }) }
|
||||
|
||||
// Entfernte Platzhalter (Seitenwechsel einer Einzelseiten-App) wandern in `getrennt`; ein wieder eingehaengter Knoten kommt mit seinem
|
||||
// Ersatz-HTML zurueck und wird neu gezeichnet (sig leer), denn die Seite hat seinen Inhalt unterdessen vielleicht zurueckgesetzt
|
||||
function pflegePlatzhalter () {
|
||||
var i, p
|
||||
var bleibt = []
|
||||
for (i = 0; i < platzhalter.length; i++) {
|
||||
p = platzhalter[i]
|
||||
if (p.root.isConnected !== false) bleibt.push(p)
|
||||
else getrennt.push(p)
|
||||
}
|
||||
platzhalter = bleibt
|
||||
var rest = []
|
||||
for (i = 0; i < getrennt.length; i++) {
|
||||
p = getrennt[i]
|
||||
if (p.root.isConnected !== false) {
|
||||
p.sig = ''
|
||||
platzhalter.push(p)
|
||||
} else rest.push(p)
|
||||
}
|
||||
getrennt = rest.slice(-GETRENNT_MAX)
|
||||
}
|
||||
|
||||
function render () {
|
||||
sammle()
|
||||
platzhalter = platzhalter.filter(function (p) { return p.root.isConnected !== false })
|
||||
pflegePlatzhalter()
|
||||
var d = frisch() ? daten : null
|
||||
var t = jetzt()
|
||||
for (var i = 0; i < platzhalter.length; i++) fuelleSicher(platzhalter[i], d, t)
|
||||
@@ -760,11 +790,18 @@
|
||||
return liste
|
||||
}
|
||||
|
||||
// Feste Elemente um max(0, Leistenhoehe - Scrollposition) nach unten ruecken (Inline-top, hat Vorrang vor den Regeln der Seite)
|
||||
// Feste Elemente um max(0, Leistenhoehe - Scrollposition) nach unten ruecken (Inline-top, hat Vorrang vor den Regeln der Seite). Ist die
|
||||
// Leiste weggescrollt (s = 0), bekommt das Element seinen eigenen Inline-top zurueck (oder keinen): Koepfe, die per Klasse oder Skript beim
|
||||
// Scrollen weg- und wieder einfahren (top: -80px), arbeiten dann wie auf der Seite gedacht.
|
||||
function verschiebe (l) {
|
||||
var y = window.pageYOffset || document.documentElement.scrollTop || 0
|
||||
var s = Math.max(0, l.hoehe - Math.max(0, y))
|
||||
for (var i = 0; i < l.fest.length; i++) l.fest[i].el.style.setProperty('top', (l.fest[i].basis + s) + 'px', 'important')
|
||||
for (var i = 0; i < l.fest.length; i++) {
|
||||
var f = l.fest[i]
|
||||
if (s > 0) f.el.style.setProperty('top', (f.basis + s) + 'px', 'important')
|
||||
else if (f.wert) f.el.style.setProperty('top', f.wert, f.prio)
|
||||
else f.el.style.removeProperty('top')
|
||||
}
|
||||
}
|
||||
|
||||
function setzeFesteZurueck (l) {
|
||||
@@ -985,7 +1022,7 @@
|
||||
try { bloecke.push(JSON.parse(skripte[i].textContent)) } catch (e) { bloecke.push(null) }
|
||||
}
|
||||
var erg = jsonLdEinbauen(bloecke, jd, seite)
|
||||
for (i = 0; i < erg.geaendert.length; i++) skripte[erg.geaendert[i]].textContent = JSON.stringify(bloecke[erg.geaendert[i]])
|
||||
for (i = 0; i < erg.geaendert.length; i++) skripte[erg.geaendert[i]].textContent = jsonLdText(bloecke[erg.geaendert[i]])
|
||||
if (erg.neu) {
|
||||
if (!ldEigenes) {
|
||||
ldEigenes = document.createElement('script')
|
||||
@@ -993,7 +1030,7 @@
|
||||
ldEigenes.setAttribute('data-wkz-jsonld', '')
|
||||
;(document.head || document.documentElement).appendChild(ldEigenes)
|
||||
}
|
||||
ldEigenes.textContent = JSON.stringify(erg.neu)
|
||||
ldEigenes.textContent = jsonLdText(erg.neu)
|
||||
} else if (ldEigenes) {
|
||||
if (ldEigenes.parentNode) ldEigenes.parentNode.removeChild(ldEigenes)
|
||||
ldEigenes = null
|
||||
@@ -1039,8 +1076,8 @@
|
||||
}
|
||||
|
||||
function bestimmeApi (skript) {
|
||||
// nur das eigene Script-Tag (oder dessen Herkunft) bestimmt die Adresse: ein Platzhalter der Seite kann die Anfragen nicht umleiten
|
||||
var a = skript ? attr(skript, 'data-api') : ''
|
||||
for (var i = 0; !a && i < platzhalter.length; i++) a = attr(platzhalter[i].root, 'data-api')
|
||||
a = a.replace(/\/+$/, '')
|
||||
if (a) return a
|
||||
if (skript && skript.src) {
|
||||
@@ -1049,6 +1086,28 @@
|
||||
return ''
|
||||
}
|
||||
|
||||
// Einzelseiten-Apps (Vite/React) haengen Platzhalter erst nach einem Seitenwechsel ein. Ein Beobachter auf body (childList, subtree) sieht
|
||||
// einen neuen (noch nicht eingetragenen) oder wieder eingehaengten Platzhalter und zeichnet nach, hoechstens alle BEOBACHTER_MS; er bleibt
|
||||
// fuer die Lebensdauer der Seite (die eigenen Aenderungen des Skripts loesen nur diese billige Pruefung aus, nie ein Zeichnen).
|
||||
function pruefeNeue () {
|
||||
var neu = document.querySelector(SELEKTOR + ':not([data-wkz-init])')
|
||||
for (var i = 0; !neu && i < getrennt.length; i++) if (getrennt[i].root.isConnected !== false) neu = getrennt[i].root
|
||||
if (neu) render()
|
||||
}
|
||||
|
||||
function beobachteSeite () {
|
||||
if (typeof MutationObserver !== 'function' || !document.body) return
|
||||
var wartet = false
|
||||
new MutationObserver(function () {
|
||||
if (wartet) return
|
||||
wartet = true
|
||||
timer(function () {
|
||||
wartet = false
|
||||
sicher(pruefeNeue)
|
||||
}, BEOBACHTER_MS)
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
function start () {
|
||||
sammle()
|
||||
var skript = eigenesSkript()
|
||||
@@ -1067,6 +1126,7 @@
|
||||
}
|
||||
tick()
|
||||
})
|
||||
beobachteSeite()
|
||||
lade()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
/* Editor: Zeitplaene, Feiertage, Ausnahmen (zeiten-editor.js, zeiten-plaene.js, zeiten-ausnahmen.js) */
|
||||
.zt-editor > * + * { margin-top: 16px; }
|
||||
.zt-editor > .tm-meldung:empty, .zt-editor > .tm-box[hidden] { margin: 0; }
|
||||
/* Die Bestätigung (erstes Kind des Editors) klebt unter der Kopfleiste: nach dem Speichern eines unteren Abschnitts springt der Fokus auf den Knopf,
|
||||
und „gespeichert“ läge sonst ausser Sicht. Undurchsichtig, damit der Inhalt darunter nicht durchscheint; leer klebt nichts. */
|
||||
.zt-editor > .tm-meldung:not(:empty) { position: sticky; top: calc(var(--kb-appbar-h, 0px) + 8px); z-index: 5; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); }
|
||||
.zt-editor > .tm-meldung-ok:not(:empty) { background: linear-gradient(rgba(74, 222, 128, 0.1), rgba(74, 222, 128, 0.1)), #0c0c0e; }
|
||||
.zt-editor > .tm-meldung-fehler:not(:empty) { background: linear-gradient(rgba(248, 113, 113, 0.1), rgba(248, 113, 113, 0.1)), #0c0c0e; }
|
||||
.zt-kopfbereich:empty { display: none; }
|
||||
.zt-spaeter { margin: 0 4px; line-height: 1.5; }
|
||||
.zt-plaene { display: grid; gap: 12px; }
|
||||
|
||||
@@ -211,6 +211,25 @@ async function pruefungen() {
|
||||
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({
|
||||
@@ -420,6 +439,56 @@ async function pruefungen() {
|
||||
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()) })
|
||||
|
||||
@@ -357,6 +357,30 @@ async function pruefungen() {
|
||||
eq(puts, [3, 4], 'zweites PUT mit der neuen Version')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Bestätigung bleibt sichtbar
|
||||
// Die Meldung ist das erste Kind des Editors; nach dem Speichern eines unteren Abschnitts (Kontakt) springt der Fokus auf den Knopf. Ohne
|
||||
// klebende Meldung stünde „gespeichert“ dann ausserhalb des Fensters.
|
||||
await check('fx_meldung_bleibt_sichtbar', async (page) => {
|
||||
await oeffneEditor(page)
|
||||
const form = page.locator('form[aria-label="Kontakt-Leiste"]')
|
||||
await form.scrollIntoViewIfNeeded()
|
||||
await form.locator('input[type="tel"]').first().fill('06301 999888')
|
||||
await knopf(form, 'Kontakt-Leiste speichern').click()
|
||||
await meldung(page).filter({ hasText: 'Kontakt-Leiste gespeichert' }).waitFor()
|
||||
await page.waitForTimeout(150)
|
||||
const lage = await meldung(page).evaluate((e) => {
|
||||
const r = e.getBoundingClientRect()
|
||||
return { oben: r.top, unten: r.bottom, hoehe: r.height, fenster: window.innerHeight, pos: getComputedStyle(e).position, scroll: window.scrollY }
|
||||
})
|
||||
assert(lage.scroll > 300, `die Prüfung braucht eine gescrollte Seite (scrollY ${lage.scroll})`)
|
||||
assert(lage.hoehe > 0 && lage.oben >= -0.5 && lage.unten <= lage.fenster + 0.5, `Meldung liegt ausserhalb des Fensters: ${JSON.stringify(lage)}`)
|
||||
eq(lage.pos, 'sticky', 'Meldung klebt unter der Kopfleiste')
|
||||
// leer klebt sie nicht (kein leerer Streifen)
|
||||
await meldung(page).evaluate((e) => { e.textContent = '' })
|
||||
eq(await meldung(page).evaluate((e) => getComputedStyle(e).position), 'static', 'leere Meldung')
|
||||
await shot(page, 'meldung-sichtbar')
|
||||
}, { context: { viewport: { width: 1280, height: 600 } } })
|
||||
|
||||
// ---------------------------------------------------------------- Urlaub ⇒ Hinweis-Vorschlag, „Hinweis-Leiste anlegen“
|
||||
await check('fx_hinweis_vorschlag', async (page, _ctx, fx) => {
|
||||
await oeffneEditor(page)
|
||||
|
||||
@@ -1942,3 +1942,10 @@ test('zeiten.css: jede zt-Klasse der Editor-Module ist gestaltet', () => {
|
||||
assert.ok(gesetzt.size >= 5)
|
||||
for (const klasse of gesetzt) assert.ok(css.includes(`.${klasse}`), `.${klasse} hat keine Gestaltung`)
|
||||
})
|
||||
|
||||
test('zeiten.css: die Bestätigung des Editors klebt unter der Kopfleiste, solange sie nicht leer ist (undurchsichtig, z-index 5)', () => {
|
||||
const css = lesen('public/zeiten.css')
|
||||
assert.match(css, /\.zt-editor > \.tm-meldung:not\(:empty\) \{ position: sticky; top: calc\(var\(--kb-appbar-h, 0px\) \+ 8px\); z-index: 5;/)
|
||||
assert.match(css, /\.zt-editor > \.tm-meldung-ok:not\(:empty\) \{ background: linear-gradient\(.*\), #0c0c0e; \}/)
|
||||
assert.match(css, /\.zt-editor > \.tm-meldung-fehler:not\(:empty\) \{ background: linear-gradient\(.*\), #0c0c0e; \}/)
|
||||
})
|
||||
@@ -410,7 +410,7 @@ function leisten() {
|
||||
const tel = /\/\/ @telefon-anfang[^\n]*\n([\s\S]*?)\/\/ @telefon-ende/.exec(QUELLE)
|
||||
assert.ok(rein && tel, 'Marken fehlen')
|
||||
return vm.runInNewContext(
|
||||
`${tel[1]}; ${rein[1]}; ({ jsonLdEinbauen, kontaktKnoepfe, istEigeneAnrufLeiste, hinweisKnopf, HINWEIS_FARBEN })`,
|
||||
`${tel[1]}; ${rein[1]}; ({ jsonLdEinbauen, jsonLdText, kontaktKnoepfe, istEigeneAnrufLeiste, hinweisKnopf, HINWEIS_FARBEN })`,
|
||||
{},
|
||||
)
|
||||
}
|
||||
@@ -571,9 +571,15 @@ test('Kontakt-Knöpfe aus Teildaten', () => {
|
||||
test('Links aus der API werden erneut geprüft (https:, tel: nur über telefonLink)', () => {
|
||||
const { hinweisKnopf } = leisten()
|
||||
assert.equal(hinweisKnopf({ art: 'link', ziel: 'javascript:alert(1)', text: 'Los' }), null, 'javascript: ist kein Link')
|
||||
for (const bose of ['JaVaScRiPt:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', 'http://x.example/', '//x.example/', '/aktion', 'x.example', ' https://x.example/', 'https://x.example/a b', 'https://', 'https:x', '', 'tel:+4963011234', 'https://x.example/\n', 5, null, {}]) {
|
||||
for (const bose of ['JaVaScRiPt:alert(1)', 'data:text/html,<b>x</b>', 'vbscript:x', 'http://x.example/', '//x.example/', '/aktion', 'x.example', ' https://x.example/', 'https://x.example/a b', 'https://', 'https:x', '', 'tel:+4963011234', 'https://x.example/\n', 5, null, {},
|
||||
// Zugangsdaten in der Adresse (wie der Server und linkOk des Portals): nie ein Link
|
||||
'https://user:pw@x.example/', 'https://user@x.example/', 'https://:pw@x.example/', 'https://x.example@evil.example/', 'https://evil.example\\@x.example/']) {
|
||||
assert.equal(hinweisKnopf({ art: 'link', ziel: bose, text: 'Los' }), null, JSON.stringify(bose))
|
||||
}
|
||||
// Pfad, Suche, Anker und Port bleiben erlaubt (ein @ dort ist keine Zugangsdaten)
|
||||
for (const gut of ['https://x.example', 'https://x.example:8443/a', 'https://x.example?x=1', 'https://x.example#a', 'https://x.example/mail@x.example', 'https://x.example/a?u=a@b#c@d']) {
|
||||
assert.equal(hinweisKnopf({ art: 'link', ziel: gut, text: 'Los' })?.href, gut, gut)
|
||||
}
|
||||
assert.deepEqual(norm(hinweisKnopf({ art: 'link', ziel: 'https://baeckerei.example/aktion?x=1#a', text: 'Zur Aktion' })), { art: 'link', href: 'https://baeckerei.example/aktion?x=1#a', text: 'Zur Aktion' })
|
||||
assert.deepEqual(norm(hinweisKnopf({ art: 'anruf', ziel: '06301 1234', text: 'Jetzt anrufen' })), { art: 'anruf', href: 'tel:063011234', text: 'Jetzt anrufen' })
|
||||
assert.deepEqual(norm(hinweisKnopf({ art: 'anruf', ziel: '+49 6301 1234', text: '' })), { art: 'anruf', href: 'tel:+4963011234', text: 'Anrufen' }, 'ohne Beschriftung eine Vorgabe')
|
||||
@@ -583,6 +589,44 @@ test('Links aus der API werden erneut geprüft (https:, tel: nur über telefonLi
|
||||
for (const leer of [null, undefined, {}, 'x', 5, []]) assert.equal(hinweisKnopf(leer), null, JSON.stringify(leer))
|
||||
})
|
||||
|
||||
test('Kontakt-Knöpfe: Route und WhatsApp mit Zugangsdaten in der Adresse fallen weg', () => {
|
||||
const { kontaktKnoepfe } = leisten()
|
||||
assert.deepEqual(norm(kontaktKnoepfe({ telefon: null, routeUrl: 'https://user:pw@www.google.com/maps', whatsappUrl: 'https://wa.me@evil.example/491711234567' })), [])
|
||||
})
|
||||
|
||||
test('JSON-LD-Text: kein "<" im geschriebenen Text (</script> beendet sonst ein serialisiertes Skript), Inhalt bleibt gleich lesbar', () => {
|
||||
const { jsonLdText } = leisten()
|
||||
const wert = { '@type': 'LocalBusiness', name: 'Bäckerei </script><img src=x onerror=alert(1)> & Co', address: 'a<b' }
|
||||
const text = jsonLdText(wert)
|
||||
assert.ok(!text.includes('<'), text)
|
||||
assert.ok(text.includes('\\u003c/script>'))
|
||||
assert.deepEqual(JSON.parse(text), wert, 'JSON.parse macht daraus wieder dasselbe')
|
||||
assert.equal(jsonLdText([]), '[]')
|
||||
// beide Stellen, die JSON-LD in die Seite schreiben, gehen über den Helfer
|
||||
assert.doesNotMatch(OHNE_KOMMENTARE, /textContent = JSON\.stringify\(/, 'JSON-LD nie ohne Escape in textContent')
|
||||
assert.equal([...OHNE_KOMMENTARE.matchAll(/textContent = jsonLdText\(/g)].length, 2, 'eigener und fremder Block')
|
||||
})
|
||||
|
||||
test('Quelltext: die Adresse der Schnittstelle bestimmt nur das eigene Script-Tag (data-api) oder dessen Herkunft, nie ein Platzhalter', () => {
|
||||
const api = /function bestimmeApi \(skript\) \{([\s\S]*?)\n \}\n/.exec(OHNE_KOMMENTARE)
|
||||
assert.ok(api, 'bestimmeApi fehlt')
|
||||
assert.match(api[1], /attr\(skript, 'data-api'\)/)
|
||||
assert.doesNotMatch(api[1], /platzhalter/, 'kein data-api an Platzhaltern: eine Seite könnte sonst die Anfragen umleiten')
|
||||
// der siteKey darf (Spec 4.2) auch am Platzhalter stehen
|
||||
const key = /function bestimmeSiteKey \(skript\) \{([\s\S]*?)\n \}\n/.exec(OHNE_KOMMENTARE)
|
||||
assert.ok(key && /attr\(skript, 'data-site'\)/.test(key[1]) && /attr\(platzhalter\[i\]\.root, 'data-webklar-zeiten'\)/.test(key[1]))
|
||||
})
|
||||
|
||||
test('Späte Platzhalter (Einzelseiten-Apps): ein MutationObserver auf body, entprellt über timer, rendert nur bei neuem oder wieder eingehängtem Platzhalter', () => {
|
||||
assert.match(OHNE_KOMMENTARE, /new MutationObserver\(/)
|
||||
assert.match(OHNE_KOMMENTARE, /observe\(document\.body, \{ childList: true, subtree: true \}\)/)
|
||||
const beo = /function beobachteSeite \(\) \{([\s\S]*?)\n \}\n/.exec(OHNE_KOMMENTARE)
|
||||
assert.ok(beo, 'beobachteSeite fehlt')
|
||||
assert.match(beo[1], /timer\(/, 'die Entprellung läuft über timer (die einzige setTimeout-Stelle)')
|
||||
assert.match(OHNE_KOMMENTARE, /var BEOBACHTER_MS = 200\b/)
|
||||
assert.match(OHNE_KOMMENTARE, /querySelector\(SELEKTOR \+ ':not\(\[data-wkz-init\]\)'\)/)
|
||||
})
|
||||
|
||||
test('istEigeneAnrufLeiste: festes oder klebendes Element unten mit tel:-Link; oben, ausgeblendet oder bildschirmhoch zählt nicht', () => {
|
||||
const { istEigeneAnrufLeiste } = leisten()
|
||||
const fenster = 844
|
||||
@@ -629,7 +673,7 @@ test('Leisten im Quelltext: Shadow DOM, Rolle und Name, Handy-Abfrage, z-index 4
|
||||
assert.doesNotMatch(OHNE_KOMMENTARE, /aria-live/)
|
||||
assert.ok(!OHNE_KOMMENTARE.includes('close') && !/Schlie(ss|ß)en/.test(OHNE_KOMMENTARE), 'keine Schließen-Schaltfläche')
|
||||
// JSON-LD wird über textContent geschrieben (nie innerHTML), der Selektor des Teams nur in try/catch
|
||||
assert.match(OHNE_KOMMENTARE, /\.textContent = JSON\.stringify\(/)
|
||||
assert.match(OHNE_KOMMENTARE, /\.textContent = jsonLdText\(/, "JSON-LD wird über jsonLdText (ohne <) geschrieben")
|
||||
assert.match(OHNE_KOMMENTARE, /try \{[^}]*querySelectorAll\(nav\)[^}]*\} catch/, 'navSelektor nur in try/catch')
|
||||
// Linkziele nie per String in HTML, sondern per setAttribute; jeder Link bekommt rel
|
||||
assert.ok(OHNE_KOMMENTARE.includes("'rel', 'noopener noreferrer'"))
|
||||
|
||||
Reference in new issue
Block a user