Prüfstand: entry.jsx zeigt nur noch die echte Seite WorkersPage (BrowserRouter, Adresse mit ?tab= lesbar); shot.cjs neu mit den Szenarien A–M (Hochhaus, Regie, Handy, weniger Bewegung, Notbremse, Ausfall, Detail, Etagen, Pause, Pult P3, Pult P4, Keller, Handy Pult/Keller), harte Prüfungen und Bildern mit Szenario-Vorsilbe; Befunde an der App stehen getrennt von den Verstößen. Skript-API: Gründe der Bot-Leads wörtlich wie im Dienst. Kopfzeile am Handy: zwei Reihen (Geld + Pause/Zahnrad, dann die fünf Kennzahlen), schmaler Panel-Rand, 40-px-Knöpfe, Querformat unter 500 px Höhe in einer Reihe; hustlerKopf.test um die Zusicherungen dazu ergänzt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1555 lines
116 KiB
JavaScript
1555 lines
116 KiB
JavaScript
// Abnahme der Hustler-Pixelwelt im echten Browser (Chromium, headless shell) gegen den Prüfstand (entry.jsx, die echte Seite WorkersPage).
|
||
// Aufruf (immer unter Speicherdeckel, seriell, nie zwei Browser gleichzeitig und nie parallel zur Test-Suite):
|
||
// flock /run/lock/hustler-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 timeout 600 \
|
||
// node --max-old-space-size=600 scripts/workers-harness/shot.cjs <distDir> <outDir>
|
||
// <distDir> = Ausgabe von build.mjs, <outDir> = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo.
|
||
// Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1.
|
||
// Szenarien (Name für HARNESS_ONLY, Vorsilbe der Bilder):
|
||
// A hochhaus (a-) Hochhaus am Desktop 1280 x 720 und 1920 x 1080: keine Fehler, nichts läuft über den Rand, 19 Figuren mit aria-label, Kim/Pia/Finn grau, Kopfzeile, Briefkasten
|
||
// B regie (b-) Regie: innerhalb von 15 s erscheint eine Sprechblase, höchstens vier Effekte gleichzeitig
|
||
// C handy (c-) Handy 390 x 844 (hochkant) und 844 x 390 (quer): Kopfzeile höchstens zwei Reihen, Wischen verschiebt, Doppeltipp zeigt das ganze Haus, Detail als Vollbild
|
||
// D bewegung (d-) „Weniger Bewegung“: Schnee und Flackern stehen, kein Gleiten, nur Sprechblasen
|
||
// E notbremse (e-) ?halted=1: Banner mit Grund, Einstellungen mit dem Notbremse-Block
|
||
// F ausfall (f-) ?fault=down: Leerzustand „Der Hustler-Dienst ist noch nicht erreichbar“, nach „Neu laden“ das Haus, Verbindungsfehler-Banner
|
||
// G detail (g-) Detailansicht: Dialog, Blättern mit ←/→, Esc mit Fokus zurück auf die Figur, lange Spur mit ◂ n / 19 ▸, ein Bild je Status
|
||
// H etagen (h-) Etage zoomen (mit Gleiten), Tasten +/−/0, Etage „Freigabe“ → Pult und zurück, Keller-Etage → Bot-Leads und zurück
|
||
// I pause (i-) Pause mit Pixel-Rückfrage, Animationen stehen (hk-steht), Fortsetzen ohne Rückfrage
|
||
// J pult (j-) Pult mit den P3-Daten: Reiter, Umschlag/Brief, Ich kümmere mich, Kopieren & öffnen, Als gesendet markieren, Verwerfen mit Rückfrage, Interessent (WOMS-Aussehen), Fokus
|
||
// K pult-p4 (k-) Pult mit den P4-Daten (?p4=1): als Mensch antworten, Vorschau freigeben, Antwort des Kunden einfügen, Vorschau bauen
|
||
// L keller (l-) Keller: Reiter, Suche, Blättern, Akte (Heilberuf ohne Nummer, Quelle als Link), Einzelansicht (?bot=), leer (?botleads=leer), Arne
|
||
// M handy-pult-keller (m-) Pult und Keller am Handy 390 x 844: volle Breite, Brief und Akte als Vollbild, nichts läuft über den Rand
|
||
// Jedes Szenario läuft für sich: bricht eines ab, laufen die übrigen weiter und der Abbruch steht als Verstoß in der Liste. HARNESS_ONLY=detail,pult
|
||
// (Namen siehe `SCENARIOS` unten) lässt nur diese laufen (für die Arbeit am Prüfstand; die Abnahme läuft immer vollständig).
|
||
'use strict'
|
||
const { createRequire } = require('module')
|
||
const path = require('path')
|
||
const fs = require('fs')
|
||
const os = require('os')
|
||
const http = require('http')
|
||
const req = createRequire('/root/.npm/_npx/db89d7302a373f10/node_modules/')
|
||
const { chromium } = req('playwright-core')
|
||
|
||
const repo = path.resolve(__dirname, '../..')
|
||
const distArg = process.argv[2]
|
||
const outArg = process.argv[3]
|
||
if (!distArg || !outArg) { console.error('usage: node shot.cjs <distDir> <outDir>'); process.exit(2) }
|
||
const dist = path.resolve(distArg)
|
||
const out = path.resolve(outArg)
|
||
const tmp = path.resolve(process.env.TMPDIR || os.tmpdir())
|
||
const okBase = [tmp, path.join(repo, '.superpowers')]
|
||
if (!okBase.some((b) => out.startsWith(b + path.sep))) { console.error('refusing: outDir must be below ' + okBase.join(' or ')); process.exit(2) }
|
||
if (fs.existsSync(out) && fs.readdirSync(out).length > 0) { console.error('refusing: outDir must be new or empty'); process.exit(2) }
|
||
if (!fs.existsSync(path.join(dist, 'index.html'))) { console.error('refusing: no index.html in ' + dist); process.exit(2) }
|
||
fs.mkdirSync(out, { recursive: true })
|
||
|
||
const CHROME = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell'
|
||
const FONT_FILES = ['/fonts/PixelifySans-Variable.woff2', '/fonts/PressStart2P-Regular.woff2']
|
||
const HALT_LINE = 'Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.'
|
||
const PAUSED_LINE = 'Angehalten: Es wird nichts gesendet und nichts gebaut.'
|
||
const HALT_QUESTION = 'Die Worker hören sofort auf, bis jemand fortsetzt.'
|
||
const NO_SERVICE = 'Der Hustler-Dienst ist noch nicht erreichbar'
|
||
|
||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }
|
||
const fontLog = []
|
||
|
||
function serve() {
|
||
const server = http.createServer((rq, rs) => {
|
||
let rel
|
||
try { rel = decodeURIComponent(new URL(rq.url, 'http://x').pathname) } catch { rs.writeHead(400).end(); return }
|
||
const fonts = rel.startsWith('/fonts/')
|
||
const root = fonts ? path.join(repo, 'public', 'fonts') : dist
|
||
const file = path.resolve(root, '.' + (fonts ? rel.slice('/fonts'.length) : (rel === '/' ? '/index.html' : rel)))
|
||
if (rq.method !== 'GET' || !file.startsWith(root + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||
if (fonts) fontLog.push({ url: rel, status: 404 })
|
||
rs.writeHead(404).end('not found')
|
||
return
|
||
}
|
||
if (fonts) fontLog.push({ url: rel, status: 200 })
|
||
rs.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' })
|
||
fs.createReadStream(file).pipe(rs)
|
||
})
|
||
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
|
||
}
|
||
|
||
const problems = []
|
||
const findings = [] // Befunde: Abweichungen, die an der App liegen (nicht am Prüfstand); sie lassen den Lauf nicht scheitern, stehen aber im Bericht
|
||
const errors = []
|
||
const checks = {}
|
||
const metrics = {}
|
||
const shots = []
|
||
const perScenario = {} // Name -> { checks, failed }
|
||
let current = '' // das laufende Szenario (für die Zählung)
|
||
let lastPage = null // die zuletzt geöffnete Seite: bricht ein Szenario ab, wird von hier aus der Zustand im Bild festgehalten (abbruch-<Szenario>.png)
|
||
let openContexts = [] // Kontexte des laufenden Szenarios (bei einem Abbruch schließt der Läufer sie)
|
||
|
||
function check(name, ok, detail) {
|
||
checks[name] = ok ? 'ok' : 'FEHLER' + (detail === undefined ? '' : ' ' + JSON.stringify(detail))
|
||
const entry = perScenario[current] || (perScenario[current] = { checks: 0, failed: 0 })
|
||
entry.checks += 1
|
||
if (!ok) {
|
||
entry.failed += 1
|
||
problems.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail)))
|
||
}
|
||
}
|
||
|
||
// Wie check, aber für einen Befund an der App: bei einem Verstoß kein Fehlschlag des Laufs, sondern ein Eintrag unter „befunde“ (JSON-Zeile und Standardfehlerausgabe).
|
||
// Wird der Befund in der App behoben, schlägt der Aufruf nie an; dann kann aus befund wieder check werden.
|
||
function befund(name, ok, detail) {
|
||
checks[name] = ok ? 'ok' : 'BEFUND' + (detail === undefined ? '' : ' ' + JSON.stringify(detail))
|
||
const entry = perScenario[current] || (perScenario[current] = { checks: 0, failed: 0 })
|
||
entry.checks += 1
|
||
if (!ok) {
|
||
entry.befunde = (entry.befunde || 0) + 1
|
||
findings.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail)))
|
||
}
|
||
}
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||
const textOf = async (loc) => ((await loc.textContent()) || '').replace(/\s+/g, ' ').trim()
|
||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
||
const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
|
||
async function shoot(page, name, options = {}) {
|
||
const file = path.join(out, name)
|
||
await page.screenshot({ path: file, ...options })
|
||
shots.push(file)
|
||
}
|
||
|
||
// Neuer Kontext mit Fehlersammlung (pageerror, console.error, fehlgeschlagene und 4xx/5xx-Anfragen)
|
||
async function newPage(browser, label, contextOptions) {
|
||
const context = await browser.newContext({ deviceScaleFactor: 1, ...contextOptions })
|
||
openContexts.push(context)
|
||
const page = await context.newPage()
|
||
lastPage = page
|
||
page.setDefaultTimeout(20000)
|
||
page.on('pageerror', (e) => errors.push(label + ' pageerror: ' + String(e)))
|
||
page.on('console', (m) => { if (m.type() === 'error') errors.push(label + ' console.error: ' + m.text()) })
|
||
page.on('requestfailed', (r) => errors.push(label + ' requestfailed: ' + r.url()))
|
||
page.on('response', (r) => { if (r.status() >= 400) errors.push(label + ' http ' + r.status() + ': ' + r.url()) })
|
||
return { context, page }
|
||
}
|
||
|
||
// Seite mit ersetzter Zwischenablage und ersetztem window.open (beides vor dem Laden): halten fest, was die Oberfläche tut (window.__clip, __opened, __openMeta).
|
||
// Die Zwischenablage antwortet erst nach 300 ms (wie ein Browser, der nach der Erlaubnis fragt): wer vor dem Öffnen auf das Kopieren wartet, öffnet später
|
||
// als 300 ms nach dem Klick und verliert die Nutzer-Geste.
|
||
async function newStubPage(browser, label, contextOptions) {
|
||
const made = await newPage(browser, label, contextOptions)
|
||
await made.context.addInitScript(() => {
|
||
window.__clip = []
|
||
window.__opened = []
|
||
window.__openMeta = []
|
||
window.__lastClickAt = 0
|
||
document.addEventListener('click', () => { window.__lastClickAt = performance.now() }, true)
|
||
Object.defineProperty(navigator, 'clipboard', {
|
||
configurable: true,
|
||
value: { writeText: (text) => { window.__clip.push(text); return new Promise((resolve) => setTimeout(resolve, 300)) } },
|
||
})
|
||
window.open = (...args) => {
|
||
window.__opened.push(args)
|
||
window.__openMeta.push({ sinceClick: performance.now() - window.__lastClickAt, active: navigator.userActivation ? navigator.userActivation.isActive : null })
|
||
return null
|
||
}
|
||
})
|
||
return made
|
||
}
|
||
|
||
const settleFonts = (page) => page.evaluate(() => Promise.all([
|
||
document.fonts.load('600 12px "Pixelify Sans"'),
|
||
document.fonts.load('12px "Press Start 2P"'),
|
||
]).then(() => document.fonts.ready))
|
||
|
||
// Seite laden und warten, bis das Haus mit allen 19 Figuren steht
|
||
async function ready(page, url) {
|
||
await page.goto(url)
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-hk-hustler]').length === 19)
|
||
await settleFonts(page)
|
||
}
|
||
|
||
const apiOf = (page, key) => page.evaluate((k) => JSON.parse(JSON.stringify(window.__wk.api[k])), key)
|
||
const setFault = (page, key, value) => page.evaluate(([k, v]) => { window.__wk.api.faults[k] = v }, [key, value])
|
||
const widthOf = (page) => page.evaluate(() => ({
|
||
scroll: document.scrollingElement.scrollWidth,
|
||
inner: window.innerWidth,
|
||
body: document.body.scrollWidth,
|
||
root: (() => { const el = document.querySelector('.hk-hochhaus, .hk-pult, .hk-keller'); return el ? { scroll: el.scrollWidth, client: el.clientWidth } : null })(),
|
||
}))
|
||
const noSideScroll = (w) => w.scroll <= w.inner && w.body <= w.inner && (w.root === null || w.root.scroll <= w.root.client)
|
||
const rectOf = (loc) => loc.evaluate((el) => { const b = el.getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height), r: Math.round(b.right), b: Math.round(b.bottom) } })
|
||
// Kamera der Hochhaus-Welt aus dem Stil der Bühne: transform: translate(x px, y px) scale(s)
|
||
const cameraOf = (page) => page.evaluate(() => {
|
||
const el = document.querySelector('.hk-buehne')
|
||
const m = el ? /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/.exec(el.style.transform) : null
|
||
return m ? { x: Number(m[1]), y: Number(m[2]), s: Number(m[3]) } : null
|
||
})
|
||
const focusInfo = (page) => page.evaluate(() => {
|
||
const el = document.activeElement
|
||
if (!el) return { tag: null }
|
||
return {
|
||
tag: el.tagName,
|
||
hustler: el.getAttribute('data-hk-hustler'),
|
||
zeile: el.getAttribute('data-hk-zeile'),
|
||
label: el.getAttribute('aria-label'),
|
||
pause: el.hasAttribute('data-hk-pause'),
|
||
role: el.getAttribute('role'),
|
||
cls: String(el.className || '').slice(0, 60),
|
||
text: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60),
|
||
inDialog: Boolean(el.closest('[role="dialog"]')),
|
||
}
|
||
})
|
||
|
||
// ---------- Szenario A: Hochhaus am Desktop (1280 x 720 und 1920 x 1080) ----------
|
||
|
||
async function hochhaus(browser, base) {
|
||
for (const [w, h] of [[1280, 720], [1920, 1080]]) {
|
||
const tag = `${w}x${h}`
|
||
const { context, page } = await newPage(browser, 'hochhaus-' + tag, { viewport: { width: w, height: h } })
|
||
await ready(page, base + '/index.html')
|
||
await sleep(700)
|
||
const m = await page.evaluate(() => {
|
||
const box = (el) => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, r: b.right, b: b.bottom, w: b.width, h: b.height } }
|
||
const sicht = box(document.querySelector('.hk-sicht'))
|
||
const inside = (b) => b.x >= sicht.x - 2 && b.y >= sicht.y - 2 && b.r <= sicht.r + 2 && b.b <= sicht.b + 2
|
||
const figuren = [...document.querySelectorAll('[data-hk-hustler]')].map((el) => {
|
||
const b = box(el)
|
||
const sprite = el.firstElementChild
|
||
return { id: el.getAttribute('data-hk-hustler'), tag: el.tagName, type: el.getAttribute('type'), label: el.getAttribute('aria-label') || '', w: Math.round(b.w), h: Math.round(b.h), inside: inside(b), sprite: sprite ? sprite.className : '' }
|
||
})
|
||
const text = (sel) => [...document.querySelectorAll(sel)].map((el) => el.textContent.replace(/\s+/g, ' ').trim())
|
||
return {
|
||
sicht,
|
||
haus: inside(box(document.querySelector('.hk-haus-bild'))),
|
||
kopf: { ...box(document.querySelector('.hk-kopf')), betraege: text('.hk-kopf-betrag'), zahlen: [...document.querySelectorAll('.hk-kopf-zahl')].map((z) => z.lastElementChild.textContent.trim()) },
|
||
figuren,
|
||
grauRaeume: [...document.querySelectorAll('[class*="hk-raum-grau-"]')].map((el) => el.className.replace('hk-raum-grau-', '')).sort(),
|
||
zettel: text('.hk-zettel'),
|
||
briefkasten: text('.hk-briefkasten'),
|
||
tresor: text('.hk-schildtext-tresor'),
|
||
keller: text('.hk-schildtext-keller'),
|
||
meldungen: document.querySelectorAll('.hk-meldung').length,
|
||
dialoge: document.querySelectorAll('[role="dialog"]').length,
|
||
fonts: { pixelify: document.fonts.check('600 12px "Pixelify Sans"'), pressStart: document.fonts.check('12px "Press Start 2P"') },
|
||
}
|
||
})
|
||
const camera = await cameraOf(page)
|
||
metrics['hochhaus ' + tag] = { sicht: { w: Math.round(m.sicht.w), h: Math.round(m.sicht.h) }, zoom: camera && Number(camera.s.toFixed(3)), kopf: { h: Math.round(m.kopf.h) } }
|
||
|
||
const wide = await widthOf(page)
|
||
check(`hochhaus ${tag}: kein waagrechtes Scrollen`, noSideScroll(wide), wide)
|
||
check(`hochhaus ${tag}: das ganze Haus steht im Bild`, m.haus, m.sicht)
|
||
check(`hochhaus ${tag}: 19 Figuren-Knöpfe (echte <button>) mit aria-label „Name, Modell, Status“, alle sichtbar im Bild`,
|
||
m.figuren.length === 19 && m.figuren.every((f) => f.tag === 'BUTTON' && f.type === 'button' && /^[^,]+, (Opus|Sonnet|Haiku), .+$/.test(f.label) && f.w >= 8 && f.h >= 8 && f.inside),
|
||
m.figuren.filter((f) => !(f.tag === 'BUTTON' && /^[^,]+, (Opus|Sonnet|Haiku), .+$/.test(f.label) && f.w >= 8 && f.h >= 8 && f.inside)))
|
||
const grey = ['kim', 'pia', 'finn']
|
||
check(`hochhaus ${tag}: Kim, Pia und Finn sind grau (Label „noch nicht angeschlossen“, Figur „nicht“), die übrigen 16 nicht`,
|
||
m.figuren.every((f) => (grey.includes(f.id)
|
||
? f.label.endsWith('noch nicht angeschlossen') && /-nicht\b/.test(f.sprite)
|
||
: !f.label.includes('noch nicht angeschlossen') && !/-nicht\b/.test(f.sprite))),
|
||
m.figuren.map((f) => [f.id, f.label, f.sprite]))
|
||
check(`hochhaus ${tag}: graue Raum-Ebene für Kleinanzeigen, Portale und Foren (nur diese drei) samt Papierschild „nicht angeschlossen“`,
|
||
same(m.grauRaeume, ['foren', 'kleinanzeigen', 'portale']) && m.zettel.length === 3 && m.zettel.every((t) => t.includes('nicht angeschlossen')), { raeume: m.grauRaeume, zettel: m.zettel })
|
||
check(`hochhaus ${tag}: Kopfzeile mit Werten (+60 €/m, -5,10 $, Kennzahlen 3, 3, 7, 2, 112)`,
|
||
m.kopf.betraege.length === 2 && m.kopf.betraege[0].includes('+60 €/m') && m.kopf.betraege[1].includes('-5,10 $') && same(m.kopf.zahlen, ['3', '3', '7', '2', '112']) && m.kopf.x >= 0 && m.kopf.r <= w, m.kopf)
|
||
check(`hochhaus ${tag}: Briefkasten-Zähler „2“, Tresor „3 Deals“, Keller „112 Bot-Leads“`, same(m.briefkasten, ['2']) && same(m.tresor, ['3 Deals']) && same(m.keller, ['112 Bot-Leads']), { b: m.briefkasten, t: m.tresor, k: m.keller })
|
||
check(`hochhaus ${tag}: ohne Notbremse und Pause keine Meldung, kein Dialog`, m.meldungen === 0 && m.dialoge === 0, { meldungen: m.meldungen, dialoge: m.dialoge })
|
||
check(`hochhaus ${tag}: Schriften Pixelify Sans und Press Start 2P geladen`, m.fonts.pixelify && m.fonts.pressStart, m.fonts)
|
||
await shoot(page, `a-hochhaus-${tag}.png`)
|
||
await context.close()
|
||
}
|
||
const fonts = FONT_FILES.map((f) => ({ f, ok: fontLog.some((e) => e.url === f && e.status === 200) }))
|
||
check('hochhaus: beide Schriftdateien wurden mit 200 geliefert, kein /fonts-Abruf mit 404', fonts.every((x) => x.ok) && !fontLog.some((e) => e.status !== 200), { fonts, log: fontLog.filter((e) => e.status !== 200) })
|
||
}
|
||
|
||
// ---------- Szenario B: Regie (Sprechblasen) ----------
|
||
|
||
// Effekte der Regie-Ebene: Art (blase | icon | flug | jubel), Text der Blasen, Lage der ersten Blase
|
||
const regieOf = (page) => page.evaluate(() => {
|
||
const root = document.querySelector('.hk-regie')
|
||
const kids = root ? [...root.children] : []
|
||
const art = (el) => (['blase', 'icon', 'flug', 'jubel'].find((a) => el.classList.contains('hk-regie-' + a)) || 'andere')
|
||
const blase = kids.find((el) => el.classList.contains('hk-regie-blase'))
|
||
const rect = blase ? blase.getBoundingClientRect() : null
|
||
return {
|
||
root: Boolean(root),
|
||
hidden: root ? root.getAttribute('aria-hidden') : null,
|
||
arten: kids.map(art),
|
||
blasen: kids.filter((el) => el.classList.contains('hk-regie-blase')).map((el) => el.textContent.trim()),
|
||
rect: rect ? { x: Math.round(rect.x), y: Math.round(rect.y), r: Math.round(rect.right), b: Math.round(rect.bottom) } : null,
|
||
}
|
||
})
|
||
|
||
async function regie(browser, base) {
|
||
const { context, page } = await newPage(browser, 'regie', { viewport: { width: 1280, height: 720 } })
|
||
await ready(page, base + '/index.html')
|
||
const t0 = Date.now()
|
||
let first = null
|
||
let firstAt = 0
|
||
let most = 0
|
||
const arten = new Set()
|
||
let viele = false
|
||
while (Date.now() - t0 < 15000 && !(first && Date.now() - firstAt > 5000)) {
|
||
const st = await regieOf(page)
|
||
most = Math.max(most, st.arten.length)
|
||
st.arten.forEach((a) => arten.add(a))
|
||
if (st.blasen.length > 0 && !first) {
|
||
first = st
|
||
firstAt = Date.now()
|
||
metrics.regieErsteBlaseNachMs = firstAt - t0
|
||
await shoot(page, 'b-regie-blase.png')
|
||
} else if (first && !viele && st.arten.length >= 3) {
|
||
viele = true
|
||
await shoot(page, 'b-regie-mehrere-effekte.png')
|
||
}
|
||
await sleep(200)
|
||
}
|
||
check('regie: innerhalb von 15 s erscheint mindestens eine Sprechblase (.hk-regie .hk-regie-blase) mit Text', Boolean(first) && first.blasen.every((t) => t !== ''), { first, arten: [...arten] })
|
||
check('regie: die Blase hat höchstens 20 Zeichen und liegt im Bild', Boolean(first) && first.blasen.every((t) => Array.from(t).length <= 20) && first.rect !== null && first.rect.x >= 0 && first.rect.y >= 0 && first.rect.r <= 1280 && first.rect.b <= 720, first)
|
||
check('regie: höchstens vier Effekte gleichzeitig', most <= 4, most)
|
||
check('regie: die Ebene ist für Bildschirmleser versteckt (aria-hidden) und nimmt keine Klicks an', await page.evaluate(() => { const r = document.querySelector('.hk-regie'); return Boolean(r) && r.getAttribute('aria-hidden') === 'true' && getComputedStyle(r).pointerEvents === 'none' }))
|
||
metrics.regieArten = [...arten]
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario C: Handy (390 x 844 hochkant, 844 x 390 quer) ----------
|
||
|
||
// Kopfzeile am Handy: Reihen (verschiedene Oberkanten der Panels), Höhe, Knöpfe, Überlauf der Panels
|
||
const kopfOf = (page) => page.evaluate(() => {
|
||
const kids = [...document.querySelectorAll('.hk-kopf-zeile > *')].filter((e) => e.getBoundingClientRect().height > 0)
|
||
const tops = [...new Set(kids.map((e) => Math.round(e.getBoundingClientRect().top)))].sort((a, b) => a - b)
|
||
const size = (e) => { const b = e.getBoundingClientRect(); return { w: Math.round(b.width), h: Math.round(b.height) } }
|
||
const kopf = document.querySelector('.hk-kopf').getBoundingClientRect()
|
||
return {
|
||
rows: tops.length,
|
||
tops,
|
||
x: Math.round(kopf.x),
|
||
r: Math.round(kopf.right),
|
||
h: Math.round(kopf.height),
|
||
buttons: [...document.querySelectorAll('.hk-hud-knopf')].map(size),
|
||
overflow: [...document.querySelectorAll('.hk-kopf-geld, .hk-kopf-zahlen')].map((e) => e.scrollWidth - e.clientWidth),
|
||
zahlen: [...document.querySelectorAll('.hk-kopf-zahl')].map((z) => z.lastElementChild.textContent.trim()),
|
||
}
|
||
})
|
||
|
||
// Wischen mit echten Touch-Ereignissen (Chrome DevTools Protocol); die Kamera hört auf Zeiger vom Typ „touch“
|
||
async function swipe(cdp, from, to, steps = 8) {
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] })
|
||
for (let i = 1; i <= steps; i += 1) {
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: from.x + ((to.x - from.x) * i) / steps, y: from.y + ((to.y - from.y) * i) / steps }] })
|
||
await sleep(16)
|
||
}
|
||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||
}
|
||
const near = (a, b, tol = 1.5) => Boolean(a && b) && Math.abs(a.x - b.x) <= tol && Math.abs(a.y - b.y) <= tol && Math.abs(a.s - b.s) <= 0.005
|
||
|
||
async function handy(browser, base) {
|
||
const portrait = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }
|
||
const { context, page } = await newPage(browser, 'handy', portrait)
|
||
await ready(page, base + '/index.html')
|
||
await sleep(800)
|
||
|
||
const kopf = await kopfOf(page)
|
||
metrics.handyKopf = { rows: kopf.rows, h: kopf.h }
|
||
check('handy: Kopfzeile höchstens zwei Reihen (hochkant genau zwei: Geld + Pause/Zahnrad, dann die Kennzahlen) und im Bild', kopf.rows === 2 && kopf.x >= 0 && kopf.r <= 390 && kopf.h <= 120, kopf)
|
||
check('handy: Pause- und Zahnrad-Knopf mindestens 40 x 40 px, nichts in den Panels läuft über', kopf.buttons.length === 2 && kopf.buttons.every((b) => b.w >= 40 && b.h >= 40) && kopf.overflow.every((o) => o <= 1), kopf)
|
||
check('handy: alle fünf Kennzahlen stehen in der Kopfzeile (3, 3, 7, 2, 112)', same(kopf.zahlen, ['3', '3', '7', '2', '112']), kopf.zahlen)
|
||
const wide = await widthOf(page)
|
||
check('handy hochkant: kein waagrechtes Scrollen', noSideScroll(wide), wide)
|
||
await shoot(page, 'c-handy-hochformat.png')
|
||
|
||
// Wischen verschiebt die Welt, der Zoom bleibt
|
||
const cdp = await context.newCDPSession(page)
|
||
const c0 = await cameraOf(page)
|
||
await swipe(cdp, { x: 300, y: 560 }, { x: 170, y: 560 })
|
||
await sleep(300)
|
||
const c1 = await cameraOf(page)
|
||
check('handy: Wischen mit dem Finger verschiebt die Welt (Kamera x ändert sich um mehr als 60 px, Zoom gleich)', Boolean(c0 && c1) && Math.abs(c1.x - c0.x) > 60 && Math.abs(c1.s - c0.s) < 0.001, { c0, c1 })
|
||
await shoot(page, 'c-handy-gewischt.png')
|
||
|
||
// Doppeltipp auf eine freie Stelle zeigt das ganze Haus
|
||
await page.touchscreen.tap(375, 300)
|
||
await sleep(40)
|
||
await page.touchscreen.tap(375, 300)
|
||
await sleep(1100)
|
||
const c2 = await cameraOf(page)
|
||
check('handy: Doppeltipp auf eine freie Stelle zeigt wieder das ganze Haus (Kamera wie am Anfang)', near(c2, c0), { c0, c2 })
|
||
await shoot(page, 'c-handy-ganz.png')
|
||
|
||
// Pause-Rückfrage am Handy
|
||
await page.locator('[data-hk-pause]').tap()
|
||
const frage = page.locator('.hk-rueckfrage')
|
||
await frage.waitFor()
|
||
const fb = await rectOf(frage)
|
||
const wFrage = await widthOf(page)
|
||
check('handy: die Rückfrage „Alles anhalten?“ steht im Bild, nichts läuft über den Rand', fb.x >= 0 && fb.r <= 390 && fb.y >= 0 && fb.b <= 844 && noSideScroll(wFrage), { fb, wFrage })
|
||
await shoot(page, 'c-handy-rueckfrage.png')
|
||
await page.getByRole('button', { name: 'Abbrechen', exact: true }).tap()
|
||
await frage.waitFor({ state: 'detached' })
|
||
|
||
// Detailansicht als Vollbild
|
||
await page.locator('[data-hk-hustler="rita"]').tap()
|
||
await page.getByRole('dialog', { name: 'Hustler Rita' }).waitFor()
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-dialog-spurzeile').length > 0)
|
||
await sleep(500)
|
||
const dlg = await rectOf(page.locator('.hk-dialog'))
|
||
const hk = await rectOf(page.locator('.hk-hochhaus'))
|
||
const fuss = await rectOf(page.locator('.hk-dialog-fuss'))
|
||
const nummer = await textOf(page.locator('.hk-dialog-nummer'))
|
||
const wDetail = await widthOf(page)
|
||
metrics.handyDetail = { dialog: dlg, haus: hk }
|
||
check('handy: Detailansicht als Vollbild (Dialog füllt die Fläche des Hauses), nichts läuft über den Rand', dlg.w >= 385 && dlg.h >= hk.h - 8 && noSideScroll(wDetail), { dlg, hk, wDetail })
|
||
check('handy: in der Detailansicht ist „◂ n / 19 ▸“ sichtbar (Fuß im Bild, Nummer „2 / 19“)', fuss.b <= 844 && fuss.y >= 0 && nummer === '2 / 19', { fuss, nummer })
|
||
await shoot(page, 'c-handy-detail.png')
|
||
await page.getByRole('button', { name: 'Schließen', exact: true }).tap()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
await context.close()
|
||
|
||
// quer
|
||
const landscape = await newPage(browser, 'handy-quer', { viewport: { width: 844, height: 390 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
|
||
const lp = landscape.page
|
||
await ready(lp, base + '/index.html')
|
||
await sleep(800)
|
||
const kq = await kopfOf(lp)
|
||
const wq = await widthOf(lp)
|
||
metrics.handyQuerKopf = { rows: kq.rows, h: kq.h }
|
||
check('handy quer: Kopfzeile höchstens zwei Reihen (hier eine) und im Bild, kein waagrechtes Scrollen', kq.rows <= 2 && kq.x >= 0 && kq.r <= 844 && noSideScroll(wq), { kq, wq })
|
||
check('handy quer: Pause- und Zahnrad-Knopf mindestens 40 x 40 px', kq.buttons.length === 2 && kq.buttons.every((b) => b.w >= 40 && b.h >= 40), kq.buttons)
|
||
await shoot(lp, 'c-handy-querformat.png')
|
||
await lp.locator('[data-hk-hustler="rita"]').tap()
|
||
await lp.getByRole('dialog', { name: 'Hustler Rita' }).waitFor()
|
||
await lp.waitForFunction(() => document.querySelectorAll('.hk-dialog-spurzeile').length > 0)
|
||
await sleep(500)
|
||
const fq = await rectOf(lp.locator('.hk-dialog-fuss'))
|
||
const dq = await rectOf(lp.locator('.hk-dialog'))
|
||
const xq = await rectOf(lp.getByRole('button', { name: 'Schließen', exact: true }))
|
||
check('handy quer: Detailansicht ohne Überlauf nach rechts (Dialog im Bild, kein waagrechtes Scrollen)', dq.x >= 0 && dq.r <= 844 && noSideScroll(await widthOf(lp)), { dq })
|
||
// BEFUND (App): ab 720 px Breite gilt die Desktop-Anordnung von hustler-detail.css (Szene 640 px breit, Daten rutschen darunter); bei 844 x 390 sieht man nur die Szene,
|
||
// der Schließen-Knopf und die Fußzeile ◂ 2 / 19 ▸ liegen unterhalb des sichtbaren Teils (Handy-quer-Regel gilt nur unter 720 px Breite)
|
||
befund('handy quer (844 x 390): in der Detailansicht sind der Schließen-Knopf und „◂ 2 / 19 ▸“ ohne Scrollen im Bild', xq.b <= 390 && xq.y >= 0 && fq.b <= 390 && fq.y >= 0, { schliessen: xq, fuss: fq, dialog: dq })
|
||
await shoot(lp, 'c-handy-quer-detail.png')
|
||
await landscape.context.close()
|
||
}
|
||
|
||
// ---------- Szenario D: Weniger Bewegung ----------
|
||
|
||
// Steht das Element still? (kein Anzeigen, keine Animation oder alle pausiert) – aus den berechneten Stilen
|
||
const stillOf = (page, selectors) => page.evaluate((sels) => sels.map((sel) => {
|
||
const el = document.querySelector(sel)
|
||
if (!el) return { sel, fehlt: true }
|
||
const cs = getComputedStyle(el)
|
||
const names = cs.animationName.split(',').map((s) => s.trim())
|
||
const states = cs.animationPlayState.split(',').map((s) => s.trim())
|
||
return { sel, display: cs.display, names, states, steht: cs.display === 'none' || names.every((n) => n === 'none') || states.every((s) => s === 'paused') }
|
||
}), selectors)
|
||
|
||
async function bewegung(browser, base) {
|
||
const welt = ['.hk-schnee-1', '.hk-schnee-2', '.hk-schnee-3', '.hk-funkeln', '.hk-licht-1', '.hk-licht-2', '.hk-licht-3', '.hk-licht-4', '.hk-laternen', '.hk-fenster-a', '.hk-fenster-b', '.hk-fenster-c', '.hk-neon', '.hk-antenne', '.hk-aufzug', '.hk-seil', '.hk-huepf']
|
||
// Gegenprobe: ohne die Einstellung läuft derselbe Satz (sonst prüft der Test nichts)
|
||
const normal = await newPage(browser, 'bewegung-normal', { viewport: { width: 1280, height: 720 } })
|
||
await ready(normal.page, base + '/index.html')
|
||
await sleep(400)
|
||
const running = await stillOf(normal.page, welt)
|
||
check('bewegung: Gegenprobe ohne „weniger Bewegung“: Schnee, Neon und Lichter laufen (der Test kann also fehlschlagen)', running.filter((r) => !r.fehlt && !r.steht).length >= 10, running.filter((r) => r.steht || r.fehlt).map((r) => r.sel))
|
||
await normal.context.close()
|
||
|
||
const { context, page } = await newPage(browser, 'bewegung', { viewport: { width: 1280, height: 720 }, reducedMotion: 'reduce' })
|
||
await ready(page, base + '/index.html')
|
||
await sleep(500)
|
||
const still = await stillOf(page, welt)
|
||
check('bewegung: Schnee, Flackern (Lichter, Fenster, Neon, Antenne, Aufzug, Briefkasten) stehen still (animation-name none, pausiert oder ausgeblendet)', still.every((r) => !r.fehlt && r.steht), still.filter((r) => r.fehlt || !r.steht))
|
||
check('bewegung: der Schnee ist ausgeblendet', still.filter((r) => r.sel.startsWith('.hk-schnee')).every((r) => r.display === 'none'), still.filter((r) => r.sel.startsWith('.hk-schnee')))
|
||
await shoot(page, 'd-bewegung-hochhaus.png')
|
||
|
||
// kein Gleiten: Etage heranzoomen springt sofort, die Klasse hk-gleitet kommt nie
|
||
const c0 = await cameraOf(page)
|
||
await page.locator('.hk-etage[aria-label="Prüfung heranzoomen"]').click()
|
||
const gleitet = await page.evaluate(() => document.querySelector('.hk-buehne').classList.contains('hk-gleitet'))
|
||
await sleep(60)
|
||
const c1 = await cameraOf(page)
|
||
const dauer = await page.evaluate(() => getComputedStyle(document.querySelector('.hk-buehne')).transitionDuration)
|
||
check('bewegung: kein Gleiten (Klasse hk-gleitet fehlt, keine Übergangszeit), die Kamera springt zur Etage', !gleitet && /^0s(, 0s)*$/.test(dauer) && c1.s > c0.s * 1.2, { gleitet, dauer, c0, c1 })
|
||
|
||
// Regie: nur Sprechblasen, keine Symbole, Flüge oder Hüpfer
|
||
const t0 = Date.now()
|
||
const arten = new Set()
|
||
let blase = false
|
||
while (Date.now() - t0 < 15000 && !(blase && Date.now() - t0 > 9000)) {
|
||
const st = await regieOf(page)
|
||
st.arten.forEach((a) => arten.add(a))
|
||
if (st.blasen.length > 0) blase = true
|
||
await sleep(200)
|
||
}
|
||
check('bewegung: die Regie zeigt Sprechblasen, aber keine Symbole, Flüge oder Hüpfer', blase && [...arten].every((a) => a === 'blase'), { blase, arten: [...arten] })
|
||
|
||
// Pult und Keller: Szenen stehen (animation none, pausiert oder ausgeblendet)
|
||
await page.goto(base + '/index.html?fault=none&tab=freigabe')
|
||
await page.waitForSelector('.hk-pult .hk-pult-szene')
|
||
await settleFonts(page)
|
||
const pult = await stillOf(page, ['.hk-sz-schnee', '.hk-sz-l55', '.hk-sz-kette0', '.hk-boten-weg', '.hk-boten-bild', '.hk-sz-tuer', '.hk-sz-briefkasten', '.hk-sz-sekunde'])
|
||
check('bewegung: Pult-Szene steht (Schnee, Lichter, Lichterkette, Boten, Tür, Briefkasten, Sekundenzeiger)', pult.every((r) => !r.fehlt && r.steht), pult.filter((r) => r.fehlt || !r.steht))
|
||
await shoot(page, 'd-bewegung-pult.png')
|
||
await page.goto(base + '/index.html?fault=none&tab=botleads')
|
||
await page.waitForSelector('.hk-keller .hk-kl-szene')
|
||
await settleFonts(page)
|
||
const keller = await stillOf(page, ['.hk-sz-staub', '.hk-sz-fensterschnee', '.hk-sz-licht-92', '.hk-sz-licht-halle', '.hk-sz-rohrpost', '.hk-sz-schublade', '.hk-sz-buch-stempel', '.hk-sz-maus-weg', '.hk-sz-passant-weg', '.hk-sz-led'])
|
||
check('bewegung: Keller-Szene steht (Staub, Schnee, Lichter, Rohrpost, Schublade, Stempel, Maus, Passant, Blinkpunkte)', keller.every((r) => !r.fehlt && r.steht), keller.filter((r) => r.fehlt || !r.steht))
|
||
await shoot(page, 'd-bewegung-keller.png')
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario E: Notbremse ----------
|
||
|
||
async function notbremse(browser, base) {
|
||
const { context, page } = await newPage(browser, 'notbremse', { viewport: { width: 1280, height: 720 } })
|
||
await ready(page, base + '/index.html?halted=1')
|
||
await sleep(500)
|
||
const banner = page.locator('.hk-meldung-notbremse')
|
||
await banner.waitFor()
|
||
const bb = await rectOf(banner)
|
||
check('notbremse: Banner mit Grund („Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.“)', (await textOf(banner)) === HALT_LINE, await textOf(banner))
|
||
check('notbremse: das Banner ist ein Alarm (role=alert), steht im Bild und ist höchstens 580 px breit; sonst keine Meldung', (await banner.getAttribute('role')) === 'alert' && bb.x >= 0 && bb.r <= 1280 && bb.b <= 720 && bb.w <= 580 && (await page.locator('.hk-meldung').count()) === 1, bb)
|
||
await shoot(page, 'e-notbremse.png')
|
||
|
||
// Einstellungen: der WOMS-Dialog in der Pixelwelt zeigt den Notbremse-Block
|
||
await page.getByRole('button', { name: 'Einstellungen', exact: true }).click()
|
||
const dialog = page.getByRole('dialog', { name: 'Hustler-Einstellungen' })
|
||
await dialog.waitFor()
|
||
await dialog.getByLabel('Tagesgrenze (USD)').waitFor()
|
||
const look = await dialog.evaluate((el) => { const cs = getComputedStyle(el); return { bg: cs.backgroundColor, font: cs.fontFamily, woms: Boolean(el.closest('.hk-woms')) } })
|
||
check('notbremse: Einstellungen öffnen im WOMS-Aussehen (weiß, WOMS-Schrift) mit dem Block „Versand gestoppt“', (await dialog.locator('.wk-halt').count()) === 1 && look.bg === 'rgb(255, 255, 255)' && !/Pixelify|Press Start/.test(look.font) && look.woms, look)
|
||
await sleep(600)
|
||
await shoot(page, 'e-einstellungen.png')
|
||
await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('notbremse: „Abbrechen“ schließt die Einstellungen, das Banner bleibt', (await page.locator('.hk-meldung-notbremse').count()) === 1)
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario F: Ausfall und Wiederkehr ----------
|
||
|
||
async function ausfall(browser, base) {
|
||
const { context, page } = await newPage(browser, 'ausfall', { viewport: { width: 1280, height: 720 } })
|
||
await page.goto(base + '/index.html?fault=down')
|
||
const leer = page.locator('.hk-hochhaus .hk-leer')
|
||
await leer.waitFor()
|
||
await settleFonts(page)
|
||
const titel = await textOf(leer.locator('.hk-leer-titel'))
|
||
const satz = await textOf(leer.locator('.hk-leer-text'))
|
||
check('ausfall: Leerzustand „Der Hustler-Dienst ist noch nicht erreichbar“ mit dem Satz des Dienstes und „Neu laden“, kein Haus', titel === NO_SERVICE && satz !== '' && (await leer.getByRole('button', { name: 'Neu laden', exact: true }).count()) === 1 && (await page.locator('[data-hk-hustler]').count()) === 0, { titel, satz })
|
||
await shoot(page, 'f-ausfall.png')
|
||
|
||
await page.evaluate(() => { window.__wk.control.fault = null })
|
||
await leer.getByRole('button', { name: 'Neu laden', exact: true }).click()
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-hk-hustler]').length === 19)
|
||
check('ausfall: nach „Neu laden“ (Dienst wieder da) steht das Haus mit 19 Figuren, der Leerzustand ist weg', (await page.locator('.hk-leer').count()) === 0 && (await page.locator('[data-hk-hustler]').count()) === 19)
|
||
await sleep(500)
|
||
await shoot(page, 'f-ausfall-wieder-da.png')
|
||
|
||
// der Dienst fällt später wieder aus: das Haus bleibt stehen, ein Banner meldet die gestörte Verbindung
|
||
await page.evaluate(() => { window.__wk.control.fault = 'down' })
|
||
const banner = page.locator('.hk-meldung-verbindung')
|
||
await banner.waitFor({ timeout: 25000 })
|
||
const bt = await textOf(banner)
|
||
check('ausfall: bricht die Verbindung später ab, bleibt das Haus stehen und ein Banner meldet „Verbindung gestört: … Es wird weiter versucht.“', bt.startsWith('Verbindung gestört:') && bt.endsWith('Es wird weiter versucht.') && (await page.locator('[data-hk-hustler]').count()) === 19, bt)
|
||
await shoot(page, 'f-ausfall-verbindung-gestoert.png')
|
||
await page.evaluate(() => { window.__wk.control.fault = null })
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario G: Detailansicht ----------
|
||
|
||
const slug = (text) => text.toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||
// Name, Status, Nummer und die Zeilen „Modell … Kosten“ der offenen Detailansicht
|
||
const detailOf = (page) => page.evaluate(() => {
|
||
const text = (sel) => { const el = document.querySelector(sel); return el ? el.textContent.replace(/\s+/g, ' ').trim() : null }
|
||
const dl = {}
|
||
const dt = [...document.querySelectorAll('.hk-dialog-dl dt')]
|
||
dt.forEach((el) => { dl[el.textContent.trim()] = el.nextElementSibling ? el.nextElementSibling.textContent.replace(/\s+/g, ' ').trim() : null })
|
||
const szene = document.querySelector('.hk-dialog .hk-sz')
|
||
return {
|
||
name: text('.hk-dialog-name'),
|
||
status: text('.hk-dialog-status'),
|
||
nummer: text('.hk-dialog-nummer'),
|
||
dl,
|
||
szene: szene ? szene.className : '',
|
||
schild: text('.hk-dialog-schild'),
|
||
alarm: Boolean(document.querySelector('.hk-dialog .hk-alarm-gross')),
|
||
zeilen: document.querySelectorAll('.hk-dialog-spurzeile').length,
|
||
}
|
||
})
|
||
|
||
async function detail(browser, base) {
|
||
const { context, page } = await newPage(browser, 'detail', { viewport: { width: 1280, height: 720 } })
|
||
await ready(page, base + '/index.html')
|
||
const t0 = Date.now()
|
||
const figur = (id) => page.locator(`[data-hk-hustler="${id}"]`)
|
||
const dialog = (name) => page.getByRole('dialog', { name: `Hustler ${name}` })
|
||
const openDetail = async (id, name) => {
|
||
await figur(id).click()
|
||
await dialog(name).waitFor()
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-dialog-spurzeile').length > 0)
|
||
await sleep(350)
|
||
}
|
||
const closeDetail = async () => {
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
}
|
||
await sleep(300)
|
||
|
||
// 1. Ein Bild je Status (gleich nach dem Laden, ehe das Drehbuch viel ändert; der Dateiname trägt den Status, den die Ansicht wirklich zeigt)
|
||
const gesehen = []
|
||
for (const [id, name] of [['otto', 'Overseer'], ['rita', 'Rita'], ['ali', 'Ali'], ['hanna', 'Hanna'], ['kim', 'Kim']]) {
|
||
await openDetail(id, name)
|
||
const d = await detailOf(page)
|
||
gesehen.push(`${id}:${d.status}`)
|
||
check(`detail ${name}: Dialog mit Name „${name}“, Status, Modell, Etage · Raum, Aufgabe, Gerade, Heute, Kosten und Spur`,
|
||
d.name === name && d.status !== '' && ['Modell', 'Etage', 'Aufgabe', 'Gerade', 'Heute', 'Kosten'].every((k) => d.dl[k] !== undefined && d.dl[k] !== '') && d.zeilen > 0, d)
|
||
await shoot(page, `g-detail-${id}-${slug(d.status)}.png`)
|
||
await closeDetail()
|
||
}
|
||
metrics.detailStatus = gesehen
|
||
|
||
// 2. Rita: Inhalt, sichere Links, lange Spur mit Fuß
|
||
await openDetail('rita', 'Rita')
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-dialog-spurzeile').length === 12)
|
||
const rita = await detailOf(page)
|
||
check('detail Rita: Dialog mit role=dialog, aria-modal und dem Namen „Hustler Rita“; Modell „Claude Haiku 5.5“, Etage „Anfragen · Netz-Radar“, Kosten „0,37 $ · 20.700 Tokens“',
|
||
(await dialog('Rita').getAttribute('aria-modal')) === 'true' && rita.dl.Modell === 'Claude Haiku 5.5' && rita.dl.Etage === 'Anfragen · Netz-Radar' && rita.dl.Kosten === '0,37 $ · 20.700 Tokens', rita.dl)
|
||
check('detail Rita: beim Öffnen liegt der Fokus auf „Schließen“', (await focusInfo(page)).label === 'Schließen', await focusInfo(page))
|
||
const links = await page.evaluate(() => [...document.querySelectorAll('.hk-dialog-spurliste a')].map((a) => ({ href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel') })))
|
||
const unsafeRow = page.locator('.hk-dialog-spurzeile', { hasText: 'Link mit Skript abgelehnt' })
|
||
check('detail Rita: Spur mit 12 Zeilen; nur sichere Links (https extern mit noopener noreferrer, intern ohne neuen Tab), nie javascript:',
|
||
rita.zeilen === 12 && links.length === 2 && links.some((l) => l.href === 'https://example.org/fund' && l.target === '_blank' && l.rel === 'noopener noreferrer') && links.every((l) => !/^javascript:/i.test(l.href)) && (await unsafeRow.count()) === 1 && (await unsafeRow.locator('a').count()) === 0, links)
|
||
// lange Spur: Fuß ◂ n / 19 ▸ bleibt im Bild und frei (nicht verdeckt)
|
||
const fuss = await rectOf(page.locator('.hk-dialog-fuss'))
|
||
const knoepfe = await page.evaluate(() => ['Vorheriger Hustler', 'Nächster Hustler'].map((label) => {
|
||
const el = document.querySelector(`.hk-dialog-fuss [aria-label="${label}"]`)
|
||
const b = el.getBoundingClientRect()
|
||
const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2)
|
||
return { label, visible: b.top >= 0 && b.bottom <= window.innerHeight, frei: hit === el || el.contains(hit) }
|
||
}))
|
||
const dlgBox = await rectOf(page.locator('.hk-dialog'))
|
||
const scrolls = await page.evaluate(() => { const el = document.querySelector('.hk-dialog'); return el.scrollHeight > el.clientHeight + 1 })
|
||
metrics.detailDesktop = { dialog: dlgBox, fuss, scrolltDasPanel: scrolls }
|
||
check('detail Rita: bei langer Spur steht „◂ 2 / 19 ▸“ im Bild und ist nicht verdeckt (Fuß klebt unten)', rita.nummer === '2 / 19' && fuss.b <= 720 && fuss.y >= 0 && knoepfe.every((k) => k.visible && k.frei), { fuss, knoepfe, rita: rita.nummer })
|
||
await shoot(page, 'g-detail-rita-lange-spur.png')
|
||
|
||
// 3. Fokusfalle: Tab vom letzten Knopf geht zu „Schließen“, Umschalt+Tab von „Schließen“ zum letzten
|
||
await page.locator('.hk-dialog-fuss [aria-label="Nächster Hustler"]').focus()
|
||
await page.keyboard.press('Tab')
|
||
const nachTab = await focusInfo(page)
|
||
await page.keyboard.press('Shift+Tab')
|
||
const nachShiftTab = await focusInfo(page)
|
||
check('detail: Tab bleibt im Dialog (vom letzten Knopf zu „Schließen“ und mit Umschalt+Tab zurück)', nachTab.label === 'Schließen' && nachShiftTab.label === 'Nächster Hustler', { nachTab, nachShiftTab })
|
||
|
||
// 4. Blättern mit den Pfeiltasten (im Kreis, in der Reihenfolge der Hustler)
|
||
await page.keyboard.press('ArrowRight')
|
||
await page.getByRole('dialog', { name: 'Hustler Kim' }).waitFor()
|
||
const kim = await detailOf(page)
|
||
check('detail: → blättert zu Kim („3 / 19“, Stecker-Szene „noch nicht angeschlossen“, Status „Noch nicht angeschlossen“)', kim.name === 'Kim' && kim.nummer === '3 / 19' && /hk-sz-nicht/.test(kim.szene) && kim.schild === 'noch nicht angeschlossen' && kim.status === 'Noch nicht angeschlossen', kim)
|
||
await page.keyboard.press('ArrowLeft')
|
||
await page.getByRole('dialog', { name: 'Hustler Rita' }).waitFor()
|
||
await page.keyboard.press('ArrowLeft')
|
||
await page.getByRole('dialog', { name: 'Hustler Overseer' }).waitFor()
|
||
const ovs = await detailOf(page)
|
||
check('detail: ← blättert zurück zu Rita und zum Overseer („1 / 19“, Brücken-Szene)', ovs.nummer === '1 / 19' && /hk-sz-bruecke/.test(ovs.szene), ovs)
|
||
await page.keyboard.press('ArrowLeft')
|
||
await page.getByRole('dialog', { name: 'Hustler Arne' }).waitFor()
|
||
check('detail: ← vom Ersten springt im Kreis zum Letzten (Arne, „19 / 19“)', (await textOf(page.locator('.hk-dialog-nummer'))) === '19 / 19')
|
||
await page.getByRole('button', { name: 'Nächster Hustler', exact: true }).click()
|
||
await page.getByRole('dialog', { name: 'Hustler Overseer' }).waitFor()
|
||
await page.getByRole('button', { name: 'Vorheriger Hustler', exact: true }).click()
|
||
await page.getByRole('dialog', { name: 'Hustler Arne' }).waitFor()
|
||
check('detail: die Knöpfe ◂ und ▸ blättern wie die Pfeiltasten', true)
|
||
// hier wurde in der Detailansicht geblättert: die Kamera der Welt darf sich dabei nicht bewegt haben
|
||
const cam0 = await cameraOf(page)
|
||
|
||
// 5. Esc schließt, der Fokus liegt wieder auf der Figur, die den Dialog geöffnet hat
|
||
await closeDetail()
|
||
const nachEsc = await focusInfo(page)
|
||
check('detail: Esc schließt den Dialog, der Fokus liegt wieder auf der Figur (Rita), die ihn geöffnet hat', nachEsc.hustler === 'rita', nachEsc)
|
||
check('detail: die Kamera der Welt hat sich beim Blättern nicht bewegt (Pfeiltasten gehören dem Dialog)', near(cam0, await cameraOf(page)), cam0)
|
||
|
||
// 6. Schließen-Knopf und Klick auf den Hintergrund (erst nach 0,4 s) schließen ebenfalls
|
||
await openDetail('ali', 'Ali')
|
||
await page.getByRole('button', { name: 'Schließen', exact: true }).click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('detail: „Schließen“ schließt, Fokus zurück auf die Figur (Ali)', (await focusInfo(page)).hustler === 'ali', await focusInfo(page))
|
||
await openDetail('hanna', 'Hanna')
|
||
await sleep(700) // der Hintergrund ist die ersten 0,4 s gesperrt (der Antipp-Klick, der den Dialog öffnet, darf ihn nicht gleich wieder schließen)
|
||
await page.mouse.click(240, 360)
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('detail: ein Klick auf den Hintergrund neben dem Dialog schließt ihn', true)
|
||
|
||
// 7. Fehler-Status: das Drehbuch lässt Sven zwischen 39,6 s und 45,8 s im Fehler stehen
|
||
let fehlerGesehen = false
|
||
while (Date.now() - t0 < 62000) {
|
||
const label = await figur('sven').getAttribute('aria-label')
|
||
if (label && label.endsWith('Fehler')) { fehlerGesehen = true; break }
|
||
await sleep(250)
|
||
}
|
||
check('detail: Sven steht im Fehler (Label endet mit „Fehler“) – roter „!“-Marker am Haus', fehlerGesehen && (await figur('sven').locator('.hk-alarm').count()) === 1 && (await textOf(figur('sven').locator('.hk-alarm'))) === '!', await figur('sven').getAttribute('aria-label'))
|
||
if (fehlerGesehen) {
|
||
await page.evaluate(() => { document.querySelector('[data-hk-hustler="sven"]').scrollIntoView({ block: 'center' }) })
|
||
await shoot(page, 'g-haus-fehler.png')
|
||
await figur('sven').click()
|
||
await dialog('Sven').waitFor()
|
||
await sleep(350)
|
||
const sven = await detailOf(page)
|
||
check('detail Sven (Fehler): Status „Fehler“ in Rot, großer „!“-Marker an der Figur', sven.status === 'Fehler' && sven.alarm, sven)
|
||
await shoot(page, 'g-detail-sven-fehler.png')
|
||
await closeDetail()
|
||
}
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario H: Etagen, Tasten, Freigabe und Keller ----------
|
||
|
||
async function etagen(browser, base) {
|
||
const { context, page } = await newPage(browser, 'etagen', { viewport: { width: 1280, height: 720 } })
|
||
await ready(page, base + '/index.html')
|
||
await sleep(500)
|
||
const tabOf = () => new URL(page.url()).searchParams.get('tab')
|
||
|
||
const etagen = await page.evaluate(() => [...document.querySelectorAll('.hk-etage')].map((el) => ({ tag: el.tagName, label: el.getAttribute('aria-label'), href: el.getAttribute('href') })))
|
||
check('etagen: zehn Etagen – acht Knöpfe „… heranzoomen“, dazu die Links „Freigabe“ und „Keller · Bot-Leads“, alle mit aria-label',
|
||
etagen.length === 10 && etagen.filter((e) => e.tag === 'BUTTON' && /heranzoomen$/.test(e.label || '')).length === 8
|
||
&& etagen.some((e) => e.tag === 'A' && e.label === 'Freigabe: Freigabe-Pult öffnen' && e.href === '/hustler?tab=freigabe')
|
||
&& etagen.some((e) => e.tag === 'A' && e.label === 'Keller · Bot-Leads: Bot-Leads öffnen' && e.href === '/hustler?tab=botleads'), etagen)
|
||
|
||
// 1. Etage zoomen: gleitet, die Etage füllt das Bild
|
||
const c0 = await cameraOf(page)
|
||
const pruefung = page.locator('.hk-etage[aria-label="Prüfung heranzoomen"]')
|
||
await pruefung.click()
|
||
const gleitet = await page.evaluate(() => document.querySelector('.hk-buehne').classList.contains('hk-gleitet'))
|
||
await sleep(900)
|
||
const c1 = await cameraOf(page)
|
||
const eb = await rectOf(pruefung)
|
||
const sicht = await rectOf(page.locator('.hk-sicht'))
|
||
check('etagen: Klick auf die Etage „Prüfung“ zoomt heran (gleitet, Zoom wächst um mehr als 30 %), die Etage steht im Bild und ist breit', gleitet && c1.s > c0.s * 1.3 && eb.x >= sicht.x - 2 && eb.r <= sicht.r + 2 && eb.y >= sicht.y && eb.b <= sicht.b + 2 && eb.w >= 400, { gleitet, c0, c1, eb, sicht })
|
||
const nachGleiten = await page.evaluate(() => document.querySelector('.hk-buehne').classList.contains('hk-gleitet'))
|
||
check('etagen: nach dem Gleiten ist die Klasse hk-gleitet wieder weg', !nachGleiten)
|
||
await shoot(page, 'h-etage-zoom.png')
|
||
|
||
// 2. Tastenkürzel +, −, 0, Esc
|
||
await page.keyboard.press('0')
|
||
await sleep(900)
|
||
check('etagen: Taste 0 zeigt wieder das ganze Haus', near(await cameraOf(page), c0), { c0, jetzt: await cameraOf(page) })
|
||
await page.keyboard.press('+')
|
||
await sleep(80)
|
||
const plus = await cameraOf(page)
|
||
await page.keyboard.press('-')
|
||
await sleep(80)
|
||
const minus = await cameraOf(page)
|
||
check('etagen: + zoomt hinein, − wieder heraus', plus.s > c0.s * 1.1 && minus.s < plus.s, { c0, plus, minus })
|
||
await pruefung.click()
|
||
await sleep(900)
|
||
await page.keyboard.press('Escape')
|
||
await sleep(900)
|
||
check('etagen: Esc zeigt das ganze Haus', near(await cameraOf(page), c0), { c0, jetzt: await cameraOf(page) })
|
||
await page.getByRole('button', { name: 'Ganzes Haus zeigen', exact: true }).evaluate((el) => el.click())
|
||
check('etagen: „Ganzes Haus zeigen“ (nur Tastatur) ist ein echter Knopf in der Kopfzeile', (await page.getByRole('button', { name: 'Ganzes Haus zeigen', exact: true }).count()) === 1)
|
||
|
||
// 3. Etage „Freigabe“ → Pult (Adresse tab=freigabe), „◂ Hochhaus“ zurück
|
||
const freigabe = page.locator('a.hk-etage[aria-label^="Freigabe"]')
|
||
await freigabe.click({ position: { x: 16, y: 8 } })
|
||
await page.locator('.hk-pult').waitFor()
|
||
check('etagen: Etage „Freigabe“ öffnet das Pult (Adresse tab=freigabe), das Hochhaus ist weg', tabOf() === 'freigabe' && (await page.locator('.hk-hochhaus').count()) === 0 && (await page.locator('[data-hk-hustler]').count()) === 0, { tab: tabOf(), url: page.url() })
|
||
await settleFonts(page)
|
||
await sleep(600)
|
||
await shoot(page, 'h-pult-von-etage.png')
|
||
await page.getByRole('button', { name: 'Hochhaus', exact: true }).click()
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-hk-hustler]').length === 19)
|
||
check('etagen: „◂ Hochhaus“ führt zurück ins Haus mit 19 Figuren (Adresse tab=hochhaus)', tabOf() === 'hochhaus' && (await page.locator('.hk-pult').count()) === 0, { tab: tabOf() })
|
||
|
||
// 4. Keller-Etage → Bot-Leads, „Zurück zum Hochhaus“
|
||
await sleep(500)
|
||
const keller = page.locator('a.hk-etage[aria-label^="Keller"]')
|
||
await keller.click({ position: { x: 16, y: 8 } })
|
||
await page.locator('.hk-keller').waitFor()
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-zeile').length > 0)
|
||
check('etagen: Keller-Etage öffnet die Bot-Leads (Adresse tab=botleads) mit Einträgen im Register-Buch', tabOf() === 'botleads' && (await page.locator('.hk-zeile').count()) === 50, { tab: tabOf() })
|
||
await sleep(600)
|
||
await shoot(page, 'h-keller-von-etage.png')
|
||
await page.getByRole('button', { name: 'Zurück zum Hochhaus', exact: true }).click()
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-hk-hustler]').length === 19)
|
||
check('etagen: „Zurück zum Hochhaus“ führt ins Haus zurück (Adresse tab=hochhaus)', tabOf() === 'hochhaus' && (await page.locator('.hk-keller').count()) === 0, { tab: tabOf() })
|
||
|
||
// 5. Zurück-Taste des Browsers: Haus → Pult → Haus über die Chronik
|
||
await page.goBack()
|
||
await page.locator('.hk-keller').waitFor()
|
||
check('etagen: die Zurück-Taste des Browsers geht von Haus zurück zu den Bot-Leads (Chronik), nicht aus der Seite', tabOf() === 'botleads', { tab: tabOf() })
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario I: Pause mit Rückfrage, Animationen stehen ----------
|
||
|
||
async function pause(browser, base) {
|
||
const { context, page } = await newPage(browser, 'pause', { viewport: { width: 1280, height: 720 } })
|
||
await ready(page, base + '/index.html')
|
||
await sleep(500)
|
||
const pauseBtn = page.locator('[data-hk-pause]')
|
||
const calls = () => apiOf(page, 'pauseCalls')
|
||
const frage = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true })
|
||
// Rita tippt: der Sprite läuft (background-position ändert sich), solange nichts angehalten ist
|
||
const probe = async () => {
|
||
const werte = []
|
||
for (let i = 0; i < 7; i += 1) {
|
||
werte.push(await page.evaluate(() => {
|
||
const el = document.querySelector('[data-hk-hustler="rita"] .hk-spr')
|
||
const cs = getComputedStyle(el)
|
||
return { pos: cs.backgroundPosition, state: cs.animationPlayState, schnee: getComputedStyle(document.querySelector('.hk-schnee-1')).animationPlayState }
|
||
}))
|
||
await sleep(170)
|
||
}
|
||
return werte
|
||
}
|
||
const lauf = await probe()
|
||
check('pause: Gegenprobe vor dem Anhalten: Ritas Sprite läuft (mehrere Bildausschnitte, Zustand „running“)', new Set(lauf.map((w) => w.pos)).size >= 2 && lauf.every((w) => w.state === 'running'), lauf)
|
||
|
||
// 1. Rückfrage statt sofortigem Anhalten
|
||
await pauseBtn.click()
|
||
await frage.waitFor()
|
||
const text = await textOf(frage.locator('.hk-rueckfrage-text'))
|
||
const namen = await frage.getByRole('button').allTextContents()
|
||
check('pause: „Alles anhalten“ öffnet die Pixel-Rückfrage „Alles anhalten?“ mit dem Satz zu den Workern und den Knöpfen „Abbrechen“ und „Anhalten“', text === HALT_QUESTION && same(namen.map((t) => t.trim()), ['Abbrechen', 'Anhalten']) && (await frage.getAttribute('aria-modal')) === 'true', { text, namen })
|
||
check('pause: vor der Bestätigung ist nichts angehalten (kein Aufruf, keine Meldung), der Fokus liegt auf „Abbrechen“', same(await calls(), []) && (await page.locator('.hk-meldung').count()) === 0 && (await focusInfo(page)).text === 'Abbrechen', { calls: await calls(), fokus: await focusInfo(page) })
|
||
await sleep(300)
|
||
await shoot(page, 'i-pause-frage.png')
|
||
|
||
// 2. Abbrechen und Esc tun nichts, der Fokus kehrt auf den Pause-Knopf zurück
|
||
await frage.getByRole('button', { name: 'Abbrechen', exact: true }).click()
|
||
await frage.waitFor({ state: 'detached' })
|
||
check('pause: „Abbrechen“ schließt die Rückfrage, nichts wird angehalten, der Fokus liegt wieder auf dem Pause-Knopf', same(await calls(), []) && (await focusInfo(page)).pause === true, { calls: await calls(), fokus: await focusInfo(page) })
|
||
await pauseBtn.click()
|
||
await frage.waitFor()
|
||
await page.keyboard.press('Escape')
|
||
await frage.waitFor({ state: 'detached' })
|
||
check('pause: Esc wirkt wie „Abbrechen“ (nichts angehalten, Fokus auf dem Pause-Knopf)', same(await calls(), []) && (await focusInfo(page)).pause === true, { calls: await calls(), fokus: await focusInfo(page) })
|
||
|
||
// 3. Anhalten hält an: Banner, hk-steht, alle Animationen pausiert
|
||
await pauseBtn.click()
|
||
await frage.waitFor()
|
||
await frage.getByRole('button', { name: 'Anhalten', exact: true }).click()
|
||
await frage.waitFor({ state: 'detached' })
|
||
const banner = page.locator('.hk-meldung-angehalten')
|
||
await banner.waitFor()
|
||
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor()
|
||
check('pause: „Anhalten“ pausiert genau einmal (pause(true)), Banner „Angehalten: Es wird nichts gesendet und nichts gebaut.“, Knopf „Fortsetzen“', same(await calls(), [true]) && (await textOf(banner)) === PAUSED_LINE && (await banner.getAttribute('role')) === 'status', { calls: await calls(), banner: await textOf(banner) })
|
||
const steht = await page.evaluate(() => ({ sicht: document.querySelector('.hk-sicht').classList.contains('hk-steht'), buehne: document.querySelector('.hk-buehne').classList.contains('hk-steht') }))
|
||
check('pause: Sichtfenster und Bühne tragen hk-steht', steht.sicht && steht.buehne, steht)
|
||
const fokus = await focusInfo(page)
|
||
check('pause: nach dem Anhalten steht der Fokus nicht auf <body> (Tastatur-Nutzer verlieren ihre Stelle nicht)', fokus.tag !== null && fokus.tag !== 'BODY', fokus)
|
||
const still = await probe()
|
||
check('pause: angehalten – Ritas Sprite steht (immer derselbe Bildausschnitt, Zustand „paused“), auch der Schnee ist pausiert', new Set(still.map((w) => w.pos)).size === 1 && still.every((w) => w.state === 'paused' && w.schnee === 'paused'), still)
|
||
check('pause: die Figuren sind unverändert da (19 Knöpfe, Label von Rita bleibt „Rita, Haiku, …“)', (await page.locator('[data-hk-hustler]').count()) === 19 && /^Rita, Haiku, /.test(await page.locator('[data-hk-hustler="rita"]').getAttribute('aria-label')))
|
||
await shoot(page, 'i-pause-angehalten.png')
|
||
|
||
// 4. Fortsetzen geht ohne Rückfrage
|
||
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click()
|
||
await page.locator('.hk-meldung-angehalten').waitFor({ state: 'detached' })
|
||
await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor()
|
||
const weiter = await probe()
|
||
check('pause: „Fortsetzen“ geht ohne Rückfrage (kein Dialog, pause(false)), das Banner verschwindet, hk-steht ist weg und der Sprite läuft wieder',
|
||
(await page.getByRole('dialog').count()) === 0 && same(await calls(), [true, false]) && (await page.locator('.hk-steht').count()) === 0 && new Set(weiter.map((w) => w.pos)).size >= 2, { calls: await calls(), weiter })
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Pult: gemeinsame Hilfen (Szenarien J, K und M) ----------
|
||
// Die Seite läuft über den echten API-Client und die Fetch-Weiche (fault=none): Pult-Aufrufe, Fehlertexte und Zeitgrenzen sind die des Betriebs. Was die Oberfläche
|
||
// geschickt hat, steht in window.__wk.api.pultCalls (Skript-API); Zwischenablage und window.open sind ersetzt (newStubPage).
|
||
|
||
const PULT_URL = '/index.html?fault=none&tab=freigabe'
|
||
const P4_URL = '/index.html?fault=none&p4=1&tab=freigabe'
|
||
const T3 = {
|
||
mail: 'Anfrage: Vereinswebsite für den SV Namborn',
|
||
plat: 'Kleinanzeigen: Website für Friseursalon gesucht',
|
||
koll: 'Kleinanzeigen: Homepage für Handwerksbetrieb',
|
||
uns: 'Unsichere Antwort: bitte prüfen',
|
||
alt: 'freelancermap: WordPress-Relaunch',
|
||
ges: 'Kleinanzeigen: Fotograf sucht Portfolio-Seite',
|
||
erl: 'Beschwerde über eine Vorschau',
|
||
}
|
||
const T4 = {
|
||
mail: 'Anfrage: Vereinswebsite für den SV Namborn',
|
||
frei: 'Vorschau freigeben: Friseur Muster',
|
||
falsch: 'Vorschau freigeben: Bäckerei Korn',
|
||
uns: 'Unsichere Antwort: Ist das ein Ja?',
|
||
koll: 'Anfrage: Tischlerei Holz',
|
||
plat: 'Kleinanzeigen: Website für Fotostudio',
|
||
ang: 'Kleinanzeigen: Website für Fotostudio (Angebot)',
|
||
}
|
||
const HAIR_URL = 'https://www.kleinanzeigen.de/s-anzeige/website-fuer-friseursalon/2870000001-1-1'
|
||
const WEBER_URL = 'https://www.kleinanzeigen.de/s-anzeige/homepage-handwerksbetrieb/2870000002-1-1'
|
||
const PREVIEW_URL = 'https://entwurf.project.webklar.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/'
|
||
const CONFLICT = 'Wird schon von einem Kollegen bearbeitet.'
|
||
const GONE = 'Dieser Kontakt ist nicht mehr aktiv.'
|
||
const NEED_TEXT = 'Bitte einen Text eingeben.'
|
||
const BUILD_HINT = 'Startet einen Opus-Lauf und baut die Vorschau, als hätte der Interessent Ja gesagt.'
|
||
const PASTE_HINT = 'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.'
|
||
const PULT_DOWN = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
|
||
// Telefon: das Wort oder eine Nummer (+49 / Vorwahl mit 0, Trennzeichen, weitere Ziffern); Postleitzahlen und Beträge passen nicht darauf
|
||
const PHONE_WORD = /telefon|phone|\btel\.|handynummer|mobilnummer/i
|
||
const PHONE_NUMBER = /\+49|\b0\d{2,5}[\s/-]\d{3,}/
|
||
|
||
const umschlag = (page, title) => page.locator('.hk-umschlag').filter({ has: page.locator('.hk-umschlag-titel', { hasText: new RegExp('^' + escapeRe(title) + '$') }) })
|
||
const titlesOf = (page) => page.locator('.hk-umschlag-titel').allTextContents()
|
||
const waitTitles = (page, want, timeout = 8000) => page.waitForFunction((w) => JSON.stringify([...document.querySelectorAll('.hk-umschlag-titel')].map((el) => el.textContent)) === JSON.stringify(w), want, { timeout })
|
||
const brief = (page) => page.locator('.hk-brief')
|
||
const stempel = (page, name) => page.locator('.hk-brief .hk-aktionen').getByRole('button', { name, exact: true })
|
||
const aktionen = (page) => page.locator('.hk-brief .hk-aktionen').locator('button, a').evaluateAll((els) => els.map((el) => el.textContent.replace(/\s+/g, ' ').trim()))
|
||
const briefNote = (page) => page.locator('.hk-brief .hk-hinweis')
|
||
const meldungOf = (page) => page.locator('section.hk-klemmbrett > .hk-hinweis-ok')
|
||
const pultCalls = (page) => apiOf(page, 'pultCalls')
|
||
const callsOf = async (page, action, id) => (await pultCalls(page)).filter((c) => c.action === action && (id === undefined || c.id === id))
|
||
const stubState = (page) => page.evaluate(() => ({ clip: window.__clip.slice(), opened: window.__opened.map((a) => Array.from(a)), meta: window.__openMeta.slice() }))
|
||
const resetStubs = (page) => page.evaluate(() => { window.__clip.length = 0; window.__opened.length = 0; window.__openMeta.length = 0 })
|
||
const reiterBtn = (page, name) => page.locator('.hk-reiter-leiste .hk-reiter', { hasText: name })
|
||
const phoneFree = async (page) => {
|
||
const html = await page.evaluate(() => document.documentElement.outerHTML)
|
||
const text = await page.evaluate(() => document.body.innerText)
|
||
return !PHONE_WORD.test(html) && !PHONE_NUMBER.test(html) && !PHONE_WORD.test(text) && !PHONE_NUMBER.test(text)
|
||
}
|
||
|
||
// Pult öffnen: warten, bis alle wartenden Umschläge und die Mitarbeiternamen („übernommen von Justin“) da sind
|
||
async function openPult(page, base, url, count) {
|
||
await page.goto(base + url)
|
||
await page.waitForFunction((n) => document.querySelectorAll('.hk-umschlag').length === n, count)
|
||
await page.waitForFunction(() => document.querySelector('.hk-klemmbrett').textContent.includes('Justin'))
|
||
await settleFonts(page)
|
||
await sleep(300)
|
||
}
|
||
async function openBrief(page, title) {
|
||
await umschlag(page, title).click()
|
||
await brief(page).waitFor()
|
||
await sleep(250)
|
||
}
|
||
const closeBrief = async (page) => {
|
||
await page.keyboard.press('Escape')
|
||
await brief(page).waitFor({ state: 'detached' })
|
||
}
|
||
|
||
// ---------- Szenario J: Pult mit den P3-Daten ----------
|
||
|
||
async function pult(browser, base) {
|
||
const { context, page } = await newStubPage(browser, 'pult', { viewport: { width: 1280, height: 720 } })
|
||
await openPult(page, base, PULT_URL, 4)
|
||
|
||
// 1. Seite: Szene, Kopf, Reiter, Umschläge
|
||
const reiter = await page.evaluate(() => [...document.querySelectorAll('.hk-reiter-leiste .hk-reiter')].map((b) => ({ text: b.textContent.replace(/\s+/g, ' ').trim(), pressed: b.getAttribute('aria-pressed'), zahl: b.querySelector('.hk-zahl') ? b.querySelector('.hk-zahl').textContent : null })))
|
||
check('pult: drei Reiter „Wartet“, „Veraltet“, „Erledigt“, „Wartet“ gedrückt, nur der offene mit einer Zahl', same(reiter.map((r) => r.text.replace(/\d+$/, '').trim()), ['Wartet', 'Veraltet', 'Erledigt']) && same(reiter.map((r) => r.pressed), ['true', 'false', 'false']) && /^[0-9]+$/.test(reiter[0].zahl || '') && reiter[1].zahl === null && reiter[2].zahl === null, reiter)
|
||
const kopf = await page.evaluate(() => ({ h1: document.querySelector('.hk-pult-h1').textContent, zahl: (document.querySelector('.hk-pult-zahl') || {}).textContent || '', zurueck: [...document.querySelectorAll('.hk-pult-knopf')].map((b) => b.textContent.replace(/\s+/g, ' ').trim()) }))
|
||
check('pult: Messingschild „Freigabe“ mit der Zahl „Wartet auf dich“, Knöpfe „Hochhaus“ und „Pult zuklappen“, Szene dahinter', kopf.h1 === 'Freigabe' && /\d/.test(kopf.zahl) && same(kopf.zurueck, ['Hochhaus', 'Pult zuklappen']) && (await page.locator('.hk-pult-szene').count()) === 1, kopf)
|
||
check('pult: „Wartet“ zeigt die vier Einträge, neueste zuerst', same(await titlesOf(page), [T3.mail, T3.plat, T3.koll, T3.uns]), await titlesOf(page))
|
||
const platText = await textOf(umschlag(page, T3.plat))
|
||
check('pult: Plattform-Umschlag nennt Art, Alter, „läuft in 20 Std. ab“, Ort und Score', platText.includes('Antwort auf Plattform-Anfrage') && platText.includes('vor 5 Std.') && platText.includes('läuft in 20 Std. ab') && platText.includes('67655 Kaiserslautern · Score 72'), platText)
|
||
check('pult: Kollegen-Umschlag trägt „übernommen von Justin“', (await textOf(umschlag(page, T3.koll))).includes('übernommen von Justin'))
|
||
check('pult: Umschläge sind echte Knöpfe mit aria-haspopup=dialog und einem Namen „Art: Titel, …“', await page.evaluate(() => [...document.querySelectorAll('.hk-umschlag')].every((b) => b.tagName === 'BUTTON' && b.getAttribute('aria-haspopup') === 'dialog' && /^[^:]+: .+/.test(b.getAttribute('aria-label') || ''))))
|
||
await shoot(page, 'j-pult-wartet.png')
|
||
|
||
// 2. Umschlag öffnen: der Brief
|
||
await openBrief(page, T3.plat)
|
||
const dlg = await page.evaluate(() => { const b = document.querySelector('.hk-brief'); return { role: b.getAttribute('role'), modal: b.getAttribute('aria-modal'), label: b.getAttribute('aria-label') } })
|
||
check('pult: ein Klick auf den Umschlag öffnet den Brief (role=dialog, aria-modal, Name „Antwort auf Plattform-Anfrage: …“), der Fokus liegt auf „Schließen“', dlg.role === 'dialog' && dlg.modal === 'true' && dlg.label === `Antwort auf Plattform-Anfrage: ${T3.plat}` && (await focusInfo(page)).label === 'Schließen', { dlg, fokus: await focusInfo(page) })
|
||
const draft = await brief(page).locator('.hk-brief-entwurf').inputValue()
|
||
check('pult: der Brief zeigt die Originalanfrage, den bearbeitbaren Entwurf und den sicheren Link „Anzeige öffnen“ (https, neuer Tab, noopener noreferrer)', (await textOf(brief(page).locator('.hk-brief-zitat'))).includes('Suche jemanden für eine einfache Website') && draft.startsWith('Hallo, ich habe Ihre Anzeige gesehen.') && (await brief(page).getByRole('link', { name: `Anzeige öffnen: ${T3.plat}`, exact: true }).getAttribute('href')) === HAIR_URL && (await brief(page).getByRole('link', { name: `Anzeige öffnen: ${T3.plat}`, exact: true }).getAttribute('rel')) === 'noopener noreferrer', draft)
|
||
check('pult: Aktionen der Plattform-Antwort: Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen, Verwerfen', same(await aktionen(page), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await aktionen(page))
|
||
await shoot(page, 'j-brief-plattform.png')
|
||
await page.keyboard.press('Escape')
|
||
await brief(page).waitFor({ state: 'detached' })
|
||
const nachEsc = await focusInfo(page)
|
||
check('pult: Esc schließt den Brief, der Fokus liegt wieder auf dem Umschlag, der ihn geöffnet hat', nachEsc.cls.includes('hk-umschlag') && nachEsc.text.includes(T3.plat), nachEsc)
|
||
|
||
// 3. „Ich kümmere mich“
|
||
await openBrief(page, T3.plat)
|
||
await stempel(page, 'Ich kümmere mich').click()
|
||
await briefNote(page).waitFor()
|
||
const claim = await callsOf(page, 'claim', 'pult-plat')
|
||
check('pult: „Ich kümmere mich“ übernimmt den Eintrag (ein Aufruf claim), Meldung „Du kümmerst dich darum.“, der Knopf ist weg', claim.length === 1 && (await textOf(briefNote(page))) === 'Du kümmerst dich darum.' && (await stempel(page, 'Ich kümmere mich').count()) === 0, { claim, note: await textOf(briefNote(page)) })
|
||
await shoot(page, 'j-brief-uebernommen.png')
|
||
|
||
// 4. „Kopieren & öffnen“ mit bearbeitetem Text
|
||
const edited = draft + '\nPS: Bearbeitet im Prüfstand.'
|
||
await brief(page).locator('.hk-brief-entwurf').fill(edited)
|
||
await resetStubs(page)
|
||
await stempel(page, 'Kopieren & öffnen').click()
|
||
await page.waitForFunction(() => { const el = document.querySelector('.hk-brief .hk-hinweis'); return el && el.textContent === 'Kopiert und geöffnet.' })
|
||
const stub = await stubState(page)
|
||
check('pult: „Kopieren & öffnen“ kopiert den bearbeiteten Text und öffnet genau die Anzeige im neuen Tab (noopener,noreferrer)', same(stub.clip, [edited]) && same(stub.opened, [[HAIR_URL, '_blank', 'noopener,noreferrer']]), stub)
|
||
check('pult: „Kopieren & öffnen“ öffnet im Zug des Klicks (binnen 150 ms, Nutzer-Geste aktiv), ohne die Zwischenablage abzuwarten', stub.meta.length === 1 && stub.meta[0].sinceClick < 150 && stub.meta[0].active === true, stub.meta)
|
||
check('pult: Meldung „Kopiert und geöffnet.“ (grün, role=status); der Eintrag war schon meiner, also kein zweites claim', (await briefNote(page).getAttribute('role')) === 'status' && /hk-hinweis-ok/.test(await briefNote(page).getAttribute('class')) && (await callsOf(page, 'claim', 'pult-plat')).length === 1)
|
||
|
||
// 5. „Als gesendet markieren“: der Brief schließt, die Meldung steht im Klemmbrett und hat den Fokus
|
||
await stempel(page, 'Als gesendet markieren').click()
|
||
await meldungOf(page).first().waitFor()
|
||
const fertig = await textOf(meldungOf(page).first())
|
||
const sent = (await callsOf(page, 'sent', 'pult-plat')).pop()
|
||
check('pult: „Als gesendet markieren“ schickt den bearbeiteten Text (body.text), der Brief schließt, die Meldung „…“ als gesendet markiert. steht im Klemmbrett (role=status)', fertig === `„${T3.plat}“ als gesendet markiert.` && (await meldungOf(page).first().getAttribute('role')) === 'status' && sent !== undefined && sent.body.text === edited && (await brief(page).count()) === 0, { fertig, sent })
|
||
const fokus = await focusInfo(page)
|
||
check('pult: nach dem Abschluss liegt der Fokus auf der Meldung (der Umschlag ist weg, der Fokus bleibt in der Nähe der Liste)', fokus.role === 'status' && fokus.text !== '' && fertig.startsWith(fokus.text), fokus)
|
||
check('pult: der abgeschlossene Eintrag ist aus „Wartet“ verschwunden', !(await titlesOf(page)).includes(T3.plat), await titlesOf(page))
|
||
await shoot(page, 'j-pult-gesendet.png')
|
||
|
||
// 6. Interessent: das bestehende Panel im WOMS-Aussehen
|
||
await openBrief(page, T3.mail)
|
||
await brief(page).getByRole('button', { name: /^Interessent/ }).click()
|
||
const panel = page.getByRole('dialog', { name: 'SV Namborn', exact: true })
|
||
await panel.waitFor()
|
||
await panel.locator('.wk-prospect').waitFor()
|
||
await sleep(600) // der Dialog blendet ein
|
||
const look = await panel.evaluate((el) => { const cs = getComputedStyle(el); return { bg: cs.backgroundColor, font: cs.fontFamily, bild: cs.imageRendering, woms: Boolean(el.closest('.hk-woms')), modal: el.getAttribute('aria-modal') } })
|
||
check('pult: „Interessent“ öffnet das Panel im WOMS-Aussehen (weiße Box, WOMS-Schrift, kein Pixel-Rendering, aria-modal) mit Namen als Titel', look.bg === 'rgb(255, 255, 255)' && !/Pixelify|Press Start/.test(look.font) && look.bild !== 'pixelated' && look.woms && look.modal === 'true', look)
|
||
const asked = await apiOf(page, 'prospectCalls')
|
||
const score = await textOf(panel.locator('.wk-prospect-score'))
|
||
check('pult: das Panel fragt nur den Interessenten p-mail ab und zeigt „Score 84 · hoch“ mit Begründung; kein Telefon im Bild', asked.length >= 1 && asked.every((id) => id === 'p-mail') && score.startsWith('Score 84 · hoch') && score.includes('Klarer Auftrag') && (await phoneFree(page)), { asked, score })
|
||
check('pult: Nachrichtenverlauf mit „Interessent“ und „Justin“ (Name aus der Mitarbeiterliste)', same(await panel.locator('li.wk-prospect-msg strong').allTextContents(), ['Interessent', 'Justin']))
|
||
await shoot(page, 'j-interessent-panel.png')
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor({ state: 'detached' })
|
||
const backFocus = await focusInfo(page)
|
||
check('pult: Esc schließt nur das Panel (der Brief bleibt offen), der Fokus liegt wieder auf „Interessent“', (await brief(page).count()) === 1 && backFocus.text === 'Interessent', backFocus)
|
||
|
||
// 7. Verwerfen mit Rückfrage (4 s), dann zwei Klicks
|
||
const verwerfen = stempel(page, 'Verwerfen')
|
||
await verwerfen.click()
|
||
const frageName = stempel(page, 'Wirklich verwerfen?')
|
||
check('pult: Verwerfen fragt beim ersten Klick „Wirklich verwerfen?“ (Stempel mit Rückfrage), noch keine Aktion am Dienst', (await frageName.count()) === 1 && (await frageName.getAttribute('class')).includes('hk-stempel-frage') && (await callsOf(page, 'discard')).length === 0)
|
||
await shoot(page, 'j-brief-verwerfen-frage.png')
|
||
await sleep(4400)
|
||
check('pult: die Rückfrage verfällt nach 4 s (wieder „Verwerfen“)', (await stempel(page, 'Verwerfen').count()) === 1 && (await frageName.count()) === 0)
|
||
await stempel(page, 'Verwerfen').click()
|
||
await stempel(page, 'Wirklich verwerfen?').click()
|
||
await page.waitForFunction((t) => { const el = document.querySelector('section.hk-klemmbrett > .hk-hinweis-ok'); return el && el.textContent === `„${t}“ verworfen.` }, T3.mail)
|
||
const discards = await callsOf(page, 'discard')
|
||
check('pult: zwei Klicks verwerfen genau einmal (Body {}: die Oberfläche schickt keinen Grund), Meldung „…“ verworfen., Brief zu, Umschlag weg', discards.length === 1 && discards[0].id === 'pult-mail' && same(discards[0].body, {}) && (await brief(page).count()) === 0 && !(await titlesOf(page)).includes(T3.mail), discards)
|
||
|
||
// 8. Ein Kollege hat den Eintrag schon: „Kopieren & öffnen“ klappt, der Dienst antwortet 409
|
||
await resetStubs(page)
|
||
await openBrief(page, T3.koll)
|
||
check('pult: Kollegen-Eintrag: kein „Ich kümmere mich“ im Brief', (await stempel(page, 'Ich kümmere mich').count()) === 0, await aktionen(page))
|
||
await stempel(page, 'Kopieren & öffnen').click()
|
||
await page.waitForFunction(() => { const el = document.querySelector('.hk-brief .hk-hinweis'); return el && el.textContent.length > 0 && el.textContent.includes('Justin') })
|
||
const kollNote = briefNote(page)
|
||
const kollStub = await stubState(page)
|
||
check('pult: 409 beim Übernehmen: „Kopiert und geöffnet. Wird schon von Justin bearbeitet.“ als roter Hinweis (role=alert), Anzeige trotzdem kopiert und geöffnet', (await textOf(kollNote)) === 'Kopiert und geöffnet. Wird schon von Justin bearbeitet.' && (await kollNote.getAttribute('role')) === 'alert' && kollStub.clip.length === 1 && same(kollStub.opened, [[WEBER_URL, '_blank', 'noopener,noreferrer']]), { note: await textOf(kollNote), kollStub })
|
||
await shoot(page, 'j-brief-409-kollege.png')
|
||
await closeBrief(page)
|
||
|
||
// 9. Reiter: Veraltet, Erledigt, Leerzustand, Ausfall mit „Neu laden“
|
||
await reiterBtn(page, 'Veraltet').click()
|
||
await waitTitles(page, [T3.alt])
|
||
check('pult: „Veraltet“ zeigt den veralteten Eintrag mit der Marke „veraltet“, der Reiter ist gedrückt; Filterwechsel räumt die Meldung weg', (await textOf(umschlag(page, T3.alt))).includes('veraltet') && (await reiterBtn(page, 'Veraltet').getAttribute('aria-pressed')) === 'true' && (await meldungOf(page).count()) === 0)
|
||
await shoot(page, 'j-pult-veraltet.png')
|
||
await reiterBtn(page, 'Erledigt').click()
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-umschlag').length === 4)
|
||
const erledigt = await titlesOf(page)
|
||
const gesText = await textOf(umschlag(page, T3.ges))
|
||
check('pult: „Erledigt“ zeigt gesendet, verworfen und erledigt (auch die eben abgeschlossenen), Stempel mit Person und Zeitpunkt („Gesendet von Kenso · vor 1 Tag“)', [T3.ges, T3.erl, T3.plat, T3.mail].every((t) => erledigt.includes(t)) && gesText.includes('Gesendet von Kenso · vor 1 Tag'), { erledigt, gesText })
|
||
await shoot(page, 'j-pult-erledigt.png')
|
||
await page.evaluate(() => { window.__wk.api.pultEmpty = true })
|
||
await reiterBtn(page, 'Wartet').click()
|
||
await page.locator('.hk-leer-titel').waitFor()
|
||
check('pult: Leerzustand „Nichts wartet auf dich“ mit Hinweis', (await textOf(page.locator('.hk-leer'))).startsWith('Nichts wartet auf dich') && (await textOf(page.locator('.hk-leer'))).includes('Neue Antworten erscheinen hier'), await textOf(page.locator('.hk-leer')))
|
||
await shoot(page, 'j-pult-leer.png')
|
||
await setFault(page, 'pult', { status: 503, message: PULT_DOWN })
|
||
await reiterBtn(page, 'Veraltet').click()
|
||
await page.getByRole('button', { name: 'Neu laden', exact: true }).waitFor()
|
||
check('pult: Pult nicht erreichbar: „Das Pult ist gerade nicht erreichbar“ mit dem Satz des Dienstes und „Neu laden“', (await textOf(page.locator('.hk-leer'))).includes('Das Pult ist gerade nicht erreichbar') && (await textOf(page.locator('.hk-leer'))).includes(PULT_DOWN), await textOf(page.locator('.hk-leer')))
|
||
await shoot(page, 'j-pult-ausfall.png')
|
||
await setFault(page, 'pult', null)
|
||
await page.getByRole('button', { name: 'Neu laden', exact: true }).click()
|
||
await page.waitForFunction(() => (document.querySelector('.hk-leer-titel') || {}).textContent === 'Nichts ist veraltet')
|
||
await page.evaluate(() => { window.__wk.api.pultEmpty = false })
|
||
await reiterBtn(page, 'Wartet').click()
|
||
await waitTitles(page, [T3.koll, T3.uns])
|
||
check('pult: „Neu laden“ fragt erneut, danach ist die Liste wieder da (Kollege und „unsicher“)', true)
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario K: Pult mit den P4-Daten (?p4=1) ----------
|
||
|
||
async function pultP4(browser, base) {
|
||
const { context, page } = await newStubPage(browser, 'pult-p4', { viewport: { width: 1280, height: 720 } })
|
||
await openPult(page, base, P4_URL, 5)
|
||
check('pult-p4: wartende Einträge neueste zuerst (Mail, zwei Freigaben, unsicher, Kollege)', same(await titlesOf(page), [T4.mail, T4.frei, T4.falsch, T4.uns, T4.koll]), await titlesOf(page))
|
||
await shoot(page, 'k-pult-p4.png')
|
||
|
||
// 1. Als Mensch antworten
|
||
await openBrief(page, T4.mail)
|
||
const draft = (await page.evaluate(() => window.__wk.api.pult('offen,in_arbeit'))).items.find((i) => i.$id === 'p4-mail').draft
|
||
check('pult-p4: Mail-Brief: E-Mail schreiben, Ich kümmere mich, Als gesendet markieren, Als Mensch antworten, Verwerfen', same(await aktionen(page), ['E-Mail schreiben', 'Ich kümmere mich', 'Als gesendet markieren', 'Als Mensch antworten', 'Verwerfen']), await aktionen(page))
|
||
const opener = stempel(page, 'Als Mensch antworten')
|
||
check('pult-p4: vor dem Öffnen kein Antwortfeld, der Knopf trägt aria-expanded=false', (await page.locator('.hk-feld-antwort').count()) === 0 && (await opener.getAttribute('aria-expanded')) === 'false')
|
||
await opener.click()
|
||
const panel = page.locator('.hk-feld-antwort')
|
||
await panel.waitFor()
|
||
const area = panel.locator('textarea')
|
||
const count = () => textOf(panel.locator('.hk-zaehler'))
|
||
const send = panel.getByRole('button', { name: 'Jetzt senden', exact: true })
|
||
check('pult-p4: „Als Mensch antworten“ klappt das Feld auf (aria-expanded=true), vorbelegt mit dem Entwurf, Zähler zeigt die Zeichen, Textfeld heißt „Text der Antwort: …“', (await opener.getAttribute('aria-expanded')) === 'true' && (await area.inputValue()) === draft && (await count()) === `${Array.from(draft.trim()).length} / 8000` && (await area.getAttribute('aria-label')) === `Text der Antwort: ${T4.mail}`, { count: await count() })
|
||
await shoot(page, 'k-brief-antwort-offen.png')
|
||
await area.fill('Hallo 😀')
|
||
check('pult-p4: der Zähler zählt Zeichen (ein Emoji ist ein Zeichen: „7 / 8000“)', (await count()) === '7 / 8000', await count())
|
||
await area.fill('')
|
||
await send.click()
|
||
await briefNote(page).waitFor()
|
||
check('pult-p4: leerer Text ⇒ „Bitte einen Text eingeben.“ (rot, role=alert), kein Aufruf am Dienst', (await textOf(briefNote(page))) === NEED_TEXT && (await briefNote(page).getAttribute('role')) === 'alert' && (await callsOf(page, 'reply')).length === 0, await textOf(briefNote(page)))
|
||
await area.fill('😀'.repeat(8001))
|
||
check('pult-p4: 8001 Zeichen: Zähler „8001 / 8000“ ist rot (Klasse hk-zaehler-ueber)', (await count()) === '8001 / 8000' && (await panel.locator('.hk-zaehler').getAttribute('class')).includes('hk-zaehler-ueber'), await count())
|
||
const answer = ' Guten Tag Herr Beispiel,\n\nanbei die Vorschau, wie besprochen.\n\nBeste Grüße\nKenso \n'
|
||
await area.fill(answer)
|
||
await setFault(page, 'reply', { status: 409, message: CONFLICT })
|
||
await send.click()
|
||
await page.waitForFunction((t) => { const el = document.querySelector('.hk-brief .hk-hinweis'); return el && el.textContent === t }, CONFLICT)
|
||
check('pult-p4: 409 ⇒ der Satz des Dienstes steht im Brief (rot, role=alert), Text und Feld bleiben, „Jetzt senden“ ist wieder frei', (await briefNote(page).getAttribute('role')) === 'alert' && (await area.inputValue()) === answer && (await send.isEnabled()), await textOf(briefNote(page)))
|
||
await shoot(page, 'k-brief-antwort-409.png')
|
||
await setFault(page, 'reply', { status: 409, message: GONE })
|
||
await send.click()
|
||
await page.waitForFunction((t) => { const el = document.querySelector('.hk-brief .hk-hinweis'); return el && el.textContent === t }, GONE)
|
||
check('pult-p4: ein zweiter 409 ersetzt die Meldung („Dieser Kontakt ist nicht mehr aktiv.“), nicht angehäuft', (await briefNote(page).count()) === 1)
|
||
await setFault(page, 'reply', { status: 0, message: 'Verbindung weg' })
|
||
await send.click()
|
||
await page.waitForFunction(() => { const el = document.querySelector('.hk-brief .hk-hinweis'); return el && el.textContent === 'Kann gesendet sein – Pult neu laden.' })
|
||
check('pult-p4: Verbindungsabbruch beim Senden ⇒ „Kann gesendet sein – Pult neu laden.“ (nicht „nicht gesendet“), Text bleibt', (await area.inputValue()) === answer)
|
||
await setFault(page, 'reply', null)
|
||
const before = (await callsOf(page, 'reply')).length
|
||
await page.evaluate(() => { window.__wk.api.pultDelayMs = 1500 })
|
||
await send.click()
|
||
const gesperrt = []
|
||
for (const name of ['Ich kümmere mich', 'Als gesendet markieren', 'Als Mensch antworten', 'Verwerfen']) gesperrt.push(await stempel(page, name).isDisabled())
|
||
check('pult-p4: während die Anfrage läuft sind „Jetzt senden“ und alle Stempel des Briefs gesperrt', (await send.isDisabled()) && gesperrt.every(Boolean), gesperrt)
|
||
await send.evaluate((el) => el.click()) // ein zweiter Klick auf den gesperrten Knopf darf nichts auslösen
|
||
await meldungOf(page).first().waitFor()
|
||
const replyCalls = await callsOf(page, 'reply')
|
||
const last = replyCalls[replyCalls.length - 1]
|
||
check('pult-p4: Erfolg ⇒ Meldung „…“ gesendet. im Klemmbrett, Brief zu; genau ein Aufruf trotz Doppelklick, gesendet wird der getrimmte Text', (await textOf(meldungOf(page).first())) === `„${T4.mail}“ gesendet.` && (await brief(page).count()) === 0 && replyCalls.length === before + 1 && last.id === 'p4-mail' && same(last.body, { text: answer.trim() }), { replyCalls: replyCalls.length, before, last })
|
||
await page.evaluate(() => { window.__wk.api.pultDelayMs = 0 })
|
||
|
||
// 2. Vorschau freigeben (Rückfrage), falsche Adresse gesperrt
|
||
await openBrief(page, T4.frei)
|
||
const vorschau = brief(page).getByRole('link', { name: `Vorschau ansehen: ${T4.frei}`, exact: true })
|
||
check('pult-p4: Freigabe-Brief: „Vorschau ansehen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer), Freigeben & senden und Verwerfen; „Verwerfen“ sendet die Vorschau nicht',
|
||
(await vorschau.getAttribute('href')) === PREVIEW_URL && (await vorschau.getAttribute('target')) === '_blank' && (await vorschau.getAttribute('rel')) === 'noopener noreferrer'
|
||
&& (await stempel(page, 'Freigeben & senden').count()) === 1 && (await brief(page).textContent()).includes('„Verwerfen“ sendet die Vorschau nicht.'), await aktionen(page))
|
||
await stempel(page, 'Freigeben & senden').click()
|
||
const wirklich = stempel(page, 'Wirklich freigeben?')
|
||
check('pult-p4: Freigeben fragt beim ersten Klick „Wirklich freigeben?“ (Stempel mit Rückfrage), noch keine Aktion am Dienst', (await wirklich.count()) === 1 && (await wirklich.getAttribute('class')).includes('hk-stempel-frage') && (await callsOf(page, 'approve-preview')).length === 0)
|
||
await shoot(page, 'k-brief-freigeben-frage.png')
|
||
await wirklich.click()
|
||
await page.waitForFunction((t) => { const el = document.querySelector('section.hk-klemmbrett > .hk-hinweis-ok'); return el && el.textContent === `„${t}“ freigegeben. Max sendet den Link im nächsten Sendefenster.` }, T4.frei)
|
||
const freigaben = await callsOf(page, 'approve-preview')
|
||
check('pult-p4: zweiter Klick gibt frei (genau ein Aufruf approve-preview), Meldung „… freigegeben. Max sendet den Link im nächsten Sendefenster.“', freigaben.length === 1 && freigaben[0].id === 'p4-freigabe', freigaben)
|
||
await openBrief(page, T4.falsch)
|
||
const nurSperre = await stempel(page, 'Freigeben & senden').isDisabled()
|
||
check('pult-p4: Vorschau mit falscher Adresse (kein https): kein Link „Vorschau ansehen“, Hinweis „Keine gültige Vorschau-Adresse hinterlegt.“, „Freigeben & senden“ gesperrt', (await brief(page).getByRole('link', { name: /^Vorschau ansehen/ }).count()) === 0 && (await brief(page).textContent()).includes('Keine gültige Vorschau-Adresse hinterlegt.') && nurSperre)
|
||
await closeBrief(page)
|
||
|
||
// 3. Vorschau bauen (Rückfrage mit Hinweis)
|
||
await openBrief(page, T4.uns)
|
||
check('pult-p4: „unsicher“-Brief: Ich kümmere mich, Als Mensch antworten, Vorschau bauen, Erledigt, Verwerfen (kein Mail-Link, kein Entwurf)', same(await aktionen(page), ['Ich kümmere mich', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen']) && (await brief(page).locator('textarea').count()) === 0, await aktionen(page))
|
||
await stempel(page, 'Vorschau bauen').click()
|
||
const bauen = stempel(page, 'Wirklich bauen?')
|
||
const hinweis = await textOf(brief(page).locator('.hk-brief-hinweis-bau'))
|
||
check('pult-p4: „Vorschau bauen“ fragt beim ersten Klick „Wirklich bauen?“ und nennt den Hinweis „Startet einen Opus-Lauf …“, noch keine Aktion am Dienst', (await bauen.count()) === 1 && hinweis === BUILD_HINT && (await callsOf(page, 'start-build')).length === 0, { hinweis })
|
||
await shoot(page, 'k-brief-bauen-frage.png')
|
||
await bauen.click()
|
||
await page.waitForFunction((t) => { const el = document.querySelector('section.hk-klemmbrett > .hk-hinweis-ok'); return el && el.textContent === `„${t}“ wird gebaut.` }, T4.uns)
|
||
const builds = await callsOf(page, 'start-build')
|
||
check('pult-p4: zweiter Klick startet den Bau (genau ein Aufruf start-build), Meldung „…“ wird gebaut.', builds.length === 1 && builds[0].id === 'p4-unsicher', builds)
|
||
|
||
// 4. Antwort des Kunden einfügen (bei den gesendeten Plattform-Antworten, Reiter „Erledigt“)
|
||
await reiterBtn(page, 'Erledigt').click()
|
||
await page.waitForFunction((t) => [...document.querySelectorAll('.hk-umschlag-titel')].some((el) => el.textContent === t), T4.plat)
|
||
const gesendet = await titlesOf(page)
|
||
check('pult-p4: „Erledigt“ zeigt die beiden gesendeten Plattform-Antworten des Interessenten (Standard-Platz und „Angebot“) und die eben abgeschlossenen Einträge', gesendet.includes(T4.plat) && gesendet.includes(T4.ang) && gesendet.includes(T4.mail) && gesendet.includes(T4.frei) && gesendet.includes(T4.uns), gesendet)
|
||
await openBrief(page, T4.plat)
|
||
check('pult-p4: gesendete Plattform-Antwort: einziger Stempel „Antwort des Kunden einfügen“', same(await aktionen(page), ['Antwort des Kunden einfügen']), await aktionen(page))
|
||
await stempel(page, 'Antwort des Kunden einfügen').click()
|
||
const paste = page.locator('.hk-feld-einfuegen')
|
||
await paste.waitFor()
|
||
const pasteArea = paste.locator('textarea')
|
||
check('pult-p4: das Einfüge-Feld ist leer, trägt den Hinweis „Kopiere die Antwort aus der Plattform hierher …“ und heißt „Antwort des Kunden: …“', (await pasteArea.inputValue()) === '' && (await textOf(paste.locator('.hk-brief-hinweis'))) === PASTE_HINT && (await pasteArea.getAttribute('aria-label')) === `Antwort des Kunden: ${T4.plat}`)
|
||
await shoot(page, 'k-brief-einfuegen.png')
|
||
const kunde = 'Danke für die Vorschau. Gern ein Angebot für die Seite.'
|
||
await pasteArea.fill(kunde)
|
||
await paste.getByRole('button', { name: 'Einfügen', exact: true }).click()
|
||
await page.waitForFunction(() => { const el = document.querySelector('.hk-brief .hk-hinweis'); return el && el.textContent === 'Eingefügt. Nora sortiert die Antwort ein.' })
|
||
const pastes = await callsOf(page, 'paste')
|
||
check('pult-p4: „Einfügen“ schickt die Antwort (ein Aufruf paste mit { text }), Meldung „Eingefügt. Nora sortiert die Antwort ein.“, Feld geleert und zugeklappt', pastes.length === 1 && pastes[0].id === 'p4-plat-gesendet' && same(pastes[0].body, { text: kunde }) && (await page.locator('.hk-feld-einfuegen').count()) === 0, pastes)
|
||
await stempel(page, 'Antwort des Kunden einfügen').click()
|
||
await page.locator('.hk-feld-einfuegen textarea').fill(kunde)
|
||
await page.locator('.hk-feld-einfuegen').getByRole('button', { name: 'Einfügen', exact: true }).click()
|
||
await page.waitForFunction(() => { const el = document.querySelector('.hk-brief .hk-hinweis'); return el && el.textContent === 'Diese Antwort war schon eingefügt.' })
|
||
check('pult-p4: dieselbe Antwort ein zweites Mal ⇒ „Diese Antwort war schon eingefügt.“', true)
|
||
check('pult-p4: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page))
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario L: Keller (Bot-Leads) ----------
|
||
// Daten der Skript-API (scripted-api.js, „Bot-Leads (P6)“): 60 sichtbare Einträge (je 15 in den vier Räumen; Seite 1 hat 50, Seite 2 hat 10), drei gekürzte Stubs
|
||
// (nur mit ?bot=<Id> zu sehen), Heilberufe mit Altbestand-Telefon im Speicher, eine http-Quelle und zwei feindliche Quellen (javascript:, Zugangsdaten).
|
||
// Was die Oberfläche angefragt hat, steht in window.__wk.api.botleadCalls.
|
||
|
||
const KELLER_URL = '/index.html?fault=none&tab=botleads'
|
||
const BL = { health: 'bl-aerzte-01', http: 'bl-handwerk-02', javascript: 'bl-laden-04', credentials: 'bl-selbst-03', long: 'bl-handwerk-01', cut: 'bl-gekuerzt-01' }
|
||
const BL_HEALTH_PHONE = 'bewusst nicht gespeichert'
|
||
const BL_NO_PHONE = 'nicht verfügbar'
|
||
const BL_HINT = 'Reine Liste: keine Anrufe, kein Mailversand.'
|
||
|
||
const zeilen = (page) => page.locator('.hk-zeile')
|
||
const blCalls = (page) => apiOf(page, 'botleadCalls')
|
||
const blSeiten = (page) => page.evaluate(() => { const el = document.querySelector('.hk-kl-seiten'); return el ? el.textContent.trim() : null })
|
||
// Fertig geladen: kein „Wird geladen …“ mehr und Zeilen, Leerzustand oder Fehler da
|
||
const blSettled = (page) => page.waitForFunction(() => !document.querySelector('.hk-kl-laden') && Boolean(document.querySelector('.hk-zeile, .hk-kl-leer, .hk-kl-fehler')))
|
||
const blTabOf = (page) => new URL(page.url()).searchParams.get('tab')
|
||
const akteOf = (page) => page.evaluate(() => {
|
||
const dl = {}
|
||
document.querySelectorAll('.hk-kl-akte-dl dt').forEach((dt) => { dl[dt.textContent.trim()] = dt.nextElementSibling ? dt.nextElementSibling.textContent.replace(/\s+/g, ' ').trim() : null })
|
||
const a = document.querySelector('.hk-akte-link')
|
||
return {
|
||
titel: (document.querySelector('.hk-kl-akte-name') || {}).textContent || '',
|
||
raum: ((document.querySelector('.hk-kl-akte-raum') || {}).textContent || '').replace(/\s+/g, ' ').trim(),
|
||
dl,
|
||
link: a ? { href: a.getAttribute('href'), target: a.getAttribute('target'), rel: a.getAttribute('rel'), name: a.getAttribute('aria-label') } : null,
|
||
erklaerung: ((document.querySelector('.hk-kl-akte-erklaerung') || {}).textContent || '').trim(),
|
||
zaehler: ((document.querySelector('.hk-kl-sr') || {}).textContent || '').trim(),
|
||
text: document.querySelector('.hk-akte').textContent,
|
||
}
|
||
})
|
||
|
||
async function keller(browser, base) {
|
||
const { context, page } = await newPage(browser, 'keller', { viewport: { width: 1280, height: 720 } })
|
||
await page.goto(base + KELLER_URL)
|
||
await page.locator('.hk-keller .hk-kl-szene').waitFor()
|
||
await blSettled(page)
|
||
await page.waitForFunction(() => (document.querySelector('.hk-kl-zahl') || {}).textContent === '112')
|
||
await settleFonts(page)
|
||
const t0 = Date.now()
|
||
await sleep(300)
|
||
|
||
// 1. Arne döst, solange er nicht arbeitet: Schleife, Rohrpost, Schublade und Stempel ruhen
|
||
const ruhe = await page.evaluate(() => ({
|
||
arne: document.querySelector('.hk-kl-szene').getAttribute('data-hk-arne'),
|
||
weg: document.querySelectorAll('.hk-arne-weg').length,
|
||
ruht: document.querySelectorAll('.hk-kl-ruht').length,
|
||
schlaeft: document.querySelectorAll('.hk-kl-fig-schlafen').length,
|
||
zzz: document.querySelectorAll('.hk-kl-zzz').length,
|
||
}))
|
||
check('keller: Arne im Leerlauf döst am Pult (Haus-Figur „schlafen“ mit Zzz), die Ablage-Schleife läuft nicht, Rohrpost, Schublade und Stempel ruhen', ruhe.arne === 'leerlauf' && ruhe.weg === 0 && ruhe.ruht === 3 && ruhe.schlaeft === 1 && ruhe.zzz === 1, ruhe)
|
||
await shoot(page, 'l-keller-arne-doest.png')
|
||
|
||
// 2. Aufbau: Schild, Raum-Reiter, Register-Buch, 50 Zeilen, Seitenzeile
|
||
const kopf = await page.evaluate(() => ({
|
||
titel: document.querySelector('.hk-kl-titel').textContent,
|
||
zahl: document.querySelector('.hk-kl-zahl').textContent,
|
||
knoepfe: [...document.querySelectorAll('.hk-kl-knopf-klein')].map((b) => b.getAttribute('aria-label')),
|
||
reiter: [...document.querySelectorAll('.hk-kl-reiter button')].map((b) => ({ pressed: b.getAttribute('aria-pressed'), label: b.getAttribute('aria-label') })),
|
||
buch: document.querySelector('.hk-buch').getAttribute('aria-label'),
|
||
hinweis: (document.querySelector('.hk-kl-hinweis') || {}).textContent,
|
||
spalten: [...document.querySelectorAll('.hk-kopfzeile span')].map((s) => s.textContent.trim()).filter(Boolean),
|
||
}))
|
||
check('keller: Messingschild „Bot-Leads“ mit der Zahl 112, Knöpfe „Zurück zum Hochhaus“ und „Register zuklappen“, Hinweis „Reine Liste: keine Anrufe, kein Mailversand.“',
|
||
kopf.titel === 'Bot-Leads' && kopf.zahl === '112' && same(kopf.knoepfe, ['Zurück zum Hochhaus', 'Register zuklappen']) && kopf.hinweis === BL_HINT && kopf.buch === 'Nie-doppelt-Register', kopf)
|
||
check('keller: fünf Raum-Reiter (Alle + vier Räume), „Alle“ gedrückt mit „60 Einträge“; Spalten Betrieb, Punkte, Grund, Telefon',
|
||
kopf.reiter.length === 5 && kopf.reiter[0].pressed === 'true' && kopf.reiter.slice(1).every((r) => r.pressed === 'false') && kopf.reiter[0].label === 'Alle Räume, 60 Einträge' && kopf.spalten.some((t) => t.startsWith('Betrieb')) && kopf.spalten.includes('Punkte') && kopf.spalten.includes('Grund') && kopf.spalten.includes('Telefon'), kopf)
|
||
check('keller: Seite 1 zeigt 50 Zeilen, Seitenzeile „Seite 1 von 2 · 60 Einträge“, jede Zeile ist ein Knopf mit Namen', (await zeilen(page).count()) === 50 && (await blSeiten(page)) === 'Seite 1 von 2 · 60 Einträge' && (await page.evaluate(() => [...document.querySelectorAll('.hk-zeile')].every((b) => b.tagName === 'BUTTON' && (b.getAttribute('aria-label') || '').length > 5))), { seiten: await blSeiten(page) })
|
||
await shoot(page, 'l-keller-liste.png')
|
||
|
||
// 3. Suche (mit Verzögerung), Raum-Reiter, Blättern
|
||
const search = page.getByLabel('Betriebe suchen', { exact: true })
|
||
const anfragenVorher = (await blCalls(page)).length
|
||
await search.fill('Bäckerei')
|
||
await page.waitForFunction(() => window.__wk.api.botleadCalls.some((c) => c.q === 'Bäckerei'))
|
||
await blSettled(page)
|
||
const erwartet = await page.evaluate(() => window.__wk.api.botleads({ q: 'Bäckerei' }))
|
||
const gefunden = await zeilen(page).count()
|
||
check('keller: Suche „Bäckerei“ fragt den Dienst (eine Anfrage nach der Verzögerung) und zeigt genau dessen Treffer', gefunden === erwartet.items.length && gefunden >= 1 && (await textOf(page.locator('.hk-kl-name').first())).includes('Bäckerei'), { gefunden, erwartet: erwartet.items.length })
|
||
await shoot(page, 'l-keller-suche.png')
|
||
await search.fill('')
|
||
await page.waitForFunction(() => window.__wk.api.botleadCalls.at(-1).q === '')
|
||
await blSettled(page)
|
||
const nachSuche = (await blCalls(page)).length - anfragenVorher
|
||
check('keller: leere Suche zeigt wieder 50 Zeilen; die Verzögerung hält die Anfragen niedrig (höchstens zwei Anfragen für das Tippen und das Leeren)', (await zeilen(page).count()) === 50 && nachSuche <= 3, { nachSuche })
|
||
await page.locator('.hk-kl-reiter button', { hasText: 'Ärzte' }).click()
|
||
await page.waitForFunction(() => window.__wk.api.botleadCalls.at(-1).room === 'aerzte')
|
||
await blSettled(page)
|
||
check('keller: Reiter „Ärzte“ fragt room=aerzte, zeigt 15 Zeilen, „Seite 1 von 1 · 15 Einträge“, keine Blätterknöpfe', (await zeilen(page).count()) === 15 && (await blSeiten(page)) === 'Seite 1 von 1 · 15 Einträge' && (await page.getByRole('button', { name: 'Nächste Seite' }).count()) === 0, await blSeiten(page))
|
||
await page.locator('.hk-kl-reiter button', { hasText: 'Alle' }).click()
|
||
await blSettled(page)
|
||
await page.getByRole('button', { name: 'Nächste Seite', exact: true }).click()
|
||
await page.waitForFunction(() => (document.querySelector('.hk-kl-seiten') || {}).textContent === 'Seite 2 von 2 · 60 Einträge')
|
||
await blSettled(page)
|
||
const seite2 = await blCalls(page)
|
||
const fokusBlaettern = await focusInfo(page)
|
||
check('keller: „Nächste Seite“ zeigt Seite 2 (10 Zeilen, page=2 am Dienst); der Fokus geht auf einen Blätterknopf zurück (der gedrückte ist gesperrt, also der andere)', (await zeilen(page).count()) === 10 && seite2.at(-1).page === 2 && ['Vorherige Seite', 'Nächste Seite'].includes(fokusBlaettern.label), { fokusBlaettern, letzte: seite2.at(-1) })
|
||
await shoot(page, 'l-keller-seite-2.png')
|
||
await page.getByRole('button', { name: 'Vorherige Seite', exact: true }).click()
|
||
await page.waitForFunction(() => (document.querySelector('.hk-kl-seiten') || {}).textContent === 'Seite 1 von 2 · 60 Einträge')
|
||
await blSettled(page)
|
||
|
||
// 4. Akte: Heilberuf ohne Nummer, Quelle als sicherer Link, Blättern, Esc mit Fokus zurück auf die Zeile
|
||
await zeilen(page).first().click()
|
||
const akteDialog = page.getByRole('dialog', { name: 'Akte Praxis Dr. Lindner', exact: true })
|
||
await akteDialog.waitFor()
|
||
await sleep(350)
|
||
const a0 = await akteOf(page)
|
||
const heilNummern = await apiOf(page, 'botleadsHealthPhones')
|
||
check('keller: Akte (role=dialog, aria-modal) zu „Praxis Dr. Lindner“: Raum „Ärzte & Praxen“ mit Scout, Punkte, Grund mit Erklärung, Gefunden; der Fokus liegt auf „Schließen“',
|
||
(await akteDialog.getAttribute('aria-modal')) === 'true' && a0.raum.startsWith('Ärzte & Praxen · gefunden von') && /Punkte/.test(a0.dl.Punkte || '') && a0.dl.Grund !== '' && a0.erklaerung !== '' && (a0.dl.Gefunden || '') !== '' && (await focusInfo(page)).label === 'Schließen', { a0, fokus: await focusInfo(page) })
|
||
check('keller: Heilberuf – Telefon „bewusst nicht gespeichert“, keine der im Speicher liegenden Nummern erscheint irgendwo (auch nicht im HTML)', a0.dl.Telefon === BL_HEALTH_PHONE && heilNummern.length >= 1 && (await page.evaluate((nummern) => { const html = document.documentElement.outerHTML; return nummern.every((n) => !html.includes(n)) }, heilNummern)), { telefon: a0.dl.Telefon, nummern: heilNummern.length })
|
||
check('keller: Quelle ist ein sicherer Link (https, neuer Tab, noopener noreferrer) mit Namen „Quelle öffnen: …“', a0.link !== null && /^https:\/\/www\.11880\.com\/branchenbuch\//.test(a0.link.href) && a0.link.target === '_blank' && a0.link.rel === 'noopener noreferrer' && a0.link.name === 'Quelle öffnen: Praxis Dr. Lindner', a0.link)
|
||
check('keller: die Akte kündigt sich Screenreadern an („Akte 1 von 50: …“) und sagt, dass nichts verschickt wird', a0.zaehler === 'Akte 1 von 50: Praxis Dr. Lindner' && a0.text.includes('Nur zum Nachsehen, hier wird nichts verschickt.'), a0.zaehler)
|
||
await shoot(page, 'l-akte.png')
|
||
// durch die Akten blättern: Heilberufe nie mit Nummer, alle anderen mit Nummer (Quelle belegt) oder „nicht verfügbar“
|
||
const besucht = [a0]
|
||
for (let i = 0; i < 14; i += 1) {
|
||
await page.keyboard.press('ArrowRight')
|
||
await page.waitForFunction((n) => (document.querySelector('.hk-kl-sr') || {}).textContent.startsWith(`Akte ${n} von 50`), i + 2)
|
||
besucht.push(await akteOf(page))
|
||
}
|
||
const nummer = /^0\d{3} 555 \d{4}$/
|
||
check('keller: → blättert durch die Akten (im Kreis, innerhalb der Seite); Heilberufe nie mit Nummer, alle anderen mit Nummer oder „nicht verfügbar“, mindestens eine mit Nummer',
|
||
besucht.every((a) => (a.raum.startsWith('Ärzte & Praxen') ? a.dl.Telefon === BL_HEALTH_PHONE : (a.dl.Telefon === BL_HEALTH_PHONE || a.dl.Telefon === BL_NO_PHONE || nummer.test(a.dl.Telefon)))) && besucht.some((a) => nummer.test(a.dl.Telefon)), besucht.map((a) => [a.titel, a.raum.slice(0, 14), a.dl.Telefon]))
|
||
for (let i = 0; i < 14; i += 1) await page.keyboard.press('ArrowLeft')
|
||
await page.waitForFunction(() => (document.querySelector('.hk-kl-sr') || {}).textContent.startsWith('Akte 1 von 50'))
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('keller: Esc schließt die Akte, der Fokus liegt wieder auf der Zeile, die sie geöffnet hat', (await focusInfo(page)).zeile === '0', await focusInfo(page))
|
||
|
||
// 5. Fehler des Dienstes: Satz, „Erneut versuchen“
|
||
await setFault(page, 'botleads', { status: 503, message: 'Der Hustler-Dienst ist gerade nicht erreichbar.' })
|
||
await page.locator('.hk-kl-reiter button', { hasText: 'Handwerk' }).click()
|
||
await page.locator('.hk-kl-fehler').waitFor()
|
||
check('keller: Fehler des Dienstes zeigt den Satz (role=alert) mit „Erneut versuchen“, keine Zeilen', (await textOf(page.locator('.hk-kl-fehler'))) === 'Der Hustler-Dienst ist gerade nicht erreichbar.' && (await page.locator('.hk-kl-fehler').getAttribute('role')) === 'alert' && (await page.getByRole('button', { name: 'Erneut versuchen', exact: true }).count()) === 1 && (await zeilen(page).count()) === 0)
|
||
await shoot(page, 'l-keller-fehler.png')
|
||
await setFault(page, 'botleads', null)
|
||
await page.getByRole('button', { name: 'Erneut versuchen', exact: true }).click()
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-zeile').length === 15)
|
||
check('keller: „Erneut versuchen“ fragt dieselbe Anfrage noch einmal (Handwerk, 15 Zeilen)', true)
|
||
|
||
// 6. Arne wird zur Arbeit gerufen: das Drehbuch lässt ihn nach 8 s „doppelt“ melden, dann läuft die Ablage-Schleife
|
||
let arbeitet = false
|
||
while (Date.now() - t0 < 40000) {
|
||
arbeitet = (await page.locator('.hk-kl-szene').getAttribute('data-hk-arne')) === 'arbeitet'
|
||
if (arbeitet) break
|
||
await sleep(300)
|
||
}
|
||
const regung = await page.evaluate(() => ({ weg: document.querySelectorAll('.hk-arne-weg').length, ruht: document.querySelectorAll('.hk-kl-ruht').length, daheim: document.querySelectorAll('.hk-kl-arne').length }))
|
||
check('keller: Arne arbeitet (nach dem Ereignis des Drehbuchs): die Ablage-Schleife läuft, Rohrpost, Schublade und Stempel ruhen nicht mehr, er sitzt nicht mehr am Pult', arbeitet && regung.weg === 1 && regung.ruht === 0 && regung.daheim === 0, regung)
|
||
await shoot(page, 'l-keller-arne-arbeitet.png')
|
||
|
||
// 7. Zuklappen: nur die Szene bleibt
|
||
await page.getByRole('button', { name: 'Register zuklappen', exact: true }).click()
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-buch').length === 0)
|
||
check('keller: „Register zuklappen“ zeigt nur die Szene (Buch weg, Knopf heißt „Register aufklappen“, aria-expanded=false)', (await page.getByRole('button', { name: 'Register aufklappen', exact: true }).getAttribute('aria-expanded')) === 'false')
|
||
await shoot(page, 'l-keller-zugeklappt.png')
|
||
|
||
// 8. Quellen mit Haken: nie ein javascript:-Link, nie ein Link mit Zugangsdaten; http ist erlaubt
|
||
const quelle = async (id, name) => {
|
||
await page.goto(base + KELLER_URL + '&bot=' + id)
|
||
await blSettled(page)
|
||
await zeilen(page).first().click()
|
||
await page.getByRole('dialog', { name: `Akte ${name}`, exact: true }).waitFor()
|
||
await sleep(250)
|
||
return akteOf(page)
|
||
}
|
||
const js = await quelle(BL.javascript, 'Restaurant Zum Hirschen')
|
||
const cr = await quelle(BL.credentials, 'Fotografie Lichtblick')
|
||
const ht = await quelle(BL.http, 'Elektro Hartmann GmbH')
|
||
check('keller: Quelle „javascript:…“ und Quelle mit Zugangsdaten in der Adresse werden nie ein Link (Anzeige „–“), http ist ein Link mit http-Adresse',
|
||
js.link === null && js.dl.Quelle === '–' && cr.link === null && cr.dl.Quelle === '–' && ht.link !== null && /^http:\/\/www\.elektro-hartmann-alt\.example\//.test(ht.link.href) && (await page.locator('a[href^="javascript:" i]').count()) === 0, { js: js.dl.Quelle, cr: cr.dl.Quelle, ht: ht.link })
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
|
||
// 9. Einzelansicht (?bot=): ein Eintrag, hervorgehoben, „Alle anzeigen“ (Link aus dem Ereignisstrom)
|
||
await page.goto(base + KELLER_URL + '&bot=' + BL.health)
|
||
await blSettled(page)
|
||
const einzel = await page.evaluate(() => ({ zeilen: document.querySelectorAll('.hk-zeile').length, aktuell: document.querySelector('.hk-zeile').getAttribute('aria-current'), reiter: document.querySelectorAll('.hk-kl-reiter').length, suche: document.querySelectorAll('.hk-suche').length, text: (document.querySelector('.hk-kl-einzeln-text') || {}).textContent }))
|
||
check('keller: ?bot=<Id> zeigt genau einen Eintrag (hervorgehoben, aria-current) mit „Alle anzeigen“, ohne Raum-Reiter und Suche', einzel.zeilen === 1 && einzel.aktuell === 'true' && einzel.reiter === 0 && einzel.suche === 0 && einzel.text === 'Einzelner Eintrag aus dem Ereignisstrom' && (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1, einzel)
|
||
await shoot(page, 'l-keller-einzelansicht.png')
|
||
await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).click()
|
||
await blSettled(page)
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-zeile').length === 50)
|
||
check('keller: „Alle anzeigen“ führt zur Liste (50 Zeilen, Raum-Reiter wieder da, bot nicht mehr in der Adresse), der aktive Reiter bekommt den Fokus', new URL(page.url()).searchParams.get('bot') === null && blTabOf(page) === 'botleads' && (await page.locator('.hk-kl-reiter').count()) === 1 && (await focusInfo(page)).cls.includes('hk-reiter'), { url: page.url(), fokus: await focusInfo(page) })
|
||
// gekürzter Stub nur mit Kennung
|
||
await page.goto(base + KELLER_URL + '&bot=' + BL.cut)
|
||
await blSettled(page)
|
||
await zeilen(page).first().click()
|
||
await page.getByRole('dialog', { name: 'Akte [gekürzt]', exact: true }).waitFor()
|
||
await sleep(250)
|
||
const gekuerzt = await akteOf(page)
|
||
check('keller: gekürzter Eintrag (nur mit Kennung): Akte mit „Kürzung: gekürzt“, ohne Quelle und ohne Telefonnummer', gekuerzt.dl.Kürzung === 'gekürzt' && gekuerzt.link === null && !nummer.test(gekuerzt.dl.Telefon), gekuerzt.dl)
|
||
await shoot(page, 'l-akte-gekuerzt.png')
|
||
await page.keyboard.press('Escape')
|
||
|
||
// 10. Leer (?botleads=leer)
|
||
await page.goto(base + KELLER_URL + '&botleads=leer')
|
||
await blSettled(page)
|
||
check('keller: leere Liste zeigt „Noch keine Bot-Leads.“ mit Hinweis, keine Zeilen', (await textOf(page.locator('.hk-kl-leer-titel'))) === 'Noch keine Bot-Leads.' && (await zeilen(page).count()) === 0 && (await textOf(page.locator('.hk-kl-leer-text'))).includes('Betriebe, die nicht angeschrieben werden'))
|
||
await shoot(page, 'l-keller-leer.png')
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario M: Pult und Keller am Handy (390 x 844) ----------
|
||
|
||
// Knöpfe und Links eines Bereichs: jeder wird erst in den sichtbaren Teil seiner Liste gerollt (scrollIntoView nearest), dann gemessen: Lage, Höhe in px
|
||
// (Tippfläche) und ob die Mitte von einem anderen Element verdeckt wird. Das verändert die Rollstände: Bilder werden vorher aufgenommen.
|
||
const reachOf = (page, selector) => page.evaluate((sel) => {
|
||
const out = []
|
||
for (const el of document.querySelectorAll(sel)) {
|
||
el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||
const r = el.getBoundingClientRect()
|
||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||
out.push({ text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28), left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height), covered: !(hit && (hit === el || el.contains(hit))) })
|
||
}
|
||
return out
|
||
}, selector)
|
||
// Verstöße: außerhalb der Breite des Fensters (außer bei horizontal=false: scrollbare Reiterleiste), verdeckt oder niedriger als minHeight
|
||
const badReach = (list, minHeight = 40, horizontal = true) => list.filter((r) => (horizontal && (r.left < 0 || r.right > 390)) || r.covered || r.height < minHeight)
|
||
|
||
async function handyPultKeller(browser, base) {
|
||
const phone = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }
|
||
const { context, page } = await newStubPage(browser, 'handy-pult', phone)
|
||
await openPult(page, base, PULT_URL, 4)
|
||
|
||
// Pult: Klemmbrett in voller Breite über der Szene
|
||
const w0 = await widthOf(page)
|
||
const brett = await rectOf(page.locator('.hk-klemmbrett'))
|
||
const pultBox = await rectOf(page.locator('.hk-pult'))
|
||
metrics.handyPult = { brett, pult: pultBox }
|
||
check('handy pult: kein waagrechtes Scrollen, das Klemmbrett nimmt die volle Breite (mindestens 358 von 390 px)', noSideScroll(w0) && brett.w >= 358 && brett.x >= 0 && brett.r <= 390, { w0, brett })
|
||
await shoot(page, 'm-pult-liste.png')
|
||
const bedien = badReach(await reachOf(page, '.hk-reiter-leiste .hk-reiter, .hk-pult-knopf'))
|
||
const umschlaege = badReach(await reachOf(page, '.hk-umschlag'), 44)
|
||
check('handy pult: Reiter und Knöpfe oben mindestens 40 px hoch und im Bild, Umschläge mindestens 44 px hoch, nichts verdeckt', bedien.length === 0 && umschlaege.length === 0, { bedien, umschlaege })
|
||
|
||
// Brief als Vollbild (der Umschlag mit dem langen Wort ohne Leerzeichen prüft den Umbruch)
|
||
await umschlag(page, T3.mail).tap()
|
||
await brief(page).waitFor()
|
||
await sleep(400)
|
||
const bb = await rectOf(brief(page))
|
||
const wB = await widthOf(page)
|
||
await shoot(page, 'm-pult-brief.png')
|
||
const aktB = badReach(await reachOf(page, '.hk-brief .hk-aktionen button, .hk-brief .hk-aktionen a, .hk-brief .hk-brief-zu'))
|
||
check('handy pult: der Brief füllt die Fläche als Vollbild (volle Breite, volle Höhe des Pults), kein waagrechtes Scrollen trotz langem Wort im Text', bb.w >= 385 && bb.h >= pultBox.h - 8 && noSideScroll(wB), { bb, pult: pultBox, wB })
|
||
check('handy pult: Schließen und alle Stempel im Brief mindestens 40 px hoch, im Bild und nicht verdeckt', aktB.length === 0, aktB)
|
||
const linksB = badReach(await reachOf(page, '.hk-brief-meta .hk-link'))
|
||
befund('handy pult: die Textlinks „Anzeige öffnen“ und „Interessent“ im Kopf des Briefs sind mindestens 40 px hoch (Tippfläche)', linksB.length === 0, linksB)
|
||
await stempel(page, 'Als Mensch antworten').tap()
|
||
await page.locator('.hk-feld-antwort textarea').waitFor()
|
||
await sleep(300)
|
||
const wA = await widthOf(page)
|
||
const ta = await rectOf(page.locator('.hk-feld-antwort textarea'))
|
||
check('handy pult: das aufgeklappte Antwortfeld passt in die Breite (kein waagrechtes Scrollen), das Textfeld steht im Bild', noSideScroll(wA) && ta.x >= 0 && ta.r <= 390, { wA, ta })
|
||
await shoot(page, 'm-pult-brief-antwort.png')
|
||
// Interessent: WOMS-Dialog als Bottom-Sheet im Bild
|
||
await brief(page).getByRole('button', { name: /^Interessent/ }).tap()
|
||
const sheet = page.getByRole('dialog', { name: 'SV Namborn', exact: true })
|
||
await sheet.waitFor()
|
||
await sheet.locator('.wk-prospect').waitFor()
|
||
await sleep(600)
|
||
const sb = await rectOf(sheet)
|
||
const wS = await widthOf(page)
|
||
check('handy pult: das Interessenten-Panel steht im Bild (Bottom-Sheet), nichts läuft über den Rand', sb.x >= 0 && sb.r <= 390 && sb.b <= 844 && noSideScroll(wS), { sb, wS })
|
||
await shoot(page, 'm-pult-interessent.png')
|
||
await page.keyboard.press('Escape')
|
||
await sheet.waitFor({ state: 'detached' })
|
||
await page.getByRole('button', { name: 'Schließen', exact: true }).tap()
|
||
await brief(page).waitFor({ state: 'detached' })
|
||
// zuklappen: die Szene bleibt
|
||
await page.getByRole('button', { name: 'Pult zuklappen', exact: true }).tap()
|
||
await page.waitForFunction(() => document.querySelectorAll('.hk-klemmbrett').length === 0)
|
||
await sleep(300)
|
||
await shoot(page, 'm-pult-zugeklappt.png')
|
||
check('handy pult: „Pult zuklappen“ zeigt die Szene ganz (Klemmbrett weg, Knopf heißt „Pult aufklappen“)', (await page.getByRole('button', { name: 'Pult aufklappen', exact: true }).count()) === 1)
|
||
|
||
// Keller
|
||
await page.goto(base + KELLER_URL)
|
||
await page.locator('.hk-keller .hk-kl-szene').waitFor()
|
||
await blSettled(page)
|
||
await settleFonts(page)
|
||
await sleep(400)
|
||
const wK = await widthOf(page)
|
||
const buch = await rectOf(page.locator('.hk-buch'))
|
||
const kellerBox = await rectOf(page.locator('.hk-keller'))
|
||
metrics.handyKeller = { buch, keller: kellerBox }
|
||
check('handy keller: kein waagrechtes Scrollen, das Register-Buch nimmt die volle Breite (mindestens 358 von 390 px)', noSideScroll(wK) && buch.w >= 358 && buch.x >= 0 && buch.r <= 390, { wK, buch })
|
||
await shoot(page, 'm-keller-liste.png')
|
||
const kBedien = badReach(await reachOf(page, '.hk-kl-knopf-klein, .hk-kl-fuss button')).concat(badReach(await reachOf(page, '.hk-kl-reiter button'), 40, false))
|
||
const kZeilen = (await reachOf(page, '.hk-zeile')).filter((r) => r.left < 0 || r.right > 390 || r.height < 44)
|
||
const kSuche = badReach(await reachOf(page, '.hk-suche'))
|
||
check('handy keller: Reiter, Knöpfe und Blätterknöpfe mindestens 40 px hoch und im Bild, Zeilen mindestens 44 px hoch, nichts verdeckt', kBedien.length === 0 && kZeilen.length === 0 && kSuche.every((r) => r.left >= 0 && r.right <= 390 && !r.covered), { kBedien, kZeilen: kZeilen.slice(0, 3), kSuche })
|
||
befund('handy keller: das Suchfeld ist mindestens 40 px hoch (Tippfläche)', kSuche.every((r) => r.height >= 40), kSuche)
|
||
await zeilen(page).first().tap()
|
||
const akte = page.getByRole('dialog', { name: 'Akte Praxis Dr. Lindner', exact: true })
|
||
await akte.waitFor()
|
||
await sleep(400)
|
||
const ab = await rectOf(akte)
|
||
const wAkte = await widthOf(page)
|
||
await shoot(page, 'm-keller-akte.png')
|
||
const aktBedien = badReach(await reachOf(page, '.hk-akte button'))
|
||
const aktLink = badReach(await reachOf(page, '.hk-akte-link'))
|
||
check('handy keller: die Akte füllt die Fläche als Vollbild (volle Breite, volle Höhe des Kellers), nichts läuft über den Rand', ab.w >= 385 && ab.h >= kellerBox.h - 8 && noSideScroll(wAkte), { ab, keller: kellerBox, wAkte })
|
||
check('handy keller: Schließen und Blättern in der Akte mindestens 40 px hoch, im Bild, nicht verdeckt; der Quell-Link im Bild und nicht verdeckt', aktBedien.length === 0 && aktLink.every((r) => r.left >= 0 && r.right <= 390 && !r.covered), { aktBedien, aktLink })
|
||
befund('handy keller: der Quell-Link „Quelle öffnen“ in der Akte ist mindestens 40 px hoch (Tippfläche)', aktLink.every((r) => r.height >= 40), aktLink)
|
||
await page.getByRole('button', { name: 'Schließen', exact: true }).tap()
|
||
await akte.waitFor({ state: 'detached' })
|
||
// der Eintrag mit dem langen Namen und dem langen Grund ohne Umbruchstellen
|
||
await page.goto(base + KELLER_URL + '&bot=' + BL.long)
|
||
await blSettled(page)
|
||
await sleep(300)
|
||
const wL = await widthOf(page)
|
||
const zeileOk = await page.evaluate(() => { const z = document.querySelector('.hk-zeile'); return z.scrollWidth <= z.clientWidth + 1 })
|
||
check('handy keller: langer Name und langer Grund ohne Leerzeichen laufen nicht über den Rand (Seite und Zeile)', noSideScroll(wL) && zeileOk, { wL, zeileOk })
|
||
await shoot(page, 'm-keller-einzeln-lang.png')
|
||
await zeilen(page).first().tap()
|
||
await page.getByRole('dialog', { name: /^Akte Elektroinstallation/ }).waitFor()
|
||
await sleep(400)
|
||
const wLA = await widthOf(page)
|
||
check('handy keller: die Akte zum langen Eintrag läuft nicht über den Rand', noSideScroll(wLA), wLA)
|
||
await shoot(page, 'm-keller-akte-lang.png')
|
||
await context.close()
|
||
}
|
||
|
||
const SCENARIOS = [
|
||
['hochhaus', hochhaus], ['regie', regie], ['handy', handy], ['bewegung', bewegung], ['notbremse', notbremse], ['ausfall', ausfall],
|
||
['detail', detail], ['etagen', etagen], ['pause', pause],
|
||
['pult', pult], ['pult-p4', pultP4], ['keller', keller], ['handy-pult-keller', handyPultKeller],
|
||
];
|
||
|
||
(async () => {
|
||
const server = await serve()
|
||
const base = 'http://127.0.0.1:' + server.address().port
|
||
const browser = await chromium.launch({ executablePath: CHROME, args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage'] })
|
||
const ran = []
|
||
try {
|
||
const only = (process.env.HARNESS_ONLY || '').split(',').map((name) => name.trim()).filter(Boolean)
|
||
for (const [name, run] of SCENARIOS) {
|
||
if (only.length > 0 && !only.includes(name)) continue
|
||
current = name
|
||
ran.push(name)
|
||
openContexts = []
|
||
lastPage = null
|
||
const before = errors.length
|
||
const started = Date.now()
|
||
try {
|
||
await run(browser, base)
|
||
} catch (e) {
|
||
problems.push('Abbruch in Szenario ' + name + ': ' + (e && e.stack ? e.stack : String(e)))
|
||
if (lastPage) await shoot(lastPage, 'abbruch-' + name + '.png', { fullPage: true }).catch(() => {})
|
||
}
|
||
for (const context of openContexts) await context.close().catch(() => {})
|
||
const fresh = errors.slice(before)
|
||
check(name + ': keine pageerror/console.error/fehlgeschlagene Anfragen', fresh.length === 0, fresh)
|
||
metrics['dauer ' + name] = Math.round((Date.now() - started) / 1000) + ' s'
|
||
console.error(`[${name}] fertig in ${metrics['dauer ' + name]}, Verstöße bisher: ${problems.length}`)
|
||
}
|
||
} finally {
|
||
await browser.close()
|
||
server.close()
|
||
}
|
||
const ok = problems.length === 0
|
||
console.log(JSON.stringify({ scenarios: ran.map((name) => ({ name, ...perScenario[name] })), shots: shots.map((f) => path.basename(f)), violations: problems, befunde: findings, metrics, errors, fontRequests: fontLog.length }))
|
||
if (findings.length > 0) console.error('BEFUNDE (App, kein Fehlschlag):\n' + findings.join('\n'))
|
||
if (!ok) { console.error('FAIL:\n' + problems.join('\n')); process.exit(1) }
|
||
console.log('OK')
|
||
})().catch((e) => { console.error('FAIL', e); process.exit(1) })
|