Files
tickte-system/scripts/workers-harness/shot.cjs
T

1556 lines
116 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
// Stufen-Sperre (src/lib/hustlerStufe.js): build.mjs baut standardmäßig mit VITE_HUSTLER_STUFE=6 (P4-Aktionen im Pult offen, Keller mit Live-Daten); alle Szenarien unten setzen das voraus. Der Keller bei Stufe 3 (leeres Register) ist in botleadList.test.mjs geprüft.
// 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)
check('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'))
check('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 })
check('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 })
check('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) })