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>
384 lines
27 KiB
JavaScript
384 lines
27 KiB
JavaScript
// 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)
|