Skript-API: botleads({ room, q, page, id }) antwortet wie GET /botleads des Dienstes
(items, total, pageSize 50; Telefon nie bei Heilberufen, gekürzte Stubs nur mit id).
60 sichtbare Einträge in vier Räumen (zwei Seiten), 3 gekürzte Stubs, Heilberufe mit
Altbestand-Telefon, eine http-Quelle, zwei feindliche Quellen, lange Namen und Gründe.
Schalter: faults.botleads, botleadsDelayMs, botleadsEmpty (?botleads=leer), botleadCalls.
Fetch-Weiche und entry.jsx (?bot=<Id>) um die Route und den Deep-Link ergänzt.
Szenarien: botleads (Reiter, Liste, Raumfilter, Blättern mit Fokus, Suche, Leerzustände,
Fehlertexte, Deep-Link, Quellen-Links, Telefon) und handy-botleads (390 x 844: kein
seitliches Scrollen, Knopfhöhe, Kontrast der gekürzten Zeile, Wortumbruch).
Der Pin in workersHarness.test.mjs prüft nun die neue Antwortform statt der leeren Liste.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2199 lines
164 KiB
JavaScript
2199 lines
164 KiB
JavaScript
// Manuelle Abnahme der Hustler-Seite im echten Browser (Chromium, headless shell) gegen den Prüfstand.
|
||
// Aufruf (immer unter Speicherdeckel, serielle Läufe):
|
||
// systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 300 node --max-old-space-size=400 \
|
||
// scripts/workers-harness/shot.cjs <distDir> <outDir>
|
||
// <distDir> = Ausgabe von build.mjs, <outDir> = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo.
|
||
// Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1.
|
||
// Szenarien: A Desktop, B Blasen, C Handy, D weniger Bewegung, E Notbremse, F Ausfall (P2); G Freigabe, H Interessenten-Panel, I Anhalten
|
||
// mit Rückfrage, J Handy mit Freigabe und Panel (P3); K Antworten als Mensch, L Vorschau freigeben, M Antwort des Kunden einfügen, N Vorschau
|
||
// bauen, O Fokus, P Handy für alle Felder (P4, eigener Datensatz ?p4=1, Bilder mit Vorsilbe antworten-, vorschau-, einfuegen-, bauen-, fokus-,
|
||
// handy-p4-); Q Bot-Leads am Desktop und R Bot-Leads am Handy (P6, eigene Daten der Skript-API, Bilder mit Vorsilbe botleads-, handy-botleads-).
|
||
// 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=freigabe,interessent (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 })
|
||
|
||
// Namen und Modelle aus der gemeinsamen Roster-Fixture (CommonJS kann den ESM-Quelltext nicht laden)
|
||
const roster = JSON.parse(fs.readFileSync(path.join(repo, 'tests/fixtures/workers-roster.json'), 'utf8')).roles
|
||
const MODEL = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' }
|
||
const FONT_URL = '/fonts/PixelifySans-Variable.woff2'
|
||
const CHROME = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell'
|
||
const HALT_LINE = 'Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.'
|
||
|
||
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 errors = []
|
||
const checks = {}
|
||
const metrics = {}
|
||
const shots = []
|
||
|
||
function check(name, ok, detail) {
|
||
checks[name] = ok ? 'ok' : 'FEHLER' + (detail === undefined ? '' : ' ' + JSON.stringify(detail))
|
||
if (!ok) problems.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail)))
|
||
}
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||
let lastPage = null // die zuletzt geöffnete Seite: bricht ein Szenario ab, wird von hier aus der Zustand im Bild festgehalten (abbruch-<Szenario>.png)
|
||
const textOf = async (loc) => ((await loc.textContent()) || '').replace(/\s+/g, ' ').trim()
|
||
|
||
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; rAF wird mitgezählt (eine Schleife = höchstens ein wartender Rückruf)
|
||
async function newPage(browser, label, contextOptions) {
|
||
const context = await browser.newContext(contextOptions)
|
||
await context.addInitScript(() => {
|
||
const raf = window.requestAnimationFrame.bind(window)
|
||
const caf = window.cancelAnimationFrame.bind(window)
|
||
const waiting = new Set()
|
||
window.__raf = { waiting, calls: 0 }
|
||
window.requestAnimationFrame = (cb) => {
|
||
window.__raf.calls += 1
|
||
const id = raf((t) => { waiting.delete(id); cb(t) })
|
||
waiting.add(id)
|
||
return id
|
||
}
|
||
window.cancelAnimationFrame = (id) => { waiting.delete(id); caf(id) }
|
||
})
|
||
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.url().endsWith(FONT_URL)) metrics.fontStatus = r.status()
|
||
if (r.status() >= 400) errors.push(label + ' http ' + r.status() + ': ' + r.url())
|
||
})
|
||
return { context, page }
|
||
}
|
||
|
||
async function ready(page, url) {
|
||
await page.goto(url)
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
|
||
await page.evaluate(() => document.fonts.load('600 12px "Pixelify Sans"'))
|
||
await page.waitForFunction(() => window.__wk && window.__wk.stage.current && window.__wk.stage.current.stats().frames > 0)
|
||
await page.evaluate(() => document.fonts.ready)
|
||
}
|
||
|
||
const stats = (page) => page.evaluate(() => window.__wk.stage.current.stats())
|
||
const topOfPage = (page) => page.evaluate(() => window.scrollTo(0, 0))
|
||
|
||
async function pickFromList(page, id, name) {
|
||
await page.locator(`[data-wk-worker="${id}"]`).click()
|
||
await page.waitForFunction((n) => (document.querySelector('.wk-name') || {}).textContent === n, name)
|
||
}
|
||
|
||
// ---------- Szenario A: Desktop 1280 x 900 ----------
|
||
|
||
async function desktop(browser, base) {
|
||
const { context, page } = await newPage(browser, 'desktop', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
const t0 = Date.now()
|
||
await ready(page, base + '/index.html')
|
||
await sleep(500)
|
||
await topOfPage(page)
|
||
|
||
// 1. Schrift: geladen, Status 200, document.fonts.check
|
||
check('schrift geladen', await page.evaluate(() => document.fonts.check('600 12px "Pixelify Sans"')))
|
||
check('schrift mit 200 geliefert', metrics.fontStatus === 200, metrics.fontStatus)
|
||
check('keine Warnzeile ohne ?halted=1', (await page.locator('.msg.err[role="alert"]').count()) === 0)
|
||
|
||
// a) erstes Bild: Hochhaus komplett (Bühne mindestens 560 px breit, Zoom 1)
|
||
const box = await page.locator('.wk-canvas').boundingBox()
|
||
const first = await stats(page)
|
||
metrics.desktopStage = { width: Math.round(box.width), height: Math.round(box.height), top: Math.round(box.y), zoom: first.zoom }
|
||
check('desktop: ganzes Hochhaus im Bild (Zoom 1, Bühne >= 560 px, Unterkante im Fenster)', first.zoom === 1 && box.width >= 560 && box.y + box.height <= 900, metrics.desktopStage)
|
||
await shoot(page, 'desktop-start.png')
|
||
|
||
// 2. 19 Knöpfe; Klick auf jeden zeigt den Namen aus ROLES und den vollen Modellnamen
|
||
check('19 Knöpfe', (await page.locator('[data-wk-worker]').count()) === 19)
|
||
const wrong = []
|
||
for (const r of roster) {
|
||
await pickFromList(page, r.workerId, r.name)
|
||
const name = await textOf(page.locator('.wk-name'))
|
||
const chip = await textOf(page.locator('.wk-chip'))
|
||
if (name !== r.name || chip !== MODEL[r.model]) wrong.push({ id: r.workerId, name, chip })
|
||
}
|
||
check('Klick auf jeden Knopf: Name und Modell-Chip stimmen', wrong.length === 0, wrong)
|
||
|
||
// 3. sechs Figuren auf der Leinwand anklicken
|
||
await page.locator('button[aria-label="Karte schließen"]').click()
|
||
const hits = []
|
||
for (const id of ['otto', 'kim', 'rita', 'bea', 'bob', 'arne']) {
|
||
await topOfPage(page)
|
||
const pos = await page.evaluate((who) => window.__wk.stage.current.locate(who), id)
|
||
const cb = await page.locator('.wk-canvas').boundingBox()
|
||
if (!pos) { hits.push({ id, locate: null }); continue }
|
||
await page.mouse.click(cb.x + pos.x, cb.y + pos.y)
|
||
const want = roster.find((r) => r.workerId === id).name
|
||
let got = ''
|
||
try {
|
||
await page.waitForFunction((n) => (document.querySelector('.wk-name') || {}).textContent === n, want, { timeout: 3000 })
|
||
got = want
|
||
} catch {
|
||
got = await textOf(page.locator('.wk-name').first()).catch(() => '')
|
||
}
|
||
if (got !== want) hits.push({ id, want, got })
|
||
}
|
||
check('Klick auf sechs Figuren trifft die Figur (locate)', hits.length === 0, hits)
|
||
|
||
// 4. Spur von kim: javascript:-Zeile nur Text, https-Zeile ein sicherer Link
|
||
await pickFromList(page, 'kim', 'Kim')
|
||
await page.waitForFunction(() => document.querySelectorAll('.wk-trace li').length === 12)
|
||
check('Spur hat 12 Zeilen', (await page.locator('.wk-trace li').count()) === 12)
|
||
check('kein Link mit javascript:', (await page.locator('.wk-trace a[href^="javascript:" i]').count()) === 0)
|
||
const fund = page.locator('.wk-trace a[href="https://example.org/fund"]')
|
||
check('https-Zeile ist ein Link', (await fund.count()) === 1)
|
||
if ((await fund.count()) === 1) {
|
||
check('https-Link: rel noopener noreferrer, target _blank', (await fund.getAttribute('rel')) === 'noopener noreferrer' && (await fund.getAttribute('target')) === '_blank')
|
||
}
|
||
const unsafeRow = page.locator('.wk-trace li', { hasText: 'Link mit Skript abgelehnt' })
|
||
check('javascript:-Zeile steht als Text', (await unsafeRow.count()) === 1 && (await unsafeRow.locator('a').count()) === 0)
|
||
|
||
// Karte offen (Bea: Sonnet-Chip, Spur) für das Bild
|
||
await pickFromList(page, 'bea', 'Bea')
|
||
await page.waitForFunction(() => document.querySelectorAll('.wk-trace li').length === 12)
|
||
await topOfPage(page)
|
||
await shoot(page, 'desktop-karte.png')
|
||
await page.locator('button[aria-label="Karte schließen"]').click()
|
||
|
||
// 5. Pausenknopf und Einstellungen
|
||
const pauseBtn = page.getByRole('button', { name: 'Alles anhalten', exact: true })
|
||
await pauseBtn.click()
|
||
// Anhalten betrifft alle Mitarbeiter und fragt nach (P2-T21): erst „Anhalten“ im Dialog „Alles anhalten?“ pausiert
|
||
await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).getByRole('button', { name: 'Anhalten', exact: true }).click()
|
||
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor()
|
||
check('Pausenknopf: „Fortsetzen“ nach dem Anhalten', true)
|
||
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click()
|
||
await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor()
|
||
check('Pausenknopf: zurück auf „Alles anhalten“', true)
|
||
|
||
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()
|
||
check('Einstellungen: Dialog „Hustler-Einstellungen“ mit Feld „Tagesgrenze (USD)“', true)
|
||
check('Einstellungen ohne Notbremse: kein Block „Versand gestoppt“', (await page.locator('.wk-halt').count()) === 0)
|
||
await sleep(600) // der Dialog blendet ein
|
||
await shoot(page, 'desktop-einstellungen.png')
|
||
await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('Einstellungen: „Abbrechen“ schließt den Dialog', true)
|
||
|
||
// 6. nach 20 s Skriptlauf: Bild, Zeichenzeit, Bilder pro Sekunde, eine einzige Schleife
|
||
await topOfPage(page)
|
||
const wait = 20000 - (Date.now() - t0)
|
||
if (wait > 0) await sleep(wait)
|
||
await shoot(page, 'desktop-nach-20s.png')
|
||
const timed = () => page.evaluate(() => ({ ...window.__wk.stage.current.stats(), at: performance.now() }))
|
||
// Messfenster von zwei Sekunden: Bilder je Sekunde und wartende rAF-Rückrufe (eine Schleife = höchstens einer)
|
||
const sample = async () => {
|
||
const a = await timed()
|
||
const raf = []
|
||
for (let i = 0; i < 20; i += 1) {
|
||
raf.push(await page.evaluate(() => window.__raf.waiting.size))
|
||
await sleep(100)
|
||
}
|
||
const b = await timed()
|
||
const seconds = (b.at - a.at) / 1000
|
||
return { frames: b.frames - a.frames, seconds, fps: (b.frames - a.frames) / seconds, avgDrawMs: b.avgDrawMs, rafWaitingMax: Math.max(...raf), raf }
|
||
}
|
||
const busy = await sample()
|
||
const dbg = await page.evaluate(() => window.__wk.stage.current.debug())
|
||
metrics.fpsDuringScript = Math.round(busy.fps * 10) / 10
|
||
metrics.avgDrawMs = Math.round(busy.avgDrawMs * 100) / 100
|
||
metrics.rafWaitingMax = busy.rafWaitingMax
|
||
metrics.pending = dbg.pending
|
||
if (!(busy.avgDrawMs <= 25)) console.warn('WARNUNG: avgDrawMs ' + busy.avgDrawMs.toFixed(2) + ' > 25 (software-gerendert)')
|
||
check('Schleife läuft (Bilder wachsen)', busy.frames > 0, busy.frames)
|
||
check('StrictMode: genau eine Schleife (höchstens ein wartender rAF-Rückruf)', busy.rafWaitingMax <= 1, busy.raf)
|
||
check('debug().pending >= 0', dbg.pending >= 0, dbg.pending)
|
||
// Nach dem Skript (Ende bei 45,8 s, letzter Zustandsabruf bei 45 s) kommen keine Daten mehr: dann zählt nur die Schleife.
|
||
// Beim Eintreffen von Zustand oder Ereignissen zeichnet die Szene sofort ein Bild extra, das ist keine Schleife.
|
||
const rest = 49000 - (Date.now() - t0)
|
||
if (rest > 0) await sleep(rest)
|
||
const idle = await sample()
|
||
metrics.fpsIdle = Math.round(idle.fps * 10) / 10
|
||
metrics.fpsWindowSeconds = Math.round(idle.seconds * 100) / 100
|
||
// FRAME_MS 33 ergibt höchstens 30,3 Bilder je Sekunde; ein Bild Messkante kommt dazu
|
||
check('Bilder pro Sekunde höchstens 30 (FRAME_MS 33, Ruhe nach dem Skript)', idle.fps <= 1000 / 33 + 1 / idle.seconds, { fps: metrics.fpsIdle, window: metrics.fpsWindowSeconds })
|
||
check('Schleife läuft auch in Ruhe mit etwa 30 Bildern je Sekunde', idle.fps >= 20, idle.fps)
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario B: Blasen, Zeichen und Symbole bei doppelter Auflösung ----------
|
||
|
||
async function bubbles(browser, base) {
|
||
const { context, page } = await newPage(browser, 'blasen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 })
|
||
await ready(page, base + '/index.html')
|
||
await sleep(400)
|
||
await topOfPage(page)
|
||
const cb = await page.locator('.wk-canvas').boundingBox()
|
||
const clip = { x: cb.x, y: cb.y, width: cb.width, height: cb.height }
|
||
await shoot(page, 'desktop-hochhaus-2x.png', { clip })
|
||
// Echte Engine, selbst gebaute Ereignisse: ’ ü % und die längste erlaubte Blase (20 Zeichen) gleichzeitig
|
||
const at = Date.now()
|
||
const make = (n, workerId, kind, text, extra) => ({ id: 'probe-' + n, ts: new Date(at + n).toISOString(), workerId, kind, text, link: null, to: null, prospectId: null, costUsd: 0, meta: null, ...extra })
|
||
await page.evaluate((events) => window.__wk.stage.current.pushEvents(events), [
|
||
make(1, 'otto', 'budget_warnung', 'Tagesbudget zu 80 % erreicht'),
|
||
make(2, 'bob', 'bau_start', 'Vorschau wird gebaut'),
|
||
make(3, 'theo', 'gelesen', 'Posteingang gelesen', { meta: { count: 123456789012 } }),
|
||
])
|
||
await sleep(700)
|
||
await shoot(page, 'desktop-blasen-2x.png', { clip })
|
||
await sleep(2600)
|
||
await page.evaluate((events) => window.__wk.stage.current.pushEvents(events), [
|
||
make(4, 'paul', 'pruefung_zurueck', 'Prüfung: Runde 1 zurück', { meta: { round: 1 } }),
|
||
make(5, 'lea', 'gelesen', 'Läden gelesen', { meta: { count: 123456789012 } }),
|
||
])
|
||
await sleep(700)
|
||
await shoot(page, 'desktop-blasen-2x-b.png', { clip })
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario C: Handy 390 x 844 ----------
|
||
|
||
async function phone(browser, base) {
|
||
const { context, page } = await newPage(browser, 'handy', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
|
||
await ready(page, base + '/index.html')
|
||
await sleep(500)
|
||
const width = await page.evaluate(() => document.documentElement.scrollWidth)
|
||
metrics.phoneScrollWidth = width
|
||
check('handy: kein seitliches Scrollen (scrollWidth <= 390)', width <= 390, width)
|
||
await page.locator('.wk-stage').scrollIntoViewIfNeeded()
|
||
await sleep(200)
|
||
const zoomBtn = page.getByRole('button', { name: 'Vergrößern', exact: true })
|
||
check('handy: Zoom-Knöpfe sichtbar', (await zoomBtn.isVisible()) && (await page.getByRole('button', { name: 'Verkleinern', exact: true }).isVisible()))
|
||
await shoot(page, 'handy.png')
|
||
const z0 = (await stats(page)).zoom
|
||
await zoomBtn.click()
|
||
await sleep(300)
|
||
const z1 = (await stats(page)).zoom
|
||
metrics.phoneZoom = [Math.round(z0 * 100) / 100, Math.round(z1 * 100) / 100]
|
||
check('handy: „+“ erhöht den Zoom', z1 > z0, [z0, z1])
|
||
await shoot(page, 'handy-zoom.png')
|
||
await shoot(page, 'handy-ganz.png', { fullPage: true })
|
||
|
||
// Auswahl aus der Liste (die Liste ist auf dem Handy die Alternative zur Leinwand): die Karte kommt ins Bild und nimmt den Fokus,
|
||
// „Karte schließen“ gibt den Fokus an den Listeneintrag zurück (Endprüfung P2)
|
||
const bea = page.locator('[data-wk-worker="bea"]')
|
||
await bea.scrollIntoViewIfNeeded()
|
||
await bea.click()
|
||
await page.waitForFunction(() => document.activeElement && document.activeElement.classList.contains('wk-name') && document.activeElement.textContent === 'Bea')
|
||
check('handy: nach der Auswahl in der Liste hat der Name in der Karte den Fokus', true)
|
||
await sleep(300)
|
||
const cardBox = await page.locator('.wk-card').boundingBox()
|
||
const view = await page.evaluate(() => window.innerHeight)
|
||
metrics.phoneCardTop = Math.round(cardBox.y)
|
||
check('handy: nach der Auswahl in der Liste liegt der Kartenanfang im Fenster', cardBox.y >= 0 && cardBox.y <= view - 120, { top: cardBox.y, view })
|
||
await shoot(page, 'handy-karte.png')
|
||
await page.locator('button[aria-label="Karte schließen"]').click()
|
||
const back = await page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-wk-worker') : null))
|
||
check('handy: „Karte schließen“ gibt den Fokus an den Listeneintrag zurück', back === 'bea', back)
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario D: weniger Bewegung ----------
|
||
|
||
async function reduced(browser, base) {
|
||
const { context, page } = await newPage(browser, 'reduziert', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await page.goto(base + '/index.html')
|
||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||
await page.reload()
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
|
||
await sleep(2000)
|
||
const f2 = (await stats(page)).frames
|
||
await sleep(2300)
|
||
const f4 = (await stats(page)).frames
|
||
metrics.reducedFrames = [f2, f4]
|
||
check('weniger Bewegung: nach 2 s höchstens 5 Bilder', f2 <= 5, f2)
|
||
check('weniger Bewegung: die Schleife steht (Bildzahl bleibt)', f4 === f2, [f2, f4])
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario E: Notbremse (H4) ----------
|
||
|
||
async function halted(browser, base) {
|
||
const { context, page } = await newPage(browser, 'notbremse', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await ready(page, base + '/index.html?halted=1')
|
||
await sleep(500)
|
||
await topOfPage(page)
|
||
const alerts = page.locator('.msg.err[role="alert"]')
|
||
await alerts.first().waitFor()
|
||
check('notbremse: genau eine rote Zeile', (await alerts.count()) === 1, await alerts.count())
|
||
const line = await textOf(alerts.first())
|
||
check('notbremse: Text der Warnzeile (Punkt des Grundes nicht doppelt)', line === HALT_LINE, line)
|
||
await shoot(page, 'desktop-gestoppt.png')
|
||
|
||
await page.getByRole('button', { name: 'Einstellungen', exact: true }).click()
|
||
const dialog = page.getByRole('dialog', { name: 'Hustler-Einstellungen' })
|
||
await dialog.waitFor()
|
||
const block = page.locator('.wk-halt')
|
||
await block.waitFor()
|
||
check('notbremse: Block „Versand gestoppt“ im Dialog', (await textOf(block.locator('.wk-set-title'))) === 'Versand gestoppt')
|
||
const release = block.getByRole('button', { name: 'Versand wieder freigeben', exact: true })
|
||
check('notbremse: Knopf „Versand wieder freigeben“', (await release.count()) === 1)
|
||
await release.click()
|
||
await page.waitForFunction(() => document.querySelectorAll('.wk-halt').length === 0 && document.querySelectorAll('.msg.err[role="alert"]').length === 0, null, { timeout: 2000 })
|
||
check('notbremse: binnen 2 s sind Block und Warnzeile weg', true)
|
||
const saved = await page.evaluate(() => JSON.stringify(window.__wk.api.saved))
|
||
check('notbremse: gesendet wurde genau [{ sendingHalted: false }]', saved === '[{"sendingHalted":false}]', saved)
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario F: Ausfall, Wiederkehr, versteckter Tab, 401-Folge (Endprüfung P2) ----------
|
||
// Das Board bekommt hier den echten API-Client (createWorkersApi) hinter einer Fetch-Weiche mit Ausfall-Schalter (entry.jsx, ?fault=).
|
||
|
||
const DOWN_TEXT = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
|
||
const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
||
const callCount = (page) => page.evaluate(() => window.__wk.control.calls.length)
|
||
const freshMints = (page) => page.evaluate(() => window.__wk.control.jwts.filter(Boolean).length)
|
||
const setFault = (page, fault) => page.evaluate((f) => { window.__wk.control.fault = f }, fault)
|
||
// Tab verstecken oder zurückholen: Headless-Chromium kennt keinen versteckten Tab, darum werden die beiden Eigenschaften überschrieben
|
||
const setHidden = (page, hidden) => page.evaluate((h) => {
|
||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => h })
|
||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => (h ? 'hidden' : 'visible') })
|
||
document.dispatchEvent(new Event('visibilitychange'))
|
||
}, hidden)
|
||
|
||
async function outage(browser, base) {
|
||
const { context, page } = await newPage(browser, 'ausfall', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
|
||
// 1. Dienst beim Öffnen nicht erreichbar (503 ohne JSON): Hinweis mit „Neu laden“ statt leerer Seite
|
||
await page.goto(base + '/index.html?fault=down')
|
||
const empty = page.locator('.empty')
|
||
await empty.waitFor()
|
||
const emptyText = await textOf(empty)
|
||
check('ausfall: 503 beim Öffnen: „Der Hustler-Dienst ist noch nicht erreichbar“ mit der Dienst-Meldung', emptyText.includes('Der Hustler-Dienst ist noch nicht erreichbar') && emptyText.includes(DOWN_TEXT), emptyText)
|
||
check('ausfall: im Ausfall kein Hochhaus', (await page.locator('.wk-canvas').count()) === 0)
|
||
await shoot(page, 'desktop-ausfall.png')
|
||
const asked = await callCount(page)
|
||
await page.getByRole('button', { name: 'Neu laden', exact: true }).click()
|
||
await page.waitForFunction((n) => window.__wk.control.calls.length > n, asked)
|
||
await sleep(300)
|
||
check('ausfall: „Neu laden“ fragt den Dienst erneut, der Hinweis bleibt', (await page.locator('.empty').count()) === 1)
|
||
|
||
// 2. Dienst kehrt zurück: „Neu laden“ zeigt das Hochhaus
|
||
await setFault(page, null)
|
||
await page.getByRole('button', { name: 'Neu laden', exact: true }).click()
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19 && window.__wk.stage.current.stats().frames > 0, null, { timeout: 8000 })
|
||
check('ausfall: nach „Neu laden“ ist das Hochhaus mit 19 Figuren da', true)
|
||
check('ausfall: ohne Fehler keine Warnzeile', (await page.locator('.msg.warn').count()) === 0)
|
||
|
||
// 3. Ausfall mitten im Betrieb: Warnzeile, die Daten bleiben stehen
|
||
await setFault(page, 'down')
|
||
const warn = page.locator('.msg.warn[role="status"]')
|
||
await warn.waitFor({ timeout: 9000 })
|
||
const warnText = await textOf(warn)
|
||
check('ausfall: 503 im Betrieb: Warnzeile „Verbindung gestört: …“, das Hochhaus bleibt', warnText === 'Verbindung gestört: ' + DOWN_TEXT + ' Es wird weiter versucht.' && (await page.locator('[data-wk-worker]').count()) === 19, warnText)
|
||
await topOfPage(page)
|
||
await shoot(page, 'desktop-verbindung-gestoert.png')
|
||
|
||
// 4. Versteckter Tab: keine Abrufe, keine Bilder, auch wenn der Dienst inzwischen wieder da ist; beim Zurückkehren sofort beides
|
||
await setHidden(page, true)
|
||
await setFault(page, null)
|
||
await sleep(400)
|
||
const hiddenCalls = await callCount(page)
|
||
const hiddenFrames = (await stats(page)).frames
|
||
await sleep(6500) // länger als der Takt der Ereignisse (5 s)
|
||
const stillCalls = await callCount(page)
|
||
const stillFrames = (await stats(page)).frames
|
||
metrics.hidden = { calls: [hiddenCalls, stillCalls], frames: [hiddenFrames, stillFrames] }
|
||
check('ausfall: versteckter Tab: keine Abrufe in 6,5 s', stillCalls === hiddenCalls, [hiddenCalls, stillCalls])
|
||
check('ausfall: versteckter Tab: die Szene zeichnet nicht (höchstens 1 Bild Nachlauf)', stillFrames - hiddenFrames <= 1, [hiddenFrames, stillFrames])
|
||
check('ausfall: versteckter Tab: die Warnzeile bleibt bis zur Rückkehr stehen', (await page.locator('.msg.warn').count()) === 1)
|
||
await setHidden(page, false)
|
||
await page.waitForFunction((n) => window.__wk.control.calls.length > n, stillCalls, { timeout: 1500 })
|
||
check('ausfall: Rückkehr in den Tab fragt sofort ab (binnen 1,5 s, ohne den Takt abzuwarten)', true)
|
||
await page.waitForFunction(() => document.querySelectorAll('.msg.warn').length === 0, null, { timeout: 3000 })
|
||
check('ausfall: nach der Rückkehr verschwindet die Warnzeile', true)
|
||
const resumed = (await stats(page)).frames
|
||
await sleep(600)
|
||
check('ausfall: nach der Rückkehr zeichnet die Szene wieder', (await stats(page)).frames > resumed)
|
||
|
||
// 5. Dauerhaftes 401 (Dienst lehnt das JWT ab): weiter abfragen, aber höchstens ein frisches JWT, Meldung „Anmeldung abgelaufen“
|
||
await pickFromList(page, 'kim', 'Kim') // die Spur der Karte fragt ebenfalls ab
|
||
const before = await callCount(page)
|
||
const mintsBefore = await freshMints(page)
|
||
await setFault(page, 'session')
|
||
await page.waitForFunction((n) => window.__wk.control.calls.length >= n + 4, before, { timeout: 30000 })
|
||
const mints = (await freshMints(page)) - mintsBefore
|
||
const sessionWarn = await textOf(page.locator('.msg.warn[role="status"]'))
|
||
metrics.session401 = { calls: (await callCount(page)) - before, freshMints: mints }
|
||
check('ausfall: dauerhaftes 401: höchstens ein frisches JWT trotz mehrerer Abrufe', mints <= 1, mints)
|
||
check('ausfall: dauerhaftes 401: Warnzeile nennt die abgelaufene Anmeldung', sessionWarn === 'Verbindung gestört: ' + SESSION_TEXT + ' Es wird weiter versucht.', sessionWarn)
|
||
await setFault(page, null)
|
||
await page.waitForFunction(() => document.querySelectorAll('.msg.warn').length === 0, null, { timeout: 20000 }) // der Zustand wird nur alle 15 s geholt
|
||
check('ausfall: nimmt der Dienst das JWT wieder an, verschwindet die Warnzeile ohne neues Anmelden', true)
|
||
await context.close()
|
||
|
||
// 6. Antwort kommt nie (tote Verbindung): nach der Zeitgrenze (im Prüfstand 1,5 s) erscheint der Hinweis, nicht ewiges „Wird geladen …“
|
||
const hang = await newPage(browser, 'ausfall-haengt', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
const t0 = Date.now()
|
||
await hang.page.goto(base + '/index.html?fault=hang')
|
||
await hang.page.locator('.loading').waitFor()
|
||
await hang.page.locator('.empty').waitFor({ timeout: 6000 })
|
||
metrics.hangSeconds = Math.round((Date.now() - t0) / 100) / 10
|
||
check('ausfall: hängende Verbindung endet nach der Zeitgrenze mit der Dienst-Meldung', (await textOf(hang.page.locator('.empty'))).includes(DOWN_TEXT), metrics.hangSeconds)
|
||
await hang.context.close()
|
||
}
|
||
|
||
// ---------- P3: Freigabe-Pult, Interessenten-Panel, Anhalten mit Rückfrage ----------
|
||
// Die Seite läuft als echte WorkersPage (?page=1) über den echten API-Client und die Fetch-Weiche (&fault=none); Anmeldung, Mitarbeiterliste
|
||
// und Sitzungs-JWT sind ersetzt (build.mjs). Zwischenablage und window.open sind ersetzt und halten fest, was die Oberfläche tut
|
||
// (window.__clip, window.__opened). Die Schalter der Skript-API (window.__wk.api.faults, .pultEmpty, .pultCalls) steuern 409, 400, Ausfall und Leerzustand.
|
||
|
||
const T_MAIL = 'Anfrage: Vereinswebsite für den SV Namborn'
|
||
const T_PLAT = 'Kleinanzeigen: Website für Friseursalon gesucht'
|
||
const T_KOLL = 'Kleinanzeigen: Homepage für Handwerksbetrieb'
|
||
const T_UNS = 'Unsichere Antwort: bitte prüfen'
|
||
const T_ALT = 'freelancermap: WordPress-Relaunch'
|
||
const T_GES = 'Kleinanzeigen: Fotograf sucht Portfolio-Seite'
|
||
const T_ERL = 'Beschwerde über eine Vorschau'
|
||
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 SV_URL = 'https://www.freelancermap.de/projekt/website-sportverein'
|
||
const PULT_URL = '/index.html?page=1&fault=none'
|
||
const PULT_DOWN = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
|
||
const CONFLICT = 'Wird schon von einem Kollegen bearbeitet.'
|
||
const TOO_LONG = 'Der Text ist zu lang (höchstens 8000 Zeichen).'
|
||
const HALT_QUESTION = 'Die Worker hören sofort auf, bis jemand fortsetzt.'
|
||
// 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 same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
||
const rowOf = (page, title) => page.locator('li.wk-pult-row').filter({ has: page.locator('.wk-pult-title', { hasText: title }) })
|
||
const rowTitles = (page) => page.locator('.wk-pult-title').allTextContents()
|
||
const finishedLine = (page) => page.locator('.wk-pult-filters ~ .msg')
|
||
const setPultFault = (page, key, value) => page.evaluate(([k, v]) => { window.__wk.api.faults[k] = v }, [key, value])
|
||
const pultCalls = (page) => page.evaluate(() => window.__wk.api.pultCalls)
|
||
const resetStubs = (page) => page.evaluate(() => { window.__clip.length = 0; window.__opened.length = 0; window.__openMeta.length = 0 })
|
||
const stubState = (page) => page.evaluate(() => ({ clip: window.__clip.slice(), opened: window.__opened.map((a) => Array.from(a)), meta: window.__openMeta.slice() }))
|
||
const waitTitles = (page, titles, timeout = 5000) => page.waitForFunction(
|
||
(want) => JSON.stringify(Array.from(document.querySelectorAll('.wk-pult-title')).map((el) => el.textContent)) === JSON.stringify(want),
|
||
titles,
|
||
{ timeout },
|
||
)
|
||
const activeProbe = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-probe') : null))
|
||
const activeTag = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.tagName : null))
|
||
|
||
// Seite mit ersetzter Zwischenablage und ersetztem window.open (beides vor dem Laden der Seite)
|
||
async function newPultPage(browser, label, contextOptions) {
|
||
const made = await newPage(browser, label, contextOptions)
|
||
// 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. window.open hält Zeit und Geste fest.
|
||
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
|
||
}
|
||
|
||
// Reiter „Freigabe“ per Deep-Link öffnen und warten, bis die vier wartenden Einträge und die Mitarbeiternamen da sind
|
||
async function openPult(page, base) {
|
||
await page.goto(base + PULT_URL + '&tab=freigabe')
|
||
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4)
|
||
await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin'))
|
||
await page.evaluate(() => document.fonts.ready)
|
||
}
|
||
|
||
// ---------- Szenario G: Freigabe (Desktop 1280 x 900) ----------
|
||
|
||
async function freigabe(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'freigabe', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
|
||
// 1. Reiterleiste: erst ab zwei Reitern, Start auf „Hochhaus“, Wechsel auf „Freigabe“
|
||
await page.goto(base + PULT_URL)
|
||
await page.getByRole('tab').first().waitFor()
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
|
||
const tabNames = (await page.getByRole('tab').allTextContents()).map((t) => t.trim())
|
||
check('freigabe: Reiterleiste mit „Hochhaus“, „Freigabe“ und „Bot-Leads“', same(tabNames, ['Hochhaus', 'Freigabe', 'Bot-Leads']), tabNames)
|
||
check('freigabe: Start auf „Hochhaus“ (Hochhaus mit 19 Figuren, Reiter gewählt)', (await page.getByRole('tab', { name: 'Hochhaus', exact: true }).getAttribute('aria-selected')) === 'true')
|
||
await page.getByRole('tab', { name: 'Freigabe', exact: true }).click()
|
||
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4)
|
||
await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin'))
|
||
await page.evaluate(() => document.fonts.ready)
|
||
check('freigabe: nach dem Klick ist „Freigabe“ gewählt und das Hochhaus weg', (await page.getByRole('tab', { name: 'Freigabe', exact: true }).getAttribute('aria-selected')) === 'true' && (await page.locator('[data-wk-worker]').count()) === 0)
|
||
|
||
// 2. Filter und Liste
|
||
const filterButtons = page.locator('.wk-pult-filters .seg button')
|
||
check('freigabe: Filter „Wartet“, „Veraltet“, „Erledigt“, „Wartet“ gedrückt', same(await filterButtons.allTextContents(), ['Wartet', 'Veraltet', 'Erledigt']) && same(await filterButtons.evaluateAll((els) => els.map((el) => el.getAttribute('aria-pressed'))), ['true', 'false', 'false']))
|
||
const first = await rowTitles(page)
|
||
check('freigabe: „Wartet“ zeigt die vier Einträge, neueste zuerst', same(first, [T_MAIL, T_PLAT, T_KOLL, T_UNS]), first)
|
||
|
||
const mail = rowOf(page, T_MAIL)
|
||
const plat = rowOf(page, T_PLAT)
|
||
const koll = rowOf(page, T_KOLL)
|
||
const uns = rowOf(page, T_UNS)
|
||
const buttonNames = (row) => row.locator('.wk-pult-actions').locator('button, a').allTextContents().then((list) => list.map((t) => t.replace(/\s+/g, ' ').trim()))
|
||
|
||
const platMeta = await textOf(plat.locator('.wk-pult-meta'))
|
||
check('freigabe: Plattform-Zeile: Alter, „läuft in 20 Std. ab“, Ort und Score', platMeta.includes('vor 5 Std.') && platMeta.includes('läuft in 20 Std. ab') && platMeta.includes('67655 Kaiserslautern · Score 72'), platMeta)
|
||
check('freigabe: Plattform-Zeile: Art-Badge „Antwort auf Plattform-Anfrage“', (await textOf(plat.locator('.wk-pult-head'))).includes('Antwort auf Plattform-Anfrage'))
|
||
const link = plat.getByRole('link', { name: `Anzeige öffnen: ${T_PLAT}`, exact: true })
|
||
check('freigabe: „Anzeige öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await link.getAttribute('href')) === HAIR_URL && (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer')
|
||
check('freigabe: Plattform-Zeile (offen): Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen (P4), Verwerfen', same(await buttonNames(plat), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await buttonNames(plat))
|
||
const mailTo = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href')
|
||
check('freigabe: Mail-Zeile: Link „E-Mail schreiben“ mit mailto-Adresse, Betreff und Text', typeof mailTo === 'string' && mailTo.startsWith('mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=Guten%20Tag%20Herr%20Beispiel'), mailTo)
|
||
check('freigabe: Mail-Zeile: kein „Kopieren & öffnen“ (Antwort geht per Mailprogramm)', (await mail.getByRole('button', { name: 'Kopieren & öffnen' }).count()) === 0)
|
||
await mail.getByLabel('Entwurf der Antwort').fill('Neuer Text für die Mail.')
|
||
const mailEdited = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href')
|
||
check('freigabe: Mail-Zeile: der Mail-Link trägt den bearbeiteten Text aus dem Feld', mailEdited === 'mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=' + encodeURIComponent('Neuer Text für die Mail.'), mailEdited)
|
||
check('freigabe: Kollegen-Zeile: „übernommen von Justin“, kein „Ich kümmere mich“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin') && (await koll.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0)
|
||
check('freigabe: Zeile ohne Antwort („unsicher“): nur Ich kümmere mich, Erledigt, Verwerfen, kein Textfeld', same(await buttonNames(uns), ['Ich kümmere mich', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await buttonNames(uns))
|
||
// Wiederkehrende Bedienelemente tragen den Titel der Zeile im Namen (Screenreader-Liste), das sichtbare Wort steht vorn
|
||
const repeated = await page.evaluate(() => ({
|
||
details: [...document.querySelectorAll('.wk-pult-meta button.link')].map((el) => [el.textContent.trim(), el.getAttribute('aria-label')]),
|
||
sources: [...document.querySelectorAll('.wk-pult-meta a.link')].map((el) => [el.textContent.trim(), el.getAttribute('aria-label')]),
|
||
drafts: [...document.querySelectorAll('textarea.wk-pult-draft')].map((el) => el.getAttribute('aria-label')),
|
||
}))
|
||
const unique = (list) => new Set(list).size === list.length
|
||
check('freigabe: „Details“, „Anzeige öffnen“ und Entwurfsfeld haben je Zeile einen eigenen Namen (sichtbares Wort vorn)',
|
||
repeated.details.length > 1 && repeated.details.every(([text, name]) => text === 'Details' && typeof name === 'string' && name.startsWith('Details zu ')) && unique(repeated.details.map((d) => d[1]))
|
||
&& repeated.sources.length > 1 && repeated.sources.every(([text, name]) => text === 'Anzeige öffnen' && typeof name === 'string' && name.startsWith('Anzeige öffnen: ')) && unique(repeated.sources.map((d) => d[1]))
|
||
&& repeated.drafts.length > 1 && repeated.drafts.every((name) => typeof name === 'string' && name.startsWith('Entwurf der Antwort: ')) && unique(repeated.drafts), repeated)
|
||
await topOfPage(page)
|
||
await shoot(page, 'desktop-freigabe-wartet.png')
|
||
await shoot(page, 'desktop-freigabe-wartet-ganz.png', { fullPage: true })
|
||
|
||
// 3. Kollege hat schon übernommen: Kopieren & öffnen kopiert und öffnet, der Dienst antwortet 409, die Zeile zeigt den Namen
|
||
await koll.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click()
|
||
const kollNote = koll.locator('.msg')
|
||
await kollNote.waitFor()
|
||
const kollText = await textOf(kollNote)
|
||
check('freigabe: 409 beim Übernehmen: Hinweis „Wird schon von Justin bearbeitet.“ (zusammen mit „Kopiert und geöffnet.“)', kollText === 'Kopiert und geöffnet. Wird schon von Justin bearbeitet.', kollText)
|
||
check('freigabe: 409-Hinweis ist eine rote Zeile mit role=alert', (await kollNote.getAttribute('class')).includes('err') && (await kollNote.getAttribute('role')) === 'alert')
|
||
const kollStub = await stubState(page)
|
||
check('freigabe: Kollegen-Zeile: Text kopiert und Anzeige geöffnet (trotz 409)', kollStub.clip.length === 1 && kollStub.clip[0].startsWith('Guten Tag, gern bauen wir Ihnen eine erste Vorschau Ihrer Homepage') && same(kollStub.opened, [[WEBER_URL, '_blank', 'noopener,noreferrer']]), kollStub)
|
||
check('freigabe: Kollegen-Zeile bleibt „übernommen von Justin“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin'))
|
||
await shoot(page, 'desktop-freigabe-409-kollege.png')
|
||
await resetStubs(page)
|
||
|
||
// 4. Kopieren & öffnen mit bearbeitetem Text: kopiert den Text aus dem Feld, öffnet die Anzeige, übernimmt den Eintrag für mich
|
||
const area = plat.getByLabel('Entwurf der Antwort')
|
||
const draft = await area.inputValue()
|
||
const edited = draft + '\nPS: Bearbeitet im Prüfstand.'
|
||
await area.fill(edited)
|
||
await plat.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click()
|
||
const platNote = plat.locator('.msg')
|
||
await platNote.waitFor()
|
||
check('freigabe: Kopieren & öffnen: Meldung „Kopiert und geöffnet.“ (grün, role=status)', (await textOf(platNote)) === 'Kopiert und geöffnet.' && (await platNote.getAttribute('class')).includes('ok') && (await platNote.getAttribute('role')) === 'status', await textOf(platNote))
|
||
const platStub = await stubState(page)
|
||
check('freigabe: Kopieren & öffnen kopiert den bearbeiteten Text aus dem Feld', same(platStub.clip, [edited]), platStub.clip)
|
||
check('freigabe: Kopieren & öffnen öffnet genau die Anzeige im neuen Tab (noopener,noreferrer)', same(platStub.opened, [[HAIR_URL, '_blank', 'noopener,noreferrer']]), platStub.opened)
|
||
check('freigabe: Kopieren & öffnen öffnet im Zug des Klicks (binnen 150 ms, Nutzer-Geste aktiv), ohne die Zwischenablage abzuwarten', platStub.meta.length === 1 && platStub.meta[0].sinceClick < 150 && platStub.meta[0].active === true, platStub.meta)
|
||
await page.waitForFunction((t) => {
|
||
const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t))
|
||
return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')
|
||
}, T_PLAT)
|
||
const platCalls = (await pultCalls(page)).filter((c) => c.id === 'pult-plat')
|
||
check('freigabe: Kopieren & öffnen übernimmt den Eintrag (claim) und er steht „übernommen von Kenso“', platCalls.length >= 1 && platCalls[0].action === 'claim' && (await plat.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, platCalls)
|
||
check('freigabe: der bearbeitete Text bleibt nach dem Neuladen der Liste im Feld', (await area.inputValue()) === edited)
|
||
await shoot(page, 'desktop-freigabe-kopiert.png')
|
||
|
||
// 5. Übernehmen: 409 vom Dienst (Schalter) zeigt den Satz, danach klappt es
|
||
await setPultFault(page, 'claim', { status: 409, message: CONFLICT })
|
||
await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
|
||
const mailNote = mail.locator('.msg')
|
||
await mailNote.waitFor()
|
||
check('freigabe: Übernehmen mit 409: der Satz des Dienstes steht in der Zeile', (await textOf(mailNote)) === CONFLICT && (await mailNote.getAttribute('role')) === 'alert', await textOf(mailNote))
|
||
check('freigabe: nach dem 409 ist der Eintrag nicht übernommen (Knopf bleibt, kein „übernommen von“)', (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 1 && !(await textOf(mail.locator('.wk-pult-meta'))).includes('übernommen von'))
|
||
await shoot(page, 'desktop-freigabe-409-uebernehmen.png')
|
||
await setPultFault(page, 'claim', null)
|
||
await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
|
||
await page.waitForFunction((t) => {
|
||
const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t))
|
||
return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')
|
||
}, T_MAIL)
|
||
check('freigabe: Übernehmen: „in Arbeit“ von mir (übernommen von Kenso), Meldung „Du kümmerst dich darum.“, Knopf weg', (await textOf(mail.locator('.msg'))) === 'Du kümmerst dich darum.' && (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, await textOf(mail.locator('.msg')))
|
||
await shoot(page, 'desktop-freigabe-uebernommen.png')
|
||
|
||
// 6. Als gesendet markieren: Fehler 400 mit deutschem Satz, dann der Erfolg mit dem bearbeiteten Text
|
||
await setPultFault(page, 'sent', { status: 400, message: TOO_LONG })
|
||
await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click()
|
||
await plat.locator('.msg.err').waitFor()
|
||
check('freigabe: Fehler 400: der deutsche Satz des Dienstes steht in der Zeile (role=alert), die Zeile bleibt', (await textOf(plat.locator('.msg.err'))) === TOO_LONG && (await plat.locator('.msg.err').getAttribute('role')) === 'alert' && (await rowTitles(page)).includes(T_PLAT), await textOf(plat.locator('.msg.err')))
|
||
await shoot(page, 'desktop-freigabe-400.png')
|
||
await setPultFault(page, 'sent', null)
|
||
await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click()
|
||
await finishedLine(page).first().waitFor()
|
||
check('freigabe: gesendet: Meldung oben „…“ als gesendet markiert. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T_PLAT}“ als gesendet markiert.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && !(await rowTitles(page)).includes(T_PLAT), await textOf(finishedLine(page).first()))
|
||
const sentCall = (await pultCalls(page)).filter((c) => c.id === 'pult-plat' && c.action === 'sent').pop()
|
||
check('freigabe: gesendet schickt den bearbeiteten Text (body.text)', sentCall !== undefined && sentCall.body && sentCall.body.text === edited, sentCall)
|
||
await shoot(page, 'desktop-freigabe-gesendet.png')
|
||
|
||
// 7. Erledigt (Art ohne Antwort)
|
||
await uns.getByRole('button', { name: 'Erledigt', exact: true }).click()
|
||
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ erledigt.`, T_UNS)
|
||
check('freigabe: Erledigt: Meldung oben, die Zeile ist weg', !(await rowTitles(page)).includes(T_UNS), await rowTitles(page))
|
||
|
||
// 8. Verwerfen: erster Klick fragt nach, die Frage verfällt nach 4 s, zwei Klicks verwerfen; die Oberfläche schickt keinen Grund
|
||
const discardBtn = mail.locator('.wk-pult-actions').getByRole('button', { name: /^(Verwerfen|Wirklich verwerfen\?)$/ })
|
||
await discardBtn.click()
|
||
check('freigabe: Verwerfen: erster Klick fragt „Wirklich verwerfen?“, noch keine Aktion am Dienst', (await textOf(discardBtn)) === 'Wirklich verwerfen?' && (await pultCalls(page)).filter((c) => c.action === 'discard').length === 0)
|
||
await shoot(page, 'desktop-freigabe-verwerfen-frage.png')
|
||
await sleep(4400)
|
||
check('freigabe: Verwerfen: die Rückfrage verfällt nach 4 s (wieder „Verwerfen“)', (await textOf(discardBtn)) === 'Verwerfen')
|
||
await discardBtn.click()
|
||
await discardBtn.click()
|
||
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ verworfen.`, T_MAIL)
|
||
const discards = (await pultCalls(page)).filter((c) => c.action === 'discard')
|
||
check('freigabe: Verwerfen: genau ein Aufruf, Meldung oben „…“ verworfen., Zeile weg', discards.length === 1 && discards[0].id === 'pult-mail' && !(await rowTitles(page)).includes(T_MAIL), discards)
|
||
check('freigabe: Verwerfen schickt keinen Grund (Body {}); die Oberfläche hat kein Feld dafür', same(discards[0] && discards[0].body, {}), discards[0])
|
||
const afterAll = await rowTitles(page)
|
||
check('freigabe: „Wartet“ zeigt am Ende nur den Eintrag des Kollegen', same(afterAll, [T_KOLL]), afterAll)
|
||
|
||
// 9. Filter „Veraltet“, „Erledigt“
|
||
await filterButtons.nth(1).click()
|
||
await waitTitles(page, [T_ALT])
|
||
check('freigabe: „Veraltet“ zeigt den veralteten Eintrag mit Badge „veraltet“', (await textOf(rowOf(page, T_ALT).locator('.wk-pult-head'))).includes('veraltet') && (await filterButtons.nth(1).getAttribute('aria-pressed')) === 'true')
|
||
check('freigabe: Filterwechsel räumt die Meldung oben weg', (await finishedLine(page).count()) === 0)
|
||
check('freigabe: veralteter Eintrag lässt sich weiter bearbeiten (Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen, Verwerfen)', same(await buttonNames(rowOf(page, T_ALT)), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await buttonNames(rowOf(page, T_ALT)))
|
||
await shoot(page, 'desktop-freigabe-veraltet.png')
|
||
await rowOf(page, T_ALT).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
|
||
await page.locator('.empty').waitFor()
|
||
const emptyOld = await textOf(page.locator('.empty'))
|
||
check('freigabe: Leerzustand „Veraltet“: „Nichts ist veraltet“ mit Hinweis', emptyOld.startsWith('Nichts ist veraltet') && emptyOld.includes('Einträge, die zu lange liegen'), emptyOld)
|
||
await shoot(page, 'desktop-freigabe-leer-veraltet.png')
|
||
|
||
await filterButtons.nth(2).click()
|
||
await waitTitles(page, [T_MAIL, T_PLAT, T_UNS, T_GES, T_ERL])
|
||
// Seit P4 nimmt eine gesendete Plattform-Antwort auch danach noch die Antwort des Kunden an: dort steht der eine Knopf „Antwort des Kunden einfügen“;
|
||
// verworfene und erledigte Zeilen sowie die gesendete Mail-Antwort haben keine Aktionen mehr
|
||
const doneActions = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({
|
||
title: row.querySelector('.wk-pult-title').textContent,
|
||
actions: [...row.querySelectorAll('.wk-pult-actions > *')].map((el) => el.textContent.trim()),
|
||
})))
|
||
check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), Aktion nur „Antwort des Kunden einfügen“ an den gesendeten Plattform-Antworten',
|
||
doneActions.length === 5 && doneActions.every((r) => same(r.actions, [T_PLAT, T_GES].includes(r.title) ? ['Antwort des Kunden einfügen'] : [])), doneActions)
|
||
const doneMeta = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({
|
||
badge: [...row.querySelectorAll('.wk-pult-head .badge')].map((b) => b.textContent.trim()).filter((t) => ['Gesendet', 'Verworfen', 'Erledigt'].includes(t)),
|
||
meta: row.querySelector('.wk-pult-meta').textContent.trim(),
|
||
})))
|
||
check('freigabe: „Erledigt“: jede Zeile nennt Ergebnis (Badge), Person und Zeitpunkt der Erledigung, nicht das Alter ab Anlage',
|
||
doneMeta.length === 5 && doneMeta.every((r) => r.badge.length === 1 && new RegExp(`^${r.badge[0]} von (Kenso|Justin) · (gerade eben|vor \\d+ (Min\\.|Std\\.|Tag|Tagen))`).test(r.meta)), doneMeta)
|
||
check('freigabe: „Erledigt“: Foto-Zeile „Gesendet von Kenso · vor 1 Tag“, Beschwerde „Erledigt von Justin · vor 3 Tagen“',
|
||
(await textOf(rowOf(page, T_GES).locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso · vor 1 Tag') && (await textOf(rowOf(page, T_ERL).locator('.wk-pult-meta'))).startsWith('Erledigt von Justin · vor 3 Tagen'))
|
||
await shoot(page, 'desktop-freigabe-erledigt.png')
|
||
|
||
// 10. Leerzustand „Wartet“ und Ausfall des Pults mit „Neu laden“
|
||
await page.evaluate(() => { window.__wk.api.pultEmpty = true })
|
||
await filterButtons.nth(0).click()
|
||
await page.locator('.empty').waitFor()
|
||
const emptyWait = await textOf(page.locator('.empty'))
|
||
check('freigabe: Leerzustand „Wartet“: „Nichts wartet auf dich“ mit Hinweis', emptyWait.startsWith('Nichts wartet auf dich') && emptyWait.includes('Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'), emptyWait)
|
||
await shoot(page, 'desktop-freigabe-leer.png')
|
||
await setPultFault(page, 'pult', { status: 503, message: PULT_DOWN })
|
||
await filterButtons.nth(1).click()
|
||
await page.getByRole('button', { name: 'Neu laden', exact: true }).waitFor()
|
||
const emptyDown = await textOf(page.locator('.empty'))
|
||
check('freigabe: Pult nicht erreichbar: Hinweis mit dem Satz des Dienstes und „Neu laden“', emptyDown.includes('Das Pult ist gerade nicht erreichbar') && emptyDown.includes(PULT_DOWN), emptyDown)
|
||
await shoot(page, 'desktop-freigabe-ausfall.png')
|
||
await setPultFault(page, 'pult', null)
|
||
await page.getByRole('button', { name: 'Neu laden', exact: true }).click()
|
||
await page.waitForFunction(() => (document.querySelector('.empty') || {}).textContent?.startsWith('Nichts ist veraltet'))
|
||
check('freigabe: „Neu laden“ nach dem Ausfall fragt erneut, der Leerzustand kehrt zurück', true)
|
||
await page.evaluate(() => { window.__wk.api.pultEmpty = false })
|
||
await filterButtons.nth(0).click()
|
||
await waitTitles(page, [T_KOLL, T_ALT])
|
||
check('freigabe: nach dem Ausfall und dem Leerzustand ist die Liste wieder da (Kollege und der eben übernommene veraltete Eintrag)', true)
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario H: Interessenten-Panel (Desktop) ----------
|
||
|
||
async function interessent(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'interessent', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openPult(page, base)
|
||
|
||
// 1. „Details“ öffnet das Panel als Dialog
|
||
const opener = rowOf(page, T_MAIL).getByRole('button', { name: `Details zu ${T_MAIL}`, exact: true })
|
||
await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
|
||
await opener.click()
|
||
const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true })
|
||
await dialog.waitFor()
|
||
await dialog.locator('.wk-prospect').waitFor()
|
||
await sleep(600) // der Dialog blendet ein
|
||
check('interessent: „Details“ öffnet einen Dialog (role=dialog, aria-modal) mit dem Namen als Titel', (await dialog.getAttribute('aria-modal')) === 'true')
|
||
// Im Entwicklungsmodus hängt StrictMode jeden Effekt zweimal ein: ein oder zwei Abfragen, aber immer nur dieser Interessent
|
||
const asked = await page.evaluate(() => window.__wk.api.prospectCalls)
|
||
check('interessent: das Panel fragt den Interessenten p-mail ab (und keinen anderen)', asked.length >= 1 && asked.every((id) => id === 'p-mail'), asked)
|
||
check('interessent: der Fokus liegt beim Öffnen im Dialog', await dialog.evaluate((el) => el.contains(document.activeElement)))
|
||
const scoreParts = await dialog.locator('.wk-prospect-score').evaluate((el) => Array.from(el.children).map((child) => child.textContent.trim()))
|
||
check('interessent: Score als Badge „Score 84 · hoch“ mit Begründung', same(scoreParts, ['Score 84 · hoch', 'Klarer Auftrag, Budget genannt, Region passt.']), scoreParts)
|
||
const facts = await dialog.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim()))
|
||
check('interessent: Fakten Quelle, Gefunden, Ort, Status (ohne Telefon)', same(facts, ['Quelle', 'freelancermap', 'Gefunden', 'vor 3 Std.', 'Ort', '66640 Namborn', 'Status', 'Bewertet']), facts)
|
||
const request = dialog.locator('.wk-prospect-section').first()
|
||
const requestText = request.locator('.wk-pult-text')
|
||
check('interessent: Anfragetext als Text mit erhaltenen Zeilenumbrüchen (pre-wrap)', (await requestText.textContent()).includes('Vereinsseite neu macht.\n\nBudget bis 1.500 Euro') && (await requestText.evaluate((el) => getComputedStyle(el).whiteSpace)) === 'pre-wrap')
|
||
const open = request.getByRole('link', { name: 'Anfrage öffnen', exact: true })
|
||
check('interessent: „Anfrage öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await open.getAttribute('href')) === SV_URL && (await open.getAttribute('target')) === '_blank' && (await open.getAttribute('rel')) === 'noopener noreferrer')
|
||
const msgs = await dialog.locator('li.wk-prospect-msg').evaluateAll((els) => els.map((el) => ({ who: el.querySelector('strong').textContent, when: el.querySelector('.wk-prospect-msg-head span').textContent, body: el.querySelector('p').textContent })))
|
||
check('interessent: zwei Nachrichten: „Interessent“ (eingegangen) und „Justin“ (Mitarbeiter, Name aus der Liste), mit Alter', msgs.length === 2 && msgs[0].who === 'Interessent' && msgs[1].who === 'Justin' && msgs[0].when === 'vor 3 Std.' && msgs[1].when === 'vor 2 Std.' && msgs[0].body.startsWith('Guten Tag,\nkönnen Sie uns eine Vorschau zeigen?'), msgs)
|
||
const previewLine = await textOf(dialog.locator('.wk-prospect-section').last().locator('p.muted'))
|
||
check('interessent: Vorschau-Zeile „Vorschau: fertig · Runde 1 · erstellt vor 1 Std.“', previewLine === 'Vorschau: fertig · Runde 1 · erstellt vor 1 Std.', previewLine)
|
||
const html = await page.evaluate(() => document.documentElement.outerHTML)
|
||
const text = await page.evaluate(() => document.body.innerText)
|
||
check('interessent: nirgends im Bild ein Telefon (weder Wort noch Nummer, weder in Text noch im HTML)', !PHONE_WORD.test(html) && !PHONE_NUMBER.test(html) && !PHONE_WORD.test(text) && !PHONE_NUMBER.test(text), { word: (html.match(PHONE_WORD) || [])[0], number: (html.match(PHONE_NUMBER) || [])[0] })
|
||
check('interessent: keine Mail-Rohdaten im Bild (messageId, inReplyTo, references)', !/messageId|inReplyTo|referencesJson/.test(html))
|
||
await shoot(page, 'desktop-interessent-panel.png')
|
||
|
||
// 2. Esc schließt, der Fokus kehrt auf „Details“ zurück
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('interessent: Esc schließt das Panel', true)
|
||
check('interessent: nach Esc steht der Fokus wieder auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
|
||
|
||
// 3. „Schließen“ im Fuß und das Kreuz schließen ebenfalls und geben den Fokus zurück
|
||
await opener.click()
|
||
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
|
||
await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).last().click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('interessent: „Schließen“ im Fuß schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
|
||
await opener.click()
|
||
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
|
||
await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).first().click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('interessent: das Kreuz oben schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
|
||
|
||
// 4. Interessent ohne Score, ohne Anfragetext, ohne Verlauf
|
||
await rowOf(page, T_KOLL).getByRole('button', { name: `Details zu ${T_KOLL}`, exact: true }).click()
|
||
const dialog2 = page.getByRole('dialog', { name: 'Meisterbetrieb Weber', exact: true })
|
||
await dialog2.waitFor()
|
||
await dialog2.locator('.wk-prospect').waitFor()
|
||
const body2 = await textOf(dialog2.locator('.wk-prospect'))
|
||
check('interessent: ohne Score „Noch nicht bewertet“, ohne Anfragetext und Verlauf die Leerzeilen, keine Vorschau-Zeile', body2.includes('Noch nicht bewertet') && body2.includes('Kein Anfragetext gespeichert.') && body2.includes('Noch keine Nachrichten.') && !body2.includes('Vorschau'), body2)
|
||
const facts2 = await dialog2.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim()))
|
||
check('interessent: Fakten Kleinanzeigen / 66849 Landstuhl / Gefunden', same(facts2, ['Quelle', 'Kleinanzeigen', 'Gefunden', 'vor 8 Std.', 'Ort', '66849 Landstuhl', 'Status', 'Gefunden']), facts2)
|
||
await sleep(600)
|
||
await shoot(page, 'desktop-interessent-leer.png')
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
|
||
// 5. Dienst meldet einen Fehler: Satz im Panel, „Neu laden“ holt den Interessenten
|
||
await setPultFault(page, 'prospect', { status: 404, message: 'Interessent nicht gefunden.' })
|
||
await rowOf(page, T_PLAT).getByRole('button', { name: `Details zu ${T_PLAT}`, exact: true }).click()
|
||
const dialog3 = page.getByRole('dialog', { name: 'Interessent', exact: true })
|
||
await dialog3.getByRole('alert').waitFor()
|
||
check('interessent: Fehler vom Dienst: der Satz steht im Panel (role=alert) mit „Neu laden“', (await textOf(dialog3.getByRole('alert'))) === 'Interessent nicht gefunden.' && (await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).count()) === 1, await textOf(dialog3))
|
||
await sleep(600)
|
||
await shoot(page, 'desktop-interessent-fehler.png')
|
||
await setPultFault(page, 'prospect', null)
|
||
await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).click()
|
||
const dialog4 = page.getByRole('dialog', { name: 'Friseursalon Haarmonie', exact: true })
|
||
await dialog4.locator('.wk-prospect').waitFor()
|
||
check('interessent: „Neu laden“ holt den Interessenten (Score 72 · mittel)', (await textOf(dialog4.locator('.wk-prospect-score'))).startsWith('Score 72 · mittel'), await textOf(dialog4.locator('.wk-prospect-score')))
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario I: Alles anhalten mit Rückfrage (Desktop) ----------
|
||
|
||
async function anhalten(browser, base) {
|
||
const { context, page } = await newPage(browser, 'anhalten', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await ready(page, base + '/index.html')
|
||
await sleep(400)
|
||
await topOfPage(page)
|
||
const status = () => textOf(page.locator('.wk-status'))
|
||
const calls = () => page.evaluate(() => window.__wk.api.pauseCalls)
|
||
const pauseBtn = page.locator('.wk-toolbar').getByRole('button', { name: 'Alles anhalten', exact: true })
|
||
|
||
// 1. Rückfrage statt sofortigem Anhalten
|
||
await pauseBtn.evaluate((el) => el.setAttribute('data-probe', 'pause-button'))
|
||
await pauseBtn.click()
|
||
const dialog = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true })
|
||
await dialog.waitFor()
|
||
check('anhalten: „Alles anhalten“ öffnet den Dialog „Alles anhalten?“ mit dem Satz zu den Workern', (await textOf(dialog.locator('.dialog-body'))) === HALT_QUESTION, await textOf(dialog.locator('.dialog-body')))
|
||
check('anhalten: Dialog mit den Knöpfen „Abbrechen“ und „Anhalten“', same(await dialog.locator('.dialog-foot button').allTextContents(), ['Abbrechen', 'Anhalten']))
|
||
check('anhalten: vor der Bestätigung ist nichts angehalten (kein Aufruf, Status „Läuft“)', same(await calls(), []) && (await status()) === 'Läuft', { calls: await calls(), status: await status() })
|
||
await sleep(600)
|
||
await shoot(page, 'desktop-anhalten-frage.png')
|
||
|
||
// 2. Abbrechen tut nichts, der Fokus kehrt auf den Knopf zurück
|
||
await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('anhalten: „Abbrechen“ schließt den Dialog, nichts wird angehalten', same(await calls(), []) && (await status()) === 'Läuft' && (await pauseBtn.textContent()) === 'Alles anhalten')
|
||
check('anhalten: nach „Abbrechen“ steht der Fokus wieder auf „Alles anhalten“', (await activeProbe(page)) === 'pause-button', await activeTag(page))
|
||
|
||
// 3. Esc verhält sich wie Abbrechen
|
||
await pauseBtn.click()
|
||
await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).waitFor()
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('anhalten: Esc schließt den Dialog, nichts wird angehalten, Fokus zurück auf „Alles anhalten“', same(await calls(), []) && (await activeProbe(page)) === 'pause-button', { calls: await calls(), focus: await activeTag(page) })
|
||
|
||
// 4. Anhalten hält an
|
||
await pauseBtn.click()
|
||
const dialog2 = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true })
|
||
await dialog2.waitFor()
|
||
await dialog2.getByRole('button', { name: 'Anhalten', exact: true }).click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor()
|
||
await page.waitForFunction(() => document.querySelector('.wk-status') && document.querySelector('.wk-status').textContent.startsWith('Angehalten'))
|
||
check('anhalten: „Anhalten“ pausiert genau einmal (Aufruf pause(true)), Status „Angehalten: …“, Knopf „Fortsetzen“', same(await calls(), [true]) && (await status()) === 'Angehalten: Es wird nichts gesendet und nichts gebaut.', { calls: await calls(), status: await status() })
|
||
await sleep(500)
|
||
const focusTag = await activeTag(page)
|
||
const focusText = await page.evaluate(() => (document.activeElement ? (document.activeElement.textContent || '').trim().slice(0, 40) : null))
|
||
check('anhalten: nach „Anhalten“ steht der Fokus nicht auf <body> (Tastatur-Nutzer verlieren sonst ihre Stelle)', focusTag !== null && focusTag !== 'BODY', { tag: focusTag, text: focusText })
|
||
await topOfPage(page)
|
||
await shoot(page, 'desktop-anhalten-angehalten.png')
|
||
|
||
// 5. Fortsetzen geht ohne Rückfrage
|
||
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click()
|
||
await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor()
|
||
check('anhalten: „Fortsetzen“ geht ohne Rückfrage (kein Dialog, pause(false), Status „Läuft“)', (await page.getByRole('dialog').count()) === 0 && same(await calls(), [true, false]) && (await status()) === 'Läuft', { calls: await calls(), status: await status() })
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario J: Handy 390 x 844 für Freigabe und Panel ----------
|
||
|
||
async function handyFreigabe(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'handy-freigabe', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
|
||
await openPult(page, base)
|
||
await sleep(300)
|
||
const width = () => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth }))
|
||
const w0 = await width()
|
||
metrics.phoneFreigabe = w0
|
||
check('handy-freigabe: Freigabe ohne seitliches Scrollen (scrollingElement.scrollWidth <= innerWidth)', w0.scroll <= w0.inner, w0)
|
||
const bar = await page.evaluate(() => {
|
||
const out = {}
|
||
for (const [name, sel] of [['tabs', '.tabs'], ['filter', '.wk-pult-filters .seg']]) {
|
||
const r = document.querySelector(sel).getBoundingClientRect()
|
||
out[name] = { left: Math.round(r.left), right: Math.round(r.right) }
|
||
}
|
||
return out
|
||
})
|
||
check('handy-freigabe: Reiterleiste und Filter liegen im Fenster', bar.tabs.left >= 0 && bar.tabs.right <= 390 && bar.filter.left >= 0 && bar.filter.right <= 390, bar)
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-freigabe.png')
|
||
await shoot(page, 'handy-freigabe-ganz.png', { fullPage: true })
|
||
|
||
// Jede Schaltfläche und jeder Link der Aktionszeilen: im Fenster, nicht verdeckt, mindestens 40 px hoch (Tippfläche), volle Breite untereinander
|
||
const reach = await page.evaluate(() => {
|
||
const out = []
|
||
for (const el of document.querySelectorAll('.wk-pult-actions > *')) {
|
||
el.scrollIntoView({ block: 'center' })
|
||
const r = el.getBoundingClientRect()
|
||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||
out.push({ text: el.textContent.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
|
||
})
|
||
const bad = reach.filter((r) => r.left < 0 || r.right > 390 || r.covered || r.height < 40)
|
||
check('handy-freigabe: alle Aktionsknöpfe und Links sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach.length >= 10 && bad.length === 0, { count: reach.length, bad })
|
||
const w1 = await width()
|
||
check('handy-freigabe: nach dem Durchblättern der Aktionen weiterhin kein seitliches Scrollen', w1.scroll <= w1.inner, w1)
|
||
|
||
// Die lange Adresse steckt in der Originalanfrage: aufgeklappt darf sie die Seite nicht verbreitern
|
||
await rowOf(page, T_MAIL).locator('summary', { hasText: 'Originalanfrage' }).click()
|
||
await sleep(150)
|
||
const w1b = await width()
|
||
check('handy-freigabe: aufgeklappte „Originalanfrage“ mit langer Adresse verbreitert die Seite nicht', w1b.scroll <= w1b.inner, w1b)
|
||
await rowOf(page, T_MAIL).scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-freigabe-originalanfrage.png')
|
||
|
||
// Panel am Handy: Bottom-Sheet im Fenster, lange Wörter brechen um, Schließen erreichbar
|
||
const opener = rowOf(page, T_MAIL).getByRole('button', { name: `Details zu ${T_MAIL}`, exact: true })
|
||
await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
|
||
await opener.scrollIntoViewIfNeeded()
|
||
await opener.click()
|
||
const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true })
|
||
await dialog.waitFor()
|
||
await dialog.locator('.wk-prospect').waitFor()
|
||
await sleep(700)
|
||
const box = await dialog.evaluate((el) => {
|
||
const r = el.getBoundingClientRect()
|
||
const body = el.querySelector('.dialog-body')
|
||
return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, bodyOverflowX: body.scrollWidth - body.clientWidth, bodyScrolls: body.scrollHeight > body.clientHeight }
|
||
})
|
||
metrics.phonePanel = box
|
||
check('handy-freigabe: Panel liegt im Fenster (links >= 0, rechts <= 390, unten <= Fensterhöhe)', box.left >= 0 && box.right <= 390 && box.top >= 0 && box.bottom <= box.inner, box)
|
||
check('handy-freigabe: Panel ohne waagerechten Überlauf (lange Adressen brechen um)', box.bodyOverflowX <= 0, box)
|
||
const w2 = await width()
|
||
check('handy-freigabe: mit offenem Panel kein seitliches Scrollen', w2.scroll <= w2.inner, w2)
|
||
await shoot(page, 'handy-interessent-panel.png')
|
||
// ganz nach unten im Panel: Nachrichtenverlauf und Schließen-Knopf
|
||
await dialog.locator('.dialog-body').evaluate((el) => { el.scrollTop = el.scrollHeight })
|
||
await sleep(200)
|
||
await shoot(page, 'handy-interessent-panel-unten.png')
|
||
const closeBtn = dialog.getByRole('button', { name: 'Schließen', exact: true }).last()
|
||
const closeReach = await closeBtn.evaluate((el) => {
|
||
const r = el.getBoundingClientRect()
|
||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||
return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, covered: !(hit && (hit === el || el.contains(hit))) }
|
||
})
|
||
check('handy-freigabe: „Schließen“ im Panel ist erreichbar (im Fenster, nicht verdeckt)', closeReach.left >= 0 && closeReach.right <= 390 && closeReach.top >= 0 && closeReach.bottom <= closeReach.inner && !closeReach.covered, closeReach)
|
||
const lastMsg = await dialog.locator('li.wk-prospect-msg').last().evaluate((el) => {
|
||
const r = el.getBoundingClientRect()
|
||
const foot = el.closest('.dialog').querySelector('.dialog-foot').getBoundingClientRect()
|
||
return { bottom: Math.round(r.bottom), footTop: Math.round(foot.top) }
|
||
})
|
||
check('handy-freigabe: nach dem Blättern liegt die letzte Nachricht über dem Fuß (nichts abgeschnitten)', lastMsg.bottom <= lastMsg.footTop + 1, lastMsg)
|
||
await closeBtn.click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('handy-freigabe: „Schließen“ schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
|
||
await context.close()
|
||
}
|
||
|
||
|
||
// ---------- P4: Gespräche und Werkstatt (Pult: als Mensch antworten, Vorschau freigeben und bauen, Antwort des Kunden einfügen) ----------
|
||
// Eigener Datensatz (?p4=1, scripted-api.js p4Fixtures). Wartet: Mail-Antwort, zwei Vorschau-Freigaben (eine mit gültiger, eine mit falscher
|
||
// Vorschau-Adresse), „unsicher“ und ein Eintrag, den ein Kollege hält (fünf Zeilen). Erledigt: zwei gesendete Plattform-Antworten desselben
|
||
// Interessenten (Standard-Platz und Platz „angebot“). Die Schalter der Skript-API (faults.reply, .approve-preview, .start-build, .paste und
|
||
// pultDelayMs) steuern 400/409/413 und die Wartezeit; pultCalls hält fest, was die Oberfläche geschickt hat.
|
||
|
||
const P4_URL = '/index.html?page=1&fault=none&p4=1'
|
||
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 PREVIEW_URL = 'https://entwurf.project.webklar.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/'
|
||
const GONE = 'Dieser Kontakt ist nicht mehr aktiv.'
|
||
const TOO_BIG = 'Anfrage zu groß.'
|
||
const NEED_TEXT = 'Bitte einen Text eingeben.'
|
||
const TOO_LONG_8000 = 'Der Text ist zu lang (höchstens 8000 Zeichen).'
|
||
const PASTE_HINT = 'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.'
|
||
const PASTE_OK = 'Eingefügt. Nora sortiert die Antwort ein.'
|
||
const PASTE_TWICE = 'Diese Antwort war schon eingefügt.'
|
||
const NO_PREVIEW_URL = 'Keine gültige Vorschau-Adresse hinterlegt.'
|
||
|
||
const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
// Zeile mit genau diesem Titel (rowOf sucht nur einen Teil des Titels: „… (Angebot)“ träfe sonst auch die Zeile davor)
|
||
const row4 = (page, title) => page.locator('li.wk-pult-row').filter({ has: page.locator('.wk-pult-title', { hasText: new RegExp('^' + escapeRe(title) + '$') }) })
|
||
const actionNames = (row) => row.locator('.wk-pult-actions').locator('button, a').allTextContents().then((list) => list.map((t) => t.replace(/\s+/g, ' ').trim()))
|
||
const callsOf = async (page, action, id) => (await pultCalls(page)).filter((c) => c.action === action && (id === undefined || c.id === id))
|
||
const setDelay = (page, ms) => page.evaluate((v) => { window.__wk.api.pultDelayMs = v }, ms)
|
||
const noteOf = (row) => row.locator('.msg')
|
||
const focusInfo = (page) => page.evaluate(() => {
|
||
const el = document.activeElement
|
||
if (!el) return { tag: null }
|
||
return {
|
||
tag: el.tagName,
|
||
label: el.getAttribute('aria-label'),
|
||
probe: el.getAttribute('data-probe'),
|
||
text: (el.textContent || '').trim().slice(0, 30),
|
||
inReply: Boolean(el.closest('.wk-pult-reply')),
|
||
inPaste: Boolean(el.closest('.wk-pult-paste')),
|
||
inDialog: Boolean(el.closest('[role="dialog"]')),
|
||
}
|
||
})
|
||
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)
|
||
}
|
||
const widthOf = (page) => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth }))
|
||
// Schaltflächen und Links: im Fenster, nicht verdeckt, Höhe in px (Tippfläche)
|
||
const reachOf = (page, selector) => page.evaluate((sel) => {
|
||
const out = []
|
||
for (const el of document.querySelectorAll(sel)) {
|
||
el.scrollIntoView({ block: 'center' })
|
||
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)
|
||
const badReach = (list) => list.filter((r) => r.left < 0 || r.right > 390 || r.covered || r.height < 40)
|
||
|
||
// Reiter „Freigabe“ mit dem P4-Datensatz: fünf wartende Zeilen und die Mitarbeiternamen sind da
|
||
async function openP4(page, base) {
|
||
await page.goto(base + P4_URL + '&tab=freigabe')
|
||
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 5)
|
||
await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin'))
|
||
await page.evaluate(() => document.fonts.ready)
|
||
}
|
||
const chooseFilter4 = (page, label) => page.locator('.wk-pult-filters .seg button', { hasText: label }).click()
|
||
async function openP4Done(page, base) {
|
||
await openP4(page, base)
|
||
await chooseFilter4(page, 'Erledigt')
|
||
await waitTitles(page, [T4.ang, T4.plat])
|
||
}
|
||
|
||
// ---------- Szenario K: „Als Mensch antworten“ (Desktop 1280 x 900) ----------
|
||
|
||
async function antworten(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'antworten', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openP4(page, base)
|
||
const mail = row4(page, T4.mail)
|
||
const uns = row4(page, T4.uns)
|
||
const koll = row4(page, T4.koll)
|
||
const draft = (await page.evaluate(() => window.__wk.api.pult('offen,in_arbeit'))).items.find((i) => i.$id === 'p4-mail').draft
|
||
await topOfPage(page)
|
||
await shoot(page, 'antworten-wartet.png')
|
||
|
||
check('antworten: wartende Zeilen neueste zuerst (Mail, Freigabe, Freigabe mit falscher Adresse, unsicher, Kollege)', same(await rowTitles(page), [T4.mail, T4.frei, T4.falsch, T4.uns, T4.koll]), await rowTitles(page))
|
||
check('antworten: Mail-Zeile: E-Mail schreiben, Ich kümmere mich, Als gesendet markieren, Als Mensch antworten, Verwerfen', same(await actionNames(mail), ['E-Mail schreiben', 'Ich kümmere mich', 'Als gesendet markieren', 'Als Mensch antworten', 'Verwerfen']), await actionNames(mail))
|
||
check('antworten: Kollegen-Zeile (von Justin übernommen) bietet kein „Als Mensch antworten“', (await koll.getByRole('button', { name: 'Als Mensch antworten' }).count()) === 0, await actionNames(koll))
|
||
|
||
// 1. Aufklappen: vorbelegt mit dem Entwurf, Zähler, eigener Name des Felds
|
||
const opener = mail.getByRole('button', { name: 'Als Mensch antworten', exact: true })
|
||
check('antworten: vor dem Öffnen kein Antwortfeld, Knopf mit aria-expanded=false', (await mail.locator('.wk-pult-reply').count()) === 0 && (await opener.getAttribute('aria-expanded')) === 'false')
|
||
await opener.click()
|
||
const panel = mail.locator('.wk-pult-reply')
|
||
await panel.waitFor()
|
||
const area = panel.locator('textarea')
|
||
const count = () => textOf(panel.locator('.wk-pult-count'))
|
||
const send = panel.getByRole('button', { name: 'Jetzt senden', exact: true })
|
||
check('antworten: „Als Mensch antworten“ klappt das Feld auf (aria-expanded=true), Textfeld heißt „Text der Antwort: …“', (await opener.getAttribute('aria-expanded')) === 'true' && (await area.getAttribute('aria-label')) === `Text der Antwort: ${T4.mail}`, await area.getAttribute('aria-label'))
|
||
check('antworten: das Feld ist mit dem Entwurf vorbelegt', (await area.inputValue()) === draft)
|
||
check(`antworten: Zähler zeigt die Zeichen des Entwurfs („${Array.from(draft.trim()).length} / 8000“)`, (await count()) === `${Array.from(draft.trim()).length} / 8000`, await count())
|
||
check('antworten: Knopf „Jetzt senden“ im Fuß des Felds', (await send.count()) === 1)
|
||
await shoot(page, 'antworten-offen-vorbelegt.png')
|
||
|
||
// 2. Tippen: der Zähler zählt Zeichen (Code Points), der Entwurf in der Zeile bleibt unberührt
|
||
await area.fill('Hallo 😀')
|
||
check('antworten: Zähler zählt mit (ein Emoji ist ein Zeichen: „7 / 8000“)', (await count()) === '7 / 8000', await count())
|
||
check('antworten: das Antwortfeld ändert den Entwurf der Zeile nicht (getrennte Felder)', (await mail.getByLabel(`Entwurf der Antwort: ${T4.mail}`, { exact: true }).inputValue()) === draft)
|
||
|
||
// 3. Leerer Text: Fehler in der Oberfläche, kein Aufruf am Dienst
|
||
await area.fill('')
|
||
await send.click()
|
||
const err = mail.locator('.msg.err')
|
||
await err.waitFor()
|
||
check('antworten: leerer Text ⇒ „Bitte einen Text eingeben.“ (rot, role=alert), kein Aufruf am Dienst', (await textOf(err)) === NEED_TEXT && (await err.getAttribute('role')) === 'alert' && (await callsOf(page, 'reply')).length === 0, { text: await textOf(err), calls: (await callsOf(page, 'reply')).length })
|
||
await shoot(page, 'antworten-leer-fehler.png')
|
||
await area.fill(' \n \t ')
|
||
await send.click()
|
||
check('antworten: nur Leerraum ⇒ derselbe Satz, weiterhin kein Aufruf', (await textOf(err)) === NEED_TEXT && (await callsOf(page, 'reply')).length === 0)
|
||
|
||
// 4. Zu lang: 8001 Zeichen (Emoji zählen als eins) ⇒ Fehler, Zähler rot; genau 8000 Emoji sind erlaubt
|
||
await area.fill('😀'.repeat(8001))
|
||
check('antworten: 8001 Zeichen: Zähler „8001 / 8000“ ist rot (Klasse over)', (await count()) === '8001 / 8000' && (await panel.locator('.wk-pult-count').getAttribute('class')).includes('over'), await count())
|
||
await send.click()
|
||
await page.waitForFunction((t) => document.querySelector('li.wk-pult-row .msg.err') && document.querySelector('li.wk-pult-row .msg.err').textContent === t, TOO_LONG_8000)
|
||
check('antworten: 8001 Zeichen ⇒ „Der Text ist zu lang (höchstens 8000 Zeichen).“, kein Aufruf am Dienst', (await callsOf(page, 'reply')).length === 0)
|
||
await shoot(page, 'antworten-zu-lang.png')
|
||
await area.fill('😀'.repeat(8000))
|
||
check('antworten: genau 8000 Emoji (16000 UTF-16-Einheiten): Zähler „8000 / 8000“ ohne Rot', (await count()) === '8000 / 8000' && !(await panel.locator('.wk-pult-count').getAttribute('class')).includes('over'), await count())
|
||
|
||
// 5. 409 vom Dienst: der Satz steht in der Zeile, der Text bleibt, die Zeile bleibt, der Knopf ist wieder frei
|
||
const answer = ' Guten Tag Herr Beispiel,\n\nanbei die Vorschau, wie besprochen.\n\nBeste Grüße\nKenso \n'
|
||
await area.fill(answer)
|
||
await setPultFault(page, 'reply', { status: 409, message: CONFLICT })
|
||
await send.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), CONFLICT)
|
||
check('antworten: 409 ⇒ der Satz des Dienstes steht in der Zeile (rot, role=alert)', (await textOf(noteOf(mail))) === CONFLICT && (await noteOf(mail).getAttribute('role')) === 'alert', await textOf(noteOf(mail)))
|
||
check('antworten: nach dem 409 bleiben Zeile, Text und Feld erhalten, „Jetzt senden“ ist wieder frei', (await mail.count()) === 1 && (await area.inputValue()) === answer && (await send.isEnabled()) && (await mail.getByRole('button', { name: 'Verwerfen' }).isEnabled()))
|
||
await shoot(page, 'antworten-409.png')
|
||
await setPultFault(page, 'reply', { status: 409, message: GONE })
|
||
await send.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE)
|
||
check('antworten: zweiter 409 ersetzt die Meldung („Dieser Kontakt ist nicht mehr aktiv.“), nicht angehäuft', (await noteOf(mail).count()) === 1 && (await textOf(noteOf(mail))) === GONE)
|
||
await setPultFault(page, 'reply', { status: 413, message: TOO_BIG })
|
||
await send.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), TOO_BIG)
|
||
check('antworten: 413 ⇒ „Anfrage zu groß.“ in der Zeile, Text bleibt', (await textOf(noteOf(mail))) === TOO_BIG && (await area.inputValue()) === answer)
|
||
|
||
// Verbindung bricht beim Senden ab: ob die Mail hinausging, ist offen (D14), die Meldung sagt das und schickt nicht zum zweiten Mal
|
||
await setPultFault(page, 'reply', { status: 0, message: 'Verbindung weg' })
|
||
await send.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Kann gesendet sein – Pult neu laden.')
|
||
check('antworten: Verbindungsabbruch beim Senden ⇒ „Kann gesendet sein – Pult neu laden.“ (nicht „nicht gesendet“), Text bleibt', (await textOf(noteOf(mail))) === 'Kann gesendet sein – Pult neu laden.' && (await area.inputValue()) === answer, await textOf(noteOf(mail)))
|
||
|
||
// 6. Senden: während die Anfrage läuft sind alle Knöpfe der Zeile gesperrt, ein zweiter Klick schickt nichts, danach Erfolgsmeldung oben
|
||
await setPultFault(page, 'reply', null)
|
||
await setDelay(page, 1500)
|
||
const before = (await callsOf(page, 'reply')).length
|
||
await send.click()
|
||
const lockedNames = ['Ich kümmere mich', 'Als gesendet markieren', 'Als Mensch antworten', 'Verwerfen']
|
||
const locked = []
|
||
for (const name of lockedNames) locked.push(await mail.getByRole('button', { name, exact: true }).isDisabled())
|
||
check('antworten: während die Anfrage läuft sind „Jetzt senden“ und alle Knöpfe der Zeile gesperrt', (await send.isDisabled()) && locked.every(Boolean), locked)
|
||
await send.evaluate((el) => el.click()) // ein zweiter Klick auf den gesperrten Knopf darf nichts auslösen
|
||
await shoot(page, 'antworten-pending.png')
|
||
await finishedLine(page).first().waitFor()
|
||
const done = await textOf(finishedLine(page).first())
|
||
check('antworten: Erfolg ⇒ Meldung oben „…“ gesendet. (role=status), die Zeile ist weg', done === `„${T4.mail}“ gesendet.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && (await mail.count()) === 0, done)
|
||
const replyCalls = await callsOf(page, 'reply')
|
||
const last = replyCalls[replyCalls.length - 1]
|
||
check('antworten: Doppelklick während der Anfrage ergibt genau einen Aufruf; gesendet wird der getrimmte Text als { text }', replyCalls.length === before + 1 && last.id === 'p4-mail' && same(last.body, { text: answer.trim() }), { calls: replyCalls.length, before, last })
|
||
check('antworten: die Antworten der Fehlerfälle gingen alle mit demselben getrimmten Text hinaus', replyCalls.slice(0, before).every((c) => c.body && c.body.text === answer.trim()), replyCalls.map((c) => c.body && c.body.text && c.body.text.length))
|
||
await shoot(page, 'antworten-gesendet.png')
|
||
await setDelay(page, 0)
|
||
|
||
// 7. Eintrag „unsicher“: Antwortfeld ohne Vorbelegung, Text überlebt Zuklappen und Filterwechsel
|
||
check('antworten: „unsicher“-Zeile: Ich kümmere mich, Als Mensch antworten, Vorschau bauen, Erledigt, Verwerfen (kein Mail-Link, kein Entwurf)', same(await actionNames(uns), ['Ich kümmere mich', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await actionNames(uns))
|
||
const unsOpener = uns.getByRole('button', { name: 'Als Mensch antworten', exact: true })
|
||
await unsOpener.click()
|
||
const unsPanel = uns.locator('.wk-pult-reply')
|
||
await unsPanel.waitFor()
|
||
const unsArea = unsPanel.locator('textarea')
|
||
check('antworten: „unsicher“: Antwortfeld ist leer (kein Entwurf), Zähler „0 / 8000“', (await unsArea.inputValue()) === '' && (await textOf(unsPanel.locator('.wk-pult-count'))) === '0 / 8000')
|
||
await unsArea.fill('Zwischenstand der Antwort')
|
||
await unsOpener.click()
|
||
check('antworten: zweiter Klick auf „Als Mensch antworten“ klappt das Feld zu (aria-expanded=false)', (await uns.locator('.wk-pult-reply').count()) === 0 && (await unsOpener.getAttribute('aria-expanded')) === 'false')
|
||
await unsOpener.click()
|
||
check('antworten: wieder aufgeklappt steht der Zwischenstand noch im Feld', (await uns.locator('.wk-pult-reply textarea').inputValue()) === 'Zwischenstand der Antwort')
|
||
await chooseFilter4(page, 'Veraltet')
|
||
await page.locator('.empty').waitFor()
|
||
await chooseFilter4(page, 'Wartet')
|
||
await waitTitles(page, [T4.frei, T4.falsch, T4.uns, T4.koll])
|
||
await unsOpener.click()
|
||
check('antworten: der Text überlebt einen Wechsel des Filters', (await uns.locator('.wk-pult-reply textarea').inputValue()) === 'Zwischenstand der Antwort')
|
||
await shoot(page, 'antworten-unsicher.png')
|
||
await setPultFault(page, 'reply', { status: 400, message: NEED_TEXT })
|
||
await uns.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click()
|
||
await uns.locator('.msg.err').waitFor()
|
||
check('antworten: 400 vom Dienst („Bitte einen Text eingeben.“) erscheint in der Zeile, die Zeile bleibt', (await textOf(uns.locator('.msg.err'))) === NEED_TEXT && (await uns.count()) === 1)
|
||
await setPultFault(page, 'reply', null)
|
||
check('antworten: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page))
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario L: Vorschau freigeben (Desktop) ----------
|
||
|
||
async function vorschau(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'vorschau', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openP4(page, base)
|
||
const frei = row4(page, T4.frei)
|
||
const falsch = row4(page, T4.falsch)
|
||
await topOfPage(page)
|
||
await frei.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'vorschau-wartet.png')
|
||
|
||
// 1. Zeile und Link
|
||
check('vorschau: Art-Badge „Vorschau freigeben“, Knöpfe Ich kümmere mich, Vorschau ansehen, Freigeben & senden, Verwerfen (kein Erledigt, kein Als Mensch antworten)',
|
||
(await textOf(frei.locator('.wk-pult-head .badge').first())) === 'Vorschau freigeben' && same(await actionNames(frei), ['Ich kümmere mich', 'Vorschau ansehen', 'Freigeben & senden', 'Verwerfen']), await actionNames(frei))
|
||
const link = frei.getByRole('link', { name: `Vorschau ansehen: ${T4.frei}`, exact: true })
|
||
check('vorschau: „Vorschau ansehen“ hat genau die https-Adresse der Vorschau', (await link.getAttribute('href')) === PREVIEW_URL, await link.getAttribute('href'))
|
||
check('vorschau: „Vorschau ansehen“ öffnet im neuen Tab (target=_blank, rel=noopener noreferrer)', (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer')
|
||
check('vorschau: das sichtbare Wort „Vorschau ansehen“ steht im Namen vorn', (await textOf(link)) === 'Vorschau ansehen' && (await link.getAttribute('aria-label')).startsWith('Vorschau ansehen'))
|
||
check('vorschau: kein Link „Anzeige öffnen“ in der Zeile (die Adresse ist die Vorschau, keine Anzeige)', (await frei.getByRole('link', { name: /Anzeige öffnen/ }).count()) === 0)
|
||
check('vorschau: Hinweis „Verwerfen“ sendet die Vorschau nicht.', (await textOf(frei.locator('.wk-pult-actions'))).includes('„Verwerfen“ sendet die Vorschau nicht.'))
|
||
|
||
// 2. Vorschau-Adresse mit http statt https: kein Link, Hinweis
|
||
const fakeLinks = await falsch.locator('a').evaluateAll((els) => els.map((el) => el.getAttribute('href')))
|
||
check('vorschau: Vorschau mit http-Adresse wird kein Link (Hinweis „Keine gültige Vorschau-Adresse hinterlegt.“)', !fakeLinks.some((href) => /entwurf\.project\.webklar\.com/.test(href || '')) && (await textOf(falsch.locator('.wk-pult-actions'))).includes(NO_PREVIEW_URL), fakeLinks)
|
||
const falschApprove = falsch.getByRole('button', { name: 'Freigeben & senden', exact: true })
|
||
metrics.freigabeOhneVorschauLink = { knopfDa: (await falschApprove.count()) === 1, knopfFrei: (await falschApprove.count()) === 1 && (await falschApprove.isEnabled()) }
|
||
// Endprüfung W5: eine Vorschau ohne gültige Adresse lässt sich nicht freigeben (der Mensch könnte sie nicht ansehen); der Hinweis nennt den Ausweg, Verwerfen bleibt frei
|
||
check('vorschau: ohne gültige Vorschau-Adresse ist „Freigeben & senden“ gesperrt, der Hinweis nennt den Grund, „Verwerfen“ bleibt frei',
|
||
metrics.freigabeOhneVorschauLink.knopfDa && !metrics.freigabeOhneVorschauLink.knopfFrei && (await textOf(falsch.locator('.wk-pult-actions'))).includes('Freigabe nicht möglich')
|
||
&& (await falsch.getByRole('button', { name: 'Verwerfen', exact: true }).isEnabled()), metrics.freigabeOhneVorschauLink)
|
||
await falsch.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'vorschau-falsche-adresse.png')
|
||
|
||
// 3. Erster Klick fragt, nach 4 s verfällt die Frage
|
||
const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ })
|
||
await frei.scrollIntoViewIfNeeded()
|
||
await approveBtn.click()
|
||
check('vorschau: erster Klick fragt „Wirklich freigeben?“, noch keine Aktion am Dienst', (await textOf(approveBtn)) === 'Wirklich freigeben?' && (await callsOf(page, 'approve-preview')).length === 0, await textOf(approveBtn))
|
||
await shoot(page, 'vorschau-frage.png')
|
||
await sleep(4400)
|
||
check('vorschau: nach 4 s verfällt die Rückfrage (wieder „Freigeben & senden“), noch immer keine Aktion', (await textOf(approveBtn)) === 'Freigeben & senden' && (await callsOf(page, 'approve-preview')).length === 0, await textOf(approveBtn))
|
||
|
||
// 4. Fehler vom Dienst (409): Satz in der Zeile, Zeile bleibt, Frage ist wieder zurückgesetzt
|
||
await setPultFault(page, 'approve-preview', { status: 409, message: GONE })
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
await frei.locator('.msg.err').waitFor()
|
||
check('vorschau: 409 ⇒ „Dieser Kontakt ist nicht mehr aktiv.“ in der Zeile (rot, role=alert), Zeile bleibt', (await textOf(frei.locator('.msg.err'))) === GONE && (await frei.locator('.msg.err').getAttribute('role')) === 'alert' && (await frei.count()) === 1, await textOf(frei.locator('.msg.err')))
|
||
check('vorschau: nach dem Fehler fragt der Knopf wieder von vorn („Freigeben & senden“, frei)', (await textOf(approveBtn)) === 'Freigeben & senden' && (await approveBtn.isEnabled()))
|
||
await shoot(page, 'vorschau-409.png')
|
||
await setPultFault(page, 'approve-preview', { status: 409, message: CONFLICT })
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), CONFLICT)
|
||
check('vorschau: zweiter 409 („Wird schon von einem Kollegen bearbeitet.“) ersetzt die Meldung', (await frei.locator('.msg').count()) === 1 && (await textOf(frei.locator('.msg'))) === CONFLICT)
|
||
|
||
await setPultFault(page, 'approve-preview', { status: 0, message: 'Verbindung weg' })
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Kann gesendet sein – Pult neu laden.')
|
||
check('vorschau: Verbindungsabbruch bei der Freigabe ⇒ „Kann gesendet sein – Pult neu laden.“, die Zeile bleibt', (await textOf(frei.locator('.msg'))) === 'Kann gesendet sein – Pult neu laden.' && (await frei.count()) === 1)
|
||
|
||
// 5. Erfolg: zweiter Klick binnen 4 s sendet, währenddessen sind die Knöpfe gesperrt
|
||
await setPultFault(page, 'approve-preview', null)
|
||
await setDelay(page, 1500)
|
||
const before = (await callsOf(page, 'approve-preview')).length
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
const lockedNames = ['Ich kümmere mich', 'Freigeben & senden', 'Verwerfen']
|
||
const locked = []
|
||
for (const name of lockedNames) locked.push(await frei.getByRole('button', { name, exact: true }).isDisabled())
|
||
check('vorschau: während die Freigabe läuft sind „Freigeben & senden“, „Ich kümmere mich“ und „Verwerfen“ gesperrt', locked.every(Boolean), locked)
|
||
await approveBtn.evaluate((el) => el.click()) // weiterer Klick auf den gesperrten Knopf
|
||
await shoot(page, 'vorschau-pending.png')
|
||
await finishedLine(page).first().waitFor()
|
||
const done = await textOf(finishedLine(page).first())
|
||
check('vorschau: Erfolg ⇒ Meldung oben „…“ freigegeben. Max sendet den Link im nächsten Sendefenster. (role=status), Zeile weg', done === `„${T4.frei}“ freigegeben. Max sendet den Link im nächsten Sendefenster.` && (await frei.count()) === 0 && (await finishedLine(page).first().getAttribute('role')) === 'status', done)
|
||
const calls = await callsOf(page, 'approve-preview', 'p4-freigabe')
|
||
check('vorschau: genau ein erfolgreicher Aufruf ohne Body (drei Fehlversuche davor, der Doppelklick im Warten zählt nicht)', calls.length === before + 1 && calls.every((c) => c.body === undefined), calls)
|
||
await shoot(page, 'vorschau-freigegeben.png')
|
||
await setDelay(page, 0)
|
||
check('vorschau: die andere Freigabe (falsche Adresse) und die übrigen Zeilen bleiben unberührt', same(await rowTitles(page), [T4.mail, T4.falsch, T4.uns, T4.koll]), await rowTitles(page))
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario M: Antwort des Kunden einfügen (Desktop) ----------
|
||
|
||
async function einfuegen(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'einfuegen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openP4Done(page, base)
|
||
const plat = row4(page, T4.plat)
|
||
const ang = row4(page, T4.ang)
|
||
await topOfPage(page)
|
||
await shoot(page, 'einfuegen-erledigt.png')
|
||
|
||
// 1. Zwei Zeilen desselben Interessenten, beide gesendet, beide bieten das Einfügen
|
||
check('einfuegen: Erledigt zeigt zwei gesendete Plattform-Antworten desselben Interessenten (Standard-Platz und „angebot“)', same(await rowTitles(page), [T4.ang, T4.plat]), await rowTitles(page))
|
||
check('einfuegen: beide Zeilen sind „Gesendet von Kenso“ und bieten genau „Antwort des Kunden einfügen“', same(await actionNames(plat), ['Antwort des Kunden einfügen']) && same(await actionNames(ang), ['Antwort des Kunden einfügen']) && (await textOf(plat.locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso') && (await textOf(ang.locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso'), { plat: await actionNames(plat), ang: await actionNames(ang) })
|
||
const platOpen = plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true })
|
||
const angOpen = ang.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true })
|
||
|
||
// 2. Aufklappen: Hinweis, leeres Feld, Zähler, Knopf
|
||
check('einfuegen: vor dem Öffnen kein Feld, Knopf mit aria-expanded=false', (await plat.locator('.wk-pult-paste').count()) === 0 && (await platOpen.getAttribute('aria-expanded')) === 'false')
|
||
await platOpen.click()
|
||
const platPanel = plat.locator('.wk-pult-paste')
|
||
await platPanel.waitFor()
|
||
const platArea = platPanel.locator('textarea')
|
||
const platSend = platPanel.getByRole('button', { name: 'Einfügen', exact: true })
|
||
check('einfuegen: Hinweistext „Kopiere die Antwort aus der Plattform hierher. …“', (await textOf(platPanel.locator('.wk-hint'))) === PASTE_HINT, await textOf(platPanel.locator('.wk-hint')))
|
||
check('einfuegen: Textfeld ist leer, heißt „Antwort des Kunden: …“, Zähler „0 / 8000“, Knopf „Einfügen“', (await platArea.inputValue()) === '' && (await platArea.getAttribute('aria-label')) === `Antwort des Kunden: ${T4.plat}` && (await textOf(platPanel.locator('.wk-pult-count'))) === '0 / 8000' && (await platSend.count()) === 1 && (await platOpen.getAttribute('aria-expanded')) === 'true')
|
||
check('einfuegen: erste Zeile offen, zweite Zeile bleibt zu', (await ang.locator('.wk-pult-paste').count()) === 0)
|
||
|
||
// 3. Leerer Text: Fehler in der Oberfläche, kein Aufruf
|
||
await platSend.click()
|
||
await plat.locator('.msg.err').waitFor()
|
||
check('einfuegen: leerer Text ⇒ „Bitte einen Text eingeben.“ (rot, role=alert), kein Aufruf am Dienst', (await textOf(plat.locator('.msg.err'))) === NEED_TEXT && (await plat.locator('.msg.err').getAttribute('role')) === 'alert' && (await callsOf(page, 'paste')).length === 0, { text: await textOf(plat.locator('.msg.err')), calls: (await callsOf(page, 'paste')).length })
|
||
await shoot(page, 'einfuegen-leer-fehler.png')
|
||
|
||
// 4. Beide Zeilen unabhängig: eigene Felder, eigene Meldungen
|
||
await platArea.fill('Antwort Kunde A')
|
||
await angOpen.click()
|
||
const angPanel = ang.locator('.wk-pult-paste')
|
||
await angPanel.waitFor()
|
||
const angArea = angPanel.locator('textarea')
|
||
const angSend = angPanel.getByRole('button', { name: 'Einfügen', exact: true })
|
||
check('einfuegen: zweite Zeile öffnet ein eigenes, leeres Feld (kein Text der ersten)', (await angArea.inputValue()) === '' && (await platArea.inputValue()) === 'Antwort Kunde A')
|
||
await angArea.fill('Antwort Kunde B')
|
||
check('einfuegen: beide Felder gleichzeitig offen, jedes mit eigenem Text und eigenem Zähler', (await platArea.inputValue()) === 'Antwort Kunde A' && (await angArea.inputValue()) === 'Antwort Kunde B' && (await textOf(platPanel.locator('.wk-pult-count'))) === '15 / 8000' && (await textOf(angPanel.locator('.wk-pult-count'))) === '15 / 8000')
|
||
await shoot(page, 'einfuegen-zwei-offen.png')
|
||
check('einfuegen: die Fehlermeldung der ersten Zeile steht nur dort', (await ang.locator('.msg').count()) === 0)
|
||
|
||
// 5. Fehler vom Dienst: 413, 400 und 409 mit deutschem Satz; Text und Feld bleiben
|
||
await setPultFault(page, 'paste', { status: 413, message: TOO_BIG })
|
||
await platSend.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), TOO_BIG)
|
||
check('einfuegen: 413 ⇒ „Anfrage zu groß.“ (rot, role=alert) in der ersten Zeile; Feld und Text bleiben, Knopf frei', (await textOf(plat.locator('.msg.err'))) === TOO_BIG && (await platArea.inputValue()) === 'Antwort Kunde A' && (await platSend.isEnabled()), await textOf(plat.locator('.msg.err')))
|
||
await shoot(page, 'einfuegen-413.png')
|
||
await setPultFault(page, 'paste', { status: 400, message: NEED_TEXT })
|
||
await platSend.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), NEED_TEXT)
|
||
check('einfuegen: 400 vom Dienst ⇒ „Bitte einen Text eingeben.“, Meldung ersetzt die vorige', (await plat.locator('.msg').count()) === 1 && (await textOf(plat.locator('.msg'))) === NEED_TEXT)
|
||
await shoot(page, 'einfuegen-400.png')
|
||
await setPultFault(page, 'paste', { status: 409, message: GONE })
|
||
await platSend.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE)
|
||
check('einfuegen: 409 ⇒ deutscher Satz des Dienstes in der Zeile', (await textOf(plat.locator('.msg'))) === GONE)
|
||
check('einfuegen: die zweite Zeile zeigt von all dem nichts (keine Meldung, Text bleibt)', (await ang.locator('.msg').count()) === 0 && (await angArea.inputValue()) === 'Antwort Kunde B')
|
||
await setPultFault(page, 'paste', { status: 0, message: 'Verbindung weg' })
|
||
await platSend.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Der Hustler-Dienst ist gerade nicht erreichbar.')
|
||
check('einfuegen: Verbindungsabbruch ⇒ „Der Hustler-Dienst ist gerade nicht erreichbar.“, Text bleibt im Feld', (await textOf(plat.locator('.msg'))) === 'Der Hustler-Dienst ist gerade nicht erreichbar.' && (await platArea.inputValue()) === 'Antwort Kunde A')
|
||
await setPultFault(page, 'paste', null)
|
||
|
||
// 6. Erfolg der ersten Zeile: gesperrt beim Warten, Meldung in der Zeile, Feld leer und zu, Zeile bleibt, zweite Zeile unberührt.
|
||
// Die Wartezeit liegt unter der Zeitgrenze des Prüfstands (1,5 s, entry.jsx): „paste“ hat anders als reply, approve-preview und start-build
|
||
// keine lange Frist (SLOW_PULT_ACTIONS), eine längere Wartezeit endete hier als „nicht erreichbar“.
|
||
await setDelay(page, 900)
|
||
await platSend.click()
|
||
check('einfuegen: während die Anfrage läuft sind „Einfügen“ und „Antwort des Kunden einfügen“ der Zeile gesperrt', (await platSend.isDisabled()) && (await platOpen.isDisabled()))
|
||
check('einfuegen: die zweite Zeile bleibt währenddessen bedienbar', (await angSend.isEnabled()) && (await angOpen.isEnabled()))
|
||
await platSend.evaluate((el) => el.click())
|
||
await shoot(page, 'einfuegen-pending.png')
|
||
await plat.locator('.msg.ok').waitFor()
|
||
await setDelay(page, 0)
|
||
check('einfuegen: Erfolg ⇒ „Eingefügt. Nora sortiert die Antwort ein.“ (grün, role=status) in der Zeile', (await textOf(plat.locator('.msg.ok'))) === PASTE_OK && (await plat.locator('.msg.ok').getAttribute('role')) === 'status', await textOf(plat.locator('.msg')))
|
||
check('einfuegen: danach ist das Feld zu, die Zeile bleibt in der Liste, die zweite Zeile ist unberührt (Feld offen, Text, keine Meldung)', (await plat.locator('.wk-pult-paste').count()) === 0 && (await plat.count()) === 1 && (await ang.locator('.wk-pult-paste textarea').inputValue()) === 'Antwort Kunde B' && (await ang.locator('.msg').count()) === 0)
|
||
const pasteCalls = await callsOf(page, 'paste')
|
||
const okCall = pasteCalls[pasteCalls.length - 1]
|
||
check('einfuegen: der Aufruf geht an den Eintrag der ersten Zeile mit { text: „Antwort Kunde A“ }; Doppelklick im Warten zählt nicht', okCall.id === 'p4-plat-gesendet' && same(okCall.body, { text: 'Antwort Kunde A' }) && pasteCalls.filter((c) => c.id === 'p4-plat-gesendet').length === 5 && pasteCalls.every((c) => c.id !== 'p4-plat-angebot'), pasteCalls)
|
||
await shoot(page, 'einfuegen-erfolg.png')
|
||
|
||
// 7. Dieselbe Antwort noch einmal: „doppelt“
|
||
await platOpen.click()
|
||
const again = plat.locator('.wk-pult-paste textarea')
|
||
await again.waitFor()
|
||
check('einfuegen: wieder geöffnet ist das Feld leer (nach dem Erfolg geleert)', (await again.inputValue()) === '')
|
||
await again.fill('Antwort Kunde A')
|
||
await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg')).some((el) => el.textContent === t), PASTE_TWICE)
|
||
check('einfuegen: dieselbe Antwort ein zweites Mal ⇒ „Diese Antwort war schon eingefügt.“', (await textOf(plat.locator('.msg'))) === PASTE_TWICE, await textOf(plat.locator('.msg')))
|
||
|
||
// 8. Zweite Zeile sendet für sich
|
||
await angSend.click()
|
||
await ang.locator('.msg.ok').waitFor()
|
||
const angCall = (await callsOf(page, 'paste', 'p4-plat-angebot')).pop()
|
||
check('einfuegen: zweite Zeile schickt ihren eigenen Text an ihren eigenen Eintrag, ihre Meldung steht nur dort', (await textOf(ang.locator('.msg.ok'))) === PASTE_OK && angCall && same(angCall.body, { text: 'Antwort Kunde B' }) && (await textOf(plat.locator('.msg'))) === PASTE_TWICE)
|
||
await shoot(page, 'einfuegen-beide-fertig.png')
|
||
check('einfuegen: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page))
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario N: Vorschau bauen (Desktop) ----------
|
||
|
||
async function bauen(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'bauen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openP4(page, base)
|
||
const uns = row4(page, T4.uns)
|
||
const build = uns.getByRole('button', { name: /^(Vorschau bauen|Wirklich bauen\?)$/ })
|
||
await uns.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'bauen-zeile.png')
|
||
check('bauen: Zeile „unsicher“ bietet „Vorschau bauen“ (neben Ich kümmere mich, Als Mensch antworten, Erledigt, Verwerfen)', same(await actionNames(uns), ['Ich kümmere mich', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen']), await actionNames(uns))
|
||
check('bauen: die Kollegen-Zeile und die Mail-Zeile bieten kein „Vorschau bauen“', (await row4(page, T4.koll).getByRole('button', { name: 'Vorschau bauen' }).count()) === 0 && (await row4(page, T4.mail).getByRole('button', { name: 'Vorschau bauen' }).count()) === 0)
|
||
|
||
// 1. Fehler vom Dienst
|
||
await setPultFault(page, 'start-build', { status: 409, message: 'Für diesen Interessenten gibt es schon eine Vorschau.' })
|
||
await build.click()
|
||
await build.click()
|
||
await uns.locator('.msg.err').waitFor()
|
||
check('bauen: 409 ⇒ „Für diesen Interessenten gibt es schon eine Vorschau.“ in der Zeile (rot, role=alert), Zeile bleibt', (await textOf(uns.locator('.msg.err'))) === 'Für diesen Interessenten gibt es schon eine Vorschau.' && (await uns.locator('.msg.err').getAttribute('role')) === 'alert' && (await uns.count()) === 1, await textOf(uns.locator('.msg.err')))
|
||
check('bauen: nach dem Fehler ist „Vorschau bauen“ wieder frei', await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).isEnabled())
|
||
await shoot(page, 'bauen-409.png')
|
||
await setPultFault(page, 'start-build', { status: 409, message: GONE })
|
||
await build.click()
|
||
await build.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE)
|
||
check('bauen: zweiter 409 („Dieser Kontakt ist nicht mehr aktiv.“) ersetzt die Meldung', (await uns.locator('.msg').count()) === 1 && (await textOf(uns.locator('.msg'))) === GONE)
|
||
await setPultFault(page, 'start-build', null)
|
||
|
||
// 2. „Vorschau bauen“ fragt wie Freigeben und Verwerfen zuerst nach (Endprüfung W2): der erste Klick ruft den Dienst nicht auf, die Frage verfällt nach 4 s,
|
||
// erst der zweite Klick startet den Bau. metrics.bauenFragtNach hält fest, ob der erste Klick folgenlos blieb.
|
||
await setDelay(page, 1500)
|
||
const before = (await callsOf(page, 'start-build')).length
|
||
await build.click()
|
||
await sleep(150)
|
||
const afterFirst = (await callsOf(page, 'start-build')).length
|
||
metrics.bauenFragtNach = afterFirst === before
|
||
metrics.bauenErsterKlickRuftDienst = afterFirst === before + 1
|
||
check('bauen: erster Klick fragt „Wirklich bauen?“ (mit Hinweis zu Opus-Lauf), noch keine Aktion am Dienst', (await textOf(build)) === 'Wirklich bauen?' && afterFirst === before && (await textOf(uns.locator('.wk-pult-actions'))).includes('Startet einen Opus-Lauf'), await textOf(build))
|
||
await shoot(page, 'bauen-frage.png')
|
||
await sleep(4400)
|
||
check('bauen: nach 4 s verfällt die Rückfrage (wieder „Vorschau bauen“, kein Hinweis), noch immer keine Aktion', (await textOf(build)) === 'Vorschau bauen' && (await callsOf(page, 'start-build')).length === before && !(await textOf(uns.locator('.wk-pult-actions'))).includes('Opus'), await textOf(build))
|
||
await build.click()
|
||
await build.click()
|
||
check('bauen: während der Bau angelegt wird sind alle Knöpfe der Zeile gesperrt', (await uns.getByRole('button', { name: 'Ich kümmere mich', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Als Mensch antworten', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Erledigt', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Verwerfen', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).isDisabled()))
|
||
await shoot(page, 'bauen-pending.png')
|
||
await finishedLine(page).first().waitFor()
|
||
await setDelay(page, 0)
|
||
check('bauen: Erfolg ⇒ Meldung oben „…“ wird gebaut. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T4.uns}“ wird gebaut.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && (await uns.count()) === 0, await textOf(finishedLine(page).first()))
|
||
const calls = await callsOf(page, 'start-build', 'p4-unsicher')
|
||
check('bauen: genau ein erfolgreicher Aufruf ohne Body (nach den zwei Fehlversuchen)', calls.length === before + 1 && calls.every((c) => c.body === undefined), calls)
|
||
await shoot(page, 'bauen-gestartet.png')
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario O: Fokus der aufgeklappten Felder und nach den Aktionen (Desktop) ----------
|
||
|
||
async function fokus(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'fokus', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openP4(page, base)
|
||
const mail = row4(page, T4.mail)
|
||
const noBody = (info) => info.tag !== null && info.tag !== 'BODY'
|
||
|
||
// Esc schließt das Feld; gibt es keinen Esc-Weg, zählt ein Knopf „Abbrechen“. Ergebnis, ob eines von beiden das Feld schließt und wo dann der Fokus ist.
|
||
async function leaveByKeyboard(row, panelSelector, probe) {
|
||
const detached = (ms) => row.locator(panelSelector).waitFor({ state: 'detached', timeout: ms }).then(() => true, () => false)
|
||
await page.keyboard.press('Escape')
|
||
let how = 'esc'
|
||
let closed = await detached(700)
|
||
if (!closed) {
|
||
const cancel = row.locator(panelSelector).getByRole('button', { name: 'Abbrechen', exact: true })
|
||
if ((await cancel.count()) > 0) {
|
||
how = 'abbrechen'
|
||
await cancel.click()
|
||
closed = await detached(700)
|
||
} else {
|
||
how = 'weder Esc noch Abbrechen'
|
||
}
|
||
}
|
||
return { how, closed, focus: await focusInfo(page), onOpener: (await focusInfo(page)).probe === probe }
|
||
}
|
||
|
||
// 1. „Als Mensch antworten“
|
||
const replyOpener = mail.getByRole('button', { name: 'Als Mensch antworten', exact: true })
|
||
await replyOpener.evaluate((el) => el.setAttribute('data-probe', 'reply-opener'))
|
||
await replyOpener.click()
|
||
await mail.locator('.wk-pult-reply').waitFor()
|
||
const f1 = await focusInfo(page)
|
||
check('fokus: „Als Mensch antworten“ öffnen ⇒ der Fokus liegt im Textfeld des Antwortfelds', f1.tag === 'TEXTAREA' && f1.inReply, f1)
|
||
await shoot(page, 'fokus-antwort-offen.png')
|
||
const l1 = await leaveByKeyboard(mail, '.wk-pult-reply', 'reply-opener')
|
||
check('fokus: Esc oder „Abbrechen“ schließt das Antwortfeld und gibt den Fokus an „Als Mensch antworten“ zurück', l1.closed && l1.onOpener, l1)
|
||
if (!l1.closed) {
|
||
await replyOpener.click() // Rückweg über den Knopf selbst, damit der Rest des Szenarios weiterläuft
|
||
await mail.locator('.wk-pult-reply').waitFor({ state: 'detached' })
|
||
}
|
||
const f1b = await focusInfo(page)
|
||
check('fokus: zweiter Klick auf „Als Mensch antworten“ schließt das Feld, der Fokus bleibt auf dem Knopf (nicht auf <body>)', (await mail.locator('.wk-pult-reply').count()) === 0 && f1b.probe === 'reply-opener', f1b)
|
||
|
||
// 2. „Details“ (Dialog)
|
||
const details = mail.getByRole('button', { name: `Details zu ${T4.mail}`, exact: true })
|
||
await details.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
|
||
await details.click()
|
||
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
|
||
await sleep(500)
|
||
check('fokus: „Details“ öffnen ⇒ der Fokus liegt im Dialog', (await focusInfo(page)).inDialog, await focusInfo(page))
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('fokus: Esc schließt „Details“, der Fokus kehrt auf „Details“ zurück', (await focusInfo(page)).probe === 'details-opener', await focusInfo(page))
|
||
|
||
// 3. Fokus nach Fehler und nach Erfolg
|
||
await replyOpener.click()
|
||
const area = mail.locator('.wk-pult-reply textarea')
|
||
await area.waitFor()
|
||
await setPultFault(page, 'reply', { status: 409, message: CONFLICT })
|
||
await mail.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click()
|
||
await mail.locator('.msg.err').waitFor()
|
||
await sleep(200)
|
||
const fErr = await focusInfo(page)
|
||
check('fokus: nach einem Fehler (409) beim Senden steht der Fokus nicht auf <body> (Knopf oder Feld)', noBody(fErr), fErr)
|
||
await setPultFault(page, 'reply', null)
|
||
await mail.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click()
|
||
await finishedLine(page).first().waitFor()
|
||
await sleep(200)
|
||
const fReply = await focusInfo(page)
|
||
check('fokus: nach dem erfolgreichen Senden einer Antwort (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fReply), fReply)
|
||
|
||
const frei = row4(page, T4.frei)
|
||
const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ })
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent.includes(t), 'freigegeben')
|
||
await sleep(200)
|
||
const fApprove = await focusInfo(page)
|
||
check('fokus: nach „Freigeben & senden“ (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fApprove), fApprove)
|
||
|
||
const uns = row4(page, T4.uns)
|
||
const buildBtn = uns.getByRole('button', { name: /^(Vorschau bauen|Wirklich bauen\?)$/ })
|
||
await buildBtn.click()
|
||
await buildBtn.click()
|
||
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent.includes(t), 'wird gebaut')
|
||
await sleep(200)
|
||
const fBuild = await focusInfo(page)
|
||
check('fokus: nach „Vorschau bauen“ (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fBuild), fBuild)
|
||
|
||
// 4. „Antwort des Kunden einfügen“ (Reiter Erledigt)
|
||
await chooseFilter4(page, 'Erledigt')
|
||
await waitTitles(page, [T4.mail, T4.frei, T4.uns, T4.ang, T4.plat]) // Mail, Freigabe und unsicher sind durch die Aktionen davor hierher gewandert
|
||
const plat = row4(page, T4.plat)
|
||
const pasteOpener = plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true })
|
||
await pasteOpener.evaluate((el) => el.setAttribute('data-probe', 'paste-opener'))
|
||
await pasteOpener.click()
|
||
await plat.locator('.wk-pult-paste').waitFor()
|
||
const f4 = await focusInfo(page)
|
||
check('fokus: „Antwort des Kunden einfügen“ öffnen ⇒ der Fokus liegt im Textfeld', f4.tag === 'TEXTAREA' && f4.inPaste, f4)
|
||
await shoot(page, 'fokus-einfuegen-offen.png')
|
||
const l4 = await leaveByKeyboard(plat, '.wk-pult-paste', 'paste-opener')
|
||
check('fokus: Esc oder „Abbrechen“ schließt das Einfüge-Feld und gibt den Fokus an „Antwort des Kunden einfügen“ zurück', l4.closed && l4.onOpener, l4)
|
||
if (!l4.closed) {
|
||
await pasteOpener.click()
|
||
await plat.locator('.wk-pult-paste').waitFor({ state: 'detached' })
|
||
}
|
||
await pasteOpener.click()
|
||
await plat.locator('.wk-pult-paste textarea').fill('Danke, das passt.')
|
||
await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click()
|
||
await plat.locator('.msg.ok').waitFor()
|
||
await sleep(200)
|
||
const fPaste = await focusInfo(page)
|
||
check('fokus: nach dem erfolgreichen Einfügen (Feld klappt zu) steht der Fokus nicht auf <body>', noBody(fPaste), fPaste)
|
||
|
||
// Zum Vergleich (nur festgehalten, kein Prüfpunkt): „Ich kümmere mich“ aus P3 behält die Zeile, der Knopf ist beim Warten gesperrt
|
||
await chooseFilter4(page, 'Wartet')
|
||
await waitTitles(page, [T4.falsch, T4.koll])
|
||
await row4(page, T4.falsch).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row')).some((el) => el.textContent.includes(t) && el.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')), T4.falsch)
|
||
await sleep(200)
|
||
metrics.fokusNachIchKuemmereMich = (await focusInfo(page)).tag
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario P: Handy 390 x 844 für alle Felder und Zustände ----------
|
||
|
||
async function handyP4(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'handy-p4', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
|
||
await openP4(page, base)
|
||
await sleep(300)
|
||
const mail = row4(page, T4.mail)
|
||
const frei = row4(page, T4.frei)
|
||
const falsch = row4(page, T4.falsch)
|
||
const uns = row4(page, T4.uns)
|
||
const noScroll = async (label, detail) => {
|
||
const w = await widthOf(page)
|
||
check(`handy-p4: ${label}: kein seitliches Scrollen (scrollWidth <= innerWidth)`, w.scroll <= w.inner, { ...w, ...(detail || {}) })
|
||
}
|
||
const ACTIONS = '.wk-pult-actions > a, .wk-pult-actions > button'
|
||
|
||
// 1. Liste
|
||
await noScroll('Liste Wartet')
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-p4-wartet.png')
|
||
await shoot(page, 'handy-p4-wartet-ganz.png', { fullPage: true })
|
||
const reach1 = await reachOf(page, ACTIONS)
|
||
check('handy-p4: alle Aktionsknöpfe und Links der Liste sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach1.length >= 15 && badReach(reach1).length === 0, { count: reach1.length, bad: badReach(reach1) })
|
||
|
||
// 2. „Als Mensch antworten“ mit sehr langem Wort, Fehlermeldung
|
||
await mail.scrollIntoViewIfNeeded()
|
||
await mail.getByRole('button', { name: 'Als Mensch antworten', exact: true }).click()
|
||
const panel = mail.locator('.wk-pult-reply')
|
||
await panel.waitFor()
|
||
await sleep(200)
|
||
await noScroll('Antwortfeld offen')
|
||
const reach2 = await reachOf(page, '.wk-pult-reply textarea, .wk-pult-reply .wk-pult-panel-foot .btn')
|
||
const area = panel.locator('textarea')
|
||
const areaBox = await area.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height) } })
|
||
check('handy-p4: Antwortfeld: Textfeld, „Abbrechen“ und „Jetzt senden“ im Fenster, nicht verdeckt, Knöpfe mindestens 40 px hoch, Feld mindestens 100 px hoch', reach2.length === 3 && badReach(reach2).length === 0 && areaBox.left >= 0 && areaBox.right <= 390 && areaBox.height >= 100, { reach2, areaBox })
|
||
await panel.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-antwort-offen.png')
|
||
await area.fill('x'.repeat(400) + ' https://www.sv-namborn.example/' + 'lang/'.repeat(30))
|
||
await noScroll('Antwortfeld mit sehr langem Text')
|
||
await setPultFault(page, 'reply', { status: 409, message: 'Wird schon von einem Kollegen bearbeitet.' })
|
||
await panel.getByRole('button', { name: 'Jetzt senden', exact: true }).click()
|
||
await mail.locator('.msg.err').waitFor()
|
||
await setPultFault(page, 'reply', null)
|
||
await noScroll('Antwortfeld mit Fehlermeldung')
|
||
await mail.locator('.msg.err').scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-antwort-409.png')
|
||
|
||
// 3. Vorschau-Freigabe: Frage, Link, falsche Adresse
|
||
await frei.scrollIntoViewIfNeeded()
|
||
const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ })
|
||
await approveBtn.click()
|
||
check('handy-p4: Freigabe-Zeile fragt „Wirklich freigeben?“', (await textOf(approveBtn)) === 'Wirklich freigeben?')
|
||
await noScroll('Freigabe-Zeile mit Rückfrage')
|
||
await shoot(page, 'handy-p4-freigabe-frage.png')
|
||
await falsch.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-freigabe-falsche-adresse.png')
|
||
|
||
// 4. „unsicher“: Antwortfeld leer, „Vorschau bauen“
|
||
await uns.scrollIntoViewIfNeeded()
|
||
await uns.getByRole('button', { name: 'Als Mensch antworten', exact: true }).click()
|
||
await uns.locator('.wk-pult-reply').waitFor()
|
||
await noScroll('Zeile unsicher mit Antwortfeld')
|
||
const reach4 = await reachOf(page, 'li.wk-pult-row .wk-pult-reply .btn, li.wk-pult-row .wk-pult-actions > button')
|
||
check('handy-p4: alle Knöpfe von Antwortfeld und Aktionen sind erreichbar (nicht verdeckt, im Fenster, mindestens 40 px hoch)', reach4.length >= 10 && badReach(reach4).length === 0, { count: reach4.length, bad: badReach(reach4) })
|
||
await uns.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-unsicher.png')
|
||
|
||
// 5. „Details“ als Panel
|
||
const details = uns.getByRole('button', { name: `Details zu ${T4.uns}`, exact: true })
|
||
await details.scrollIntoViewIfNeeded()
|
||
await details.click()
|
||
const dialog = page.getByRole('dialog', { name: 'Gasthaus Sonne', exact: true })
|
||
await dialog.waitFor()
|
||
await dialog.locator('.wk-prospect').waitFor()
|
||
await sleep(700)
|
||
const box = await dialog.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight } })
|
||
check('handy-p4: „Details“-Panel liegt im Fenster (links >= 0, rechts <= 390, unten <= Fensterhöhe)', box.left >= 0 && box.right <= 390 && box.top >= 0 && box.bottom <= box.inner, box)
|
||
await noScroll('Details-Panel offen')
|
||
await shoot(page, 'handy-p4-details.png')
|
||
const closeBtn = dialog.getByRole('button', { name: 'Schließen', exact: true }).last()
|
||
const closeReach = await closeBtn.evaluate((el) => { const r = el.getBoundingClientRect(); const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), height: Math.round(r.height), inner: window.innerHeight, covered: !(hit && (hit === el || el.contains(hit))) } })
|
||
check('handy-p4: „Schließen“ im Details-Panel ist erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', closeReach.left >= 0 && closeReach.right <= 390 && closeReach.bottom <= closeReach.inner && !closeReach.covered && closeReach.height >= 40, closeReach)
|
||
await closeBtn.click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
|
||
// 6. Erledigt: „Antwort des Kunden einfügen“ in beiden Zeilen, 413
|
||
await chooseFilter4(page, 'Erledigt')
|
||
await waitTitles(page, [T4.ang, T4.plat])
|
||
const plat = row4(page, T4.plat)
|
||
const ang = row4(page, T4.ang)
|
||
await noScroll('Liste Erledigt')
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-p4-erledigt.png')
|
||
await plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }).click()
|
||
await ang.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }).click()
|
||
await plat.locator('.wk-pult-paste').waitFor()
|
||
await ang.locator('.wk-pult-paste').waitFor()
|
||
await plat.locator('.wk-pult-paste textarea').fill('Antwort Kunde A')
|
||
await setPultFault(page, 'paste', { status: 413, message: TOO_BIG })
|
||
await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click()
|
||
await plat.locator('.msg.err').waitFor()
|
||
await setPultFault(page, 'paste', null)
|
||
await noScroll('beide Einfüge-Felder offen, 413 sichtbar')
|
||
const reach6 = await reachOf(page, '.wk-pult-paste textarea, .wk-pult-paste .wk-pult-panel-foot .btn, li.wk-pult-row .wk-pult-actions > button')
|
||
check('handy-p4: Einfüge-Felder, „Abbrechen“, „Einfügen“ und „Antwort des Kunden einfügen“ sind erreichbar (im Fenster, nicht verdeckt, Knöpfe mindestens 40 px hoch)', reach6.length === 8 && badReach(reach6).length === 0, { count: reach6.length, bad: badReach(reach6) })
|
||
await plat.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-einfuegen-413.png')
|
||
await shoot(page, 'handy-p4-einfuegen-ganz.png', { fullPage: true })
|
||
check('handy-p4: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page))
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- P6: Bot-Leads (Reiter „Bot-Leads“: Desktop 1280 x 900 und Handy 390 x 844) ----------
|
||
// 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).
|
||
// Die Seite läuft über den echten API-Client und die Fetch-Weiche (fault=none): Query, Fehlertexte und Zeitgrenzen sind die des Betriebs. Was die Oberfläche
|
||
// angefragt hat, steht in window.__wk.api.botleadCalls. Die Kennungen unten sind die aus BOTLEAD_IDS in scripted-api.js.
|
||
const BL_URL = '/index.html?page=1&fault=none&tab=botleads'
|
||
const BL_IDS = { health: 'bl-aerzte-01', cut: 'bl-gekuerzt-01', cutHealth: 'bl-gekuerzt-03' } // Heilberuf (Seite 1), gekürzter Handwerk-Stub, gekürzter Heilberuf-Stub
|
||
const BL_NAMES = { health: 'Praxis Dr. Lindner', physio: 'Physiotherapie Moser', http: 'Elektro Hartmann GmbH', javascript: 'Restaurant Zum Hirschen', credentials: 'Fotografie Lichtblick' }
|
||
const BL_HEADERS = ['Betrieb', 'Raum', 'Punkte', 'Grund', 'Telefon', 'Quelle', 'Gefunden']
|
||
const BL_ROOM_LABELS = ['Alle Räume', 'Ärzte & Praxen', 'Handwerk', 'Selbstständige', 'Läden & Gastro']
|
||
const BL_HEALTH_PHONE = 'bewusst nicht gespeichert'
|
||
const BL_NO_PHONE = 'nicht verfügbar'
|
||
const BL_HINT = 'Reine Liste: keine Anrufe, kein Mailversand.'
|
||
const BL_SESSION = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
||
const BL_LABEL = (page, pages, count) => `Seite ${page} von ${pages} · ${count} ${count === 1 ? 'Eintrag' : 'Einträge'}`
|
||
|
||
const blRows = (page) => page.locator('.wk-bl-table tbody tr')
|
||
const blCalls = (page) => page.evaluate(() => window.__wk.api.botleadCalls)
|
||
const blFootLabel = (page) => page.evaluate(() => { const el = document.querySelector('.wk-bl .table-foot > span'); return el ? el.textContent : null })
|
||
const blWaitLabel = (page, label) => page.waitForFunction((want) => { const el = document.querySelector('.wk-bl .table-foot > span'); return Boolean(el) && el.textContent === want }, label)
|
||
// Fertig geladen: kein „Wird geladen …“ mehr und Tabelle, Leerzustand oder Fehlerzeile da
|
||
const blSettled = (page) => page.waitForFunction(() => {
|
||
const root = document.querySelector('.wk-bl')
|
||
return Boolean(root) && !root.querySelector('.loading') && Boolean(root.querySelector('table, .empty, .msg.err'))
|
||
})
|
||
const blRead = (page) => page.evaluate(() => Array.from(document.querySelectorAll('.wk-bl-table tbody tr')).map((tr) => {
|
||
const c = tr.children
|
||
const title = c[0].querySelector('.cell-title')
|
||
const a = c[5].querySelector('a')
|
||
return {
|
||
title: title.childNodes[0] ? title.childNodes[0].textContent : '',
|
||
tag: title.querySelector('.wk-bl-tag') ? title.querySelector('.wk-bl-tag').textContent : null,
|
||
place: c[0].querySelector('.cell-sub') ? c[0].querySelector('.cell-sub').textContent : '',
|
||
room: c[1].textContent.trim(),
|
||
score: c[2].textContent.trim(),
|
||
reason: c[3].textContent.trim(),
|
||
phone: c[4].textContent.trim(),
|
||
source: c[5].textContent.trim(),
|
||
found: c[6].textContent.trim(),
|
||
link: a ? { href: a.getAttribute('href'), rel: a.getAttribute('rel'), target: a.getAttribute('target'), label: a.getAttribute('aria-label') } : null,
|
||
cut: tr.classList.contains('wk-bl-cut'),
|
||
current: tr.getAttribute('aria-current'),
|
||
}
|
||
}))
|
||
const blFocus = (page) => page.evaluate(() => {
|
||
const el = document.activeElement
|
||
return { tag: el ? el.tagName : null, text: el ? (el.textContent || '').trim().slice(0, 30) : null, inPager: Boolean(el && el.closest && el.closest('.wk-bl-pager')), onBody: el === document.body }
|
||
})
|
||
const blDay = (text) => { const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(text); return m ? Date.UTC(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : NaN }
|
||
const blTitle = (rows, title) => rows.find((r) => r.title === title)
|
||
const blSelectRoom = async (page, value) => { await page.getByLabel('Raum', { exact: true }).selectOption(value); await blSettled(page) }
|
||
|
||
// Kontrast aller Texte im Bereich scope: Vordergrund und der wirksame Hintergrund (Ebenen der Vorfahren übereinander, auf Weiß) aus den berechneten Stilen,
|
||
// Deckkraft des Elements und seiner Vorfahren im Vordergrund; Verhältnis nach WCAG 2.x. Läuft im Browser.
|
||
function contrastProbe(scope) {
|
||
const parse = (css) => {
|
||
let m = /^rgba?\(([^)]+)\)$/.exec(css)
|
||
let scale = 1
|
||
if (!m) { m = /^color\(srgb ([^)]+)\)$/.exec(css); scale = 255 }
|
||
if (!m) return null
|
||
const p = m[1].split(/[\s,/]+/).filter(Boolean).map((v) => (v.endsWith('%') ? parseFloat(v) / 100 : parseFloat(v)))
|
||
return { r: p[0] * scale, g: p[1] * scale, b: p[2] * scale, a: p.length > 3 ? p[3] : 1 }
|
||
}
|
||
const over = (top, bottom) => ({ r: top.r * top.a + bottom.r * (1 - top.a), g: top.g * top.a + bottom.g * (1 - top.a), b: top.b * top.a + bottom.b * (1 - top.a), a: 1 })
|
||
const lum = (c) => {
|
||
const lin = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }
|
||
return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
|
||
}
|
||
const out = []
|
||
const root = document.querySelector(scope)
|
||
if (!root) return { error: 'kein Bereich ' + scope, items: out }
|
||
for (const el of [root, ...root.querySelectorAll('*')]) {
|
||
const own = Array.from(el.childNodes).filter((n) => n.nodeType === 3 && n.textContent.trim() !== '').map((n) => n.textContent.trim()).join(' ')
|
||
if (own === '') continue
|
||
const layers = []
|
||
let opacity = 1
|
||
for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
|
||
const cs = getComputedStyle(n)
|
||
const bg = parse(cs.backgroundColor)
|
||
if (bg === null) return { error: 'Hintergrund nicht lesbar: ' + cs.backgroundColor, items: out }
|
||
if (bg.a > 0) layers.push(bg)
|
||
opacity *= Number(cs.opacity)
|
||
}
|
||
let back = { r: 255, g: 255, b: 255, a: 1 }
|
||
for (const layer of layers.reverse()) back = over(layer, back)
|
||
const fgRaw = parse(getComputedStyle(el).color)
|
||
if (fgRaw === null) return { error: 'Farbe nicht lesbar: ' + getComputedStyle(el).color, items: out }
|
||
const fg = over({ ...fgRaw, a: fgRaw.a * opacity }, back)
|
||
const l1 = lum(fg)
|
||
const l2 = lum(back)
|
||
out.push({ text: own.slice(0, 40), tag: el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(/\s+/)[0] : ''), ratio: Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100, color: getComputedStyle(el).color, background: 'rgb(' + [back.r, back.g, back.b].map(Math.round).join(', ') + ')' })
|
||
}
|
||
return { error: null, items: out }
|
||
}
|
||
const blContrast = async (page, scope) => {
|
||
const report = await page.evaluate(contrastProbe, scope)
|
||
const worst = [...report.items].sort((a, b) => a.ratio - b.ratio)
|
||
return { error: report.error, count: report.items.length, min: worst.length ? worst[0].ratio : null, low: report.items.filter((i) => i.ratio < 4.5).map((i) => `${i.tag} „${i.text}“ ${i.ratio}:1 (${i.color} auf ${i.background})`) }
|
||
}
|
||
|
||
// Wörter, die ein Zeilenumbruch mitten im Wort trennt (z. B. „Kaiserslauter“ + „n“): Wörter ohne Bindestrich, Schrägstrich, Punkt, Komma und Klammern, die der
|
||
// Browser über zwei Zeilen setzt (ein Bereich um das Wort hat dann mehr als ein Rechteck). Läuft im Browser über alle Textknoten im Bereich scope.
|
||
const blBrokenWords = (page, scope) => page.evaluate((selector) => {
|
||
const root = document.querySelector(selector)
|
||
const out = []
|
||
if (!root) return { error: 'kein Bereich ' + selector, words: out, count: 0 }
|
||
let count = 0
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||
const text = node.textContent
|
||
for (const match of text.matchAll(/\S+/g)) {
|
||
const word = match[0]
|
||
if (word.length < 3 || /[-–/@.,;:()[\]]/.test(word)) continue
|
||
count += 1
|
||
const range = document.createRange()
|
||
range.setStart(node, match.index)
|
||
range.setEnd(node, match.index + word.length)
|
||
if (range.getClientRects().length > 1) out.push(word)
|
||
}
|
||
}
|
||
return { error: null, words: out, count }
|
||
}, scope)
|
||
|
||
// ---------- Szenario Q: Bot-Leads (Desktop 1280 x 900) ----------
|
||
|
||
async function botleads(browser, base) {
|
||
const { context, page } = await newPage(browser, 'botleads', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
|
||
// 1. Der Reiter: sichtbar, nicht gewählt, mit Klick gewählt
|
||
await page.goto(base + '/index.html?page=1&fault=none')
|
||
await page.getByRole('tab').first().waitFor()
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
|
||
const tab = page.getByRole('tab', { name: 'Bot-Leads', exact: true })
|
||
check('botleads: Reiter „Bot-Leads“ ist sichtbar und vor dem Klick nicht gewählt', (await tab.isVisible()) && (await tab.getAttribute('aria-selected')) === 'false')
|
||
await tab.click()
|
||
await blSettled(page)
|
||
await page.evaluate(() => document.fonts.ready)
|
||
check('botleads: nach dem Klick ist „Bot-Leads“ gewählt, das Hochhaus ist weg und die Liste da', (await tab.getAttribute('aria-selected')) === 'true' && (await page.locator('[data-wk-worker]').count()) === 0 && (await blRows(page).count()) > 0)
|
||
|
||
// 2. Liste, Seite 1
|
||
const heads = (await page.locator('.wk-bl-table thead th').allTextContents()).map((t) => t.trim())
|
||
check('botleads: Spalten Betrieb, Raum, Punkte, Grund, Telefon, Quelle, Gefunden', same(heads, BL_HEADERS), heads)
|
||
const page1 = await blRead(page)
|
||
check('botleads: Seite 1 zeigt 50 Zeilen, die Seitenzeile „Seite 1 von 2 · 60 Einträge“', page1.length === 50 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60), { rows: page1.length, label: await blFootLabel(page) })
|
||
check('botleads: Hinweis „Reine Liste: keine Anrufe, kein Mailversand.“ steht über der Liste', (await textOf(page.locator('.wk-bl > .wk-hint'))) === BL_HINT)
|
||
const pagerState = async () => ({
|
||
prev: await page.getByRole('button', { name: 'Zurück', exact: true }).isDisabled(),
|
||
next: await page.getByRole('button', { name: 'Weiter', exact: true }).isDisabled(),
|
||
})
|
||
check('botleads: auf Seite 1 ist „Zurück“ gesperrt und „Weiter“ frei', same(await pagerState(), { prev: true, next: false }), await pagerState())
|
||
const days1 = page1.map((r) => blDay(r.found))
|
||
check('botleads: neueste zuerst (Datum TT.MM.JJJJ, nie aufsteigend), erste Zeile ist der jüngste Fund', days1.every((d, i) => Number.isFinite(d) && (i === 0 || days1[i - 1] >= d)) && page1[0].title === BL_NAMES.health, { first: page1[0] })
|
||
const bad = page1.filter((r) => /undefined|NaN|\[object|null/.test(Object.values(r).filter((v) => typeof v === 'string').join(' ')))
|
||
check('botleads: kein „undefined“, „NaN“ oder „null“ in den Zeilen', bad.length === 0, bad.slice(0, 2))
|
||
check('botleads: gekürzte Einträge fehlen in der Liste (kein „[gekürzt]“, kein Kürzel, nicht in der Zählung)', page1.every((r) => r.title !== '[gekürzt]' && r.tag === null && !r.cut))
|
||
const options = await page.getByLabel('Raum', { exact: true }).locator('option').allTextContents()
|
||
check('botleads: Raumfilter bietet alle Räume und die vier Räume', same(options, BL_ROOM_LABELS), options)
|
||
const settledCalls = (await blCalls(page)).length
|
||
await sleep(1300)
|
||
check('botleads: kein Polling (nach 1,3 s keine weitere Abfrage)', (await blCalls(page)).length === settledCalls, settledCalls)
|
||
const callFirst = (await blCalls(page))[0]
|
||
check('botleads: die erste Abfrage ist Raum leer, Suche leer, Seite 1', same(callFirst, { room: '', q: '', page: 1, id: '' }), callFirst)
|
||
const w0 = await page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth }))
|
||
check('botleads: Liste ohne seitliches Scrollen am Desktop', w0.scroll <= w0.inner, w0)
|
||
const broken0 = await blBrokenWords(page, '.wk-bl-table tbody')
|
||
check('botleads: am Desktop wird kein Wort mitten im Wort umgebrochen', broken0.error === null && broken0.count > 100 && broken0.words.length === 0, broken0)
|
||
await topOfPage(page)
|
||
await shoot(page, 'botleads-liste.png')
|
||
await shoot(page, 'botleads-liste-ganz.png', { fullPage: true })
|
||
|
||
// 3. Telefon: Heilberufe nie, sonst nur mit Quelle
|
||
const healthRows = page1.filter((r) => r.room === 'Ärzte & Praxen')
|
||
check('botleads: jede Zeile im Raum „Ärzte & Praxen“ zeigt „bewusst nicht gespeichert“ statt einer Nummer', healthRows.length >= 10 && healthRows.every((r) => r.phone === BL_HEALTH_PHONE), healthRows.map((r) => r.phone))
|
||
const physio = blTitle(page1, BL_NAMES.physio)
|
||
check('botleads: auch die Physiotherapie im Raum „Selbstständige“ (Heilberuf) zeigt keine Nummer', Boolean(physio) && physio.phone === BL_HEALTH_PHONE, physio)
|
||
const storedHealthPhones = await page.evaluate(() => window.__wk.api.botleadsHealthPhones)
|
||
const bodyDigits = await page.evaluate(() => document.body.textContent.replace(/\s+/g, ' '))
|
||
check('botleads: keine der im Altbestand gespeicherten Heilberuf-Nummern steht irgendwo auf der Seite', storedHealthPhones.length >= 5 && storedHealthPhones.every((n) => !bodyDigits.includes(n) && !bodyDigits.replace(/\s/g, '').includes(n.replace(/\s/g, ''))), storedHealthPhones)
|
||
const others = page1.filter((r) => r.room !== 'Ärzte & Praxen' && r.title !== BL_NAMES.physio)
|
||
check('botleads: Betriebe außerhalb der Heilberufe zeigen ihre Nummer, wenn eine Quelle da ist, sonst „nicht verfügbar“', others.some((r) => PHONE_NUMBER.test(r.phone)) && others.some((r) => r.phone === BL_NO_PHONE) && others.every((r) => PHONE_NUMBER.test(r.phone) || r.phone === BL_NO_PHONE), others.map((r) => r.phone))
|
||
const healthRow = blRows(page).filter({ has: page.locator('.cell-title', { hasText: BL_NAMES.health }) })
|
||
await healthRow.screenshot({ path: path.join(out, 'botleads-heilberuf-ohne-telefon.png') })
|
||
shots.push(path.join(out, 'botleads-heilberuf-ohne-telefon.png'))
|
||
|
||
// 4. Quellen-Links
|
||
const links = page1.filter((r) => r.link)
|
||
const linkProblems = links.filter((r) => {
|
||
let url
|
||
try { url = new URL(r.link.href) } catch { return true }
|
||
const tokens = (r.link.rel || '').split(/\s+/)
|
||
return !(url.protocol === 'https:' || url.protocol === 'http:') || url.username !== '' || url.password !== '' || r.link.target !== '_blank' || !tokens.includes('noopener') || !tokens.includes('noreferrer') || !(r.link.label || '').startsWith('Quelle öffnen: ') || r.source !== 'Quelle öffnen'
|
||
})
|
||
check('botleads: jeder Quellen-Link ist http(s) ohne Zugangsdaten, öffnet im neuen Tab mit rel „noopener noreferrer“ und hat einen eigenen Namen', links.length >= 40 && linkProblems.length === 0, linkProblems.slice(0, 3))
|
||
const httpRow = blTitle(page1, BL_NAMES.http)
|
||
check('botleads: die http-Quelle bleibt ein Link mit rel noopener (die Ansicht lässt http und https zu)', Boolean(httpRow && httpRow.link) && httpRow.link.href.startsWith('http://') && (httpRow.link.rel || '').includes('noopener'), httpRow && httpRow.link)
|
||
const hostile = [blTitle(page1, BL_NAMES.javascript), blTitle(page1, BL_NAMES.credentials)]
|
||
check('botleads: feindliche Quellen (javascript:, Zugangsdaten in der Adresse) werden kein Link, die Zelle zeigt „–“', hostile.every((r) => r && r.link === null && r.source === '–'), hostile)
|
||
check('botleads: auf der Seite steht kein Link mit javascript: oder data:', (await page.locator('a[href^="javascript:" i], a[href^="data:" i]').count()) === 0)
|
||
|
||
// 5. Raumfilter
|
||
await blSelectRoom(page, 'handwerk')
|
||
const hw = await blRead(page)
|
||
check('botleads: Raum „Handwerk“ zeigt genau die 15 Handwerk-Zeilen, die Seitenzeile ohne Blättern', hw.length === 15 && hw.every((r) => r.room === 'Handwerk') && (await blFootLabel(page)) === BL_LABEL(1, 1, 15) && (await page.locator('.wk-bl-pager').count()) === 0, { rows: hw.length, label: await blFootLabel(page) })
|
||
const callHw = (await blCalls(page)).at(-1)
|
||
check('botleads: der Filter fragt den Dienst mit room=handwerk, Seite 1', callHw.room === 'handwerk' && callHw.page === 1 && callHw.q === '', callHw)
|
||
const longRow = blTitle(hw, 'Elektroinstallation-und-Haustechnik-Hoffmann-Sonnenschein-Wittgensteiner GmbH')
|
||
check('botleads: der lange Name und der lange Grund erscheinen vollständig', Boolean(longRow) && longRow.reason.startsWith('Impressum nennt nur eine Postfachadresse') && longRow.reason.endsWith('gehört einem Dritten.'), longRow)
|
||
await shoot(page, 'botleads-raum-handwerk.png')
|
||
await blSelectRoom(page, 'aerzte')
|
||
const ae = await blRead(page)
|
||
check('botleads: Raum „Ärzte & Praxen“ zeigt 15 Praxen, alle ohne Nummer', ae.length === 15 && ae.every((r) => r.room === 'Ärzte & Praxen' && r.phone === BL_HEALTH_PHONE), ae.map((r) => r.phone))
|
||
await shoot(page, 'botleads-raum-aerzte.png')
|
||
await blSelectRoom(page, '')
|
||
check('botleads: „Alle Räume“ bringt die 50 Zeilen von Seite 1 zurück', (await blRows(page).count()) === 50 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60))
|
||
|
||
// 6. Blättern mit der Tastatur: der Fokus bleibt auf der Seitenzeile
|
||
const next = page.getByRole('button', { name: 'Weiter', exact: true })
|
||
const prev = page.getByRole('button', { name: 'Zurück', exact: true })
|
||
await next.focus()
|
||
check('botleads: „Weiter“ lässt sich per Tastatur fokussieren', (await blFocus(page)).inPager)
|
||
await page.keyboard.press('Enter')
|
||
await blWaitLabel(page, BL_LABEL(2, 2, 60))
|
||
await blSettled(page)
|
||
const page2 = await blRead(page)
|
||
const focusNext = await blFocus(page)
|
||
check('botleads: nach „Weiter“ (Enter) bleibt der Tastaturfokus auf der Seitenzeile (Knopf in .wk-bl-pager)', focusNext.inPager, focusNext)
|
||
check('botleads: Seite 2 zeigt die letzten 10 Einträge, „Zurück“ frei und „Weiter“ gesperrt', page2.length === 10 && same(await pagerState(), { prev: false, next: true }), { rows: page2.length, state: await pagerState() })
|
||
const allTitles = [...page1, ...page2].map((r) => r.title)
|
||
check('botleads: beide Seiten zusammen sind 60 verschiedene Betriebe, Seite 2 knüpft zeitlich an Seite 1 an', new Set(allTitles).size === 60 && blDay(page2[0].found) <= blDay(page1.at(-1).found), { unique: new Set(allTitles).size })
|
||
const call2 = (await blCalls(page)).at(-1)
|
||
check('botleads: „Weiter“ fragt Seite 2 mit demselben Filter', call2.page === 2 && call2.room === '' && call2.q === '', call2)
|
||
await topOfPage(page)
|
||
await shoot(page, 'botleads-seite-2.png')
|
||
await prev.focus()
|
||
await page.keyboard.press('Enter')
|
||
await blWaitLabel(page, BL_LABEL(1, 2, 60))
|
||
await blSettled(page)
|
||
const focusPrev = await blFocus(page)
|
||
check('botleads: nach „Zurück“ (Enter) bleibt der Tastaturfokus auf der Seitenzeile', focusPrev.inPager, focusPrev)
|
||
check('botleads: „Zurück“ bringt Seite 1 mit 50 Zeilen', (await blRows(page).count()) === 50 && same((await blRead(page)).map((r) => r.title), page1.map((r) => r.title)))
|
||
metrics.botleadsFocusAfterPaging = { forward: focusNext, back: focusPrev }
|
||
|
||
// 7. Wechsel des Raums auf Seite 2 springt auf Seite 1; Ladezustand und späte Antworten
|
||
await next.click()
|
||
await blWaitLabel(page, BL_LABEL(2, 2, 60))
|
||
await blSelectRoom(page, 'laden')
|
||
const callLaden = (await blCalls(page)).at(-1)
|
||
check('botleads: Raumwechsel auf Seite 2 fragt wieder Seite 1', callLaden.room === 'laden' && callLaden.page === 1, callLaden)
|
||
await blSelectRoom(page, '')
|
||
await page.evaluate(() => { window.__wk.api.botleadsDelayMs = (query) => (query.room === 'handwerk' ? 1000 : 0) })
|
||
await page.getByLabel('Raum', { exact: true }).selectOption('handwerk')
|
||
const loadingText = await textOf(page.locator('.wk-bl .loading'))
|
||
check('botleads: während der Antwort steht „Wird geladen …“ (role=status), keine alte Tabelle', loadingText === 'Wird geladen …' && (await page.locator('.wk-bl .loading').getAttribute('role')) === 'status' && (await page.locator('.wk-bl-table').count()) === 0, loadingText)
|
||
await shoot(page, 'botleads-laedt.png')
|
||
await page.getByLabel('Raum', { exact: true }).selectOption('laden')
|
||
await blSettled(page)
|
||
const lad = await blRead(page)
|
||
await sleep(1500)
|
||
const ladLater = await blRead(page)
|
||
check('botleads: die späte Antwort des früheren Filters (Handwerk) überschreibt die neue Liste (Läden) nicht', lad.length === 15 && same(ladLater, lad) && ladLater.every((r) => r.room === 'Läden & Gastro') && (await page.getByLabel('Raum', { exact: true }).inputValue()) === 'laden', { rows: ladLater.length })
|
||
await page.evaluate(() => { window.__wk.api.botleadsDelayMs = 0 })
|
||
await blSelectRoom(page, '')
|
||
|
||
// 8. Suche und Leerzustand mit Filter
|
||
const search = page.getByLabel('Betriebe suchen', { exact: true })
|
||
await search.fill('Hartmann')
|
||
await page.waitForFunction(() => window.__wk.api.botleadCalls.some((c) => c.q === 'Hartmann'))
|
||
await blSettled(page)
|
||
const hartmann = await blRead(page)
|
||
check('botleads: Suche „Hartmann“ findet genau einen Betrieb, Seitenzeile „1 Eintrag“', hartmann.length === 1 && hartmann[0].title === BL_NAMES.http && (await blFootLabel(page)) === BL_LABEL(1, 1, 1), { rows: hartmann.length, label: await blFootLabel(page) })
|
||
await search.fill('Qwertz')
|
||
await page.waitForFunction(() => window.__wk.api.botleadCalls.some((c) => c.q === 'Qwertz'))
|
||
await blSettled(page)
|
||
const emptyTitle = await textOf(page.locator('.wk-bl .empty strong'))
|
||
const emptyHint = await textOf(page.locator('.wk-bl .empty'))
|
||
check('botleads: Suche ohne Treffer zeigt „Keine Treffer.“ mit Hinweis, ohne Tabelle und ohne Seitenzeile', emptyTitle === 'Keine Treffer.' && emptyHint.includes('Für diesen Raum und diese Suche gibt es keine Bot-Leads.') && (await page.locator('.wk-bl-table').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0, { emptyTitle, emptyHint })
|
||
check('botleads: im Leerzustand bleiben Raumfilter und Suchfeld bedienbar', (await page.getByLabel('Raum', { exact: true }).isEnabled()) && (await search.isEnabled()) && (await search.inputValue()) === 'Qwertz')
|
||
await shoot(page, 'botleads-leer-filter.png')
|
||
await blSelectRoom(page, 'handwerk')
|
||
check('botleads: Raum plus Suche ohne Treffer bleibt „Keine Treffer.“', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Keine Treffer.')
|
||
await search.fill('')
|
||
await page.waitForFunction(() => window.__wk.api.botleadCalls.at(-1).q === '' && window.__wk.api.botleadCalls.at(-1).room === 'handwerk')
|
||
await blSettled(page)
|
||
check('botleads: Suche leeren bringt die 15 Handwerk-Zeilen zurück', (await blRows(page).count()) === 15)
|
||
await blSelectRoom(page, '')
|
||
|
||
// 9. Fehlerzustand: deutscher Satz, Filter bleibt bedienbar, Erholung beim nächsten Versuch
|
||
await page.evaluate(() => { window.__wk.control.fault = 'down' })
|
||
await page.getByLabel('Raum', { exact: true }).selectOption('selbst')
|
||
await blSettled(page)
|
||
const err = page.locator('.wk-bl .msg.err')
|
||
const errText = await textOf(err)
|
||
check('botleads: Dienst nicht erreichbar zeigt den deutschen Satz „Der Hustler-Dienst ist gerade nicht erreichbar.“ (role=alert)', errText === PULT_DOWN && (await err.getAttribute('role')) === 'alert', errText)
|
||
check('botleads: die Fehlermeldung ist ein ganzer deutscher Satz ohne Technik (kein HTTP, fetch, Error, undefined)', /^[A-ZÄÖÜ][^]*[.!]$/.test(errText) && !/http|fetch|error|undefined|unbekannter fehler|\d{3}/i.test(errText), errText)
|
||
check('botleads: im Fehlerzustand keine Tabelle, keine Seitenzeile, Filter bedienbar', (await page.locator('.wk-bl-table').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0 && (await page.getByLabel('Raum', { exact: true }).isEnabled()))
|
||
await shoot(page, 'botleads-fehler.png')
|
||
await page.evaluate(() => { window.__wk.control.fault = 'session' })
|
||
await page.getByLabel('Raum', { exact: true }).selectOption('laden')
|
||
await blSettled(page)
|
||
check('botleads: abgelaufene Anmeldung zeigt „Anmeldung abgelaufen. Bitte neu anmelden.“', (await textOf(page.locator('.wk-bl .msg.err'))) === BL_SESSION, await textOf(page.locator('.wk-bl .msg.err')))
|
||
await shoot(page, 'botleads-fehler-anmeldung.png')
|
||
await page.evaluate(() => { window.__wk.control.fault = null })
|
||
await page.getByLabel('Raum', { exact: true }).selectOption('selbst')
|
||
await blSettled(page)
|
||
check('botleads: nach dem nächsten Versuch ist der Fehler weg und die 15 Selbstständigen stehen da', (await page.locator('.wk-bl .msg.err').count()) === 0 && (await blRows(page).count()) === 15)
|
||
|
||
// 10. Deep-Link ?tab=botleads&bot=<Id>: genau ein Eintrag, „Alle anzeigen“ führt zur Liste
|
||
await page.goto(base + BL_URL + '&bot=' + BL_IDS.health)
|
||
await blSettled(page)
|
||
const single = await blRead(page)
|
||
check('botleads: Deep-Link zeigt genau den einen Eintrag, hervorgehoben (aria-current)', single.length === 1 && single[0].title === BL_NAMES.health && single[0].current === 'true', single)
|
||
check('botleads: Deep-Link öffnet den Reiter „Bot-Leads“', (await page.getByRole('tab', { name: 'Bot-Leads', exact: true }).getAttribute('aria-selected')) === 'true')
|
||
check('botleads: Einzelansicht: Hinweis und „Alle anzeigen“, kein Raumfilter, keine Suche, keine Seitenzeile', (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1 && (await textOf(page.locator('.wk-bl-single'))).includes('Einzelner Eintrag aus dem Ereignisstrom') && (await page.locator('.wk-bl select, .wk-bl input').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0)
|
||
check('botleads: Einzelansicht des Heilberufs ohne Telefon', single[0].phone === BL_HEALTH_PHONE, single[0].phone)
|
||
const callSingle = (await blCalls(page)).at(-1)
|
||
check('botleads: Deep-Link fragt den Dienst mit id=<Kennung>', callSingle.id === BL_IDS.health, callSingle)
|
||
await shoot(page, 'botleads-einzeln.png')
|
||
await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).click()
|
||
await blSettled(page)
|
||
check('botleads: „Alle anzeigen“ führt zur Liste (50 Zeilen, Raumfilter und Seitenzeile, kein Einzelhinweis)', (await blRows(page).count()) === 50 && (await page.locator('.wk-bl-single').count()) === 0 && (await page.getByLabel('Raum', { exact: true }).count()) === 1 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60) && (await page.locator('tr[aria-current]').count()) === 0)
|
||
metrics.botleadsFocusAfterAlleAnzeigen = await blFocus(page)
|
||
await topOfPage(page)
|
||
await shoot(page, 'botleads-nach-alle-anzeigen.png')
|
||
|
||
// gekürzter Eintrag: nur über den Deep-Link, grau, ohne Quelle
|
||
await page.goto(base + BL_URL + '&bot=' + BL_IDS.cut)
|
||
await blSettled(page)
|
||
const cut = await blRead(page)
|
||
check('botleads: gekürzter Eintrag erscheint über den Deep-Link, mit Kürzel „gekürzt“, grauer Zeile, ohne Quelle', cut.length === 1 && cut[0].title === '[gekürzt]' && cut[0].tag === 'gekürzt' && cut[0].cut && cut[0].source === '–' && cut[0].link === null && cut[0].phone === BL_NO_PHONE, cut)
|
||
await shoot(page, 'botleads-einzeln-gekuerzt.png')
|
||
await page.goto(base + BL_URL + '&bot=' + BL_IDS.cutHealth)
|
||
await blSettled(page)
|
||
const cutHealth = await blRead(page)
|
||
check('botleads: gekürzter Heilberuf zeigt ebenfalls keine Nummer', cutHealth.length === 1 && cutHealth[0].phone === BL_HEALTH_PHONE, cutHealth)
|
||
await page.goto(base + BL_URL + '&bot=gibt-es-nicht')
|
||
await blSettled(page)
|
||
check('botleads: unbekannte Kennung zeigt „Dieser Eintrag ist nicht (mehr) in der Liste.“ und „Alle anzeigen“', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Dieser Eintrag ist nicht (mehr) in der Liste.' && (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1)
|
||
await shoot(page, 'botleads-einzeln-unbekannt.png')
|
||
await page.goto(base + BL_URL + '&bot=' + BL_IDS.health)
|
||
await blSettled(page)
|
||
await page.getByRole('tab', { name: 'Freigabe', exact: true }).click()
|
||
await page.getByRole('tab', { name: 'Bot-Leads', exact: true }).click()
|
||
await blSettled(page)
|
||
check('botleads: Reiterwechsel lässt die Einzelansicht fallen (wieder die ganze Liste)', (await blRows(page).count()) === 50)
|
||
|
||
// 11. Leerzustand ohne Filter
|
||
await page.goto(base + BL_URL + '&botleads=leer')
|
||
await blSettled(page)
|
||
check('botleads: leere Liste ohne Filter zeigt „Noch keine Bot-Leads.“ ohne Tabelle und ohne Seitenzeile', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Noch keine Bot-Leads.' && (await page.locator('.wk-bl-table').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0)
|
||
await shoot(page, 'botleads-leer.png')
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario R: Bot-Leads am Handy (390 x 844) ----------
|
||
|
||
async function handyBotleads(browser, base) {
|
||
const { context, page } = await newPage(browser, 'handy-botleads', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
|
||
const width = () => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth }))
|
||
const heightOf = (name) => page.getByRole('button', { name, exact: true }).evaluate((el) => {
|
||
el.scrollIntoView({ block: 'center' })
|
||
const r = el.getBoundingClientRect()
|
||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
|
||
return { height: Math.round(r.height * 10) / 10, width: Math.round(r.width), left: Math.round(r.left), right: Math.round(r.right), covered: !(hit && (hit === el || el.contains(hit))) }
|
||
})
|
||
|
||
// 1. Reiter am Handy: im Fenster, wählbar
|
||
await page.goto(base + '/index.html?page=1&fault=none')
|
||
await page.getByRole('tab').first().waitFor()
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
|
||
const tab = page.getByRole('tab', { name: 'Bot-Leads', exact: true })
|
||
const tabBox = await tab.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), inner: window.innerWidth } })
|
||
check('handy-botleads: Reiter „Bot-Leads“ liegt im Fenster (links >= 0, rechts <= 390) und ist nicht gewählt', tabBox.left >= 0 && tabBox.right <= tabBox.inner && (await tab.getAttribute('aria-selected')) === 'false', tabBox)
|
||
await tab.click()
|
||
await blSettled(page)
|
||
await page.evaluate(() => document.fonts.ready)
|
||
check('handy-botleads: nach dem Antippen ist „Bot-Leads“ gewählt und die Liste da', (await tab.getAttribute('aria-selected')) === 'true' && (await blRows(page).count()) === 50)
|
||
|
||
// 2. Kein seitliches Scrollen der Seite; die Tabelle scrollt in ihrem Rahmen
|
||
const w0 = await width()
|
||
metrics.phoneBotleads = w0
|
||
check('handy-botleads: Liste ohne seitliches Scrollen (scrollingElement.scrollWidth <= innerWidth)', w0.scroll <= w0.inner, w0)
|
||
const frame = await page.locator('.wk-bl-scroll').evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), clientWidth: el.clientWidth, scrollWidth: el.scrollWidth } })
|
||
check('handy-botleads: der Rahmen der Tabelle liegt im Fenster und scrollt waagerecht (Tabelle breiter als der Rahmen)', frame.left >= 0 && frame.right <= 390 && frame.scrollWidth > frame.clientWidth, frame)
|
||
const bar = await page.evaluate(() => {
|
||
const out = {}
|
||
for (const [name, sel] of [['tabs', '.tabs'], ['raum', '.wk-bl select'], ['suche', '.wk-bl input[type="search"]']]) { const r = document.querySelector(sel).getBoundingClientRect(); out[name] = { left: Math.round(r.left), right: Math.round(r.right) } }
|
||
return out
|
||
})
|
||
check('handy-botleads: Reiterleiste, Raumfilter und Suchfeld liegen im Fenster', Object.values(bar).every((b) => b.left >= 0 && b.right <= 390), bar)
|
||
const longCells = await page.evaluate(() => Array.from(document.querySelectorAll('.wk-bl-table tbody tr')).filter((tr) => tr.children[0].textContent.startsWith('Elektroinstallation-und-Haustechnik')).map((tr) => Array.from(tr.children).map((td) => td.scrollWidth - td.clientWidth)))
|
||
check('handy-botleads: der lange Name und der lange Grund laufen nicht aus ihren Zellen', longCells.length === 1 && longCells[0].every((d) => d <= 1), longCells)
|
||
const broken1 = await blBrokenWords(page, '.wk-bl-table tbody')
|
||
check('handy-botleads: Liste ohne mitten im Wort umgebrochene Wörter (Name, Ort, Raum, Grund)', broken1.error === null && broken1.count > 100 && broken1.words.length === 0, { count: broken1.count, words: broken1.words.slice(0, 8) })
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-botleads-liste.png')
|
||
await shoot(page, 'handy-botleads-liste-ganz.png', { fullPage: true })
|
||
await page.locator('.wk-bl-scroll').evaluate((el) => { el.scrollLeft = el.scrollWidth })
|
||
await page.locator('.wk-bl-scroll').scrollIntoViewIfNeeded()
|
||
await sleep(150)
|
||
await shoot(page, 'handy-botleads-tabelle-gescrollt.png')
|
||
await page.locator('.wk-bl-scroll').evaluate((el) => { el.scrollLeft = 0 })
|
||
|
||
// 3. Blätterknöpfe: im Fenster, nicht verdeckt, mindestens 40 px hoch
|
||
const prevBox = await heightOf('Zurück')
|
||
const nextBox = await heightOf('Weiter')
|
||
metrics.phoneBotleadsPager = { prev: prevBox, next: nextBox }
|
||
check('handy-botleads: Blätterknöpfe „Zurück“ und „Weiter“ sind im Fenster und nicht verdeckt', [prevBox, nextBox].every((b) => b.left >= 0 && b.right <= 390 && !b.covered), { prevBox, nextBox })
|
||
check('handy-botleads: Blätterknöpfe sind mindestens 40 px hoch (Tippfläche)', prevBox.height >= 40 && nextBox.height >= 40, { prev: prevBox.height, next: nextBox.height })
|
||
await shoot(page, 'handy-botleads-blaettern.png')
|
||
await page.getByRole('button', { name: 'Weiter', exact: true }).click()
|
||
await blWaitLabel(page, BL_LABEL(2, 2, 60))
|
||
await blSettled(page)
|
||
const w1 = await width()
|
||
check('handy-botleads: Seite 2 (10 Zeilen) ohne seitliches Scrollen, „Zurück“ erreichbar', (await blRows(page).count()) === 10 && w1.scroll <= w1.inner && !(await heightOf('Zurück')).covered, w1)
|
||
await shoot(page, 'handy-botleads-seite-2.png')
|
||
|
||
// 4. Gekürzter Eintrag: Kontrast der abgeblendeten Zeile
|
||
await page.goto(base + BL_URL + '&bot=' + BL_IDS.cut)
|
||
await blSettled(page)
|
||
const cutRows = await blRead(page)
|
||
check('handy-botleads: gekürzter Eintrag ist als graue Zeile da (Kürzel „gekürzt“)', cutRows.length === 1 && cutRows[0].cut && cutRows[0].tag === 'gekürzt', cutRows)
|
||
const w2 = await width()
|
||
check('handy-botleads: Einzelansicht ohne seitliches Scrollen', w2.scroll <= w2.inner, w2)
|
||
const broken2 = await blBrokenWords(page, '.wk-bl-table tbody')
|
||
check('handy-botleads: Einzelansicht ohne mitten im Wort umgebrochene Wörter (z. B. „Kaiserslauter-n“ im Betrieb-Feld)', broken2.error === null && broken2.count >= 5 && broken2.words.length === 0, { count: broken2.count, words: broken2.words })
|
||
const cutContrast = await blContrast(page, '.wk-bl-table tbody tr.wk-bl-cut')
|
||
metrics.phoneBotleadsContrastCut = cutContrast
|
||
check('handy-botleads: Kontrast der abgeblendeten (gekürzten) Zeile >= 4,5:1 für jeden Text (aus den berechneten Stilen)', cutContrast.error === null && cutContrast.count >= 6 && cutContrast.low.length === 0, cutContrast)
|
||
const alle = await heightOf('Alle anzeigen')
|
||
check('handy-botleads: „Alle anzeigen“ ist im Fenster und mindestens 40 px hoch (Tippfläche)', alle.left >= 0 && alle.right <= 390 && !alle.covered && alle.height >= 40, alle)
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-botleads-gekuerzt.png')
|
||
|
||
// 5. Kontrast der normalen Zeilen (Seite 1), Leerzustand und Fehler am Handy
|
||
await page.goto(base + BL_URL)
|
||
await blSettled(page)
|
||
const normalContrast = await blContrast(page, '.wk-bl-table tbody')
|
||
metrics.phoneBotleadsContrastRows = { count: normalContrast.count, min: normalContrast.min }
|
||
check('handy-botleads: Kontrast der normalen Zeilen >= 4,5:1 für jeden Text', normalContrast.error === null && normalContrast.count > 100 && normalContrast.low.length === 0, { count: normalContrast.count, min: normalContrast.min, low: normalContrast.low.slice(0, 3) })
|
||
const search = page.getByLabel('Betriebe suchen', { exact: true })
|
||
await search.fill('Qwertz')
|
||
await page.waitForFunction(() => window.__wk.api.botleadCalls.some((c) => c.q === 'Qwertz'))
|
||
await blSettled(page)
|
||
const w3 = await width()
|
||
check('handy-botleads: Leerzustand mit Filter („Keine Treffer.“) ohne seitliches Scrollen', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Keine Treffer.' && w3.scroll <= w3.inner, w3)
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-botleads-leer-filter.png')
|
||
await search.fill('')
|
||
await page.waitForFunction(() => window.__wk.api.botleadCalls.at(-1).q === '')
|
||
await blSettled(page)
|
||
await page.evaluate(() => { window.__wk.control.fault = 'down' })
|
||
await page.getByLabel('Raum', { exact: true }).selectOption('laden')
|
||
await blSettled(page)
|
||
const w4 = await width()
|
||
check('handy-botleads: Fehlerzustand zeigt den deutschen Satz und läuft nicht über den Rand', (await textOf(page.locator('.wk-bl .msg.err'))) === PULT_DOWN && w4.scroll <= w4.inner, w4)
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-botleads-fehler.png')
|
||
await page.evaluate(() => { window.__wk.control.fault = null })
|
||
|
||
// 6. Deep-Link und leere Liste am Handy
|
||
await page.goto(base + BL_URL + '&bot=' + BL_IDS.health)
|
||
await blSettled(page)
|
||
const one = await blRead(page)
|
||
const w5 = await width()
|
||
check('handy-botleads: Deep-Link zeigt genau einen Eintrag (hervorgehoben) mit „Alle anzeigen“, ohne seitliches Scrollen', one.length === 1 && one[0].current === 'true' && one[0].phone === BL_HEALTH_PHONE && (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1 && w5.scroll <= w5.inner, { rows: one.length, w5 })
|
||
const broken3 = await blBrokenWords(page, '.wk-bl-table tbody')
|
||
check('handy-botleads: Deep-Link-Einzelansicht des Heilberufs ohne mitten im Wort umgebrochene Wörter (z. B. „Kaiserslau-tern“)', broken3.error === null && broken3.count >= 5 && broken3.words.length === 0, { count: broken3.count, words: broken3.words })
|
||
await shoot(page, 'handy-botleads-einzeln.png')
|
||
await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).click()
|
||
await blSettled(page)
|
||
check('handy-botleads: „Alle anzeigen“ führt am Handy zur Liste (50 Zeilen, Raumfilter da)', (await blRows(page).count()) === 50 && (await page.getByLabel('Raum', { exact: true }).count()) === 1)
|
||
await page.goto(base + BL_URL + '&botleads=leer')
|
||
await blSettled(page)
|
||
const w6 = await width()
|
||
check('handy-botleads: leere Liste („Noch keine Bot-Leads.“) ohne seitliches Scrollen', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Noch keine Bot-Leads.' && w6.scroll <= w6.inner, w6)
|
||
await shoot(page, 'handy-botleads-leer.png')
|
||
await context.close()
|
||
}
|
||
|
||
const SCENARIOS = [
|
||
['desktop', desktop], ['blasen', bubbles], ['handy', phone], ['reduziert', reduced], ['notbremse', halted], ['ausfall', outage],
|
||
['freigabe', freigabe], ['interessent', interessent], ['anhalten', anhalten], ['handy-freigabe', handyFreigabe],
|
||
['antworten', antworten], ['vorschau', vorschau], ['einfuegen', einfuegen], ['bauen', bauen], ['fokus', fokus], ['handy-p4', handyP4],
|
||
['botleads', botleads], ['handy-botleads', handyBotleads],
|
||
];
|
||
|
||
(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'] })
|
||
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
|
||
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(() => {})
|
||
}
|
||
}
|
||
} finally {
|
||
await browser.close()
|
||
server.close()
|
||
}
|
||
check('keine pageerror/console.error', errors.length === 0, errors)
|
||
const ok = problems.length === 0
|
||
console.log(JSON.stringify({ errors, metrics, checks, shots, fontRequests: fontLog.length }, null, 2))
|
||
if (!ok) { console.error('FAIL:\n' + problems.join('\n')); process.exit(1) }
|
||
console.log('OK')
|
||
})().catch((e) => { console.error('FAIL', e); process.exit(1) })
|