Files
Webklar-Kundenbereich/test/ui/analyse.ui.mjs
T
ClaudeandClaude Opus 5.5 0db59d38e9 Analyse: Zeitraum = abgeschlossene Tage, "Heute bisher" getrennt, Feinschliff
Final-Review-Fixwelle der Cockpit-Übersicht:

- F1 Collector: Der Zeitraum sind die range Kalendertage bis gestern (Berliner Tag),
  der Vorzeitraum die range Tage davor; heute steht getrennt in today { day, pv, visitors,
  contacts }. Kein Einbruch mehr am rechten Rand von Diagramm und Mini-Verläufen,
  kein falsches "x % weniger" tagsüber. Portal: weekdayStats bekommt die ganze Reihe
  (completedDays entfällt), "Vergleich ab" = firstDay + 2 x range, Kopfzeile
  "Analyse aktiv · Heute bisher: N Aufrufe · Stand: HH:MM Uhr" (alter Collector ohne today
  bleibt ohne "Heute bisher").
- F2 "direkt kontaktiert" zählt nur Telefon, E-Mail und WhatsApp (auch im Vorzeitraum),
  Links nach außen nicht.
- F3 Mini-Verlauf "Ø Zeit pro Seite": Tage ohne Messung sind Lücken statt 0-Tälern.
- F4 Fehlerfarbe beim Ausschalten rot, --an-faint auf 55 %, Achsenschrift per
  Container-Abfragen auf 11-12 px (y-Rand wächst mit der Stellenzahl, x-Beschriftungen mit
  Mindestabstand), Ranglisten-Anteile an allen Seitenaufrufen, Tippflächen bis 880 px
  mindestens 40 px hoch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:34:43 +00:00

384 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)