// 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 // = Ausgabe von build.mjs, = 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 '); 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-.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 (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 )', (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 (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 ', 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 ', 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 ', 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 ', 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= 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=: 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=', callSingle.id === BL_IDS.health, callSingle) await shoot(page, 'botleads-einzeln.png') await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).click() await blSettled(page) check('botleads: „Alle anzeigen“ führt zur Liste (50 Zeilen, Raumfilter und Seitenzeile, kein Einzelhinweis)', (await blRows(page).count()) === 50 && (await page.locator('.wk-bl-single').count()) === 0 && (await page.getByLabel('Raum', { exact: true }).count()) === 1 && (await blFootLabel(page)) === BL_LABEL(1, 2, 60) && (await page.locator('tr[aria-current]').count()) === 0) metrics.botleadsFocusAfterAlleAnzeigen = await blFocus(page) await topOfPage(page) await shoot(page, 'botleads-nach-alle-anzeigen.png') // gekürzter Eintrag: nur über den Deep-Link, grau, ohne Quelle await page.goto(base + BL_URL + '&bot=' + BL_IDS.cut) await blSettled(page) const cut = await blRead(page) check('botleads: gekürzter Eintrag erscheint über den Deep-Link, mit Kürzel „gekürzt“, grauer Zeile, ohne Quelle', cut.length === 1 && cut[0].title === '[gekürzt]' && cut[0].tag === 'gekürzt' && cut[0].cut && cut[0].source === '–' && cut[0].link === null && cut[0].phone === BL_NO_PHONE, cut) await shoot(page, 'botleads-einzeln-gekuerzt.png') await page.goto(base + BL_URL + '&bot=' + BL_IDS.cutHealth) await blSettled(page) const cutHealth = await blRead(page) check('botleads: gekürzter Heilberuf zeigt ebenfalls keine Nummer', cutHealth.length === 1 && cutHealth[0].phone === BL_HEALTH_PHONE, cutHealth) await page.goto(base + BL_URL + '&bot=gibt-es-nicht') await blSettled(page) check('botleads: unbekannte Kennung zeigt „Dieser Eintrag ist nicht (mehr) in der Liste.“ und „Alle anzeigen“', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Dieser Eintrag ist nicht (mehr) in der Liste.' && (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1) await shoot(page, 'botleads-einzeln-unbekannt.png') await page.goto(base + BL_URL + '&bot=' + BL_IDS.health) await blSettled(page) await page.getByRole('tab', { name: 'Freigabe', exact: true }).click() await page.getByRole('tab', { name: 'Bot-Leads', exact: true }).click() await blSettled(page) check('botleads: Reiterwechsel lässt die Einzelansicht fallen (wieder die ganze Liste)', (await blRows(page).count()) === 50) // 11. Leerzustand ohne Filter await page.goto(base + BL_URL + '&botleads=leer') await blSettled(page) check('botleads: leere Liste ohne Filter zeigt „Noch keine Bot-Leads.“ ohne Tabelle und ohne Seitenzeile', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Noch keine Bot-Leads.' && (await page.locator('.wk-bl-table').count()) === 0 && (await page.locator('.wk-bl .table-foot').count()) === 0) await shoot(page, 'botleads-leer.png') await context.close() } // ---------- Szenario R: Bot-Leads am Handy (390 x 844) ---------- async function handyBotleads(browser, base) { const { context, page } = await newPage(browser, 'handy-botleads', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) const width = () => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth })) const heightOf = (name) => page.getByRole('button', { name, exact: true }).evaluate((el) => { el.scrollIntoView({ block: 'center' }) const r = el.getBoundingClientRect() const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) return { height: Math.round(r.height * 10) / 10, width: Math.round(r.width), left: Math.round(r.left), right: Math.round(r.right), covered: !(hit && (hit === el || el.contains(hit))) } }) // 1. Reiter am Handy: im Fenster, wählbar await page.goto(base + '/index.html?page=1&fault=none') await page.getByRole('tab').first().waitFor() await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19) const tab = page.getByRole('tab', { name: 'Bot-Leads', exact: true }) const tabBox = await tab.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), inner: window.innerWidth } }) check('handy-botleads: Reiter „Bot-Leads“ liegt im Fenster (links >= 0, rechts <= 390) und ist nicht gewählt', tabBox.left >= 0 && tabBox.right <= tabBox.inner && (await tab.getAttribute('aria-selected')) === 'false', tabBox) await tab.click() await blSettled(page) await page.evaluate(() => document.fonts.ready) check('handy-botleads: nach dem Antippen ist „Bot-Leads“ gewählt und die Liste da', (await tab.getAttribute('aria-selected')) === 'true' && (await blRows(page).count()) === 50) // 2. Kein seitliches Scrollen der Seite; die Tabelle scrollt in ihrem Rahmen const w0 = await width() metrics.phoneBotleads = w0 check('handy-botleads: Liste ohne seitliches Scrollen (scrollingElement.scrollWidth <= innerWidth)', w0.scroll <= w0.inner, w0) const frame = await page.locator('.wk-bl-scroll').evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), clientWidth: el.clientWidth, scrollWidth: el.scrollWidth } }) check('handy-botleads: der Rahmen der Tabelle liegt im Fenster und scrollt waagerecht (Tabelle breiter als der Rahmen)', frame.left >= 0 && frame.right <= 390 && frame.scrollWidth > frame.clientWidth, frame) const bar = await page.evaluate(() => { const out = {} for (const [name, sel] of [['tabs', '.tabs'], ['raum', '.wk-bl select'], ['suche', '.wk-bl input[type="search"]']]) { const r = document.querySelector(sel).getBoundingClientRect(); out[name] = { left: Math.round(r.left), right: Math.round(r.right) } } return out }) check('handy-botleads: Reiterleiste, Raumfilter und Suchfeld liegen im Fenster', Object.values(bar).every((b) => b.left >= 0 && b.right <= 390), bar) const longCells = await page.evaluate(() => Array.from(document.querySelectorAll('.wk-bl-table tbody tr')).filter((tr) => tr.children[0].textContent.startsWith('Elektroinstallation-und-Haustechnik')).map((tr) => Array.from(tr.children).map((td) => td.scrollWidth - td.clientWidth))) check('handy-botleads: der lange Name und der lange Grund laufen nicht aus ihren Zellen', longCells.length === 1 && longCells[0].every((d) => d <= 1), longCells) const broken1 = await blBrokenWords(page, '.wk-bl-table tbody') check('handy-botleads: Liste ohne mitten im Wort umgebrochene Wörter (Name, Ort, Raum, Grund)', broken1.error === null && broken1.count > 100 && broken1.words.length === 0, { count: broken1.count, words: broken1.words.slice(0, 8) }) await topOfPage(page) await shoot(page, 'handy-botleads-liste.png') await shoot(page, 'handy-botleads-liste-ganz.png', { fullPage: true }) await page.locator('.wk-bl-scroll').evaluate((el) => { el.scrollLeft = el.scrollWidth }) await page.locator('.wk-bl-scroll').scrollIntoViewIfNeeded() await sleep(150) await shoot(page, 'handy-botleads-tabelle-gescrollt.png') await page.locator('.wk-bl-scroll').evaluate((el) => { el.scrollLeft = 0 }) // 3. Blätterknöpfe: im Fenster, nicht verdeckt, mindestens 40 px hoch const prevBox = await heightOf('Zurück') const nextBox = await heightOf('Weiter') metrics.phoneBotleadsPager = { prev: prevBox, next: nextBox } check('handy-botleads: Blätterknöpfe „Zurück“ und „Weiter“ sind im Fenster und nicht verdeckt', [prevBox, nextBox].every((b) => b.left >= 0 && b.right <= 390 && !b.covered), { prevBox, nextBox }) check('handy-botleads: Blätterknöpfe sind mindestens 40 px hoch (Tippfläche)', prevBox.height >= 40 && nextBox.height >= 40, { prev: prevBox.height, next: nextBox.height }) await shoot(page, 'handy-botleads-blaettern.png') await page.getByRole('button', { name: 'Weiter', exact: true }).click() await blWaitLabel(page, BL_LABEL(2, 2, 60)) await blSettled(page) const w1 = await width() check('handy-botleads: Seite 2 (10 Zeilen) ohne seitliches Scrollen, „Zurück“ erreichbar', (await blRows(page).count()) === 10 && w1.scroll <= w1.inner && !(await heightOf('Zurück')).covered, w1) await shoot(page, 'handy-botleads-seite-2.png') // 4. Gekürzter Eintrag: Kontrast der abgeblendeten Zeile await page.goto(base + BL_URL + '&bot=' + BL_IDS.cut) await blSettled(page) const cutRows = await blRead(page) check('handy-botleads: gekürzter Eintrag ist als graue Zeile da (Kürzel „gekürzt“)', cutRows.length === 1 && cutRows[0].cut && cutRows[0].tag === 'gekürzt', cutRows) const w2 = await width() check('handy-botleads: Einzelansicht ohne seitliches Scrollen', w2.scroll <= w2.inner, w2) const broken2 = await blBrokenWords(page, '.wk-bl-table tbody') check('handy-botleads: Einzelansicht ohne mitten im Wort umgebrochene Wörter (z. B. „Kaiserslauter-n“ im Betrieb-Feld)', broken2.error === null && broken2.count >= 5 && broken2.words.length === 0, { count: broken2.count, words: broken2.words }) const cutContrast = await blContrast(page, '.wk-bl-table tbody tr.wk-bl-cut') metrics.phoneBotleadsContrastCut = cutContrast check('handy-botleads: Kontrast der abgeblendeten (gekürzten) Zeile >= 4,5:1 für jeden Text (aus den berechneten Stilen)', cutContrast.error === null && cutContrast.count >= 6 && cutContrast.low.length === 0, cutContrast) const alle = await heightOf('Alle anzeigen') check('handy-botleads: „Alle anzeigen“ ist im Fenster und mindestens 40 px hoch (Tippfläche)', alle.left >= 0 && alle.right <= 390 && !alle.covered && alle.height >= 40, alle) await topOfPage(page) await shoot(page, 'handy-botleads-gekuerzt.png') // 5. Kontrast der normalen Zeilen (Seite 1), Leerzustand und Fehler am Handy await page.goto(base + BL_URL) await blSettled(page) const normalContrast = await blContrast(page, '.wk-bl-table tbody') metrics.phoneBotleadsContrastRows = { count: normalContrast.count, min: normalContrast.min } check('handy-botleads: Kontrast der normalen Zeilen >= 4,5:1 für jeden Text', normalContrast.error === null && normalContrast.count > 100 && normalContrast.low.length === 0, { count: normalContrast.count, min: normalContrast.min, low: normalContrast.low.slice(0, 3) }) const search = page.getByLabel('Betriebe suchen', { exact: true }) await search.fill('Qwertz') await page.waitForFunction(() => window.__wk.api.botleadCalls.some((c) => c.q === 'Qwertz')) await blSettled(page) const w3 = await width() check('handy-botleads: Leerzustand mit Filter („Keine Treffer.“) ohne seitliches Scrollen', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Keine Treffer.' && w3.scroll <= w3.inner, w3) await topOfPage(page) await shoot(page, 'handy-botleads-leer-filter.png') await search.fill('') await page.waitForFunction(() => window.__wk.api.botleadCalls.at(-1).q === '') await blSettled(page) await page.evaluate(() => { window.__wk.control.fault = 'down' }) await page.getByLabel('Raum', { exact: true }).selectOption('laden') await blSettled(page) const w4 = await width() check('handy-botleads: Fehlerzustand zeigt den deutschen Satz und läuft nicht über den Rand', (await textOf(page.locator('.wk-bl .msg.err'))) === PULT_DOWN && w4.scroll <= w4.inner, w4) await topOfPage(page) await shoot(page, 'handy-botleads-fehler.png') await page.evaluate(() => { window.__wk.control.fault = null }) // 6. Deep-Link und leere Liste am Handy await page.goto(base + BL_URL + '&bot=' + BL_IDS.health) await blSettled(page) const one = await blRead(page) const w5 = await width() check('handy-botleads: Deep-Link zeigt genau einen Eintrag (hervorgehoben) mit „Alle anzeigen“, ohne seitliches Scrollen', one.length === 1 && one[0].current === 'true' && one[0].phone === BL_HEALTH_PHONE && (await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).count()) === 1 && w5.scroll <= w5.inner, { rows: one.length, w5 }) const broken3 = await blBrokenWords(page, '.wk-bl-table tbody') check('handy-botleads: Deep-Link-Einzelansicht des Heilberufs ohne mitten im Wort umgebrochene Wörter (z. B. „Kaiserslau-tern“)', broken3.error === null && broken3.count >= 5 && broken3.words.length === 0, { count: broken3.count, words: broken3.words }) await shoot(page, 'handy-botleads-einzeln.png') await page.getByRole('button', { name: 'Alle anzeigen', exact: true }).click() await blSettled(page) check('handy-botleads: „Alle anzeigen“ führt am Handy zur Liste (50 Zeilen, Raumfilter da)', (await blRows(page).count()) === 50 && (await page.getByLabel('Raum', { exact: true }).count()) === 1) await page.goto(base + BL_URL + '&botleads=leer') await blSettled(page) const w6 = await width() check('handy-botleads: leere Liste („Noch keine Bot-Leads.“) ohne seitliches Scrollen', (await textOf(page.locator('.wk-bl .empty strong'))) === 'Noch keine Bot-Leads.' && w6.scroll <= w6.inner, w6) await shoot(page, 'handy-botleads-leer.png') await context.close() } const SCENARIOS = [ ['desktop', desktop], ['blasen', bubbles], ['handy', phone], ['reduziert', reduced], ['notbremse', halted], ['ausfall', outage], ['freigabe', freigabe], ['interessent', interessent], ['anhalten', anhalten], ['handy-freigabe', handyFreigabe], ['antworten', antworten], ['vorschau', vorschau], ['einfuegen', einfuegen], ['bauen', bauen], ['fokus', fokus], ['handy-p4', handyP4], ['botleads', botleads], ['handy-botleads', handyBotleads], ]; (async () => { const server = await serve() const base = 'http://127.0.0.1:' + server.address().port const browser = await chromium.launch({ executablePath: CHROME, args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage'] }) try { const only = (process.env.HARNESS_ONLY || '').split(',').map((name) => name.trim()).filter(Boolean) for (const [name, run] of SCENARIOS) { if (only.length > 0 && !only.includes(name)) continue try { await run(browser, base) } catch (e) { problems.push('Abbruch in Szenario ' + name + ': ' + (e && e.stack ? e.stack : String(e))) if (lastPage) await shoot(lastPage, 'abbruch-' + name + '.png', { fullPage: true }).catch(() => {}) } } } finally { await browser.close() server.close() } check('keine pageerror/console.error', errors.length === 0, errors) const ok = problems.length === 0 console.log(JSON.stringify({ errors, metrics, checks, shots, fontRequests: fontLog.length }, null, 2)) if (!ok) { console.error('FAIL:\n' + problems.join('\n')); process.exit(1) } console.log('OK') })().catch((e) => { console.error('FAIL', e); process.exit(1) })