Endprüfung P6 (C5, W1): Beim Blättern hängt die Seitenzeile beim Laden aus, und „Alle anzeigen“ wird durch den Raumfilter ersetzt; der Fokus fiel jeweils auf <body>. - Blättern: gemerkt wird der gedrückte Knopf; nach dem Laden bekommt er den Fokus zurück, ist er gesperrt (erste oder letzte Seite), der andere. Raum oder Suche bedient der Nutzer inzwischen, geschieht nichts; scheitert die Anfrage, gibt es keine Seitenzeile und nichts geschieht. - „Alle anzeigen“: der Raumfilter bekommt den Fokus, sobald die Seite bot aus der Adresse genommen hat. Nicht beim Einhängen (am Handy öffnete das die Auswahl) und nicht bei einem anderen Weg zur Liste (Zurück-Taste). - aria-current nur mit bot (single) und passender Kennung; Zeilen ohne Kennung teilen sich keinen key mehr. - Tests: botleadList.test.mjs (Fokus-Attrappe über __fakeRef, Mutationsprobe für die Reset-Zeilen), botleadsView.test.mjs (Quelltext-Probe); der Prüfstand prüft zusätzlich den Fokus nach „Alle anzeigen“. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2200 lines
164 KiB
JavaScript
2200 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)
|
||
check('botleads: nach „Alle anzeigen“ liegt der Tastaturfokus auf dem Raumfilter (nicht auf <body>)', metrics.botleadsFocusAfterAlleAnzeigen.tag === 'SELECT' && !metrics.botleadsFocusAfterAlleAnzeigen.onBody, metrics.botleadsFocusAfterAlleAnzeigen)
|
||
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) })
|