From 3bac4093affb23d2382a5b089a9fced5302af151 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:27:02 +0000 Subject: [PATCH] =?UTF-8?q?Analyse:=20Portal-Texte,=20Verantwortlichen-Vor?= =?UTF-8?q?schau=20und=20Best=C3=A4tigungen=20nach=20gepr=C3=BCftem=20Wort?= =?UTF-8?q?laut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- public/analyse-view.js | 9 +++ public/funktionen.js | 44 ++++++++------ server/routes/funktionen.js | 2 + test/analyseRoutes.test.js | 3 +- test/analyseView.test.js | 14 ++++- test/avvPage.test.js | 10 ++-- test/funktionenTiles.test.js | 88 +++++++++++++++++++++++++++- test/ui/analyse.ui.mjs | 108 +++++++++++++++++++++++++++++++++++ 8 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 test/ui/analyse.ui.mjs diff --git a/public/analyse-view.js b/public/analyse-view.js index bb99af0..bc6f612 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -81,3 +81,12 @@ export function barRows(items, labelOf) { const max = Math.max(1, ...list.map((i) => i.count)) return list.map((i) => ({ key: i.key, label: labelOf(i.key), count: i.count, pct: Math.round((i.count / max) * 100) })) } + +/** Banner-Text der Portal-Vorschau; muss mit BANNER_TEXT in analytics/client/core.js uebereinstimmen (Paritaetstest). */ +export const BANNER_PREVIEW_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: ' + +/** Zeilen der Verantwortlichen-Vorschau: Firma (sonst Name), Anschrift falls hinterlegt, E-Mail. Leere Angaben entfallen. */ +export function controllerLines(controller) { + const c = controller || {} + return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean) +} diff --git a/public/funktionen.js b/public/funktionen.js index a792659..b343889 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -3,10 +3,9 @@ Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css. Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ -import { bannerColor, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' +import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' const PRICE = '30 €' -const AVV_URL = '/rechtliches/avv-website-analyse.html' const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um const PHOTO_MAX_BYTES = 10 * 1024 * 1024 @@ -592,23 +591,29 @@ function bannerPreview(color) { const btn = `style="background:${c};border-color:${c};color:#fff"` return ` ` } -function renderAnalyseActivation(stage, project, reload) { +const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' + +// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt +function controllerBlock(controller) { + if (controller.ok !== true) return `` + return `

Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

${controllerLines(controller).map(esc).join(' · ')}

Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

` +} + +export function renderAnalyseActivation(stage, project, reload) { const a = analyseOf(project) + const legal = a.legal || {} + const controller = a.controller || { ok: false } let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' stage.innerHTML = `

Analyse für ${esc(project.projectName)} einschalten

-

Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen. Wir speichern keine IP-Adressen und setzen keine Cookies. Alles läuft auf einem eigenen Server in Deutschland.

-
    -
  • Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.
  • -
  • Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.
  • -
  • Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.
  • -
+

Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

+

Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.

Farbe des Banners

@@ -616,7 +621,8 @@ function renderAnalyseActivation(stage, project, reload) {
- + ${controllerBlock(controller)} +
` @@ -632,9 +638,9 @@ function renderAnalyseActivation(stage, project, reload) { const box = stage.querySelector('#an-avv') const button = stage.querySelector('#an-on') const error = stage.querySelector('#an-error') - box.addEventListener('change', () => { button.disabled = !box.checked }) + box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) button.addEventListener('click', async () => { - if (!box.checked) return + if (!box.checked || controller.ok !== true) return button.disabled = true error.classList.add('hidden') try { @@ -643,7 +649,7 @@ function renderAnalyseActivation(stage, project, reload) { } catch (err) { error.textContent = err.message error.classList.remove('hidden') - button.disabled = !box.checked + button.disabled = !box.checked || controller.ok !== true } }) } @@ -757,10 +763,10 @@ function renderAnalyseHeat(box, projectId, pages) { } // Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog -function renderAnalyseOff(box, projectId, reload) { +export function renderAnalyseOff(box, projectId, reload) { box.innerHTML = `

Analyse ausschalten

-

Danach werden keine neuen Daten mehr gesammelt und der Banner verschwindet von deiner Website. Die bisherigen Daten bleiben noch 60 Tage erhalten.

+

Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

` const actions = box.querySelector('#an-off-actions') @@ -772,7 +778,11 @@ function renderAnalyseOff(box, projectId, reload) { ev.currentTarget.disabled = true try { await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) - reload() + box.innerHTML = ` +

Analyse ausgeschaltet

+

Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

+
` + box.querySelector('#an-off-ok').addEventListener('click', () => reload()) } catch (err) { renderAnalyseOff(box, projectId, reload) const e2 = box.querySelector('#an-off-error') diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 03d7faf..93cdbc9 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -1,4 +1,5 @@ import { Router } from 'express' +import { legalPaths } from '../services/analyseLegal.js' import { config } from '../config.js' import { getDocument } from '../services/appwriteAdmin.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' @@ -66,6 +67,7 @@ async function analyseView(customerId, projectId) { reason: eligibility.reason || null, reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null, avvVersion: config.analyse.avvVersion, + legal: legalPaths(config.analyse.avvVersion), controller, } } diff --git a/test/analyseRoutes.test.js b/test/analyseRoutes.test.js index 2b14ce7..59e4f7a 100644 --- a/test/analyseRoutes.test.js +++ b/test/analyseRoutes.test.js @@ -14,6 +14,7 @@ const aw = await startAppwriteMock({ const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') +const { legalPaths } = await import('../server/services/analyseLegal.js') const { startTestApp, get, rawGet, post, sessionCookie } = await import('./support/testApp.js') const { customerCookie, wartungDoc, seedFixtures } = await import('./support/fixtures.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') @@ -108,7 +109,7 @@ test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', a const body = await get('/api/funktionen', { cookie }) const p = (id) => body.projects.find((x) => x.projectId === id) assert.equal(p('p1').features.news.state, 'off') - assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } }) + assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } }) assert.equal(p('p2').analyse.eligible, false) assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich') assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich) diff --git a/test/analyseView.test.js b/test/analyseView.test.js index c1d70c6..662e7bf 100644 --- a/test/analyseView.test.js +++ b/test/analyseView.test.js @@ -1,8 +1,8 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import { contrastOk } from '../analytics/client/core.js' -import { bannerColor, chartModel, countryName, flagEmoji, niceMax, shortDay, barRows, refLabel } from '../public/analyse-view.js' +import { contrastOk, BANNER_TEXT } from '../analytics/client/core.js' +import { BANNER_PREVIEW_TEXT, controllerLines, bannerColor, chartModel, countryName, flagEmoji, niceMax, shortDay, barRows, refLabel } from '../public/analyse-view.js' test('contrast und bannerColor stimmen mit core.js ueberein', () => { for (const c of ['#000000', '#ffffff', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c', '#ffcc00', '#9d3dfe', 'x', '#12']) { @@ -57,3 +57,13 @@ test('Portal: Analyse-Ansicht ist verdrahtet', () => { const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/) }) + +test('Portal-Vorschau: Banner-Text ist der des Clients', () => { + assert.equal(BANNER_PREVIEW_TEXT, BANNER_TEXT) +}) + +test('controllerLines: Firma, Anschrift falls vorhanden, E-Mail', () => { + assert.deepEqual(controllerLines({ company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }), ['Muster GmbH', 'Weg 1, 12345 Ort', 'info@muster.de']) + assert.deepEqual(controllerLines({ company: 'Muster GmbH', address: '', email: 'info@muster.de' }), ['Muster GmbH', 'info@muster.de']) + assert.deepEqual(controllerLines(undefined), []) +}) diff --git a/test/avvPage.test.js b/test/avvPage.test.js index 2290dfc..dcf9972 100644 --- a/test/avvPage.test.js +++ b/test/avvPage.test.js @@ -24,11 +24,13 @@ test('Entwurfsseite ohne Fassung ist nicht mehr als Datei vorhanden', () => { assert.ok(!existsSync(root('public/rechtliches/leistungsbeschreibung-website-analyse.html'))) }) -test('AVV-Link im Portal zeigt auf eine ausgelieferte Adresse (Task 7 stellt auf die Fassung um)', () => { +test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => { const fn = readFileSync(root('public/funktionen.js'), 'utf8') - const url = fn.match(/const AVV_URL = '([^']+)'/)?.[1] - assert.equal(url, '/rechtliches/avv-website-analyse.html') - for (const f of FILES) assert.ok(existsSync(root(f))) + for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) { + assert.ok(!fn.includes(old), old) + } + assert.doesNotMatch(fn, /AVV_URL/) + assert.match(fn, /a\.legal/) }) test('Portal: Dev-Modus-Hinweis an der Analyse-Kachel', () => { diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index edbe3a2..764d041 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -11,7 +11,7 @@ const viewUrl = pathToFileURL(new URL('../public/analyse-view.js', import.meta.u const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8').replace("'/analyse-view.js'", `'${viewUrl}'`) const file = join(dir, 'funktionen.mjs') writeFileSync(file, src) -const { initFunktionen } = await import(pathToFileURL(file).href) +const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href) const news = { state: 'off' } const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse }) @@ -46,3 +46,89 @@ test('Kacheln rendern ohne Fehler: News, Analyse comingSoon/aus/aktiv/gesperrt', assert.match(tile(disabled, 'analyse'), /Bitte melde dich bei uns/) assert.doesNotMatch(disabled, /Dev-Modus/) }) + +// ---- Analyse-Einschalten und Ausschalten (ohne DOM: Attrappen, die innerHTML und Klick-Handler festhalten) +function fakeEl() { + const handlers = {} + return { innerHTML: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] } +} +function fakeStage() { + const els = {} + const get = (sel) => (els[sel] ||= fakeEl()) + return { els, innerHTML: '', querySelector: get, querySelectorAll: () => [] } +} +const offProject = (analyse) => ({ projectId: 'p1', projectName: 'Muster', analyse: { state: 'off', eligible: true, accentColor: '#111111', avvVersion: '2026-10-02', ...analyse } }) +const LEGAL = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' } +const CTRL = { ok: true, company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' } + +async function renderActivation(analyse) { + globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [offProject(analyse)] }) }) + const root = fakeEl() + const stage = fakeStage() + root.querySelector = (sel) => (sel === '#an-stage' ? stage : null) + await initAnalyse(root) + return stage +} + +test('Einschalten: zwei Links aus legal, Fassung, Verantwortlicher, Kinder-Satz, Banner-Vorschau', async () => { + const stage = await renderActivation({ legal: LEGAL, controller: CTRL }) + const html = stage.innerHTML + assert.ok(html.includes(`href="${LEGAL.avv}"`)) + assert.ok(html.includes(`href="${LEGAL.lb}"`)) + assert.match(html, /Fassung 2026-10-02/) + assert.match(html, /Muster GmbH/) + assert.match(html, /info@muster\.de/) + assert.match(html, /Stimmt etwas nicht\? Schreib uns im Chat, bevor Du einschaltest\./) + assert.match(html, /nicht gezielt an Kinder unter 16 Jahren/) + assert.match(html, /Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse/) + assert.doesNotMatch(html, /anonym/) + const preview = stage.els['#an-preview'].innerHTML + assert.match(preview, /Ablehnen/) + assert.match(preview, /Annehmen/) + assert.match(preview, /Datenschutz-Einstellungen/) + // Haken schaltet den Knopf frei + const box = stage.els['#an-avv'] + box.checked = true + box.handlers.change() + assert.equal(stage.els['#an-on'].disabled, false) +}) + +test('Einschalten: Firma und Anschrift werden maskiert', async () => { + const stage = await renderActivation({ legal: LEGAL, controller: { ...CTRL, company: 'A&Co' } }) + assert.match(stage.innerHTML, /A<b>&Co/) + assert.doesNotMatch(stage.innerHTML, /A/) +}) + +test('Einschalten: ohne Verantwortlichen-Angaben gesperrt, Hinweis sichtbar, kein Schreib-uns-Hinweis der Vorschau', async () => { + const stage = await renderActivation({ legal: LEGAL, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } }) + assert.match(stage.innerHTML, /fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse/) + assert.doesNotMatch(stage.innerHTML, /bevor Du einschaltest/) + const box = stage.els['#an-avv'] + box.checked = true + box.handlers.change() + assert.equal(stage.els['#an-on'].disabled, true) +}) + +test('Ausschalten: Hinweis vorher, nach "Ja, ausschalten" Bestätigung, erst "OK" lädt neu', async () => { + const calls = [] + globalThis.fetch = async (url) => { calls.push(url); return { ok: true, status: 200, json: async () => ({}) } } + const box = fakeEl() + const actions = fakeEl() + const yes = fakeEl() + const no = fakeEl() + const ok = fakeEl() + const ask = fakeEl() + box.querySelector = (sel) => ({ '#an-off-actions': actions, '#an-off-ask': ask, '#an-off-ok': ok, '#an-off-error': fakeEl() })[sel] + actions.querySelector = (sel) => ({ '#an-off-yes': yes, '#an-off-no': no })[sel] + let reloads = 0 + renderAnalyseOff(box, 'p1', () => { reloads++ }) + assert.match(box.innerHTML, /Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website/) + assert.match(box.innerHTML, /bis zum 46\. Tag kannst Du eine Kopie anfordern/) + ask.handlers.click() + await yes.handlers.click({ currentTarget: yes }) + assert.deepEqual(calls, ['/api/funktionen/analyse/deactivate']) + assert.match(box.innerHTML, /Die Website-Analyse ist ausgeschaltet\. Ab jetzt werden keine Daten mehr gespeichert\./) + assert.equal(reloads, 0) + ok.handlers.click() + assert.equal(reloads, 1) +}) diff --git a/test/ui/analyse.ui.mjs b/test/ui/analyse.ui.mjs new file mode 100644 index 0000000..88310ca --- /dev/null +++ b/test/ui/analyse.ui.mjs @@ -0,0 +1,108 @@ +// Playwright-Check Analyse-Panel (Einschalten-Dialog + aktives Panel): statischer Server fuer public/ + gemockte API. +// Nicht Teil von npm test. Aufruf: node test/ui/analyse.ui.mjs [Ausgabeordner] +import http from 'node:http' +import { readFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs' + +const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') +const SHOTS = process.argv[2] || '/root/reports/website-analyse-2026-10-01' +await mkdir(SHOTS, { recursive: true }) +const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' } +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://x') + try { + const file = path.join(pub, url.pathname === '/' ? 'dashboard.html' : url.pathname) + const data = await readFile(file) + res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' }) + res.end(data) + } catch { res.writeHead(404); res.end('nicht gefunden') } +}) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) +const base = `http://127.0.0.1:${server.address().port}` + +const legal = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' } +const controller = { ok: true, company: 'Bäckerei Berg GmbH', address: 'Hauptstraße 1, 12345 Musterstadt', email: 'info@baeckerei-berg.example' } +const base1 = { projectId: 'p1', projectName: 'Bäckerei Berg', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } } +const SCENARIOS = { + off: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } }, + missing: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } } }, + active: { ...base1, analyse: { state: 'active', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } }, +} +const days = Array.from({ length: 14 }, (_, i) => ({ day: `2026-09-${String(17 + i).padStart(2, '0')}`, pv: 20 + ((i * 7) % 13) * 4 })) +const stats = { + totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: days, + pages: [{ key: '/', count: 300 }, { key: '/kontakt', count: 120 }], refs: [{ key: '', count: 200 }, { key: 'google.com', count: 90 }], + countries: [{ key: 'DE', count: 500 }], devices: [{ key: 'm', count: 380 }, { key: 'd', count: 230 }], contacts: [{ key: 'tel', count: 12 }], + banner: { shown: 300, accept: 186, reject: 114 }, +} +let scenario = 'off' +const browser = await chromium.launch() +let failed = 0 +const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 } +try { + for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) { + const ctx = await browser.newContext({ viewport }) + const page = await ctx.newPage() + const errors = [] + page.on('pageerror', (e) => errors.push(e.message)) + await page.route('**/api/**', async (route) => { + const p = new URL(route.request().url()).pathname + const json = (b) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) }) + if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } }) + if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] }) + if (p === '/api/features') return json({ features: [] }) + if (p === '/api/activity') return json({ tickets: [] }) + if (p === '/api/funktionen') return json({ projects: [SCENARIOS[scenario]] }) + if (p === '/api/funktionen/analyse/stats') return json(stats) + if (p === '/api/funktionen/analyse/deactivate') return json({ ok: true }) + return json({}) + }) + const open = async (sc) => { + scenario = sc + await page.goto('about:blank') + await page.goto(`${base}/dashboard.html#analyse`) + await page.waitForSelector('#analyse-body .kb-panel') + await page.waitForTimeout(900) + } + const shot = async (name) => { + const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 3200)) + await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) }) + await page.waitForTimeout(150) + await page.screenshot({ path: `${SHOTS}/task-7-${label}-${name}.png` }) + await page.setViewportSize(viewport) + } + const noScroll = async (n) => check(`${label} kein_horizontaler_scroll ${n}`, await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)) + + await open('off') + const text = await page.textContent('#analyse-body') + check(`${label} verantwortlicher`, text.includes('Bäckerei Berg GmbH') && text.includes('info@baeckerei-berg.example')) + check(`${label} links`, (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`) + check(`${label} knopf_gesperrt_ohne_haken`, await page.isDisabled('#an-on')) + await page.check('#an-avv') + check(`${label} knopf_frei_mit_haken`, !(await page.isDisabled('#an-on'))) + await noScroll('einschalten') + await shot('einschalten') + + await open('missing') + await page.check('#an-avv') + check(`${label} gesperrt_ohne_angaben`, (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing'))) + + await open('active') + await page.waitForSelector('.an-kpis') + await noScroll('aktiv') + await shot('aktiv') + await page.click('#an-off-ask') + await page.click('#an-off-yes') + await page.waitForSelector('#an-off-done') + check(`${label} ausschalten_bestaetigung`, (await page.textContent('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert')) + await shot('ausgeschaltet') + check(`${label} keine_js_fehler`, errors.length === 0) + await ctx.close() + } +} finally { + await browser.close() + server.close() +} +process.exit(failed ? 1 : 0)