import { test } from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml, } from '../client/core.js'; import { buildAll } from '../client/build.js'; const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist'); // ---------- core ---------- test('vpClass', () => { assert.equal(vpClass(0), 'm'); assert.equal(vpClass(767), 'm'); assert.equal(vpClass(768), 't'); assert.equal(vpClass(1023), 't'); assert.equal(vpClass(1024), 'd'); assert.equal(vpClass(NaN), 'd'); }); test('cleanPath', () => { assert.equal(cleanPath('https://x.de/a/b?q=1#h'), '/a/b'); assert.equal(cleanPath('/über%20uns?x=1'), '/über uns'); assert.equal(cleanPath('/%E0%A4%A'), '/%E0%A4%A'); // kaputtes Encoding bleibt roh assert.equal(cleanPath('https://x.de'), '/'); assert.equal(cleanPath(''), '/'); assert.equal(cleanPath('/' + 'a'.repeat(400)).length, 300); assert.equal(cleanPath(null), '/'); assert.equal(cleanPath('//foo/bar?x=1'), '//foo/bar'); assert.equal(cleanPath('//foo'), '//foo'); }); test('refHost', () => { assert.equal(refHost('https://www.google.com/search?q=x', 'mein.de'), 'www.google.com'); assert.equal(refHost('https://mein.de/a', 'mein.de'), ''); assert.equal(refHost('https://www.mein.de/a', 'mein.de'), ''); assert.equal(refHost('', 'mein.de'), ''); assert.equal(refHost('kein url', 'mein.de'), ''); assert.equal(refHost('android-app://com.x', 'mein.de'), ''); assert.equal(refHost('HTTPS://Foo.DE/x', 'mein.de'), 'foo.de'); }); function el(tag, o = {}) { const e = { tagName: tag.toUpperCase(), id: o.id || '', parentNode: o.parent || null, previousElementSibling: o.prev || null, attrs: { class: o.cls || '' }, getAttribute(n) { return this.attrs[n] ?? null; } }; return e; } test('cssPath: nur Tags + nth-of-type, keine IDs/Klassen', () => { const body = el('body'); const div = el('div', { parent: body, cls: 'hero main' }); const p1 = el('p', { parent: div }); const btn0 = el('button', { parent: div, prev: p1 }); const btn = el('button', { parent: div, prev: btn0, cls: 'cta' }); assert.equal(cssPath(btn), 'body:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)'); const pii = el('a', { parent: body, id: 'max@firma.de', cls: 'team-max-mustermann' }); const sel = cssPath(pii); assert.equal(sel, 'body:nth-of-type(1) > a:nth-of-type(1)'); assert.ok(!/max|firma|mustermann|#|\./.test(sel)); // max. 6 Ebenen let n = el('div'); for (let i = 0; i < 9; i++) n = el('span', { parent: n }); assert.equal(cssPath(n).split(' > ').length, 6); // stoppt bei body const outer = el('html'); body.parentNode = outer; assert.equal(cssPath(div).split(' > ')[0], 'body:nth-of-type(1)'); // <= 200 let deep = el('div'); for (let i = 0; i < 5; i++) deep = el('x'.repeat(40), { parent: deep }); assert.ok(cssPath(deep).length <= 200); assert.equal(cssPath(null), ''); }); test('contactKind', () => { const k = (href) => contactKind({ href }, 'mein.de'); assert.equal(k('tel:+49123'), 'tel'); assert.equal(k('TEL:+49123'), 'tel'); assert.equal(k('mailto:a@b.de'), 'mail'); assert.equal(k('https://wa.me/49123'), 'wa'); assert.equal(k('https://api.whatsapp.com/send?phone=1'), 'wa'); assert.equal(k('whatsapp://send?text=x'), 'wa'); assert.equal(k('https://anders.de/x'), 'out'); assert.equal(k('https://mein.de/x'), null); assert.equal(k('https://www.mein.de/x'), null); assert.equal(k('/kontakt'), null); assert.equal(k('#top'), null); assert.equal(k('javascript:void(0)'), null); assert.equal(k(''), null); assert.equal(contactKind(null, 'mein.de'), null); }); 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); assert.equal(readConsent(st('kaputt{'), now), null); assert.equal(readConsent(st(null), now), null); assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null); assert.equal(readConsent(null, now), null); }); test('contrastOk', () => { assert.equal(contrastOk('#111111'), true); assert.equal(contrastOk('#ffffff'), false); assert.equal(contrastOk('#ffff00'), false); assert.equal(contrastOk('#767676'), true); // 4.54 assert.equal(contrastOk('#777777'), false); // 4.48 assert.equal(contrastOk('#00F'), false); // ungueltig assert.equal(contrastOk(undefined), false); }); test('escapeHtml', () => { assert.equal(escapeHtml(`&'`), '<a href="x">&'</a>'); assert.equal(escapeHtml(null), ''); }); // ---------- dist ---------- test('dist ist aktuell', () => { const b = buildAll(); assert.equal(readFileSync(join(DIST, 'w.js'), 'utf8'), b.w); assert.equal(readFileSync(join(DIST, 'h.js'), 'utf8'), b.h); assert.ok(!/^\s*(import|export)\s/m.test(b.w + b.h)); }); // ---------- Verhalten von w.js (Fake-DOM) ---------- function makeEl(tag) { const e = { tagName: String(tag).toUpperCase(), attrs: {}, children: [], listeners: {}, style: {}, parentNode: null, id: '', textContent: '', focused: false, removed: false, setAttribute(n, v) { this.attrs[n] = String(v); if (n === 'id') this.id = String(v); }, getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; }, appendChild(c) { c.parentNode = this; this.children.push(c); return c; }, append(...cs) { cs.forEach((c) => this.appendChild(c)); }, 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; }, remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); }, closest(sel) { const attr = /^\[([\w-]+)\]$/.exec(sel); for (let n = this; n; n = n.parentNode) if (attr && n.attrs && attr[1] in n.attrs) return n; return null; }, attachShadow() { const r = makeEl('shadow'); r.isShadow = true; this.shadow = r; return r; }, click(extra = {}) { fire(this, 'click', { target: this, ...extra }); }, }; Object.defineProperty(e, 'href', { get() { return this.attrs.href || ''; } }); Object.defineProperty(e, 'className', { set(v) { this.attrs.class = v; }, get() { return this.attrs.class || ''; } }); return e; } function fire(node, type, ev) { for (const f of [...(node.listeners[type] || [])]) f({ type, preventDefault() {}, ...ev }); } function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if (n.shadow) walk(n.shadow, fn); } 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; if (stored) store['wk-consent'] = JSON.stringify(stored); const body = makeEl('body'); 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 = {}; const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', ''); 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', documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'), createElement: makeEl, 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 win = { innerWidth: 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; } }, 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(), }; win.localStorage = sandbox.localStorage; sandbox.globalThis = sandbox; vm.createContext(sandbox); vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox); const types = () => sent.map((s) => s.d.t + (s.d.a ? ':' + s.d.a : '')); const ck = (target, ev = {}) => { for (const f of [...(docL.click || [])]) f({ type: 'click', target, pageX: 500, pageY: 1000, clientX: 500, clientY: 700, preventDefault() {}, ...ev }); }; 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 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 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' }); h.ck(target()); h.leave(); assert.deepEqual(h.types(), ['pv', 'bn:shown']); assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation }); 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'); 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/); const a = all.find((n) => n.tagName === 'A'); assert.equal(a.attrs.href, '/datenschutz-analyse.html'); assert.equal(a.textContent, 'Datenschutz'); const css = all.filter((n) => n.tagName === 'STYLE').map((n) => n.textContent).join(''); assert.match(css, /#0a5a8a/); assert.match(css, /max-width:\s*767px/); const buttons = all.filter((n) => n.tagName === 'BUTTON'); assert.deepEqual(buttons.map((b) => b.textContent), ['Ablehnen', 'Annehmen']); assert.ok(!buttons[0].focused, 'kein Fokusraub beim Laden'); assert.equal(buttons[0].attrs.class, buttons[1].attrs.class); assert.ok(!/button\.|\.p\b/.test(css), 'eine Regel fuer beide Buttons'); 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.ok(bh.removed && !hostEl(h)); const t = target(); h.ck(t); const ck = h.sent.at(-1).d; assert.deepEqual(ck, { t: 'ck', site: 'abcdef0123456789abcd', path: '/start', vp: 'd', x: 0.5, y: 0.5, sel: 'body:nth-of-type(1) > button:nth-of-type(1)' }); // Kontakt-Link: ck + ct (Kind-Element in ) const a = makeEl('a'); a.setAttribute('href', 'tel:+4912'); a.parentNode = makeEl('body'); const span = makeEl('span'); span.parentNode = a; const n = h.sent.length; h.ck(span); assert.deepEqual(h.sent.slice(n).map((s) => s.d.t), ['ck', 'ct']); assert.deepEqual(h.sent.at(-1).d, { t: 'ct', site: 'abcdef0123456789abcd', path: '/start', kind: 'tel' }); const out = makeEl('a'); out.setAttribute('href', 'https://anders.de/x'); out.parentNode = makeEl('body'); h.ck(out); assert.deepEqual(h.sent.at(-1).d, { t: 'ct', site: 'abcdef0123456789abcd', path: '/start', kind: 'out', host: 'anders.de' }); // en einmal h.leave(); h.leave(); const en = h.sent.filter((s) => s.d.t === 'en'); assert.equal(en.length, 1); 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]; assert.ok(settings, 'fester Link'); settings.click(); assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen'); btn(hostEl(h).shadow, 'Ablehnen').click(); assert.equal(h.types().at(-1), 'bn:reject'); const before = h.sent.length; h.ck(target()); h.leave(); assert.equal(h.sent.length, before); assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation assert.equal((h.docL.visibilitychange || []).length, 0); assert.equal((h.winL.pagehide || []).length, 0); }); 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 } }); assert.deepEqual(h.types(), ['pv']); assert.equal(hostEl(h), undefined); h.ck(target()); assert.equal(h.sent.at(-1).d.t, 'ck'); }); 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']); 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(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 } }]) { 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']); } }); 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); assert.equal(hostEl(h), undefined); h.link.click(); assert.ok(hostEl(h)); }); test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () => { const h = harness({ search: '?a=1&__wk_heat=TOKEN.sig' }); assert.deepEqual(h.sent, []); assert.equal(hostEl(h), undefined); assert.equal(h.history.replaced, '/start?a=1'); assert.equal(h.injected.length, 1); assert.equal(h.injected[0].attrs.src, 'https://project.webklar.com/a/h.js'); assert.equal(h.injected[0].attrs['data-token'], 'TOKEN.sig'); assert.equal((h.docL.click || []).length, 0); }); 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); 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']); }); test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => { assert.doesNotThrow(() => { const sandbox = { window: {}, document: { currentScript: null, querySelector() { throw new Error('x'); } }, navigator: {}, location: {} }; sandbox.window = sandbox; vm.createContext(sandbox); vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox); }); }); 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 }); h.rerun(); assert.deepEqual(h.types(), ['pv']); }); 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 } }); 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.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.storage(); h.ck(target()); assert.equal(h.sent.length, n + 1); }); 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 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 }); 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 } }); 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.storage(); const n = h.sent.length; h.leave(); assert.equal(h.sent.length, n); }); 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 } }); h.leave(); assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 1); for (const f of h.winL.pageshow || []) f({ persisted: false }); assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 1); for (const f of h.winL.pageshow || []) f({ persisted: true }); assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 2); h.leave(); assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 2); }); 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 } }); // 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() {} }); assert.ok(hostEl(h)); assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused); }); // ---------- Verweildauer bei internem Seitenwechsel ---------- const ACC = { v: 1, a: 'accept', 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; test('w.js: interner Link-Klick sendet en sofort, pagehide danach nicht doppelt', () => { const h = harness({ stored: ACC }); h.ck(lnk('/kontakt')); assert.equal(enCount(h), 1); assert.deepEqual(h.sent.map((s) => s.d.t), ['pv', 'ck', 'en']); const en = h.sent.at(-1).d; assert.equal(en.path, '/start'); assert.equal(en.scroll, 55); h.leave(); assert.equal(enCount(h), 1); h.ck(lnk('https://mein.de/andere?x=1#a')); assert.equal(enCount(h), 1); }); test('w.js: kein fruehes en bei externem Link, Anker, gleicher Seite, Modifier, Ziel/Download, defaultPrevented', () => { const h = harness({ stored: ACC }); h.ck(lnk('https://anders.de/x')); h.ck(lnk('#oben')); h.ck(lnk('/start#unten')); h.ck(lnk('/kontakt'), { ctrlKey: true }); h.ck(lnk('/kontakt'), { metaKey: true }); h.ck(lnk('/kontakt'), { shiftKey: true }); h.ck(lnk('/kontakt'), { button: 1 }); h.ck(lnk('/kontakt', { target: '_blank' })); h.ck(lnk('/datei.pdf', { download: '' })); h.ck(lnk('/kontakt'), { defaultPrevented: true }); h.ck(lnk('mailto:a@b.de')); assert.equal(enCount(h), 0); h.leave(); assert.equal(enCount(h), 1); }); test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => { const h = harness(); 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.ck(lnk('/kontakt')); assert.equal(enCount(g), 0); });