Heatmap im Portal (Funktionen -> Analyse): die echte Website laeuft jetzt in realistischen Geraeterahmen, gross MacBook oder iPad (Umschalter oben), daneben das iPhone, jeweils in echter Breite (1512 / 820 / 402 px), verkleinert und im Geraet scrollbar, mit den Klicks der passenden Geraeteklasse als Overlay. MacBook und iPhone stammen aus den Rahmen der neuen Website (device-mockups), das iPad ist im gleichen Stil nachgebaut (public/geraete.css/.js). - heatmap-link: embed: true haengt __wk_embed=1 an; w.js reicht das als data-embed an h.js. - h.js eingebettet (nur in einem Rahmen): keine Leiste, Links und Formulare gesperrt (kein Seitenwechsel, also keine Zaehlung und kein Banner), Scrollleisten aus, Klickzahl per postMessage nur an die Herkunft von h.js (project.webklar.com). - iframe mit Sandbox allow-scripts allow-same-origin (keine Formulare, Pop-ups, keine Navigation des Portals); Laden erst, wenn die Heatmap in Sicht kommt. Laesst sich eine Website nicht einbetten, kommt nach 20 s ein Hinweis mit "In neuem Tab oeffnen" (alter Weg). Banner-Skript: der Einstellungsknopf nach der Wahl ist jetzt ein angeknabberter Keks unten rechts (Inline-SVG, 44 px). Er dreht sich langsam, haelt beim Darueberfahren/Fokus an und zeigt dann "Datenschutz-Einstellungen" (auch sein Name fuer Screenreader, passend zum Bannertext); bei reduzierter Bewegung dreht er sich nicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
6.3 KiB
JavaScript
130 lines
6.3 KiB
JavaScript
// Verhalten von dist/h.js (Heatmap-Overlay) im Fake-DOM: eigenes Fenster und Einbettung im Portal.
|
||
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';
|
||
|
||
const H = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist', 'h.js'), 'utf8');
|
||
|
||
function makeEl(tag) {
|
||
const e = {
|
||
tagName: String(tag).toUpperCase(), nodeType: 1, attrs: {}, children: [], listeners: {}, style: { setProperty() {} }, parentNode: null,
|
||
textContent: '', removed: false,
|
||
setAttribute(n, v) { this.attrs[n] = String(v); },
|
||
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; },
|
||
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
|
||
addEventListener(t, f, o) { (this.listeners[t] ||= []).push({ f, o }); },
|
||
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x.f !== f); },
|
||
remove() { this.removed = true; },
|
||
attachShadow() { const r = makeEl('shadow'); this.shadow = r; return r; },
|
||
closest(sel) {
|
||
const tags = sel.split(',').map((t) => t.trim().toUpperCase());
|
||
for (let n = this; n && n.tagName; n = n.parentNode) if (tags.includes(n.tagName)) return n;
|
||
return null;
|
||
},
|
||
getContext() {
|
||
return {
|
||
createLinearGradient: () => ({ addColorStop() {} }),
|
||
createRadialGradient: () => ({ addColorStop() {} }),
|
||
fillRect() {}, clearRect() {},
|
||
getImageData: (x, y, w, hh) => ({ data: new Uint8ClampedArray(Math.max(4, w * hh * 4)) }),
|
||
putImageData() {},
|
||
};
|
||
},
|
||
};
|
||
return e;
|
||
}
|
||
|
||
async function harness({ embed = false, framed = true, fail = false, points = [{ x: 0.5, y: 0.5 }, { x: 0.1, y: 0.2 }, { x: 2, y: 0 }] } = {}) {
|
||
const body = makeEl('body'); const head = makeEl('head'); const html = makeEl('html');
|
||
Object.assign(html, { scrollWidth: 402, scrollHeight: 2000, clientWidth: 402, clientHeight: 800 });
|
||
const script = makeEl('script');
|
||
script.setAttribute('src', 'https://project.webklar.com/a/h.js');
|
||
script.setAttribute('data-token', 'TOK.sig');
|
||
if (embed) script.setAttribute('data-embed', '1');
|
||
script.src = 'https://project.webklar.com/a/h.js';
|
||
const fetches = []; const posted = []; const winL = {};
|
||
const document = {
|
||
currentScript: script, documentElement: html, body, head,
|
||
createElement: (t) => makeEl(t),
|
||
};
|
||
const win = {
|
||
document,
|
||
addEventListener(t, f, o) { (winL[t] ||= []).push({ f, o }); },
|
||
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x.f !== f); },
|
||
};
|
||
const parent = framed ? { postMessage(data, origin) { posted.push({ data, origin }); } } : win;
|
||
win.parent = parent;
|
||
const sandbox = {
|
||
window: win, document, URL, JSON, Math, String, Number, Array, Object, RegExp, Error, Uint8ClampedArray, Promise,
|
||
setInterval: () => 1, clearInterval() {},
|
||
fetch: (u, o) => {
|
||
fetches.push({ u, o });
|
||
if (fail) return Promise.resolve({ ok: false, json: () => Promise.resolve({}) });
|
||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ points }) });
|
||
},
|
||
};
|
||
sandbox.globalThis = sandbox;
|
||
vm.createContext(sandbox);
|
||
vm.runInContext(H, sandbox);
|
||
await new Promise((r) => setTimeout(r, 10));
|
||
const fire = (type, target) => {
|
||
const ev = { type, target, prevented: false, stopped: false, preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; } };
|
||
for (const { f } of winL[type] || []) f(ev);
|
||
return ev;
|
||
};
|
||
const shadowHosts = body.children.filter((c) => c.shadow);
|
||
return { body, head, fetches, posted, winL, fire, shadowHosts };
|
||
}
|
||
|
||
const linkIn = () => { const a = makeEl('a'); a.setAttribute('href', '/kontakt'); const span = makeEl('span'); span.parentNode = a; return span; };
|
||
|
||
test('h.js eigenes Fenster: schwarze Leiste mit Klickzahl, keine Nachricht, Links bleiben klickbar', async () => {
|
||
const h = await harness();
|
||
assert.equal(h.fetches.length, 1);
|
||
assert.equal(h.fetches[0].u, 'https://project.webklar.com/a/heat?token=TOK.sig');
|
||
assert.equal(h.fetches[0].o.credentials, 'omit');
|
||
assert.equal(h.shadowHosts.length, 1, 'Leiste');
|
||
const msg = h.shadowHosts[0].shadow.children.find((c) => c.className === 'bar').children[0];
|
||
assert.equal(msg.textContent, 'Heatmap – 2 Klicks (letzte 30 Tage)');
|
||
assert.deepEqual(h.posted, []);
|
||
assert.equal(h.fire('click', linkIn()).prevented, false);
|
||
});
|
||
|
||
test('h.js eingebettet: keine Leiste, meldet Klickzahl nur an project.webklar.com', async () => {
|
||
const h = await harness({ embed: true });
|
||
assert.equal(h.shadowHosts.length, 0, 'keine Leiste');
|
||
assert.ok(h.body.children.some((c) => c.tagName === 'CANVAS'), 'Overlay da');
|
||
assert.deepEqual(JSON.parse(JSON.stringify(h.posted)), [{ data: { wk: 'heat', state: 'ready', count: 2 }, origin: 'https://project.webklar.com' }]);
|
||
// Scrollleisten ausgeblendet (Handy/Tablet haben ueberlagernde Leisten)
|
||
assert.ok(h.head.children.some((c) => c.tagName === 'STYLE' && /scrollbar-width:none/.test(c.textContent)));
|
||
});
|
||
|
||
test('h.js eingebettet: Links und Formulare gesperrt, andere Klicks nicht', async () => {
|
||
const h = await harness({ embed: true });
|
||
assert.ok((h.winL.click || []).some((l) => l.o === true), 'Klick-Sperre in der Capture-Phase');
|
||
const ev = h.fire('click', linkIn());
|
||
assert.equal(ev.prevented, true);
|
||
assert.equal(ev.stopped, true, 'Router der Seite bekommt den Klick nicht');
|
||
const area = h.fire('click', makeEl('area'));
|
||
assert.equal(area.prevented, true);
|
||
const other = h.fire('click', makeEl('button'));
|
||
assert.equal(other.prevented, false);
|
||
const sub = h.fire('submit', makeEl('form'));
|
||
assert.equal(sub.prevented, true);
|
||
});
|
||
|
||
test('h.js eingebettet: Fehler beim Laden => state error', async () => {
|
||
const h = await harness({ embed: true, fail: true });
|
||
assert.deepEqual(JSON.parse(JSON.stringify(h.posted)), [{ data: { wk: 'heat', state: 'error', count: 0 }, origin: 'https://project.webklar.com' }]);
|
||
});
|
||
|
||
test('h.js mit data-embed, aber nicht in einem Rahmen: verhaelt sich wie das eigene Fenster', async () => {
|
||
const h = await harness({ embed: true, framed: false });
|
||
assert.equal(h.shadowHosts.length, 1);
|
||
assert.deepEqual(h.posted, []);
|
||
assert.equal(h.fire('click', linkIn()).prevented, false);
|
||
});
|