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 `
Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: Datenschutz
+${esc(BANNER_PREVIEW_TEXT)}Datenschutz
${esc(MISSING_PRIVACY_HINT)}
` + 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.
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.
-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.
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 = ` +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)