// Playwright-Check Analyse (Cockpit): statischer Server fuer public/ + gemockte API. // Zustaende: Einschalten, aktiv (volle Daten, neu, wenig Daten, Dev-Modus, alter Collector, langer Pfad, 503), Reiter, Zeitraum. // 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/analyse-ueberarbeitung-2026-10-04/screens' 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}` /* --- Testdaten: Form wie analytics/src/stats.js, Werte wie die Fixture "Boulderhalle Überhang" --- */ 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 baseProject = { projectId: 'p1', projectName: 'Boulderhalle Überhang', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } } const analyse = (over) => ({ state: 'active', eligible: true, devMode: false, accentColor: '#1d4ed8', avvVersion: '2026-10-02', activatedAt: '2026-08-12T09:00:00.000Z', legal, controller, ...over }) const project = (over = {}) => ({ ...baseProject, analyse: analyse(over) }) const TODAY = '2026-10-04' const addDays = (day, n) => new Date(Date.UTC(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10) + n)).toISOString().slice(0, 10) const daysOf = (from, n, base, spike = -1) => Array.from({ length: n }, (_, i) => ({ day: addDays(from, i), pv: i === spike ? 371 : Math.round(base * (1 + 0.25 * Math.sin(i / 2.3))), visitors: 80 + (i % 7) * 4, contacts: 4 + (i % 5), avgSec: 100 + (i % 9) * 3, })) const TOTALS = { 7: { pv: 1640, visitors: 560, contacts: 41, consentRate: 0.61, engaged: 400, avgSec: 101, avgScroll: 62 }, 30: { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 }, 90: { pv: 19800, visitors: 6700, contacts: 530, consentRate: 0.6, engaged: 5000, avgSec: 111, avgScroll: 63 }, } const BEFORE = { 7: { pv: 1500, visitors: 520, contacts: 35, consentRate: 0.6, engaged: 380, avgSec: 99, avgScroll: 60 }, 30: { pv: 6080, visitors: 2210, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 }, 90: { pv: 17900, visitors: 6200, contacts: 470, consentRate: 0.6, engaged: 4500, avgSec: 112, avgScroll: 61 }, } const SCALE = { 7: 0.25, 30: 1, 90: 3 } // Anteil der 30-Tage-Zahlen je Zeitraum const PAGES = [['/', 2480], ['/preise', 1310], ['/kurse', 1150], ['/routen', 960], ['/oeffnungszeiten', 640], ['/team', 410], ['/kontakt', 300], ['/impressum', 120], ['/datenschutz', 90]] // Der Zeitraum endet gestern (abgeschlossene Tage); heute steht getrennt in `today` function makeStats(range, over = {}) { const from = addDays(TODAY, -range) const pfrom = addDays(from, -range) return { range, from, to: addDays(TODAY, -1), firstDay: addDays(pfrom, -40), totals: TOTALS[range], series: daysOf(from, range, 231, Math.min(14, range - 1)), today: { day: TODAY, pv: 42, visitors: 17, contacts: 2 }, pages: PAGES.map(([key, count]) => ({ key, count: Math.round(count * SCALE[range]) })), refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }].map((r) => ({ ...r, count: Math.round(r.count * SCALE[range]) })), countries: [{ key: 'DE', count: 6000 }, { key: 'AT', count: 600 }, { key: 'CH', count: 330 }], devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2030 }, { key: 't', count: 310 }], contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 41 }, { key: 'wa', count: 38 }, { key: 'out', count: 13 }], banner: { shown: 3950, accept: 2410, reject: 1540 }, previous: { from: pfrom, to: addDays(from, -1), complete: true, totals: BEFORE[range], series: daysOf(pfrom, range, 200), contacts: [{ key: 'tel', count: 75 }, { key: 'mail', count: 32 }, { key: 'wa', count: 21 }, { key: 'out', count: 13 }], }, ...over, } } const longPath = `/${'a'.repeat(120)}` const OLD = (range) => ({ totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: daysOf(addDays(TODAY, -13), 14, 40).map(({ day, pv }) => ({ day, pv })), pages: [{ key: '/', count: 300 }], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 10, accept: 6, reject: 4 }, range, }) // je Zustand: die Website und die Statistik (nach range) const SCENARIOS = { off: { project: project({ state: 'off' }) }, missing: { project: project({ state: 'off', controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } }) }, full: { project: project(), stats: (r) => makeStats(r) }, neu: { project: project(), stats: (r) => makeStats(r, { firstDay: addDays(TODAY, -12), previous: { ...makeStats(r).previous, complete: false } }) }, wenig: { project: project(), stats: (r) => makeStats(r, { totals: { ...TOTALS[r], pv: 12 } }) }, dev: { project: project({ devMode: true }), stats: (r) => makeStats(r) }, alt: { project: project(), stats: (r) => OLD(r) }, stark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 2400, Math.min(14, r - 1)) }) }, sehrStark: { project: project(), stats: (r) => makeStats(r, { series: daysOf(addDays(TODAY, -r), r, 12000, Math.min(14, r - 1)) }) }, langerPfad: { project: project(), stats: (r) => makeStats(r, { pages: [{ key: longPath, count: 900 }, { key: '/kurz', count: 500 }], refs: [{ key: `${'h'.repeat(90)}.example.com`, count: 300 }] }) }, fehler: { project: project(), stats: null }, zwei: { projects: [project(), { ...baseProject, projectId: 'p2', projectName: 'Zweite Website mit einem wirklich sehr langen Namen, der nicht in eine Zeile passt', analyse: analyse({}) }], stats: (r) => makeStats(r) }, } let scenario = 'off' let statsRequests = [] let delayFirst30 = 0 const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const launch = () => chromium.launch().catch(() => chromium.launch({ executablePath: '/root/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' })) const browser = await launch() let failed = 0 const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 } const TIMEOUT = Number(process.env.UI_TIMEOUT) || 120000 try { for (const [label, viewport, touch] of [['desktop', { width: 1440, height: 1000 }, false], ['phone', { width: 402, height: 874 }, true]]) { const ctx = await browser.newContext({ viewport, hasTouch: touch }) ctx.setDefaultTimeout(TIMEOUT) const page = await ctx.newPage() const errors = [] page.on('pageerror', (e) => errors.push(e.message)) await page.route('**/api/**', async (route) => { const url = new URL(route.request().url()) const p = url.pathname const json = (b, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(b) }).catch(() => {}) const sc = SCENARIOS[scenario] 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: 'Boulderhalle Überhang', status: 'live' }] }) if (p === '/api/features') return json({ features: [] }) if (p === '/api/activity') return json({ tickets: [] }) if (p === '/api/funktionen') return json({ projects: sc.projects || [sc.project] }) if (p === '/api/funktionen/analyse/stats') { const range = Number(url.searchParams.get('range')) statsRequests.push(range) if (range === 30 && delayFirst30 > 0) { delayFirst30 -= 1 await sleep(1500) } if (!sc.stats) return json({ error: 'Auswertung nicht erreichbar' }, 503) return json(sc.stats(range)) } if (p === '/api/funktionen/analyse/heatmap-link') return json({ url: `${base}/offline.html` }) if (p === '/api/funktionen/analyse/deactivate') return json({ ok: true }) return json({}) }) // waitFor: Selektor, der den fertigen Zustand anzeigt const open = async (sc, waitFor = '.an-ov:not(.an-skeleton)') => { scenario = sc statsRequests = [] await page.goto('about:blank') await page.goto(`${base}/dashboard.html#analyse`) await page.waitForSelector('#analyse-body .an') await page.waitForSelector(waitFor) await page.waitForTimeout(300) } const shot = async (name) => { const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 4200)) await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) }) await page.waitForTimeout(250) await page.screenshot({ path: `${SHOTS}/${label}-${name}.png` }) await page.setViewportSize(viewport) } const noScroll = () => page.evaluate(() => document.scrollingElement.scrollWidth <= window.innerWidth) const text = (sel) => page.evaluate((s) => document.querySelector(s)?.textContent ?? '', sel) const count = (sel) => page.evaluate((s) => document.querySelectorAll(s).length, sel) const L = (name) => `${label} ${name}` /* --- Einschalten (wie bisher) --- */ await open('off', '#an-on') const body = await text('#analyse-body') const linkOk = (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}` const disabledFirst = await page.isDisabled('#an-on') const preview = (await text('#an-preview')).includes('Annehmen') && (await text('#an-preview')).includes('Ablehnen') await page.check('#an-avv') const enabled = !(await page.isDisabled('#an-on')) check(L('Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher)'), body.includes('Bäckerei Berg GmbH') && body.includes('info@baeckerei-berg.example') && linkOk && preview && disabledFirst && enabled) check(L('Einschalten: Kopfleiste ohne Zeitraum und Reiter, Status "Analyse ist noch ausgeschaltet"'), (await count('.an-top')) === 1 && (await count('.an-seg')) === 0 && (await count('.an-tabs')) === 0 && (await text('.an-status')).includes('Analyse ist noch ausgeschaltet')) check(L('kein_horizontaler_scroll einschalten'), await noScroll()) await shot('einschalten') await open('missing', '#an-missing') await page.check('#an-avv') check(L('gesperrt_ohne_angaben'), (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing'))) await shot('fehlende-angaben') /* --- aktiv, volle Daten --- */ await open('full') check(L('aktiv: Block "Das Wichtigste" mit "14 % mehr"'), (await text('.an-sum')).includes('Das Wichtigste') && (await text('.an-sum')).includes('14 % mehr')) check(L('aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼'), (await count('.an-kpis .an-kpi')) === 4 && (await count('.an-kpis .an-delta.up')) === 3 && (await count('.an-kpis .an-delta.down')) === 1 && (await text('.an-kpis')).includes('▲') && (await text('.an-kpis')).includes('▼')) check(L('aktiv: Kopfleiste mit "Heute bisher: 42 Aufrufe" und Stand-Uhrzeit, Zeitraum 30 Tage gewählt, Reiter Übersicht gewählt'), /^Analyse aktiv · Heute bisher: 42 Aufrufe · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status')) && (await page.getAttribute('.an-seg .on', 'data-range')) === '30' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht') check(L('kein_horizontaler_scroll aktiv'), await noScroll()) // Raster: Desktop 4 Kacheln nebeneinander, Handy 2 x 2; Diagramm und Kontaktkarte gleich hoch (Desktop) const tiles = await page.$$eval('.an-kpis .an-kpi', (els) => els.map((e) => { const r = e.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width) } })) check(L(touch ? 'Kacheln 2 × 2' : 'Kacheln 4 nebeneinander'), touch ? tiles[0].y === tiles[1].y && tiles[2].y === tiles[3].y && tiles[2].y > tiles[0].y && tiles[0].x === tiles[2].x && tiles[1].x > tiles[0].x : tiles.every((t) => t.y === tiles[0].y)) await page.evaluate(() => document.querySelector('.an-chart').scrollIntoView({ block: 'center' })) await page.waitForTimeout(200) const boxes = await page.evaluate(() => { const r = (s) => { const b = document.querySelector(s).getBoundingClientRect(); return { top: Math.round(b.top), height: Math.round(b.height), left: b.left, right: b.right } } return { chart: r('.an-chartbox'), contacts: r('.an-contacts'), svg: r('.an-chart') } }) if (!touch) { check(L(`Diagramm und Kontaktkarte gleich hoch (${boxes.chart.height} / ${boxes.contacts.height} px)`), boxes.chart.top === boxes.contacts.top && Math.abs(boxes.chart.height - boxes.contacts.height) <= 1) check(L(`Diagramm füllt seine Karte (${boxes.svg.height} von ${boxes.chart.height} px)`), boxes.svg.height >= boxes.chart.height * 0.6) } else { check(L('Handy: Diagramm und Kontaktkarte untereinander'), boxes.contacts.top > boxes.chart.top && boxes.chart.left === boxes.contacts.left) } // Blase am Diagramm: nächster Tag, zeigt "…: N Aufrufe (davor M)"; Maus verlässt / Tippen daneben blendet aus const svg = boxes.svg const px = svg.left + (svg.right - svg.left) * 0.62 const py = svg.top + svg.height / 2 if (touch) { await page.touchscreen.tap(px, py) } else { await page.mouse.move(px, py) } await page.waitForSelector('.an-tip:not([hidden])') const tip = await text('.an-tip') check(L(`Diagramm-Blase: "${tip}"`), /^\S+, \d\d\.\d\d\.: [\d.]+ Aufrufe \(davor [\d.]+\)$/.test(tip) && (await count('.an-dot.on')) === 1) if (touch) { // neben das Diagramm tippen (nicht in die App-Leiste oben, die führt zurück) const h = await page.locator('.an-chartbox h3').boundingBox() await page.touchscreen.tap(h.x + 10, h.y + h.height / 2) } else { await page.mouse.move(2, 2) } await page.waitForFunction(() => document.querySelector('.an-tip')?.hidden === true) check(L('Diagramm-Blase: verschwindet wieder, kein Punkt bleibt markiert'), (await count('.an-dot.on')) === 0) // Achsenbeschriftung: gerendert ca. 11-12 px (viewBox 640 skaliert mit der Breite), nichts ragt aus der Karte, x-Beschriftungen überlappen nicht const axis = () => page.evaluate(() => { const svg = document.querySelector('.an-chart') const scale = svg.getBoundingClientRect().width / 640 const box = document.querySelector('.an-chartbox').getBoundingClientRect() const texts = [...svg.querySelectorAll('.an-axis')].map((t) => ({ r: t.getBoundingClientRect(), px: parseFloat(getComputedStyle(t).fontSize) * scale, anchor: t.getAttribute('text-anchor'), s: t.textContent })) const xs = texts.filter((t) => t.anchor === 'middle').sort((a, b) => a.r.left - b.r.left) return { px: Math.min(...texts.map((t) => t.px)), pxMax: Math.max(...texts.map((t) => t.px)), inside: texts.every((t) => t.r.left >= box.left + 1 && t.r.right <= box.right - 1), gap: xs.every((t, i) => !i || t.r.left >= xs[i - 1].r.right + 4), y: texts.filter((t) => t.anchor === 'end').map((t) => t.s).join(','), } }) const ax = await axis() check(L(`Achsenbeschriftung ca. 11-12 px (${ax.px.toFixed(1)} px), liegt in der Karte, x-Werte überlappen nicht`), ax.px >= 10.5 && ax.pxMax <= 13 && ax.inside && ax.gap) const faint = await page.evaluate(() => getComputedStyle(document.querySelector('.an-credit')).color) check(L(`Hilfstext "--an-faint" kontrastreich (${faint})`), Number(/rgba?\([^)]*?,\s*([\d.]+)\)$/.exec(faint)?.[1] ?? 1) >= 0.55) if (touch) { const heights = await page.evaluate(() => Object.fromEntries(['.an-tabbtn', '.an-seg button', '.an-ltab', '.an-more', '.an-teaser .an-btn'].map((sel) => [sel, Math.min(...[...document.querySelectorAll(sel)].map((e) => e.getBoundingClientRect().height))]))) check(L(`Tippflächen mindestens 40 px: ${Object.entries(heights).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(', ')}`), Object.values(heights).every((h) => h >= 39.5)) } // Ranglisten: Umschalter und "Alle anzeigen" ändern nur ihre Karte check(L('Rangliste: 6 Zeilen und "Alle anzeigen"'), (await count('[data-rank="seen"] .an-lr')) === 6 && (await count('[data-rank="seen"] [data-rank-more]')) === 1) await page.click('[data-rank="seen"] [data-rank-more]') check(L('Rangliste: "Alle anzeigen" zeigt alle 9 Seiten'), (await count('[data-rank="seen"] .an-lr')) === 9 && (await count('[data-rank="seen"] [data-rank-more]')) === 0) await page.click('[data-rank="seen"] [data-rank-tab="refs"]') check(L('Rangliste: Umschalter Herkunft, Seiten-Reiter der anderen Karte unverändert'), (await page.getAttribute('[data-rank="seen"]', 'data-rank-active')) === 'refs' && (await page.getAttribute('[data-rank="who"]', 'data-rank-active')) === 'devices' && (await text('[data-rank="seen"]')).includes('Google')) await shot('aktiv') // Reiter per Pfeiltaste (Reihenfolge Übersicht, Heatmap, Einstellungen) await page.focus('.an-tabbtn[data-tab="uebersicht"]') await page.keyboard.press('ArrowRight') await page.waitForSelector('.an-tabbtn[data-tab="heatmap"][aria-selected="true"]') await page.keyboard.press('ArrowLeft') await page.waitForSelector('.an-tabbtn[data-tab="uebersicht"][aria-selected="true"]') check(L('Reiter: Pfeiltasten wechseln die Reiter'), (await count('.an-ov:not(.an-skeleton) .an-kpis')) === 1) // Zeitraum 7: neue Anfrage, nur die Übersicht lädt neu, Reiter bleibt const before = statsRequests.length await page.click('.an-seg [data-range="7"]') await page.waitForFunction(() => document.querySelector('.an-lead')?.textContent.includes('7 Tagen')) check(L('Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht'), statsRequests.length > before && statsRequests.at(-1) === 7 && (await page.getAttribute('.an-seg .on', 'data-range')) === '7' && (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'uebersicht') // Heatmap: iframes erst nach dem Reiterwechsel (Teaser-Knopf und Reiterknopf wechseln beide) await open('full') const iframesBefore = await count('iframe') await page.click('.an-teaser [data-tab="heatmap"]') await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2) check(L('Teaser "Öffnen" wechselt in den Reiter Heatmap'), (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'heatmap' && (await count('.an-top .an-seg')) === 0) await open('full') await page.click('.an-tabbtn[data-tab="heatmap"]') await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2) check(L('Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes'), iframesBefore === 0 && (await count('.hs-dev iframe')) === 2 && (await count('#an-heat-page')) === 1) check(L('Heatmap: Seitenauswahl und Computer/Tablet-Schalter in einer Leiste'), await page.evaluate(() => { const bar = document.querySelector('.an-heat-bar'); return Boolean(bar?.querySelector('#an-heat-page') && bar.querySelector('.hs-switch button')) })) check(L('kein_horizontaler_scroll heatmap'), await noScroll()) await page.waitForTimeout(600) await shot('heatmap') // zurück in die Übersicht: die iframes verschwinden, die Zahlen stehen sofort wieder da await page.click('.an-tabbtn[data-tab="uebersicht"]') await page.waitForSelector('.an-ov:not(.an-skeleton) .an-kpis') check(L('Reiterwechsel zurück: Übersicht ohne iframes'), (await count('iframe')) === 0) // Einstellungen await page.click('.an-tabbtn[data-tab="einstellungen"]') await page.waitForSelector('#an-off-ask') const settings = await text('#analyse-body') check(L('Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit"'), (await page.isVisible('#an-off-ask')) && settings.includes('Analyse ausschalten') && /aktiv seit 12\.08\.2026/.test(settings) && settings.includes('Fassung 2026-10-02') && (await count('.an-docs a')) === 2 && (await count('.an-swatch')) === 1 && (await count('.an-top .an-seg')) === 0) check(L('kein_horizontaler_scroll einstellungen'), await noScroll()) const errColor = await page.evaluate(() => { const p = document.createElement('p') p.className = 'error' p.textContent = 'x' document.querySelector('#an-off').appendChild(p) const c = getComputedStyle(p).color p.remove() return c }) check(L(`Fehlertext beim Ausschalten rot (${errColor})`), errColor === 'rgb(252, 165, 165)') await shot('einstellungen') await page.click('#an-off-ask') await page.click('#an-off-yes') await page.waitForSelector('#an-off-done') check(L('ausschalten_bestaetigung'), (await text('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert')) await shot('ausgeschaltet') // verzögerte erste Antwort überschreibt die spätere nicht delayFirst30 = 1 await open('full', '.an-seg') await page.click('.an-seg [data-range="7"]') await page.waitForFunction(() => document.querySelector('.an-lead')?.textContent.includes('7 Tagen')) await page.waitForTimeout(1900) const lateOk = (await text('.an-lead')).includes('7 Tagen') && !(await text('.an-lead')).includes('30 Tagen') && statsRequests.join() === '30,7' && (await page.getAttribute('.an-seg .on', 'data-range')) === '7' // auch der Zustand dahinter bleibt der 7-Tage-Stand: "Alle anzeigen" und der Umschalter malen mit 7-Tage-Zahlen (Startseite 620, Google 220) const firstRow = (card) => text(`[data-rank="${card}"] .an-lr`) const before7 = await firstRow('seen') await page.click('[data-rank="seen"] [data-rank-more]') const more = await firstRow('seen') await page.click('[data-rank="seen"] [data-rank-tab="refs"]') const refs = await firstRow('seen') check(L('verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht'), lateOk && before7.includes('620') && more.includes('620') && !more.includes('2.480') && refs.includes('220') && !refs.includes('880') && (await count('[data-rank="seen"] .an-lr')) === 4) delayFirst30 = 0 // Heatmap-Reiter während der ersten Übersicht-Anfrage: nutzt sie mit, statt dieselbe Anfrage noch einmal zu stellen delayFirst30 = 1 await open('full', '.an-seg') await page.click('.an-tabbtn[data-tab="heatmap"]') await page.waitForFunction(() => document.querySelectorAll('.hs-dev iframe').length === 2) check(L('Heatmap-Reiter nutzt die laufende 30-Tage-Anfrage mit'), statsRequests.join() === '30') delayFirst30 = 0 // Website wechseln: Reiter bleibt await open('zwei') await page.click('.an-tabbtn[data-tab="einstellungen"]') await page.waitForSelector('#an-off-ask') check(L('mehrere Websites: Auswahlfeld in der Kopfleiste, langer Name bricht nichts'), (await count('#an-project option')) === 2 && (await noScroll())) await page.selectOption('#an-project', 'p2') await page.waitForSelector('#an-off-ask') check(L('Websitewechsel: Reiter Einstellungen bleibt'), (await page.getAttribute('.an-tabbtn[aria-selected="true"]', 'data-tab')) === 'einstellungen' && (await page.inputValue('#an-project')) === 'p2') await shot('zwei-websites') /* --- weitere Zustände --- */ await open('neu') check(L('neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse"'), (await count('.an-kpis .an-delta')) === 0 && (await text('.an-kpis')).includes('Vergleich ab') && (await text('.an-sum')).includes('Seit dem Start der Analyse') && (await count('.an-prev')) === 0) await shot('neu') await open('wenig') check(L('wenig: Leerzustand'), (await count('.an-sum-empty')) === 1 && (await text('.an-sum')).includes('Sobald ein paar mehr Besuche da sind')) await shot('wenig') await open('dev') check(L('dev: "Im Dev-Modus wird nicht gemessen."'), (await text('.an-status')).includes('Im Dev-Modus wird nicht gemessen.') && (await page.getAttribute('.an-site', 'data-state')) === 'warn') await shot('dev') await open('alt') check(L('alt: kein "Heute bisher" (alter Collector), Stand bleibt'), /^Analyse aktiv · Stand: \d\d:\d\d Uhr$/.test(await text('.an-status'))) check(L('alt: keine Chips, keine pageerror'), (await count('.an-delta')) === 0 && (await count('.an-prev')) === 0 && errors.length === 0 && (await text('.an-kpis')).includes('612')) await shot('alt') await open('fehler', '.an-error') const requestsBefore = statsRequests.length check(L('fehler: "Auswertung gerade nicht erreichbar" + Neu laden'), (await text('.an-error')).includes('Auswertung gerade nicht erreichbar') && (await text('#an-retry')) === 'Neu laden' && (await count('.an-tabs')) === 1) await page.click('#an-retry') for (let i = 0; i < 50 && statsRequests.length === requestsBefore; i += 1) await sleep(100) await page.waitForSelector('.an-error') check(L('fehler: "Neu laden" fragt erneut an'), statsRequests.length === requestsBefore + 1) await shot('fehler') for (const [sc, label5] of [['stark', 'vierstellige'], ['sehrStark', 'fünfstellige']]) { await open(sc) const big = await axis() check(L(`${label5} y-Werte (${big.y}) nicht abgeschnitten, x-Werte überlappen nicht, Schrift ${big.px.toFixed(1)} px`), big.inside && big.gap && big.px >= 10.5 && big.pxMax <= 13) await shot(sc === 'stark' ? 'skala-4-stellig' : 'skala-5-stellig') } await open('langerPfad') const name = await page.evaluate(() => { const n = document.querySelector('[data-rank="seen"] .an-name'); return { sw: n.scrollWidth, cw: n.clientWidth, title: n.title } }) check(L(`langer Pfad${touch ? ', 402 px' : ''}: document.scrollingElement.scrollWidth <= innerWidth`), (await noScroll()) && name.sw > name.cw && name.title.length > 100) await shot('langer-pfad') check(L('keine pageerror in allen Zuständen'), errors.length === 0) if (errors.length) console.log(errors.join('\n')) await ctx.close() } } finally { await browser.close() server.close() } process.exit(failed ? 1 : 0)