From 8d64a18d3572912486f43a2348c498e5b4f74bb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:53:35 +0000 Subject: [PATCH] =?UTF-8?q?Analyse:=20Client=20pr=C3=BCft=20Einwilligung?= =?UTF-8?q?=20vor=20dem=20Senden,=20Fassung=20der=20Banner-Wahl,=20fetch?= =?UTF-8?q?=20ohne=20Cookies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- analytics/client/core.js | 8 +- analytics/client/dist/h.js | 8 +- analytics/client/dist/w.js | 113 ++++++++++----- analytics/client/w.js | 107 ++++++++++----- analytics/test/client.test.js | 249 +++++++++++++++++++++++++--------- 5 files changed, 354 insertions(+), 131 deletions(-) diff --git a/analytics/client/core.js b/analytics/client/core.js index 1736666..e017e1a 100644 --- a/analytics/client/core.js +++ b/analytics/client/core.js @@ -3,6 +3,11 @@ export const CONSENT_KEY = 'wk-consent'; export const CONSENT_MAX_AGE = 183 * 86400000; +export const BANNER_VERSION = '2026-10-02'; +export const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; +export const BANNER_LINK_LABEL = 'Datenschutz'; +export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; +export const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; export function vpClass(width) { const w = Number(width); @@ -89,9 +94,10 @@ export function readConsent(storage, now) { if (!raw) return null; const c = JSON.parse(raw); if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; + if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen const age = now - c.t; if (age < 0 || age >= CONSENT_MAX_AGE) return null; - return { v: 1, a: c.a, t: c.t }; + return { v: 1, a: c.a, t: c.t, bv: c.bv }; } catch (e) { return null; } } diff --git a/analytics/client/dist/h.js b/analytics/client/dist/h.js index ea725df..eb417cc 100644 --- a/analytics/client/dist/h.js +++ b/analytics/client/dist/h.js @@ -6,6 +6,11 @@ const CONSENT_KEY = 'wk-consent'; const CONSENT_MAX_AGE = 183 * 86400000; +const BANNER_VERSION = '2026-10-02'; +const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; +const BANNER_LINK_LABEL = 'Datenschutz'; +const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; +const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; function vpClass(width) { const w = Number(width); @@ -92,9 +97,10 @@ function readConsent(storage, now) { if (!raw) return null; const c = JSON.parse(raw); if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; + if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen const age = now - c.t; if (age < 0 || age >= CONSENT_MAX_AGE) return null; - return { v: 1, a: c.a, t: c.t }; + return { v: 1, a: c.a, t: c.t, bv: c.bv }; } catch (e) { return null; } } diff --git a/analytics/client/dist/w.js b/analytics/client/dist/w.js index fcdce99..2d8c0b5 100644 --- a/analytics/client/dist/w.js +++ b/analytics/client/dist/w.js @@ -6,6 +6,11 @@ const CONSENT_KEY = 'wk-consent'; const CONSENT_MAX_AGE = 183 * 86400000; +const BANNER_VERSION = '2026-10-02'; +const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; +const BANNER_LINK_LABEL = 'Datenschutz'; +const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; +const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; function vpClass(width) { const w = Number(width); @@ -92,9 +97,10 @@ function readConsent(storage, now) { if (!raw) return null; const c = JSON.parse(raw); if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null; + if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen const age = now - c.t; if (age < 0 || age >= CONSENT_MAX_AGE) return null; - return { v: 1, a: c.a, t: c.t }; + return { v: 1, a: c.a, t: c.t, bv: c.bv }; } catch (e) { return null; } } @@ -113,7 +119,8 @@ function escapeHtml(s) { } // WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites. -// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen. +// Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen; +// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus. function main() { const DOC = document; @@ -149,7 +156,6 @@ function main() { function send(obj) { try { const body = JSON.stringify(obj); - if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return; fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {}); } catch (e) { /* egal */ } } @@ -157,37 +163,71 @@ function main() { const vp = () => vpClass(WIN.innerWidth); const ownHost = () => WIN.location.hostname; - // ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ---- + // ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ---- + let storage = null; + try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ } + const dnt = (v) => v === '1' || v === 'yes'; + const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); + if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks + const stored = readConsent(storage, Date.now()); + let memChoice = null; + let tracking = false; + // Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt) + function choiceNow() { + const c = readConsent(storage, Date.now()); + return c ? c.a : (memChoice ? memChoice.a : null); + } + const counting = () => choiceNow() !== 'reject'; + + // ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ---- let lastPath = null; let pvStart = Date.now(); let maxScroll = 0; let enSent = false; + let pvSent = false; + let cxSent = false; function sendPv(first) { + if (!counting()) return; lastPath = cleanPath(WIN.location.pathname); pvStart = Date.now(); maxScroll = 0; enSent = false; - send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() }); + const ev = { t: 'pv', site, path: lastPath }; + if (choiceNow() === 'accept') { + if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; } + ev.vp = vp(); + } + pvSent = true; + send(ev); } let navTimer = null; function scheduleNav() { - if (navTimer) return; + if (navTimer || !counting()) return; navTimer = setTimeout(safe(function () { navTimer = null; - if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false); + if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false); }), 300); } - sendPv(true); - WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); })); - try { - const orig = WIN.history.pushState; - WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; }; - WIN.addEventListener('popstate', safe(scheduleNav)); - } catch (e) { /* egal */ } + let hooksOn = false; + function ensureHooks() { + if (hooksOn) return; + hooksOn = true; + WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); })); + try { + const orig = WIN.history.pushState; + WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; }; + WIN.addEventListener('popstate', safe(scheduleNav)); + } catch (e) { /* egal */ } + } + // Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden + function onAccept() { + ensureHooks(); + if (!pvSent) { sendPv(true); return; } + if (cxSent) return; + cxSent = true; + send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() }); + } + if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); } // ---- Tracking (nur nach Annehmen erreichbar) ---- - let storage = null; - try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ } - let memChoice = null; - let tracking = false; const hosts = []; const scrollPct = () => { const h = DOC.documentElement.scrollHeight || 1; @@ -239,8 +279,7 @@ function main() { // Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen) function allowed() { - const c = readConsent(storage, Date.now()); - const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept'); + const ok = choiceNow() === 'accept'; if (!ok && tracking) stopTracking(); return ok; } @@ -264,19 +303,14 @@ function main() { } // ---- Einwilligung ---- - const dnt = (v) => v === '1' || v === 'yes'; - const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); - if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking - - const stored = readConsent(storage, Date.now()); let bannerHost = null; let linkHostEl = null; function choose(a) { memChoice = null; - try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } + try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } send({ t: 'bn', site, a }); - if (a === 'accept') startTracking(); else stopTracking(); + if (a === 'accept') { startTracking(); onAccept(); } else stopTracking(); closeBanner(); } @@ -297,6 +331,13 @@ function main() { 'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' + '@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}'; + let prevPad = ''; + function applyPad() { + if (!bannerHost) return; + const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0); + DOC.body.style.paddingBottom = h + 'px'; + } + const onResize = safe(applyPad); function openBanner(focus) { if (bannerHost) return; const prev = DOC.activeElement; @@ -306,9 +347,9 @@ function main() { hosts.push(bannerHost); const root = bannerHost.attachShadow({ mode: 'open' }); root.appendChild(el('style', {}, CSS)); - const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' }); - const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: '); - p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz')); + const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA }); + const p = el('p', { class: 't' }, BANNER_TEXT); + p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL)); const row = el('div', { class: 'r' }); const no = el('button', { type: 'button' }, 'Ablehnen'); const yes = el('button', { type: 'button' }, 'Annehmen'); @@ -318,6 +359,10 @@ function main() { bar.appendChild(p); bar.appendChild(row); root.appendChild(bar); DOC.body.appendChild(bannerHost); + // Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben + prevPad = DOC.body.style.paddingBottom; + applyPad(); + WIN.addEventListener('resize', onResize); if (linkHostEl) linkHostEl.style.display = 'none'; if (focus === true) no.focus(); } @@ -327,6 +372,8 @@ function main() { if (i !== -1) hosts.splice(i, 1); bannerHost.remove(); bannerHost = null; + WIN.removeEventListener('resize', onResize); + DOC.body.style.paddingBottom = prevPad; if (linkHostEl) linkHostEl.style.display = ''; } @@ -336,8 +383,8 @@ function main() { st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646'; hosts.push(linkHostEl); const root = linkHostEl.attachShadow({ mode: 'open' }); - root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}')); - const b = el('button', { type: 'button' }, 'Cookie-Einstellungen'); + root.appendChild(el('style', {}, ':host{all:initial}button{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}')); + const b = el('button', { type: 'button' }, SETTINGS_LABEL); b.addEventListener('click', safe(function () { openBanner(true); })); root.appendChild(b); DOC.body.appendChild(linkHostEl); @@ -353,7 +400,7 @@ function main() { WIN.addEventListener('storage', safe(function (e) { if (e.key !== null && e.key !== CONSENT_KEY) return; const c = readConsent(storage, Date.now()); - if (c && c.a === 'accept') startTracking(); else stopTracking(); + if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (c) closeBanner(); })); diff --git a/analytics/client/w.js b/analytics/client/w.js index 8ffd22f..3c5c8d2 100644 --- a/analytics/client/w.js +++ b/analytics/client/w.js @@ -1,6 +1,7 @@ // WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites. -// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen. -import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY } from './core.js'; +// Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen; +// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus. +import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY, BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js'; function main() { const DOC = document; @@ -36,7 +37,6 @@ function main() { function send(obj) { try { const body = JSON.stringify(obj); - if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return; fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {}); } catch (e) { /* egal */ } } @@ -44,37 +44,71 @@ function main() { const vp = () => vpClass(WIN.innerWidth); const ownHost = () => WIN.location.hostname; - // ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ---- + // ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ---- + let storage = null; + try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ } + const dnt = (v) => v === '1' || v === 'yes'; + const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); + if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks + const stored = readConsent(storage, Date.now()); + let memChoice = null; + let tracking = false; + // Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt) + function choiceNow() { + const c = readConsent(storage, Date.now()); + return c ? c.a : (memChoice ? memChoice.a : null); + } + const counting = () => choiceNow() !== 'reject'; + + // ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ---- let lastPath = null; let pvStart = Date.now(); let maxScroll = 0; let enSent = false; + let pvSent = false; + let cxSent = false; function sendPv(first) { + if (!counting()) return; lastPath = cleanPath(WIN.location.pathname); pvStart = Date.now(); maxScroll = 0; enSent = false; - send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() }); + const ev = { t: 'pv', site, path: lastPath }; + if (choiceNow() === 'accept') { + if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; } + ev.vp = vp(); + } + pvSent = true; + send(ev); } let navTimer = null; function scheduleNav() { - if (navTimer) return; + if (navTimer || !counting()) return; navTimer = setTimeout(safe(function () { navTimer = null; - if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false); + if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false); }), 300); } - sendPv(true); - WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); })); - try { - const orig = WIN.history.pushState; - WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; }; - WIN.addEventListener('popstate', safe(scheduleNav)); - } catch (e) { /* egal */ } + let hooksOn = false; + function ensureHooks() { + if (hooksOn) return; + hooksOn = true; + WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); })); + try { + const orig = WIN.history.pushState; + WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; }; + WIN.addEventListener('popstate', safe(scheduleNav)); + } catch (e) { /* egal */ } + } + // Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden + function onAccept() { + ensureHooks(); + if (!pvSent) { sendPv(true); return; } + if (cxSent) return; + cxSent = true; + send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() }); + } + if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); } // ---- Tracking (nur nach Annehmen erreichbar) ---- - let storage = null; - try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ } - let memChoice = null; - let tracking = false; const hosts = []; const scrollPct = () => { const h = DOC.documentElement.scrollHeight || 1; @@ -126,8 +160,7 @@ function main() { // Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen) function allowed() { - const c = readConsent(storage, Date.now()); - const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept'); + const ok = choiceNow() === 'accept'; if (!ok && tracking) stopTracking(); return ok; } @@ -151,19 +184,14 @@ function main() { } // ---- Einwilligung ---- - const dnt = (v) => v === '1' || v === 'yes'; - const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); - if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking - - const stored = readConsent(storage, Date.now()); let bannerHost = null; let linkHostEl = null; function choose(a) { memChoice = null; - try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } + try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } send({ t: 'bn', site, a }); - if (a === 'accept') startTracking(); else stopTracking(); + if (a === 'accept') { startTracking(); onAccept(); } else stopTracking(); closeBanner(); } @@ -184,6 +212,13 @@ function main() { 'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' + '@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}'; + let prevPad = ''; + function applyPad() { + if (!bannerHost) return; + const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0); + DOC.body.style.paddingBottom = h + 'px'; + } + const onResize = safe(applyPad); function openBanner(focus) { if (bannerHost) return; const prev = DOC.activeElement; @@ -193,9 +228,9 @@ function main() { hosts.push(bannerHost); const root = bannerHost.attachShadow({ mode: 'open' }); root.appendChild(el('style', {}, CSS)); - const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' }); - const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: '); - p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz')); + const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA }); + const p = el('p', { class: 't' }, BANNER_TEXT); + p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL)); const row = el('div', { class: 'r' }); const no = el('button', { type: 'button' }, 'Ablehnen'); const yes = el('button', { type: 'button' }, 'Annehmen'); @@ -205,6 +240,10 @@ function main() { bar.appendChild(p); bar.appendChild(row); root.appendChild(bar); DOC.body.appendChild(bannerHost); + // Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben + prevPad = DOC.body.style.paddingBottom; + applyPad(); + WIN.addEventListener('resize', onResize); if (linkHostEl) linkHostEl.style.display = 'none'; if (focus === true) no.focus(); } @@ -214,6 +253,8 @@ function main() { if (i !== -1) hosts.splice(i, 1); bannerHost.remove(); bannerHost = null; + WIN.removeEventListener('resize', onResize); + DOC.body.style.paddingBottom = prevPad; if (linkHostEl) linkHostEl.style.display = ''; } @@ -223,8 +264,8 @@ function main() { st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646'; hosts.push(linkHostEl); const root = linkHostEl.attachShadow({ mode: 'open' }); - root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}')); - const b = el('button', { type: 'button' }, 'Cookie-Einstellungen'); + root.appendChild(el('style', {}, ':host{all:initial}button{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}')); + const b = el('button', { type: 'button' }, SETTINGS_LABEL); b.addEventListener('click', safe(function () { openBanner(true); })); root.appendChild(b); DOC.body.appendChild(linkHostEl); @@ -240,7 +281,7 @@ function main() { WIN.addEventListener('storage', safe(function (e) { if (e.key !== null && e.key !== CONSENT_KEY) return; const c = readConsent(storage, Date.now()); - if (c && c.a === 'accept') startTracking(); else stopTracking(); + if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (c) closeBanner(); })); diff --git a/analytics/test/client.test.js b/analytics/test/client.test.js index bb12ade..e9e904a 100644 --- a/analytics/test/client.test.js +++ b/analytics/test/client.test.js @@ -7,8 +7,10 @@ import { dirname, join } from 'node:path'; import { vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml, } from '../client/core.js'; +import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from '../client/core.js'; import { buildAll } from '../client/build.js'; +const BV = '2026-10-02'; const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist'); // ---------- core ---------- @@ -96,13 +98,17 @@ test('readConsent', () => { const now = 1_700_000_000_000; const day = 86400000; const st = (v) => ({ getItem: () => v }); - assert.deepEqual(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 10 * day })), now), { v: 1, a: 'accept', t: now - 10 * day }); - assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'reject', t: now - 182 * day })), now).a, 'reject'); - assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 183 * day })), now), null); - assert.equal(readConsent(st(JSON.stringify({ v: 2, a: 'accept', t: now })), now), null); - assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'maybe', t: now })), now), null); - assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: 'x' })), now), null); - assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now + day })), now), null); + const J = (o) => st(JSON.stringify(o)); + assert.equal(BANNER_VERSION, '2026-10-02'); + assert.deepEqual(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 10 * day }), now), { v: 1, a: 'accept', t: now - 10 * day, bv: BV }); + assert.equal(readConsent(J({ v: 1, a: 'reject', bv: BV, t: now - 182 * day }), now).a, 'reject'); + assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 183 * day }), now), null); + assert.equal(readConsent(J({ v: 1, a: 'accept', t: now }), now), null, 'ohne bv'); + assert.equal(readConsent(J({ v: 1, a: 'accept', bv: '2026-01-01', t: now }), now), null, 'alte Fassung'); + assert.equal(readConsent(J({ v: 2, a: 'accept', bv: BV, t: now }), now), null); + assert.equal(readConsent(J({ v: 1, a: 'maybe', bv: BV, t: now }), now), null); + assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: 'x' }), now), null); + assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now + day }), now), null); assert.equal(readConsent(st('kaputt{'), now), null); assert.equal(readConsent(st(null), now), null); assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null); @@ -144,6 +150,7 @@ function makeEl(tag) { addEventListener(t, f) { (this.listeners[t] ||= []).push(f); }, removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x !== f); }, focus() { this.focused = true; }, + getBoundingClientRect() { return { height: this.fakeHeight || 0 }; }, remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); }, closest(sel) { const attr = /^\[([\w-]+)\]$/.exec(sel); @@ -162,10 +169,10 @@ function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; } const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0]; -function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false } = {}) { - const sent = []; const store = {}; let now = 1_700_000_000_000; +function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad } = {}) { + const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000; if (stored) store['wk-consent'] = JSON.stringify(stored); - const body = makeEl('body'); const html = makeEl('html'); + const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html'); const timers = []; const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src; const docL = {}; const winL = {}; @@ -173,28 +180,29 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar const injected = []; if (link) link.click = () => { for (const f of [...(docL.click || [])]) f({ type: 'click', target: link, preventDefault() {} }); }; const document = { - currentScript: script, referrer: 'https://www.google.com/', visibilityState: 'visible', + currentScript: script, get referrer() { reads.referrer++; return 'https://www.google.com/'; }, visibilityState: 'visible', documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'), - createElement: makeEl, activeElement: null, + createElement(t) { const e = makeEl(t); if (bannerHeight) e.offsetHeight = bannerHeight; return e; }, activeElement: null, querySelector(s) { return s === '[data-webklar-consent]' ? link : null; }, addEventListener(t, f) { (docL[t] ||= []).push(f); }, removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); }, }; body.appendChild = function (c) { c.parentNode = this; this.children.push(c); if (c.attrs.src) injected.push(c); return c; }; document.head.appendChild = body.appendChild; - const history = { pushState() {}, replaceState(s, t, u) { this.replaced = u; } }; + const origPush = function () {}; + const history = { pushState: origPush, replaceState(s, t, u) { this.replaced = u; } }; const win = { - innerWidth: 1200, innerHeight: 800, scrollX: 0, scrollY: 300, document, history, + get innerWidth() { reads.innerWidth++; return 1200; }, innerHeight: 800, scrollX: 0, scrollY: 300, document, history, location: { hostname: 'mein.de', origin: 'https://mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' }, addEventListener(t, f) { (winL[t] ||= []).push(f); }, removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); }, }; const sandbox = { window: win, document, history, location: win.location, URL, URLSearchParams, JSON, Math, Date: { now: () => now }, String, Number, Array, Object, RegExp, Error, parseInt, - navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { sent.push({ u, d: JSON.parse(d) }); return true; } }, + navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { beacon.push({ u, d }); return true; } }, localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } }, setTimeout: (f, ms) => { timers.push(f); return timers.length; }, clearTimeout() {}, - setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: () => Promise.resolve(), + setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: (u, o) => { fetches.push({ u, o }); sent.push({ u, d: JSON.parse(o.body) }); return Promise.resolve(); }, }; win.localStorage = sandbox.localStorage; sandbox.globalThis = sandbox; @@ -205,31 +213,46 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar const leave = () => { for (const f of [...(winL.pagehide || [])]) f({}); document.visibilityState = 'hidden'; for (const f of [...(docL.visibilitychange || [])]) f({}); }; const rerun = () => vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox); const storage = () => (winL.storage || []).forEach((f) => f({ key: 'wk-consent' })); - return { rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document }; + const nav = (p) => { win.location.pathname = p; history.pushState(null, '', p); timers.splice(0).forEach((f) => f()); }; + return { nav, reads, beacon, fetches, origPush, rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document }; } const target = () => { const b = makeEl('button'); b.parentNode = makeEl('body'); b.attrs.class = 'cta'; return b; }; const hostEl = (h) => h.body.children.find((c) => c.shadow && findAll(c.shadow, (n) => n.attrs && n.attrs.role === 'dialog').length); -test('w.js: ohne Einwilligung nur pv + bn shown, keine ck/ct/en', () => { +const SITE = 'abcdef0123456789abcd'; +const ACC0 = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 }; +test('w.js: ohne Einwilligung nur pv {t,site,path} + bn shown, kein Lesen von referrer/innerWidth', () => { const h = harness(); assert.deepEqual(h.types(), ['pv', 'bn:shown']); assert.equal(h.sent[0].u, 'https://project.webklar.com/a/e'); - assert.deepEqual(h.sent[0].d, { t: 'pv', site: 'abcdef0123456789abcd', path: '/start', ref: 'www.google.com', vp: 'd' }); - assert.deepEqual(h.sent[1].d, { t: 'bn', site: 'abcdef0123456789abcd', a: 'shown' }); + assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start' }); + assert.deepEqual(Object.keys(h.sent[0].d).sort(), ['path', 'site', 't']); + assert.deepEqual(h.sent[1].d, { t: 'bn', site: SITE, a: 'shown' }); + assert.equal(h.reads.referrer, 0); + assert.equal(h.reads.innerWidth, 0); h.ck(target()); h.leave(); assert.deepEqual(h.types(), ['pv', 'bn:shown']); assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation }); +test('core: Banner-Konstanten', () => { + assert.match(BANNER_TEXT, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./); + assert.match(BANNER_TEXT, /Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast\./); + assert.match(BANNER_TEXT, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/); + assert.equal(BANNER_LINK_LABEL, 'Datenschutz'); + assert.equal(BANNER_ARIA, 'Einwilligung zur Reichweitenmessung'); + assert.equal(SETTINGS_LABEL, 'Datenschutz-Einstellungen'); +}); + test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)', () => { const h = harness(); const host = hostEl(h); assert.ok(host, 'Banner im Shadow Root'); const dlg = findAll(host.shadow, (n) => n.attrs.role === 'dialog')[0]; - assert.equal(dlg.attrs['aria-label'], 'Cookie-Einstellungen'); + assert.equal(dlg.attrs['aria-label'], BANNER_ARIA); const all = findAll(host.shadow, () => true); const text = all.map((n) => n.textContent).join(' '); - assert.match(text, /Wir möchten mit deiner Zustimmung anonym auswerten/); + assert.ok(all.find((n) => n.tagName === 'P').textContent.startsWith(BANNER_TEXT), 'Text beginnt mit BANNER_TEXT'); const a = all.find((n) => n.tagName === 'A'); assert.equal(a.attrs.href, '/datenschutz-analyse.html'); assert.equal(a.textContent, 'Datenschutz'); @@ -244,7 +267,6 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)' assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/); assert.match(css, /button\{[^}]*[; ]width:150px/); assert.equal((css.match(/(? bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort', const h = harness(); const bh = hostEl(h); btn(bh.shadow, 'Annehmen').click(); - assert.equal(h.types().at(-1), 'bn:accept'); - assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', t: 1_700_000_000_000 }); + assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']); + assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 }); assert.ok(bh.removed && !hostEl(h)); const t = target(); h.ck(t); @@ -281,7 +303,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort', assert.equal(en[0].d.scroll, 55); // (300+800)/2000 assert.equal(typeof en[0].d.sec, 'number'); // Widerruf ueber Einstellungs-Link - const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === 'Cookie-Einstellungen')[0]; + const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0]; assert.ok(settings, 'fester Link'); settings.click(); assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen'); @@ -296,7 +318,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort', }); test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () => { - const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); + const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } }); assert.deepEqual(h.types(), ['pv']); assert.equal(hostEl(h), undefined); h.ck(target()); @@ -304,28 +326,132 @@ test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () }); test('w.js: abgelaufene/abgelehnte Einwilligung', () => { - const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 - 5000 } }); - assert.deepEqual(h.types(), ['pv']); + const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 - 5000 } }); + assert.deepEqual(h.types(), []); h.ck(target()); h.leave(); - assert.deepEqual(h.types(), ['pv']); - const h2 = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 200 * 86400000 } }); + assert.deepEqual(h.types(), []); + const h2 = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 200 * 86400000 } }); assert.deepEqual(h2.types(), ['pv', 'bn:shown']); }); -test('w.js: GPC und DNT => nur pv, kein Banner, nie Tracking', () => { - for (const o of [{ gpc: true }, { dnt: '1' }, { gpc: true, stored: { v: 1, a: 'accept', t: 1_700_000_000_000 } }]) { +test('w.js: GPC und DNT => nichts senden, kein Banner, keine Hooks (T1)', () => { + for (const o of [{ gpc: true }, { dnt: '1' }, { dnt: 'yes' }, { gpc: true, stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 } }]) { const h = harness(o); - assert.deepEqual(h.types(), ['pv']); - assert.equal(hostEl(h), undefined); - assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0); - h.ck(target()); h.leave(); - assert.deepEqual(h.types(), ['pv']); + assert.equal(h.sent.length, 0); + assert.equal(h.winL.pageshow, undefined); + assert.equal(h.winL.popstate, undefined); + assert.equal(h.history.pushState, h.origPush); + assert.equal(h.body.children.length, 0, 'kein Banner, kein Einstellungsknopf'); + h.ck(target()); h.leave(); h.nav('/neu'); + assert.equal(h.sent.length, 0); + assert.equal(h.reads.referrer + h.reads.innerWidth, 0); } }); +test('w.js: gespeichertes Ablehnen => nichts senden, nur Einstellungsknopf; spaeteres Annehmen => bn:accept, pv mit ref+vp', () => { + const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } }); + assert.equal(h.sent.length, 0); + assert.equal(h.winL.pageshow, undefined); + assert.equal(h.winL.popstate, undefined); + assert.equal(h.history.pushState, h.origPush); + assert.equal(h.body.children.length, 1); + const open = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0]; + assert.ok(open); + open.click(); + assert.equal(h.sent.length, 0); + btn(hostEl(h).shadow, 'Annehmen').click(); + assert.deepEqual(h.types(), ['bn:accept', 'pv']); + assert.deepEqual(h.sent[1].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' }); + assert.ok(h.winL.pageshow && h.winL.popstate, 'Hooks nachtraeglich installiert'); + h.nav('/zwei'); + assert.equal(h.types().at(-1), 'pv'); + assert.equal(h.sent.at(-1).d.path, '/zwei'); +}); + +test('w.js: Ablehnen => genau ein bn:reject, danach nichts mehr (auch nicht Seitenwechsel)', () => { + const h2 = harness(); + btn(hostEl(h2).shadow, 'Ablehnen').click(); + assert.deepEqual(h2.types(), ['pv', 'bn:shown', 'bn:reject']); + const n = h2.sent.length; + h2.nav('/zwei'); h2.nav('/drei'); + for (const f of h2.winL.pageshow || []) f({ persisted: true }); + for (const f of h2.winL.popstate || []) f({}); + h2.timers.splice(0).forEach((f) => f()); + h2.ck(target()); h2.leave(); + for (const f of h2.docL.scroll || []) f({}); + assert.equal(h2.sent.length, n); + assert.equal(h2.types().filter((t) => t === 'bn:reject').length, 1); +}); + +test('w.js: Seitenwechsel-Timer vor dem Ablehnen sendet nichts', () => { + const h = harness(); + h.win.location.pathname = '/zwei'; + h.history.pushState(null, '', '/zwei'); // Timer geplant + assert.equal(h.timers.length, 1); + btn(hostEl(h).shadow, 'Ablehnen').click(); + const n = h.sent.length; + h.timers.splice(0).forEach((f) => f()); + assert.equal(h.sent.length, n); +}); + +test('w.js: Ablehnen in anderem Tab (storage) => danach kein pv beim Seitenwechsel', () => { + const h = harness(); + const n = h.sent.length; + h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }); + h.storage(); + h.nav('/zwei'); + for (const f of h.winL.pageshow || []) f({ persisted: true }); + assert.equal(h.sent.length, n); +}); + +test('w.js: Annehmen auf laufender Seite => bn:accept, genau ein cx mit ref+vp; kein zweites cx', () => { + const h = harness(); + assert.deepEqual(h.types(), ['pv', 'bn:shown']); + btn(hostEl(h).shadow, 'Annehmen').click(); + assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']); + assert.deepEqual(h.sent.at(-1).d, { t: 'cx', site: SITE, path: '/start', ref: refHost('https://www.google.com/', 'mein.de'), vp: 'd' }); + // Widerruf, erneut annehmen + findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click(); + btn(hostEl(h).shadow, 'Ablehnen').click(); + findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click(); + btn(hostEl(h).shadow, 'Annehmen').click(); + assert.equal(h.sent.filter((s) => s.d.t === 'cx').length, 1); +}); + +test('w.js: gespeichertes Annehmen => erstes pv mit ref+vp, kein cx', () => { + const h = harness({ stored: ACC0 }); + assert.deepEqual(h.types(), ['pv']); + assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' }); +}); + +test('w.js: T15 kein sendBeacon, fetch mit keepalive, omit, POST, text/plain', () => { + const h = harness(); + assert.ok(h.fetches.length >= 1); + assert.equal(h.beacon.length, 0); + for (const f of h.fetches) { + assert.equal(f.o.keepalive, true); + assert.equal(f.o.credentials, 'omit'); + assert.equal(f.o.method, 'POST'); + assert.equal(f.o.headers['Content-Type'], 'text/plain'); + } +}); + +test('w.js: T19 Platz unten solange Banner offen; Knopf mindestens 24px', () => { + const h = harness({ bannerHeight: 90, bodyPad: '5px' }); + assert.equal(h.body.style.paddingBottom, '90px'); + btn(hostEl(h).shadow, 'Ablehnen').click(); + assert.equal(h.body.style.paddingBottom, '5px'); + const g = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } }); + const css = findAll(g.body, (n) => n.tagName === 'STYLE').map((n) => n.textContent).join(''); + assert.match(css, /min-width:24px/); + assert.match(css, /min-height:32px/); + assert.match(css, /padding:8px 12px/); + assert.match(css, /font-size:13px/); +}); + test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => { - const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 }, withConsentLink: true }); - assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0); + const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }, withConsentLink: true }); + assert.equal(findAll(h.body, (x) => x.textContent === SETTINGS_LABEL).length, 0); assert.equal(hostEl(h), undefined); h.link.click(); assert.ok(hostEl(h)); @@ -343,14 +469,14 @@ test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () => }); test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => { - const h = harness({ gpc: true }); - h.win.location.pathname = '/start'; - h.history.pushState(null, '', '/start?x=1'); h.timers.splice(0).forEach((f) => f()); - assert.equal(h.sent.length, 1); + const h = harness(); + h.nav('/start'); + assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 1); h.win.location.pathname = '/neu'; h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu'); h.timers.splice(0).forEach((f) => f()); - assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/neu']); + assert.deepEqual(h.sent.filter((s) => s.d.t === 'pv').map((s) => s.d.path), ['/start', '/neu']); + assert.deepEqual(h.sent.at(-1).d, { t: 'pv', site: SITE, path: '/neu' }); }); test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => { @@ -361,36 +487,33 @@ test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => { }); }); -test('w.js: DNT yes / msDoNotTrack => nur pv', () => { - for (const o of [{ dnt: 'yes' }]) { const h = harness(o); assert.deepEqual(h.types(), ['pv']); } -}); - test('w.js: doppelt eingebunden => nur ein pv', () => { - const h = harness({ gpc: true }); + const h = harness(); h.rerun(); - assert.deepEqual(h.types(), ['pv']); + assert.deepEqual(h.types(), ['pv', 'bn:shown']); }); test('w.js: Widerruf in anderem Tab (storage-Ereignis) stoppt Tracking sofort', () => { - const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); + const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } }); h.ck(target()); assert.equal(h.sent.at(-1).d.t, 'ck'); - h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 }); + h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }); h.storage(); const n = h.sent.length; h.ck(target()); h.leave(); assert.equal(h.sent.length, n); assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation // Annehmen in anderem Tab startet Tracking - h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', t: 1_700_000_000_000 }); + h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 }); h.storage(); h.ck(target()); - assert.equal(h.sent.length, n + 1); + assert.equal(h.sent.length, n + 1); // nur ck, kein cx (pv trug ref/vp schon) + assert.ok(!h.sent.some((x) => x.d.t === 'cx')); }); test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereignis)', () => { - const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); - h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 }); + const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } }); + h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }); const n = h.sent.length; h.ck(target()); h.leave(); assert.equal(h.sent.length, n); @@ -398,13 +521,13 @@ test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereign }); test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () => { - const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); + const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } }); h.win.location.pathname = '/zwei'; h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f()); assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']); h.ck(target()); assert.equal(h.sent.at(-1).d.path, '/zwei'); - h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 }); + h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }); h.storage(); const n = h.sent.length; h.leave(); @@ -412,7 +535,7 @@ test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () => }); test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => { - const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } }); + const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } }); h.leave(); assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 1); for (const f of h.winL.pageshow || []) f({ persisted: false }); @@ -424,7 +547,7 @@ test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => { }); test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegation', () => { - const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 } }); + const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } }); // kein Link beim Laden => fester Link; spaeteres Element oeffnet ebenfalls const late = makeEl('a'); late.setAttribute('data-webklar-consent', ''); for (const f of [...(h.docL.click || [])]) f({ type: 'click', target: late, preventDefault() {} }); @@ -433,7 +556,7 @@ test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegat }); // ---------- Verweildauer bei internem Seitenwechsel ---------- -const ACC = { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 }; +const ACC = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 }; const lnk = (href, attrs = {}) => { const a = makeEl('a'); a.setAttribute('href', href); for (const k in attrs) a.setAttribute(k, attrs[k]); a.parentNode = makeEl('body'); return a; }; const enCount = (h) => h.sent.filter((s) => s.d.t === 'en').length; @@ -473,7 +596,7 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => { h.ck(lnk('/kontakt')); assert.equal(enCount(h), 0); const g = harness({ stored: ACC }); - g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 }); + g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }); g.ck(lnk('/kontakt')); assert.equal(enCount(g), 0); });