Files
tickte-system/scripts/workers-harness/shot.cjs
T
ClaudeandClaude Sonnet 5.5 5138efd2bf Workers P4: Prüfstand für das neue Pult – Antworten, Vorschau freigeben, Einfügen, Bauen, Fokus, Handy
Die Skript-API kennt jetzt reply, approve-preview, start-build und paste mit den Prüfreihenfolgen und Sätzen des
Dienstes (pultMail.js, paste.js) und einen eigenen Datensatz (?p4=1: Mail-Antwort, zwei Vorschau-Freigaben, unsicher,
Kollegen-Eintrag, zwei gesendete Plattform-Antworten desselben Interessenten). Schalter für 400/409/413, Verbindungsabbruch
(status 0) und eine Wartezeit je Aktion.

Sechs neue Szenarien in shot.cjs: antworten, vorschau, einfuegen, bauen, fokus, handy-p4 (109 Prüfpunkte, Bilder je Zustand).
Drei veraltete P3-Prüfpunkte angepasst (seit P4 bietet eine Plattform-Zeile auch „Antwort des Kunden einfügen“). Bricht ein
Szenario ab, hält abbruch-<Szenario>.png den Zustand fest.

Der Lauf deckt Fehler der App auf (Fokus nach Aktionen, kein Esc/Abbrechen an den Feldern, 32-px-Schließen am Handy); die
Prüfpunkte dazu bleiben rot, an der App ist nichts geändert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 22:39:52 +00:00

1705 lines
124 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Manuelle Abnahme der Hustler-Seite im echten Browser (Chromium, headless shell) gegen den Prüfstand.
// Aufruf (immer unter Speicherdeckel, serielle Läufe):
// systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 300 node --max-old-space-size=400 \
// scripts/workers-harness/shot.cjs <distDir> <outDir>
// <distDir> = Ausgabe von build.mjs, <outDir> = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo.
// Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1.
// Szenarien: A Desktop, B Blasen, C Handy, D weniger Bewegung, E Notbremse, F Ausfall (P2); G Freigabe, H Interessenten-Panel, I Anhalten
// mit Rückfrage, J Handy mit Freigabe und Panel (P3); K Antworten als Mensch, L Vorschau freigeben, M Antwort des Kunden einfügen, N Vorschau
// bauen, O Fokus, P Handy für alle Felder (P4, eigener Datensatz ?p4=1, Bilder mit Vorsilbe antworten-, vorschau-, einfuegen-, bauen-, fokus-,
// handy-p4-). Jedes Szenario läuft für sich: bricht eines ab, laufen die übrigen weiter und der
// Abbruch steht als Verstoß in der Liste. HARNESS_ONLY=freigabe,interessent (Namen siehe `SCENARIOS` unten) lässt nur diese laufen (für
// die Arbeit am Prüfstand; die Abnahme läuft immer vollständig).
'use strict'
const { createRequire } = require('module')
const path = require('path')
const fs = require('fs')
const os = require('os')
const http = require('http')
const req = createRequire('/root/.npm/_npx/db89d7302a373f10/node_modules/')
const { chromium } = req('playwright-core')
const repo = path.resolve(__dirname, '../..')
const distArg = process.argv[2]
const outArg = process.argv[3]
if (!distArg || !outArg) { console.error('usage: node shot.cjs <distDir> <outDir>'); process.exit(2) }
const dist = path.resolve(distArg)
const out = path.resolve(outArg)
const tmp = path.resolve(process.env.TMPDIR || os.tmpdir())
const okBase = [tmp, path.join(repo, '.superpowers')]
if (!okBase.some((b) => out.startsWith(b + path.sep))) { console.error('refusing: outDir must be below ' + okBase.join(' or ')); process.exit(2) }
if (fs.existsSync(out) && fs.readdirSync(out).length > 0) { console.error('refusing: outDir must be new or empty'); process.exit(2) }
if (!fs.existsSync(path.join(dist, 'index.html'))) { console.error('refusing: no index.html in ' + dist); process.exit(2) }
fs.mkdirSync(out, { recursive: true })
// Namen und Modelle aus der gemeinsamen Roster-Fixture (CommonJS kann den ESM-Quelltext nicht laden)
const roster = JSON.parse(fs.readFileSync(path.join(repo, 'tests/fixtures/workers-roster.json'), 'utf8')).roles
const MODEL = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' }
const FONT_URL = '/fonts/PixelifySans-Variable.woff2'
const CHROME = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell'
const HALT_LINE = 'Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.'
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }
const fontLog = []
function serve() {
const server = http.createServer((rq, rs) => {
let rel
try { rel = decodeURIComponent(new URL(rq.url, 'http://x').pathname) } catch { rs.writeHead(400).end(); return }
const fonts = rel.startsWith('/fonts/')
const root = fonts ? path.join(repo, 'public', 'fonts') : dist
const file = path.resolve(root, '.' + (fonts ? rel.slice('/fonts'.length) : (rel === '/' ? '/index.html' : rel)))
if (rq.method !== 'GET' || !file.startsWith(root + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
if (fonts) fontLog.push({ url: rel, status: 404 })
rs.writeHead(404).end('not found')
return
}
if (fonts) fontLog.push({ url: rel, status: 200 })
rs.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' })
fs.createReadStream(file).pipe(rs)
})
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)))
}
const problems = []
const errors = []
const checks = {}
const metrics = {}
const shots = []
function check(name, ok, detail) {
checks[name] = ok ? 'ok' : 'FEHLER' + (detail === undefined ? '' : ' ' + JSON.stringify(detail))
if (!ok) problems.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail)))
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let lastPage = null // die zuletzt geöffnete Seite: bricht ein Szenario ab, wird von hier aus der Zustand im Bild festgehalten (abbruch-<Szenario>.png)
const textOf = async (loc) => ((await loc.textContent()) || '').replace(/\s+/g, ' ').trim()
async function shoot(page, name, options = {}) {
const file = path.join(out, name)
await page.screenshot({ path: file, ...options })
shots.push(file)
}
// Neuer Kontext mit Fehlersammlung; rAF wird mitgezählt (eine Schleife = höchstens ein wartender Rückruf)
async function newPage(browser, label, contextOptions) {
const context = await browser.newContext(contextOptions)
await context.addInitScript(() => {
const raf = window.requestAnimationFrame.bind(window)
const caf = window.cancelAnimationFrame.bind(window)
const waiting = new Set()
window.__raf = { waiting, calls: 0 }
window.requestAnimationFrame = (cb) => {
window.__raf.calls += 1
const id = raf((t) => { waiting.delete(id); cb(t) })
waiting.add(id)
return id
}
window.cancelAnimationFrame = (id) => { waiting.delete(id); caf(id) }
})
const page = await context.newPage()
lastPage = page
page.setDefaultTimeout(20000)
page.on('pageerror', (e) => errors.push(label + ' pageerror: ' + String(e)))
page.on('console', (m) => { if (m.type() === 'error') errors.push(label + ' console.error: ' + m.text()) })
page.on('requestfailed', (r) => errors.push(label + ' requestfailed: ' + r.url()))
page.on('response', (r) => {
if (r.url().endsWith(FONT_URL)) metrics.fontStatus = r.status()
if (r.status() >= 400) errors.push(label + ' http ' + r.status() + ': ' + r.url())
})
return { context, page }
}
async function ready(page, url) {
await page.goto(url)
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
await page.evaluate(() => document.fonts.load('600 12px "Pixelify Sans"'))
await page.waitForFunction(() => window.__wk && window.__wk.stage.current && window.__wk.stage.current.stats().frames > 0)
await page.evaluate(() => document.fonts.ready)
}
const stats = (page) => page.evaluate(() => window.__wk.stage.current.stats())
const topOfPage = (page) => page.evaluate(() => window.scrollTo(0, 0))
async function pickFromList(page, id, name) {
await page.locator(`[data-wk-worker="${id}"]`).click()
await page.waitForFunction((n) => (document.querySelector('.wk-name') || {}).textContent === n, name)
}
// ---------- Szenario A: Desktop 1280 x 900 ----------
async function desktop(browser, base) {
const { context, page } = await newPage(browser, 'desktop', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
const t0 = Date.now()
await ready(page, base + '/index.html')
await sleep(500)
await topOfPage(page)
// 1. Schrift: geladen, Status 200, document.fonts.check
check('schrift geladen', await page.evaluate(() => document.fonts.check('600 12px "Pixelify Sans"')))
check('schrift mit 200 geliefert', metrics.fontStatus === 200, metrics.fontStatus)
check('keine Warnzeile ohne ?halted=1', (await page.locator('.msg.err[role="alert"]').count()) === 0)
// a) erstes Bild: Hochhaus komplett (Bühne mindestens 560 px breit, Zoom 1)
const box = await page.locator('.wk-canvas').boundingBox()
const first = await stats(page)
metrics.desktopStage = { width: Math.round(box.width), height: Math.round(box.height), top: Math.round(box.y), zoom: first.zoom }
check('desktop: ganzes Hochhaus im Bild (Zoom 1, Bühne >= 560 px, Unterkante im Fenster)', first.zoom === 1 && box.width >= 560 && box.y + box.height <= 900, metrics.desktopStage)
await shoot(page, 'desktop-start.png')
// 2. 19 Knöpfe; Klick auf jeden zeigt den Namen aus ROLES und den vollen Modellnamen
check('19 Knöpfe', (await page.locator('[data-wk-worker]').count()) === 19)
const wrong = []
for (const r of roster) {
await pickFromList(page, r.workerId, r.name)
const name = await textOf(page.locator('.wk-name'))
const chip = await textOf(page.locator('.wk-chip'))
if (name !== r.name || chip !== MODEL[r.model]) wrong.push({ id: r.workerId, name, chip })
}
check('Klick auf jeden Knopf: Name und Modell-Chip stimmen', wrong.length === 0, wrong)
// 3. sechs Figuren auf der Leinwand anklicken
await page.locator('button[aria-label="Karte schließen"]').click()
const hits = []
for (const id of ['otto', 'kim', 'rita', 'bea', 'bob', 'arne']) {
await topOfPage(page)
const pos = await page.evaluate((who) => window.__wk.stage.current.locate(who), id)
const cb = await page.locator('.wk-canvas').boundingBox()
if (!pos) { hits.push({ id, locate: null }); continue }
await page.mouse.click(cb.x + pos.x, cb.y + pos.y)
const want = roster.find((r) => r.workerId === id).name
let got = ''
try {
await page.waitForFunction((n) => (document.querySelector('.wk-name') || {}).textContent === n, want, { timeout: 3000 })
got = want
} catch {
got = await textOf(page.locator('.wk-name').first()).catch(() => '')
}
if (got !== want) hits.push({ id, want, got })
}
check('Klick auf sechs Figuren trifft die Figur (locate)', hits.length === 0, hits)
// 4. Spur von kim: javascript:-Zeile nur Text, https-Zeile ein sicherer Link
await pickFromList(page, 'kim', 'Kim')
await page.waitForFunction(() => document.querySelectorAll('.wk-trace li').length === 12)
check('Spur hat 12 Zeilen', (await page.locator('.wk-trace li').count()) === 12)
check('kein Link mit javascript:', (await page.locator('.wk-trace a[href^="javascript:" i]').count()) === 0)
const fund = page.locator('.wk-trace a[href="https://example.org/fund"]')
check('https-Zeile ist ein Link', (await fund.count()) === 1)
if ((await fund.count()) === 1) {
check('https-Link: rel noopener noreferrer, target _blank', (await fund.getAttribute('rel')) === 'noopener noreferrer' && (await fund.getAttribute('target')) === '_blank')
}
const unsafeRow = page.locator('.wk-trace li', { hasText: 'Link mit Skript abgelehnt' })
check('javascript:-Zeile steht als Text', (await unsafeRow.count()) === 1 && (await unsafeRow.locator('a').count()) === 0)
// Karte offen (Bea: Sonnet-Chip, Spur) für das Bild
await pickFromList(page, 'bea', 'Bea')
await page.waitForFunction(() => document.querySelectorAll('.wk-trace li').length === 12)
await topOfPage(page)
await shoot(page, 'desktop-karte.png')
await page.locator('button[aria-label="Karte schließen"]').click()
// 5. Pausenknopf und Einstellungen
const pauseBtn = page.getByRole('button', { name: 'Alles anhalten', exact: true })
await pauseBtn.click()
// Anhalten betrifft alle Mitarbeiter und fragt nach (P2-T21): erst „Anhalten“ im Dialog „Alles anhalten?“ pausiert
await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).getByRole('button', { name: 'Anhalten', exact: true }).click()
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor()
check('Pausenknopf: „Fortsetzen“ nach dem Anhalten', true)
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click()
await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor()
check('Pausenknopf: zurück auf „Alles anhalten“', true)
await page.getByRole('button', { name: 'Einstellungen', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Hustler-Einstellungen' })
await dialog.waitFor()
await dialog.getByLabel('Tagesgrenze (USD)').waitFor()
check('Einstellungen: Dialog „Hustler-Einstellungen“ mit Feld „Tagesgrenze (USD)“', true)
check('Einstellungen ohne Notbremse: kein Block „Versand gestoppt“', (await page.locator('.wk-halt').count()) === 0)
await sleep(600) // der Dialog blendet ein
await shoot(page, 'desktop-einstellungen.png')
await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click()
await page.getByRole('dialog').waitFor({ state: 'detached' })
check('Einstellungen: „Abbrechen“ schließt den Dialog', true)
// 6. nach 20 s Skriptlauf: Bild, Zeichenzeit, Bilder pro Sekunde, eine einzige Schleife
await topOfPage(page)
const wait = 20000 - (Date.now() - t0)
if (wait > 0) await sleep(wait)
await shoot(page, 'desktop-nach-20s.png')
const timed = () => page.evaluate(() => ({ ...window.__wk.stage.current.stats(), at: performance.now() }))
// Messfenster von zwei Sekunden: Bilder je Sekunde und wartende rAF-Rückrufe (eine Schleife = höchstens einer)
const sample = async () => {
const a = await timed()
const raf = []
for (let i = 0; i < 20; i += 1) {
raf.push(await page.evaluate(() => window.__raf.waiting.size))
await sleep(100)
}
const b = await timed()
const seconds = (b.at - a.at) / 1000
return { frames: b.frames - a.frames, seconds, fps: (b.frames - a.frames) / seconds, avgDrawMs: b.avgDrawMs, rafWaitingMax: Math.max(...raf), raf }
}
const busy = await sample()
const dbg = await page.evaluate(() => window.__wk.stage.current.debug())
metrics.fpsDuringScript = Math.round(busy.fps * 10) / 10
metrics.avgDrawMs = Math.round(busy.avgDrawMs * 100) / 100
metrics.rafWaitingMax = busy.rafWaitingMax
metrics.pending = dbg.pending
if (!(busy.avgDrawMs <= 25)) console.warn('WARNUNG: avgDrawMs ' + busy.avgDrawMs.toFixed(2) + ' > 25 (software-gerendert)')
check('Schleife läuft (Bilder wachsen)', busy.frames > 0, busy.frames)
check('StrictMode: genau eine Schleife (höchstens ein wartender rAF-Rückruf)', busy.rafWaitingMax <= 1, busy.raf)
check('debug().pending >= 0', dbg.pending >= 0, dbg.pending)
// Nach dem Skript (Ende bei 45,8 s, letzter Zustandsabruf bei 45 s) kommen keine Daten mehr: dann zählt nur die Schleife.
// Beim Eintreffen von Zustand oder Ereignissen zeichnet die Szene sofort ein Bild extra, das ist keine Schleife.
const rest = 49000 - (Date.now() - t0)
if (rest > 0) await sleep(rest)
const idle = await sample()
metrics.fpsIdle = Math.round(idle.fps * 10) / 10
metrics.fpsWindowSeconds = Math.round(idle.seconds * 100) / 100
// FRAME_MS 33 ergibt höchstens 30,3 Bilder je Sekunde; ein Bild Messkante kommt dazu
check('Bilder pro Sekunde höchstens 30 (FRAME_MS 33, Ruhe nach dem Skript)', idle.fps <= 1000 / 33 + 1 / idle.seconds, { fps: metrics.fpsIdle, window: metrics.fpsWindowSeconds })
check('Schleife läuft auch in Ruhe mit etwa 30 Bildern je Sekunde', idle.fps >= 20, idle.fps)
await context.close()
}
// ---------- Szenario B: Blasen, Zeichen und Symbole bei doppelter Auflösung ----------
async function bubbles(browser, base) {
const { context, page } = await newPage(browser, 'blasen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2 })
await ready(page, base + '/index.html')
await sleep(400)
await topOfPage(page)
const cb = await page.locator('.wk-canvas').boundingBox()
const clip = { x: cb.x, y: cb.y, width: cb.width, height: cb.height }
await shoot(page, 'desktop-hochhaus-2x.png', { clip })
// Echte Engine, selbst gebaute Ereignisse: ’ ü % und die längste erlaubte Blase (20 Zeichen) gleichzeitig
const at = Date.now()
const make = (n, workerId, kind, text, extra) => ({ id: 'probe-' + n, ts: new Date(at + n).toISOString(), workerId, kind, text, link: null, to: null, prospectId: null, costUsd: 0, meta: null, ...extra })
await page.evaluate((events) => window.__wk.stage.current.pushEvents(events), [
make(1, 'otto', 'budget_warnung', 'Tagesbudget zu 80 % erreicht'),
make(2, 'bob', 'bau_start', 'Vorschau wird gebaut'),
make(3, 'theo', 'gelesen', 'Posteingang gelesen', { meta: { count: 123456789012 } }),
])
await sleep(700)
await shoot(page, 'desktop-blasen-2x.png', { clip })
await sleep(2600)
await page.evaluate((events) => window.__wk.stage.current.pushEvents(events), [
make(4, 'paul', 'pruefung_zurueck', 'Prüfung: Runde 1 zurück', { meta: { round: 1 } }),
make(5, 'lea', 'gelesen', 'Läden gelesen', { meta: { count: 123456789012 } }),
])
await sleep(700)
await shoot(page, 'desktop-blasen-2x-b.png', { clip })
await context.close()
}
// ---------- Szenario C: Handy 390 x 844 ----------
async function phone(browser, base) {
const { context, page } = await newPage(browser, 'handy', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
await ready(page, base + '/index.html')
await sleep(500)
const width = await page.evaluate(() => document.documentElement.scrollWidth)
metrics.phoneScrollWidth = width
check('handy: kein seitliches Scrollen (scrollWidth <= 390)', width <= 390, width)
await page.locator('.wk-stage').scrollIntoViewIfNeeded()
await sleep(200)
const zoomBtn = page.getByRole('button', { name: 'Vergrößern', exact: true })
check('handy: Zoom-Knöpfe sichtbar', (await zoomBtn.isVisible()) && (await page.getByRole('button', { name: 'Verkleinern', exact: true }).isVisible()))
await shoot(page, 'handy.png')
const z0 = (await stats(page)).zoom
await zoomBtn.click()
await sleep(300)
const z1 = (await stats(page)).zoom
metrics.phoneZoom = [Math.round(z0 * 100) / 100, Math.round(z1 * 100) / 100]
check('handy: „+“ erhöht den Zoom', z1 > z0, [z0, z1])
await shoot(page, 'handy-zoom.png')
await shoot(page, 'handy-ganz.png', { fullPage: true })
// Auswahl aus der Liste (die Liste ist auf dem Handy die Alternative zur Leinwand): die Karte kommt ins Bild und nimmt den Fokus,
// „Karte schließen“ gibt den Fokus an den Listeneintrag zurück (Endprüfung P2)
const bea = page.locator('[data-wk-worker="bea"]')
await bea.scrollIntoViewIfNeeded()
await bea.click()
await page.waitForFunction(() => document.activeElement && document.activeElement.classList.contains('wk-name') && document.activeElement.textContent === 'Bea')
check('handy: nach der Auswahl in der Liste hat der Name in der Karte den Fokus', true)
await sleep(300)
const cardBox = await page.locator('.wk-card').boundingBox()
const view = await page.evaluate(() => window.innerHeight)
metrics.phoneCardTop = Math.round(cardBox.y)
check('handy: nach der Auswahl in der Liste liegt der Kartenanfang im Fenster', cardBox.y >= 0 && cardBox.y <= view - 120, { top: cardBox.y, view })
await shoot(page, 'handy-karte.png')
await page.locator('button[aria-label="Karte schließen"]').click()
const back = await page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-wk-worker') : null))
check('handy: „Karte schließen“ gibt den Fokus an den Listeneintrag zurück', back === 'bea', back)
await context.close()
}
// ---------- Szenario D: weniger Bewegung ----------
async function reduced(browser, base) {
const { context, page } = await newPage(browser, 'reduziert', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
await page.goto(base + '/index.html')
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.reload()
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
await sleep(2000)
const f2 = (await stats(page)).frames
await sleep(2300)
const f4 = (await stats(page)).frames
metrics.reducedFrames = [f2, f4]
check('weniger Bewegung: nach 2 s höchstens 5 Bilder', f2 <= 5, f2)
check('weniger Bewegung: die Schleife steht (Bildzahl bleibt)', f4 === f2, [f2, f4])
await context.close()
}
// ---------- Szenario E: Notbremse (H4) ----------
async function halted(browser, base) {
const { context, page } = await newPage(browser, 'notbremse', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
await ready(page, base + '/index.html?halted=1')
await sleep(500)
await topOfPage(page)
const alerts = page.locator('.msg.err[role="alert"]')
await alerts.first().waitFor()
check('notbremse: genau eine rote Zeile', (await alerts.count()) === 1, await alerts.count())
const line = await textOf(alerts.first())
check('notbremse: Text der Warnzeile (Punkt des Grundes nicht doppelt)', line === HALT_LINE, line)
await shoot(page, 'desktop-gestoppt.png')
await page.getByRole('button', { name: 'Einstellungen', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Hustler-Einstellungen' })
await dialog.waitFor()
const block = page.locator('.wk-halt')
await block.waitFor()
check('notbremse: Block „Versand gestoppt“ im Dialog', (await textOf(block.locator('.wk-set-title'))) === 'Versand gestoppt')
const release = block.getByRole('button', { name: 'Versand wieder freigeben', exact: true })
check('notbremse: Knopf „Versand wieder freigeben“', (await release.count()) === 1)
await release.click()
await page.waitForFunction(() => document.querySelectorAll('.wk-halt').length === 0 && document.querySelectorAll('.msg.err[role="alert"]').length === 0, null, { timeout: 2000 })
check('notbremse: binnen 2 s sind Block und Warnzeile weg', true)
const saved = await page.evaluate(() => JSON.stringify(window.__wk.api.saved))
check('notbremse: gesendet wurde genau [{ sendingHalted: false }]', saved === '[{"sendingHalted":false}]', saved)
await context.close()
}
// ---------- Szenario F: Ausfall, Wiederkehr, versteckter Tab, 401-Folge (Endprüfung P2) ----------
// Das Board bekommt hier den echten API-Client (createWorkersApi) hinter einer Fetch-Weiche mit Ausfall-Schalter (entry.jsx, ?fault=).
const DOWN_TEXT = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
const callCount = (page) => page.evaluate(() => window.__wk.control.calls.length)
const freshMints = (page) => page.evaluate(() => window.__wk.control.jwts.filter(Boolean).length)
const setFault = (page, fault) => page.evaluate((f) => { window.__wk.control.fault = f }, fault)
// Tab verstecken oder zurückholen: Headless-Chromium kennt keinen versteckten Tab, darum werden die beiden Eigenschaften überschrieben
const setHidden = (page, hidden) => page.evaluate((h) => {
Object.defineProperty(document, 'hidden', { configurable: true, get: () => h })
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => (h ? 'hidden' : 'visible') })
document.dispatchEvent(new Event('visibilitychange'))
}, hidden)
async function outage(browser, base) {
const { context, page } = await newPage(browser, 'ausfall', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
// 1. Dienst beim Öffnen nicht erreichbar (503 ohne JSON): Hinweis mit „Neu laden“ statt leerer Seite
await page.goto(base + '/index.html?fault=down')
const empty = page.locator('.empty')
await empty.waitFor()
const emptyText = await textOf(empty)
check('ausfall: 503 beim Öffnen: „Der Hustler-Dienst ist noch nicht erreichbar“ mit der Dienst-Meldung', emptyText.includes('Der Hustler-Dienst ist noch nicht erreichbar') && emptyText.includes(DOWN_TEXT), emptyText)
check('ausfall: im Ausfall kein Hochhaus', (await page.locator('.wk-canvas').count()) === 0)
await shoot(page, 'desktop-ausfall.png')
const asked = await callCount(page)
await page.getByRole('button', { name: 'Neu laden', exact: true }).click()
await page.waitForFunction((n) => window.__wk.control.calls.length > n, asked)
await sleep(300)
check('ausfall: „Neu laden“ fragt den Dienst erneut, der Hinweis bleibt', (await page.locator('.empty').count()) === 1)
// 2. Dienst kehrt zurück: „Neu laden“ zeigt das Hochhaus
await setFault(page, null)
await page.getByRole('button', { name: 'Neu laden', exact: true }).click()
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19 && window.__wk.stage.current.stats().frames > 0, null, { timeout: 8000 })
check('ausfall: nach „Neu laden“ ist das Hochhaus mit 19 Figuren da', true)
check('ausfall: ohne Fehler keine Warnzeile', (await page.locator('.msg.warn').count()) === 0)
// 3. Ausfall mitten im Betrieb: Warnzeile, die Daten bleiben stehen
await setFault(page, 'down')
const warn = page.locator('.msg.warn[role="status"]')
await warn.waitFor({ timeout: 9000 })
const warnText = await textOf(warn)
check('ausfall: 503 im Betrieb: Warnzeile „Verbindung gestört: …“, das Hochhaus bleibt', warnText === 'Verbindung gestört: ' + DOWN_TEXT + ' Es wird weiter versucht.' && (await page.locator('[data-wk-worker]').count()) === 19, warnText)
await topOfPage(page)
await shoot(page, 'desktop-verbindung-gestoert.png')
// 4. Versteckter Tab: keine Abrufe, keine Bilder, auch wenn der Dienst inzwischen wieder da ist; beim Zurückkehren sofort beides
await setHidden(page, true)
await setFault(page, null)
await sleep(400)
const hiddenCalls = await callCount(page)
const hiddenFrames = (await stats(page)).frames
await sleep(6500) // länger als der Takt der Ereignisse (5 s)
const stillCalls = await callCount(page)
const stillFrames = (await stats(page)).frames
metrics.hidden = { calls: [hiddenCalls, stillCalls], frames: [hiddenFrames, stillFrames] }
check('ausfall: versteckter Tab: keine Abrufe in 6,5 s', stillCalls === hiddenCalls, [hiddenCalls, stillCalls])
check('ausfall: versteckter Tab: die Szene zeichnet nicht (höchstens 1 Bild Nachlauf)', stillFrames - hiddenFrames <= 1, [hiddenFrames, stillFrames])
check('ausfall: versteckter Tab: die Warnzeile bleibt bis zur Rückkehr stehen', (await page.locator('.msg.warn').count()) === 1)
await setHidden(page, false)
await page.waitForFunction((n) => window.__wk.control.calls.length > n, stillCalls, { timeout: 1500 })
check('ausfall: Rückkehr in den Tab fragt sofort ab (binnen 1,5 s, ohne den Takt abzuwarten)', true)
await page.waitForFunction(() => document.querySelectorAll('.msg.warn').length === 0, null, { timeout: 3000 })
check('ausfall: nach der Rückkehr verschwindet die Warnzeile', true)
const resumed = (await stats(page)).frames
await sleep(600)
check('ausfall: nach der Rückkehr zeichnet die Szene wieder', (await stats(page)).frames > resumed)
// 5. Dauerhaftes 401 (Dienst lehnt das JWT ab): weiter abfragen, aber höchstens ein frisches JWT, Meldung „Anmeldung abgelaufen“
await pickFromList(page, 'kim', 'Kim') // die Spur der Karte fragt ebenfalls ab
const before = await callCount(page)
const mintsBefore = await freshMints(page)
await setFault(page, 'session')
await page.waitForFunction((n) => window.__wk.control.calls.length >= n + 4, before, { timeout: 30000 })
const mints = (await freshMints(page)) - mintsBefore
const sessionWarn = await textOf(page.locator('.msg.warn[role="status"]'))
metrics.session401 = { calls: (await callCount(page)) - before, freshMints: mints }
check('ausfall: dauerhaftes 401: höchstens ein frisches JWT trotz mehrerer Abrufe', mints <= 1, mints)
check('ausfall: dauerhaftes 401: Warnzeile nennt die abgelaufene Anmeldung', sessionWarn === 'Verbindung gestört: ' + SESSION_TEXT + ' Es wird weiter versucht.', sessionWarn)
await setFault(page, null)
await page.waitForFunction(() => document.querySelectorAll('.msg.warn').length === 0, null, { timeout: 20000 }) // der Zustand wird nur alle 15 s geholt
check('ausfall: nimmt der Dienst das JWT wieder an, verschwindet die Warnzeile ohne neues Anmelden', true)
await context.close()
// 6. Antwort kommt nie (tote Verbindung): nach der Zeitgrenze (im Prüfstand 1,5 s) erscheint der Hinweis, nicht ewiges „Wird geladen …“
const hang = await newPage(browser, 'ausfall-haengt', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
const t0 = Date.now()
await hang.page.goto(base + '/index.html?fault=hang')
await hang.page.locator('.loading').waitFor()
await hang.page.locator('.empty').waitFor({ timeout: 6000 })
metrics.hangSeconds = Math.round((Date.now() - t0) / 100) / 10
check('ausfall: hängende Verbindung endet nach der Zeitgrenze mit der Dienst-Meldung', (await textOf(hang.page.locator('.empty'))).includes(DOWN_TEXT), metrics.hangSeconds)
await hang.context.close()
}
// ---------- P3: Freigabe-Pult, Interessenten-Panel, Anhalten mit Rückfrage ----------
// Die Seite läuft als echte WorkersPage (?page=1) über den echten API-Client und die Fetch-Weiche (&fault=none); Anmeldung, Mitarbeiterliste
// und Sitzungs-JWT sind ersetzt (build.mjs). Zwischenablage und window.open sind ersetzt und halten fest, was die Oberfläche tut
// (window.__clip, window.__opened). Die Schalter der Skript-API (window.__wk.api.faults, .pultEmpty, .pultCalls) steuern 409, 400, Ausfall und Leerzustand.
const T_MAIL = 'Anfrage: Vereinswebsite für den SV Namborn'
const T_PLAT = 'Kleinanzeigen: Website für Friseursalon gesucht'
const T_KOLL = 'Kleinanzeigen: Homepage für Handwerksbetrieb'
const T_UNS = 'Unsichere Antwort: bitte prüfen'
const T_ALT = 'freelancermap: WordPress-Relaunch'
const T_GES = 'Kleinanzeigen: Fotograf sucht Portfolio-Seite'
const T_ERL = 'Beschwerde über eine Vorschau'
const HAIR_URL = 'https://www.kleinanzeigen.de/s-anzeige/website-fuer-friseursalon/2870000001-1-1'
const WEBER_URL = 'https://www.kleinanzeigen.de/s-anzeige/homepage-handwerksbetrieb/2870000002-1-1'
const SV_URL = 'https://www.freelancermap.de/projekt/website-sportverein'
const PULT_URL = '/index.html?page=1&fault=none'
const PULT_DOWN = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
const CONFLICT = 'Wird schon von einem Kollegen bearbeitet.'
const TOO_LONG = 'Der Text ist zu lang (höchstens 8000 Zeichen).'
const HALT_QUESTION = 'Die Worker hören sofort auf, bis jemand fortsetzt.'
// Telefon: das Wort oder eine Nummer (+49 / Vorwahl mit 0, Trennzeichen, weitere Ziffern); Postleitzahlen und Beträge passen nicht darauf
const PHONE_WORD = /telefon|phone|\btel\.|handynummer|mobilnummer/i
const PHONE_NUMBER = /\+49|\b0\d{2,5}[\s/-]\d{3,}/
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
const rowOf = (page, title) => page.locator('li.wk-pult-row').filter({ has: page.locator('.wk-pult-title', { hasText: title }) })
const rowTitles = (page) => page.locator('.wk-pult-title').allTextContents()
const finishedLine = (page) => page.locator('.wk-pult-filters ~ .msg')
const setPultFault = (page, key, value) => page.evaluate(([k, v]) => { window.__wk.api.faults[k] = v }, [key, value])
const pultCalls = (page) => page.evaluate(() => window.__wk.api.pultCalls)
const resetStubs = (page) => page.evaluate(() => { window.__clip.length = 0; window.__opened.length = 0; window.__openMeta.length = 0 })
const stubState = (page) => page.evaluate(() => ({ clip: window.__clip.slice(), opened: window.__opened.map((a) => Array.from(a)), meta: window.__openMeta.slice() }))
const waitTitles = (page, titles, timeout = 5000) => page.waitForFunction(
(want) => JSON.stringify(Array.from(document.querySelectorAll('.wk-pult-title')).map((el) => el.textContent)) === JSON.stringify(want),
titles,
{ timeout },
)
const activeProbe = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.getAttribute('data-probe') : null))
const activeTag = (page) => page.evaluate(() => (document.activeElement ? document.activeElement.tagName : null))
// Seite mit ersetzter Zwischenablage und ersetztem window.open (beides vor dem Laden der Seite)
async function newPultPage(browser, label, contextOptions) {
const made = await newPage(browser, label, contextOptions)
// Die Zwischenablage antwortet erst nach 300 ms (wie ein Browser, der nach der Erlaubnis fragt): wer vor dem Öffnen auf das Kopieren
// wartet, öffnet später als 300 ms nach dem Klick und verliert die Nutzer-Geste. window.open hält Zeit und Geste fest.
await made.context.addInitScript(() => {
window.__clip = []
window.__opened = []
window.__openMeta = []
window.__lastClickAt = 0
document.addEventListener('click', () => { window.__lastClickAt = performance.now() }, true)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: (text) => { window.__clip.push(text); return new Promise((resolve) => setTimeout(resolve, 300)) } },
})
window.open = (...args) => {
window.__opened.push(args)
window.__openMeta.push({ sinceClick: performance.now() - window.__lastClickAt, active: navigator.userActivation ? navigator.userActivation.isActive : null })
return null
}
})
return made
}
// Reiter „Freigabe“ per Deep-Link öffnen und warten, bis die vier wartenden Einträge und die Mitarbeiternamen da sind
async function openPult(page, base) {
await page.goto(base + PULT_URL + '&tab=freigabe')
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4)
await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin'))
await page.evaluate(() => document.fonts.ready)
}
// ---------- Szenario G: Freigabe (Desktop 1280 x 900) ----------
async function freigabe(browser, base) {
const { context, page } = await newPultPage(browser, 'freigabe', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
// 1. Reiterleiste: erst ab zwei Reitern, Start auf „Hochhaus“, Wechsel auf „Freigabe“
await page.goto(base + PULT_URL)
await page.getByRole('tab').first().waitFor()
await page.waitForFunction(() => document.querySelectorAll('[data-wk-worker]').length === 19)
const tabNames = (await page.getByRole('tab').allTextContents()).map((t) => t.trim())
check('freigabe: Reiterleiste mit „Hochhaus“ und „Freigabe“', same(tabNames, ['Hochhaus', 'Freigabe']), tabNames)
check('freigabe: Start auf „Hochhaus“ (Hochhaus mit 19 Figuren, Reiter gewählt)', (await page.getByRole('tab', { name: 'Hochhaus', exact: true }).getAttribute('aria-selected')) === 'true')
await page.getByRole('tab', { name: 'Freigabe', exact: true }).click()
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 4)
await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin'))
await page.evaluate(() => document.fonts.ready)
check('freigabe: nach dem Klick ist „Freigabe“ gewählt und das Hochhaus weg', (await page.getByRole('tab', { name: 'Freigabe', exact: true }).getAttribute('aria-selected')) === 'true' && (await page.locator('[data-wk-worker]').count()) === 0)
// 2. Filter und Liste
const filterButtons = page.locator('.wk-pult-filters .seg button')
check('freigabe: Filter „Wartet“, „Veraltet“, „Erledigt“, „Wartet“ gedrückt', same(await filterButtons.allTextContents(), ['Wartet', 'Veraltet', 'Erledigt']) && same(await filterButtons.evaluateAll((els) => els.map((el) => el.getAttribute('aria-pressed'))), ['true', 'false', 'false']))
const first = await rowTitles(page)
check('freigabe: „Wartet“ zeigt die vier Einträge, neueste zuerst', same(first, [T_MAIL, T_PLAT, T_KOLL, T_UNS]), first)
const mail = rowOf(page, T_MAIL)
const plat = rowOf(page, T_PLAT)
const koll = rowOf(page, T_KOLL)
const uns = rowOf(page, T_UNS)
const buttonNames = (row) => row.locator('.wk-pult-actions').locator('button, a').allTextContents().then((list) => list.map((t) => t.replace(/\s+/g, ' ').trim()))
const platMeta = await textOf(plat.locator('.wk-pult-meta'))
check('freigabe: Plattform-Zeile: Alter, „läuft in 20 Std. ab“, Ort und Score', platMeta.includes('vor 5 Std.') && platMeta.includes('läuft in 20 Std. ab') && platMeta.includes('67655 Kaiserslautern · Score 72'), platMeta)
check('freigabe: Plattform-Zeile: Art-Badge „Antwort auf Plattform-Anfrage“', (await textOf(plat.locator('.wk-pult-head'))).includes('Antwort auf Plattform-Anfrage'))
const link = plat.getByRole('link', { name: `Anzeige öffnen: ${T_PLAT}`, exact: true })
check('freigabe: „Anzeige öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await link.getAttribute('href')) === HAIR_URL && (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer')
check('freigabe: Plattform-Zeile (offen): Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen (P4), Verwerfen', same(await buttonNames(plat), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await buttonNames(plat))
const mailTo = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href')
check('freigabe: Mail-Zeile: Link „E-Mail schreiben“ mit mailto-Adresse, Betreff und Text', typeof mailTo === 'string' && mailTo.startsWith('mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=Guten%20Tag%20Herr%20Beispiel'), mailTo)
check('freigabe: Mail-Zeile: kein „Kopieren & öffnen“ (Antwort geht per Mailprogramm)', (await mail.getByRole('button', { name: 'Kopieren & öffnen' }).count()) === 0)
await mail.getByLabel('Entwurf der Antwort').fill('Neuer Text für die Mail.')
const mailEdited = await mail.getByRole('link', { name: 'E-Mail schreiben', exact: true }).getAttribute('href')
check('freigabe: Mail-Zeile: der Mail-Link trägt den bearbeiteten Text aus dem Feld', mailEdited === 'mailto:vorstand@sv-namborn.example?subject=Re%3A%20SV%20Namborn&body=' + encodeURIComponent('Neuer Text für die Mail.'), mailEdited)
check('freigabe: Kollegen-Zeile: „übernommen von Justin“, kein „Ich kümmere mich“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin') && (await koll.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0)
check('freigabe: Zeile ohne Antwort („unsicher“): nur Ich kümmere mich, Erledigt, Verwerfen, kein Textfeld', same(await buttonNames(uns), ['Ich kümmere mich', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await buttonNames(uns))
// Wiederkehrende Bedienelemente tragen den Titel der Zeile im Namen (Screenreader-Liste), das sichtbare Wort steht vorn
const repeated = await page.evaluate(() => ({
details: [...document.querySelectorAll('.wk-pult-meta button.link')].map((el) => [el.textContent.trim(), el.getAttribute('aria-label')]),
sources: [...document.querySelectorAll('.wk-pult-meta a.link')].map((el) => [el.textContent.trim(), el.getAttribute('aria-label')]),
drafts: [...document.querySelectorAll('textarea.wk-pult-draft')].map((el) => el.getAttribute('aria-label')),
}))
const unique = (list) => new Set(list).size === list.length
check('freigabe: „Details“, „Anzeige öffnen“ und Entwurfsfeld haben je Zeile einen eigenen Namen (sichtbares Wort vorn)',
repeated.details.length > 1 && repeated.details.every(([text, name]) => text === 'Details' && typeof name === 'string' && name.startsWith('Details zu ')) && unique(repeated.details.map((d) => d[1]))
&& repeated.sources.length > 1 && repeated.sources.every(([text, name]) => text === 'Anzeige öffnen' && typeof name === 'string' && name.startsWith('Anzeige öffnen: ')) && unique(repeated.sources.map((d) => d[1]))
&& repeated.drafts.length > 1 && repeated.drafts.every((name) => typeof name === 'string' && name.startsWith('Entwurf der Antwort: ')) && unique(repeated.drafts), repeated)
await topOfPage(page)
await shoot(page, 'desktop-freigabe-wartet.png')
await shoot(page, 'desktop-freigabe-wartet-ganz.png', { fullPage: true })
// 3. Kollege hat schon übernommen: Kopieren & öffnen kopiert und öffnet, der Dienst antwortet 409, die Zeile zeigt den Namen
await koll.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click()
const kollNote = koll.locator('.msg')
await kollNote.waitFor()
const kollText = await textOf(kollNote)
check('freigabe: 409 beim Übernehmen: Hinweis „Wird schon von Justin bearbeitet.“ (zusammen mit „Kopiert und geöffnet.“)', kollText === 'Kopiert und geöffnet. Wird schon von Justin bearbeitet.', kollText)
check('freigabe: 409-Hinweis ist eine rote Zeile mit role=alert', (await kollNote.getAttribute('class')).includes('err') && (await kollNote.getAttribute('role')) === 'alert')
const kollStub = await stubState(page)
check('freigabe: Kollegen-Zeile: Text kopiert und Anzeige geöffnet (trotz 409)', kollStub.clip.length === 1 && kollStub.clip[0].startsWith('Guten Tag, gern bauen wir Ihnen eine erste Vorschau Ihrer Homepage') && same(kollStub.opened, [[WEBER_URL, '_blank', 'noopener,noreferrer']]), kollStub)
check('freigabe: Kollegen-Zeile bleibt „übernommen von Justin“', (await textOf(koll.locator('.wk-pult-meta'))).includes('übernommen von Justin'))
await shoot(page, 'desktop-freigabe-409-kollege.png')
await resetStubs(page)
// 4. Kopieren & öffnen mit bearbeitetem Text: kopiert den Text aus dem Feld, öffnet die Anzeige, übernimmt den Eintrag für mich
const area = plat.getByLabel('Entwurf der Antwort')
const draft = await area.inputValue()
const edited = draft + '\nPS: Bearbeitet im Prüfstand.'
await area.fill(edited)
await plat.getByRole('button', { name: 'Kopieren & öffnen', exact: true }).click()
const platNote = plat.locator('.msg')
await platNote.waitFor()
check('freigabe: Kopieren & öffnen: Meldung „Kopiert und geöffnet.“ (grün, role=status)', (await textOf(platNote)) === 'Kopiert und geöffnet.' && (await platNote.getAttribute('class')).includes('ok') && (await platNote.getAttribute('role')) === 'status', await textOf(platNote))
const platStub = await stubState(page)
check('freigabe: Kopieren & öffnen kopiert den bearbeiteten Text aus dem Feld', same(platStub.clip, [edited]), platStub.clip)
check('freigabe: Kopieren & öffnen öffnet genau die Anzeige im neuen Tab (noopener,noreferrer)', same(platStub.opened, [[HAIR_URL, '_blank', 'noopener,noreferrer']]), platStub.opened)
check('freigabe: Kopieren & öffnen öffnet im Zug des Klicks (binnen 150 ms, Nutzer-Geste aktiv), ohne die Zwischenablage abzuwarten', platStub.meta.length === 1 && platStub.meta[0].sinceClick < 150 && platStub.meta[0].active === true, platStub.meta)
await page.waitForFunction((t) => {
const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t))
return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')
}, T_PLAT)
const platCalls = (await pultCalls(page)).filter((c) => c.id === 'pult-plat')
check('freigabe: Kopieren & öffnen übernimmt den Eintrag (claim) und er steht „übernommen von Kenso“', platCalls.length >= 1 && platCalls[0].action === 'claim' && (await plat.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, platCalls)
check('freigabe: der bearbeitete Text bleibt nach dem Neuladen der Liste im Feld', (await area.inputValue()) === edited)
await shoot(page, 'desktop-freigabe-kopiert.png')
// 5. Übernehmen: 409 vom Dienst (Schalter) zeigt den Satz, danach klappt es
await setPultFault(page, 'claim', { status: 409, message: CONFLICT })
await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
const mailNote = mail.locator('.msg')
await mailNote.waitFor()
check('freigabe: Übernehmen mit 409: der Satz des Dienstes steht in der Zeile', (await textOf(mailNote)) === CONFLICT && (await mailNote.getAttribute('role')) === 'alert', await textOf(mailNote))
check('freigabe: nach dem 409 ist der Eintrag nicht übernommen (Knopf bleibt, kein „übernommen von“)', (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 1 && !(await textOf(mail.locator('.wk-pult-meta'))).includes('übernommen von'))
await shoot(page, 'desktop-freigabe-409-uebernehmen.png')
await setPultFault(page, 'claim', null)
await mail.getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
await page.waitForFunction((t) => {
const row = Array.from(document.querySelectorAll('li.wk-pult-row')).find((el) => el.textContent.includes(t))
return row && row.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')
}, T_MAIL)
check('freigabe: Übernehmen: „in Arbeit“ von mir (übernommen von Kenso), Meldung „Du kümmerst dich darum.“, Knopf weg', (await textOf(mail.locator('.msg'))) === 'Du kümmerst dich darum.' && (await mail.getByRole('button', { name: 'Ich kümmere mich' }).count()) === 0, await textOf(mail.locator('.msg')))
await shoot(page, 'desktop-freigabe-uebernommen.png')
// 6. Als gesendet markieren: Fehler 400 mit deutschem Satz, dann der Erfolg mit dem bearbeiteten Text
await setPultFault(page, 'sent', { status: 400, message: TOO_LONG })
await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click()
await plat.locator('.msg.err').waitFor()
check('freigabe: Fehler 400: der deutsche Satz des Dienstes steht in der Zeile (role=alert), die Zeile bleibt', (await textOf(plat.locator('.msg.err'))) === TOO_LONG && (await plat.locator('.msg.err').getAttribute('role')) === 'alert' && (await rowTitles(page)).includes(T_PLAT), await textOf(plat.locator('.msg.err')))
await shoot(page, 'desktop-freigabe-400.png')
await setPultFault(page, 'sent', null)
await plat.getByRole('button', { name: 'Als gesendet markieren', exact: true }).click()
await finishedLine(page).first().waitFor()
check('freigabe: gesendet: Meldung oben „…“ als gesendet markiert. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T_PLAT}“ als gesendet markiert.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && !(await rowTitles(page)).includes(T_PLAT), await textOf(finishedLine(page).first()))
const sentCall = (await pultCalls(page)).filter((c) => c.id === 'pult-plat' && c.action === 'sent').pop()
check('freigabe: gesendet schickt den bearbeiteten Text (body.text)', sentCall !== undefined && sentCall.body && sentCall.body.text === edited, sentCall)
await shoot(page, 'desktop-freigabe-gesendet.png')
// 7. Erledigt (Art ohne Antwort)
await uns.getByRole('button', { name: 'Erledigt', exact: true }).click()
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ erledigt.`, T_UNS)
check('freigabe: Erledigt: Meldung oben, die Zeile ist weg', !(await rowTitles(page)).includes(T_UNS), await rowTitles(page))
// 8. Verwerfen: erster Klick fragt nach, die Frage verfällt nach 4 s, zwei Klicks verwerfen; die Oberfläche schickt keinen Grund
const discardBtn = mail.locator('.wk-pult-actions').getByRole('button', { name: /^(Verwerfen|Wirklich verwerfen\?)$/ })
await discardBtn.click()
check('freigabe: Verwerfen: erster Klick fragt „Wirklich verwerfen?“, noch keine Aktion am Dienst', (await textOf(discardBtn)) === 'Wirklich verwerfen?' && (await pultCalls(page)).filter((c) => c.action === 'discard').length === 0)
await shoot(page, 'desktop-freigabe-verwerfen-frage.png')
await sleep(4400)
check('freigabe: Verwerfen: die Rückfrage verfällt nach 4 s (wieder „Verwerfen“)', (await textOf(discardBtn)) === 'Verwerfen')
await discardBtn.click()
await discardBtn.click()
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent === `„${t}“ verworfen.`, T_MAIL)
const discards = (await pultCalls(page)).filter((c) => c.action === 'discard')
check('freigabe: Verwerfen: genau ein Aufruf, Meldung oben „…“ verworfen., Zeile weg', discards.length === 1 && discards[0].id === 'pult-mail' && !(await rowTitles(page)).includes(T_MAIL), discards)
check('freigabe: Verwerfen schickt keinen Grund (Body {}); die Oberfläche hat kein Feld dafür', same(discards[0] && discards[0].body, {}), discards[0])
const afterAll = await rowTitles(page)
check('freigabe: „Wartet“ zeigt am Ende nur den Eintrag des Kollegen', same(afterAll, [T_KOLL]), afterAll)
// 9. Filter „Veraltet“, „Erledigt“
await filterButtons.nth(1).click()
await waitTitles(page, [T_ALT])
check('freigabe: „Veraltet“ zeigt den veralteten Eintrag mit Badge „veraltet“', (await textOf(rowOf(page, T_ALT).locator('.wk-pult-head'))).includes('veraltet') && (await filterButtons.nth(1).getAttribute('aria-pressed')) === 'true')
check('freigabe: Filterwechsel räumt die Meldung oben weg', (await finishedLine(page).count()) === 0)
check('freigabe: veralteter Eintrag lässt sich weiter bearbeiten (Kopieren & öffnen, Ich kümmere mich, Als gesendet markieren, Antwort des Kunden einfügen, Verwerfen)', same(await buttonNames(rowOf(page, T_ALT)), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', 'Antwort des Kunden einfügen', 'Verwerfen']), await buttonNames(rowOf(page, T_ALT)))
await shoot(page, 'desktop-freigabe-veraltet.png')
await rowOf(page, T_ALT).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
await page.locator('.empty').waitFor()
const emptyOld = await textOf(page.locator('.empty'))
check('freigabe: Leerzustand „Veraltet“: „Nichts ist veraltet“ mit Hinweis', emptyOld.startsWith('Nichts ist veraltet') && emptyOld.includes('Einträge, die zu lange liegen'), emptyOld)
await shoot(page, 'desktop-freigabe-leer-veraltet.png')
await filterButtons.nth(2).click()
await waitTitles(page, [T_MAIL, T_PLAT, T_UNS, T_GES, T_ERL])
// Seit P4 nimmt eine gesendete Plattform-Antwort auch danach noch die Antwort des Kunden an: dort steht der eine Knopf „Antwort des Kunden einfügen“;
// verworfene und erledigte Zeilen sowie die gesendete Mail-Antwort haben keine Aktionen mehr
const doneActions = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({
title: row.querySelector('.wk-pult-title').textContent,
actions: [...row.querySelectorAll('.wk-pult-actions > *')].map((el) => el.textContent.trim()),
})))
check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), Aktion nur „Antwort des Kunden einfügen“ an den gesendeten Plattform-Antworten',
doneActions.length === 5 && doneActions.every((r) => same(r.actions, [T_PLAT, T_GES].includes(r.title) ? ['Antwort des Kunden einfügen'] : [])), doneActions)
const doneMeta = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({
badge: [...row.querySelectorAll('.wk-pult-head .badge')].map((b) => b.textContent.trim()).filter((t) => ['Gesendet', 'Verworfen', 'Erledigt'].includes(t)),
meta: row.querySelector('.wk-pult-meta').textContent.trim(),
})))
check('freigabe: „Erledigt“: jede Zeile nennt Ergebnis (Badge), Person und Zeitpunkt der Erledigung, nicht das Alter ab Anlage',
doneMeta.length === 5 && doneMeta.every((r) => r.badge.length === 1 && new RegExp(`^${r.badge[0]} von (Kenso|Justin) · (gerade eben|vor \\d+ (Min\\.|Std\\.|Tag|Tagen))`).test(r.meta)), doneMeta)
check('freigabe: „Erledigt“: Foto-Zeile „Gesendet von Kenso · vor 1 Tag“, Beschwerde „Erledigt von Justin · vor 3 Tagen“',
(await textOf(rowOf(page, T_GES).locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso · vor 1 Tag') && (await textOf(rowOf(page, T_ERL).locator('.wk-pult-meta'))).startsWith('Erledigt von Justin · vor 3 Tagen'))
await shoot(page, 'desktop-freigabe-erledigt.png')
// 10. Leerzustand „Wartet“ und Ausfall des Pults mit „Neu laden“
await page.evaluate(() => { window.__wk.api.pultEmpty = true })
await filterButtons.nth(0).click()
await page.locator('.empty').waitFor()
const emptyWait = await textOf(page.locator('.empty'))
check('freigabe: Leerzustand „Wartet“: „Nichts wartet auf dich“ mit Hinweis', emptyWait.startsWith('Nichts wartet auf dich') && emptyWait.includes('Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'), emptyWait)
await shoot(page, 'desktop-freigabe-leer.png')
await setPultFault(page, 'pult', { status: 503, message: PULT_DOWN })
await filterButtons.nth(1).click()
await page.getByRole('button', { name: 'Neu laden', exact: true }).waitFor()
const emptyDown = await textOf(page.locator('.empty'))
check('freigabe: Pult nicht erreichbar: Hinweis mit dem Satz des Dienstes und „Neu laden“', emptyDown.includes('Das Pult ist gerade nicht erreichbar') && emptyDown.includes(PULT_DOWN), emptyDown)
await shoot(page, 'desktop-freigabe-ausfall.png')
await setPultFault(page, 'pult', null)
await page.getByRole('button', { name: 'Neu laden', exact: true }).click()
await page.waitForFunction(() => (document.querySelector('.empty') || {}).textContent?.startsWith('Nichts ist veraltet'))
check('freigabe: „Neu laden“ nach dem Ausfall fragt erneut, der Leerzustand kehrt zurück', true)
await page.evaluate(() => { window.__wk.api.pultEmpty = false })
await filterButtons.nth(0).click()
await waitTitles(page, [T_KOLL, T_ALT])
check('freigabe: nach dem Ausfall und dem Leerzustand ist die Liste wieder da (Kollege und der eben übernommene veraltete Eintrag)', true)
await context.close()
}
// ---------- Szenario H: Interessenten-Panel (Desktop) ----------
async function interessent(browser, base) {
const { context, page } = await newPultPage(browser, 'interessent', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
await openPult(page, base)
// 1. „Details“ öffnet das Panel als Dialog
const opener = rowOf(page, T_MAIL).getByRole('button', { name: `Details zu ${T_MAIL}`, exact: true })
await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
await opener.click()
const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true })
await dialog.waitFor()
await dialog.locator('.wk-prospect').waitFor()
await sleep(600) // der Dialog blendet ein
check('interessent: „Details“ öffnet einen Dialog (role=dialog, aria-modal) mit dem Namen als Titel', (await dialog.getAttribute('aria-modal')) === 'true')
// Im Entwicklungsmodus hängt StrictMode jeden Effekt zweimal ein: ein oder zwei Abfragen, aber immer nur dieser Interessent
const asked = await page.evaluate(() => window.__wk.api.prospectCalls)
check('interessent: das Panel fragt den Interessenten p-mail ab (und keinen anderen)', asked.length >= 1 && asked.every((id) => id === 'p-mail'), asked)
check('interessent: der Fokus liegt beim Öffnen im Dialog', await dialog.evaluate((el) => el.contains(document.activeElement)))
const scoreParts = await dialog.locator('.wk-prospect-score').evaluate((el) => Array.from(el.children).map((child) => child.textContent.trim()))
check('interessent: Score als Badge „Score 84 · hoch“ mit Begründung', same(scoreParts, ['Score 84 · hoch', 'Klarer Auftrag, Budget genannt, Region passt.']), scoreParts)
const facts = await dialog.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim()))
check('interessent: Fakten Quelle, Gefunden, Ort, Status (ohne Telefon)', same(facts, ['Quelle', 'freelancermap', 'Gefunden', 'vor 3 Std.', 'Ort', '66640 Namborn', 'Status', 'Bewertet']), facts)
const request = dialog.locator('.wk-prospect-section').first()
const requestText = request.locator('.wk-pult-text')
check('interessent: Anfragetext als Text mit erhaltenen Zeilenumbrüchen (pre-wrap)', (await requestText.textContent()).includes('Vereinsseite neu macht.\n\nBudget bis 1.500 Euro') && (await requestText.evaluate((el) => getComputedStyle(el).whiteSpace)) === 'pre-wrap')
const open = request.getByRole('link', { name: 'Anfrage öffnen', exact: true })
check('interessent: „Anfrage öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await open.getAttribute('href')) === SV_URL && (await open.getAttribute('target')) === '_blank' && (await open.getAttribute('rel')) === 'noopener noreferrer')
const msgs = await dialog.locator('li.wk-prospect-msg').evaluateAll((els) => els.map((el) => ({ who: el.querySelector('strong').textContent, when: el.querySelector('.wk-prospect-msg-head span').textContent, body: el.querySelector('p').textContent })))
check('interessent: zwei Nachrichten: „Interessent“ (eingegangen) und „Justin“ (Mitarbeiter, Name aus der Liste), mit Alter', msgs.length === 2 && msgs[0].who === 'Interessent' && msgs[1].who === 'Justin' && msgs[0].when === 'vor 3 Std.' && msgs[1].when === 'vor 2 Std.' && msgs[0].body.startsWith('Guten Tag,\nkönnen Sie uns eine Vorschau zeigen?'), msgs)
const previewLine = await textOf(dialog.locator('.wk-prospect-section').last().locator('p.muted'))
check('interessent: Vorschau-Zeile „Vorschau: fertig · Runde 1 · erstellt vor 1 Std.“', previewLine === 'Vorschau: fertig · Runde 1 · erstellt vor 1 Std.', previewLine)
const html = await page.evaluate(() => document.documentElement.outerHTML)
const text = await page.evaluate(() => document.body.innerText)
check('interessent: nirgends im Bild ein Telefon (weder Wort noch Nummer, weder in Text noch im HTML)', !PHONE_WORD.test(html) && !PHONE_NUMBER.test(html) && !PHONE_WORD.test(text) && !PHONE_NUMBER.test(text), { word: (html.match(PHONE_WORD) || [])[0], number: (html.match(PHONE_NUMBER) || [])[0] })
check('interessent: keine Mail-Rohdaten im Bild (messageId, inReplyTo, references)', !/messageId|inReplyTo|referencesJson/.test(html))
await shoot(page, 'desktop-interessent-panel.png')
// 2. Esc schließt, der Fokus kehrt auf „Details“ zurück
await page.keyboard.press('Escape')
await page.getByRole('dialog').waitFor({ state: 'detached' })
check('interessent: Esc schließt das Panel', true)
check('interessent: nach Esc steht der Fokus wieder auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
// 3. „Schließen“ im Fuß und das Kreuz schließen ebenfalls und geben den Fokus zurück
await opener.click()
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).last().click()
await page.getByRole('dialog').waitFor({ state: 'detached' })
check('interessent: „Schließen“ im Fuß schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
await opener.click()
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).first().click()
await page.getByRole('dialog').waitFor({ state: 'detached' })
check('interessent: das Kreuz oben schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
// 4. Interessent ohne Score, ohne Anfragetext, ohne Verlauf
await rowOf(page, T_KOLL).getByRole('button', { name: `Details zu ${T_KOLL}`, exact: true }).click()
const dialog2 = page.getByRole('dialog', { name: 'Meisterbetrieb Weber', exact: true })
await dialog2.waitFor()
await dialog2.locator('.wk-prospect').waitFor()
const body2 = await textOf(dialog2.locator('.wk-prospect'))
check('interessent: ohne Score „Noch nicht bewertet“, ohne Anfragetext und Verlauf die Leerzeilen, keine Vorschau-Zeile', body2.includes('Noch nicht bewertet') && body2.includes('Kein Anfragetext gespeichert.') && body2.includes('Noch keine Nachrichten.') && !body2.includes('Vorschau'), body2)
const facts2 = await dialog2.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim()))
check('interessent: Fakten Kleinanzeigen / 66849 Landstuhl / Gefunden', same(facts2, ['Quelle', 'Kleinanzeigen', 'Gefunden', 'vor 8 Std.', 'Ort', '66849 Landstuhl', 'Status', 'Gefunden']), facts2)
await sleep(600)
await shoot(page, 'desktop-interessent-leer.png')
await page.keyboard.press('Escape')
await page.getByRole('dialog').waitFor({ state: 'detached' })
// 5. Dienst meldet einen Fehler: Satz im Panel, „Neu laden“ holt den Interessenten
await setPultFault(page, 'prospect', { status: 404, message: 'Interessent nicht gefunden.' })
await rowOf(page, T_PLAT).getByRole('button', { name: `Details zu ${T_PLAT}`, exact: true }).click()
const dialog3 = page.getByRole('dialog', { name: 'Interessent', exact: true })
await dialog3.getByRole('alert').waitFor()
check('interessent: Fehler vom Dienst: der Satz steht im Panel (role=alert) mit „Neu laden“', (await textOf(dialog3.getByRole('alert'))) === 'Interessent nicht gefunden.' && (await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).count()) === 1, await textOf(dialog3))
await sleep(600)
await shoot(page, 'desktop-interessent-fehler.png')
await setPultFault(page, 'prospect', null)
await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).click()
const dialog4 = page.getByRole('dialog', { name: 'Friseursalon Haarmonie', exact: true })
await dialog4.locator('.wk-prospect').waitFor()
check('interessent: „Neu laden“ holt den Interessenten (Score 72 · mittel)', (await textOf(dialog4.locator('.wk-prospect-score'))).startsWith('Score 72 · mittel'), await textOf(dialog4.locator('.wk-prospect-score')))
await page.keyboard.press('Escape')
await page.getByRole('dialog').waitFor({ state: 'detached' })
await context.close()
}
// ---------- Szenario I: Alles anhalten mit Rückfrage (Desktop) ----------
async function anhalten(browser, base) {
const { context, page } = await newPage(browser, 'anhalten', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
await ready(page, base + '/index.html')
await sleep(400)
await topOfPage(page)
const status = () => textOf(page.locator('.wk-status'))
const calls = () => page.evaluate(() => window.__wk.api.pauseCalls)
const pauseBtn = page.locator('.wk-toolbar').getByRole('button', { name: 'Alles anhalten', exact: true })
// 1. Rückfrage statt sofortigem Anhalten
await pauseBtn.evaluate((el) => el.setAttribute('data-probe', 'pause-button'))
await pauseBtn.click()
const dialog = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true })
await dialog.waitFor()
check('anhalten: „Alles anhalten“ öffnet den Dialog „Alles anhalten?“ mit dem Satz zu den Workern', (await textOf(dialog.locator('.dialog-body'))) === HALT_QUESTION, await textOf(dialog.locator('.dialog-body')))
check('anhalten: Dialog mit den Knöpfen „Abbrechen“ und „Anhalten“', same(await dialog.locator('.dialog-foot button').allTextContents(), ['Abbrechen', 'Anhalten']))
check('anhalten: vor der Bestätigung ist nichts angehalten (kein Aufruf, Status „Läuft“)', same(await calls(), []) && (await status()) === 'Läuft', { calls: await calls(), status: await status() })
await sleep(600)
await shoot(page, 'desktop-anhalten-frage.png')
// 2. Abbrechen tut nichts, der Fokus kehrt auf den Knopf zurück
await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click()
await page.getByRole('dialog').waitFor({ state: 'detached' })
check('anhalten: „Abbrechen“ schließt den Dialog, nichts wird angehalten', same(await calls(), []) && (await status()) === 'Läuft' && (await pauseBtn.textContent()) === 'Alles anhalten')
check('anhalten: nach „Abbrechen“ steht der Fokus wieder auf „Alles anhalten“', (await activeProbe(page)) === 'pause-button', await activeTag(page))
// 3. Esc verhält sich wie Abbrechen
await pauseBtn.click()
await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).waitFor()
await page.keyboard.press('Escape')
await page.getByRole('dialog').waitFor({ state: 'detached' })
check('anhalten: Esc schließt den Dialog, nichts wird angehalten, Fokus zurück auf „Alles anhalten“', same(await calls(), []) && (await activeProbe(page)) === 'pause-button', { calls: await calls(), focus: await activeTag(page) })
// 4. Anhalten hält an
await pauseBtn.click()
const dialog2 = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true })
await dialog2.waitFor()
await dialog2.getByRole('button', { name: 'Anhalten', exact: true }).click()
await page.getByRole('dialog').waitFor({ state: 'detached' })
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor()
await page.waitForFunction(() => document.querySelector('.wk-status') && document.querySelector('.wk-status').textContent.startsWith('Angehalten'))
check('anhalten: „Anhalten“ pausiert genau einmal (Aufruf pause(true)), Status „Angehalten: …“, Knopf „Fortsetzen“', same(await calls(), [true]) && (await status()) === 'Angehalten: Es wird nichts gesendet und nichts gebaut.', { calls: await calls(), status: await status() })
await sleep(500)
const focusTag = await activeTag(page)
const focusText = await page.evaluate(() => (document.activeElement ? (document.activeElement.textContent || '').trim().slice(0, 40) : null))
check('anhalten: nach „Anhalten“ steht der Fokus nicht auf <body> (Tastatur-Nutzer verlieren sonst ihre Stelle)', focusTag !== null && focusTag !== 'BODY', { tag: focusTag, text: focusText })
await topOfPage(page)
await shoot(page, 'desktop-anhalten-angehalten.png')
// 5. Fortsetzen geht ohne Rückfrage
await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click()
await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor()
check('anhalten: „Fortsetzen“ geht ohne Rückfrage (kein Dialog, pause(false), Status „Läuft“)', (await page.getByRole('dialog').count()) === 0 && same(await calls(), [true, false]) && (await status()) === 'Läuft', { calls: await calls(), status: await status() })
await context.close()
}
// ---------- Szenario J: Handy 390 x 844 für Freigabe und Panel ----------
async function handyFreigabe(browser, base) {
const { context, page } = await newPultPage(browser, 'handy-freigabe', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
await openPult(page, base)
await sleep(300)
const width = () => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth }))
const w0 = await width()
metrics.phoneFreigabe = w0
check('handy-freigabe: Freigabe ohne seitliches Scrollen (scrollingElement.scrollWidth <= innerWidth)', w0.scroll <= w0.inner, w0)
const bar = await page.evaluate(() => {
const out = {}
for (const [name, sel] of [['tabs', '.tabs'], ['filter', '.wk-pult-filters .seg']]) {
const r = document.querySelector(sel).getBoundingClientRect()
out[name] = { left: Math.round(r.left), right: Math.round(r.right) }
}
return out
})
check('handy-freigabe: Reiterleiste und Filter liegen im Fenster', bar.tabs.left >= 0 && bar.tabs.right <= 390 && bar.filter.left >= 0 && bar.filter.right <= 390, bar)
await topOfPage(page)
await shoot(page, 'handy-freigabe.png')
await shoot(page, 'handy-freigabe-ganz.png', { fullPage: true })
// Jede Schaltfläche und jeder Link der Aktionszeilen: im Fenster, nicht verdeckt, mindestens 40 px hoch (Tippfläche), volle Breite untereinander
const reach = await page.evaluate(() => {
const out = []
for (const el of document.querySelectorAll('.wk-pult-actions > *')) {
el.scrollIntoView({ block: 'center' })
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
out.push({ text: el.textContent.trim().slice(0, 28), left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height), covered: !(hit && (hit === el || el.contains(hit))) })
}
return out
})
const bad = reach.filter((r) => r.left < 0 || r.right > 390 || r.covered || r.height < 40)
check('handy-freigabe: alle Aktionsknöpfe und Links sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach.length >= 10 && bad.length === 0, { count: reach.length, bad })
const w1 = await width()
check('handy-freigabe: nach dem Durchblättern der Aktionen weiterhin kein seitliches Scrollen', w1.scroll <= w1.inner, w1)
// Die lange Adresse steckt in der Originalanfrage: aufgeklappt darf sie die Seite nicht verbreitern
await rowOf(page, T_MAIL).locator('summary', { hasText: 'Originalanfrage' }).click()
await sleep(150)
const w1b = await width()
check('handy-freigabe: aufgeklappte „Originalanfrage“ mit langer Adresse verbreitert die Seite nicht', w1b.scroll <= w1b.inner, w1b)
await rowOf(page, T_MAIL).scrollIntoViewIfNeeded()
await shoot(page, 'handy-freigabe-originalanfrage.png')
// Panel am Handy: Bottom-Sheet im Fenster, lange Wörter brechen um, Schließen erreichbar
const opener = rowOf(page, T_MAIL).getByRole('button', { name: `Details zu ${T_MAIL}`, exact: true })
await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
await opener.scrollIntoViewIfNeeded()
await opener.click()
const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true })
await dialog.waitFor()
await dialog.locator('.wk-prospect').waitFor()
await sleep(700)
const box = await dialog.evaluate((el) => {
const r = el.getBoundingClientRect()
const body = el.querySelector('.dialog-body')
return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, bodyOverflowX: body.scrollWidth - body.clientWidth, bodyScrolls: body.scrollHeight > body.clientHeight }
})
metrics.phonePanel = box
check('handy-freigabe: Panel liegt im Fenster (links >= 0, rechts <= 390, unten <= Fensterhöhe)', box.left >= 0 && box.right <= 390 && box.top >= 0 && box.bottom <= box.inner, box)
check('handy-freigabe: Panel ohne waagerechten Überlauf (lange Adressen brechen um)', box.bodyOverflowX <= 0, box)
const w2 = await width()
check('handy-freigabe: mit offenem Panel kein seitliches Scrollen', w2.scroll <= w2.inner, w2)
await shoot(page, 'handy-interessent-panel.png')
// ganz nach unten im Panel: Nachrichtenverlauf und Schließen-Knopf
await dialog.locator('.dialog-body').evaluate((el) => { el.scrollTop = el.scrollHeight })
await sleep(200)
await shoot(page, 'handy-interessent-panel-unten.png')
const closeBtn = dialog.getByRole('button', { name: 'Schließen', exact: true }).last()
const closeReach = await closeBtn.evaluate((el) => {
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, covered: !(hit && (hit === el || el.contains(hit))) }
})
check('handy-freigabe: „Schließen“ im Panel ist erreichbar (im Fenster, nicht verdeckt)', closeReach.left >= 0 && closeReach.right <= 390 && closeReach.top >= 0 && closeReach.bottom <= closeReach.inner && !closeReach.covered, closeReach)
const lastMsg = await dialog.locator('li.wk-prospect-msg').last().evaluate((el) => {
const r = el.getBoundingClientRect()
const foot = el.closest('.dialog').querySelector('.dialog-foot').getBoundingClientRect()
return { bottom: Math.round(r.bottom), footTop: Math.round(foot.top) }
})
check('handy-freigabe: nach dem Blättern liegt die letzte Nachricht über dem Fuß (nichts abgeschnitten)', lastMsg.bottom <= lastMsg.footTop + 1, lastMsg)
await closeBtn.click()
await page.getByRole('dialog').waitFor({ state: 'detached' })
check('handy-freigabe: „Schließen“ schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page))
await context.close()
}
// ---------- P4: Gespräche und Werkstatt (Pult: als Mensch antworten, Vorschau freigeben und bauen, Antwort des Kunden einfügen) ----------
// Eigener Datensatz (?p4=1, scripted-api.js p4Fixtures). Wartet: Mail-Antwort, zwei Vorschau-Freigaben (eine mit gültiger, eine mit falscher
// Vorschau-Adresse), „unsicher“ und ein Eintrag, den ein Kollege hält (fünf Zeilen). Erledigt: zwei gesendete Plattform-Antworten desselben
// Interessenten (Standard-Platz und Platz „angebot“). Die Schalter der Skript-API (faults.reply, .approve-preview, .start-build, .paste und
// pultDelayMs) steuern 400/409/413 und die Wartezeit; pultCalls hält fest, was die Oberfläche geschickt hat.
const P4_URL = '/index.html?page=1&fault=none&p4=1'
const T4 = {
mail: 'Anfrage: Vereinswebsite für den SV Namborn',
frei: 'Vorschau freigeben: Friseur Muster',
falsch: 'Vorschau freigeben: Bäckerei Korn',
uns: 'Unsichere Antwort: Ist das ein Ja?',
koll: 'Anfrage: Tischlerei Holz',
plat: 'Kleinanzeigen: Website für Fotostudio',
ang: 'Kleinanzeigen: Website für Fotostudio (Angebot)',
}
const PREVIEW_URL = 'https://entwurf.project.webklar.com/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/'
const GONE = 'Dieser Kontakt ist nicht mehr aktiv.'
const TOO_BIG = 'Anfrage zu groß.'
const NEED_TEXT = 'Bitte einen Text eingeben.'
const TOO_LONG_8000 = 'Der Text ist zu lang (höchstens 8000 Zeichen).'
const PASTE_HINT = 'Kopiere die Antwort aus der Plattform hierher. Nora sortiert sie ein; bei einer Frage schreibt Clara den nächsten Entwurf.'
const PASTE_OK = 'Eingefügt. Nora sortiert die Antwort ein.'
const PASTE_TWICE = 'Diese Antwort war schon eingefügt.'
const NO_PREVIEW_URL = 'Keine gültige Vorschau-Adresse hinterlegt.'
const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Zeile mit genau diesem Titel (rowOf sucht nur einen Teil des Titels: „… (Angebot)“ träfe sonst auch die Zeile davor)
const row4 = (page, title) => page.locator('li.wk-pult-row').filter({ has: page.locator('.wk-pult-title', { hasText: new RegExp('^' + escapeRe(title) + '$') }) })
const actionNames = (row) => row.locator('.wk-pult-actions').locator('button, a').allTextContents().then((list) => list.map((t) => t.replace(/\s+/g, ' ').trim()))
const callsOf = async (page, action, id) => (await pultCalls(page)).filter((c) => c.action === action && (id === undefined || c.id === id))
const setDelay = (page, ms) => page.evaluate((v) => { window.__wk.api.pultDelayMs = v }, ms)
const noteOf = (row) => row.locator('.msg')
const focusInfo = (page) => page.evaluate(() => {
const el = document.activeElement
if (!el) return { tag: null }
return {
tag: el.tagName,
label: el.getAttribute('aria-label'),
probe: el.getAttribute('data-probe'),
text: (el.textContent || '').trim().slice(0, 30),
inReply: Boolean(el.closest('.wk-pult-reply')),
inPaste: Boolean(el.closest('.wk-pult-paste')),
inDialog: Boolean(el.closest('[role="dialog"]')),
}
})
const phoneFree = async (page) => {
const html = await page.evaluate(() => document.documentElement.outerHTML)
const text = await page.evaluate(() => document.body.innerText)
return !PHONE_WORD.test(html) && !PHONE_NUMBER.test(html) && !PHONE_WORD.test(text) && !PHONE_NUMBER.test(text)
}
const widthOf = (page) => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth }))
// Schaltflächen und Links: im Fenster, nicht verdeckt, Höhe in px (Tippfläche)
const reachOf = (page, selector) => page.evaluate((sel) => {
const out = []
for (const el of document.querySelectorAll(sel)) {
el.scrollIntoView({ block: 'center' })
const r = el.getBoundingClientRect()
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
out.push({ text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28), left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height), covered: !(hit && (hit === el || el.contains(hit))) })
}
return out
}, selector)
const badReach = (list) => list.filter((r) => r.left < 0 || r.right > 390 || r.covered || r.height < 40)
// Reiter „Freigabe“ mit dem P4-Datensatz: fünf wartende Zeilen und die Mitarbeiternamen sind da
async function openP4(page, base) {
await page.goto(base + P4_URL + '&tab=freigabe')
await page.waitForFunction(() => document.querySelectorAll('li.wk-pult-row').length === 5)
await page.waitForFunction(() => document.body.textContent.includes('übernommen von Justin'))
await page.evaluate(() => document.fonts.ready)
}
const chooseFilter4 = (page, label) => page.locator('.wk-pult-filters .seg button', { hasText: label }).click()
async function openP4Done(page, base) {
await openP4(page, base)
await chooseFilter4(page, 'Erledigt')
await waitTitles(page, [T4.ang, T4.plat])
}
// ---------- Szenario K: „Als Mensch antworten“ (Desktop 1280 x 900) ----------
async function antworten(browser, base) {
const { context, page } = await newPultPage(browser, 'antworten', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
await openP4(page, base)
const mail = row4(page, T4.mail)
const uns = row4(page, T4.uns)
const koll = row4(page, T4.koll)
const draft = (await page.evaluate(() => window.__wk.api.pult('offen,in_arbeit'))).items.find((i) => i.$id === 'p4-mail').draft
await topOfPage(page)
await shoot(page, 'antworten-wartet.png')
check('antworten: wartende Zeilen neueste zuerst (Mail, Freigabe, Freigabe mit falscher Adresse, unsicher, Kollege)', same(await rowTitles(page), [T4.mail, T4.frei, T4.falsch, T4.uns, T4.koll]), await rowTitles(page))
check('antworten: Mail-Zeile: E-Mail schreiben, Ich kümmere mich, Als gesendet markieren, Als Mensch antworten, Verwerfen', same(await actionNames(mail), ['E-Mail schreiben', 'Ich kümmere mich', 'Als gesendet markieren', 'Als Mensch antworten', 'Verwerfen']), await actionNames(mail))
check('antworten: Kollegen-Zeile (von Justin übernommen) bietet kein „Als Mensch antworten“', (await koll.getByRole('button', { name: 'Als Mensch antworten' }).count()) === 0, await actionNames(koll))
// 1. Aufklappen: vorbelegt mit dem Entwurf, Zähler, eigener Name des Felds
const opener = mail.getByRole('button', { name: 'Als Mensch antworten', exact: true })
check('antworten: vor dem Öffnen kein Antwortfeld, Knopf mit aria-expanded=false', (await mail.locator('.wk-pult-reply').count()) === 0 && (await opener.getAttribute('aria-expanded')) === 'false')
await opener.click()
const panel = mail.locator('.wk-pult-reply')
await panel.waitFor()
const area = panel.locator('textarea')
const count = () => textOf(panel.locator('.wk-pult-count'))
const send = panel.getByRole('button', { name: 'Jetzt senden', exact: true })
check('antworten: „Als Mensch antworten“ klappt das Feld auf (aria-expanded=true), Textfeld heißt „Text der Antwort: …“', (await opener.getAttribute('aria-expanded')) === 'true' && (await area.getAttribute('aria-label')) === `Text der Antwort: ${T4.mail}`, await area.getAttribute('aria-label'))
check('antworten: das Feld ist mit dem Entwurf vorbelegt', (await area.inputValue()) === draft)
check(`antworten: Zähler zeigt die Zeichen des Entwurfs („${Array.from(draft.trim()).length} / 8000“)`, (await count()) === `${Array.from(draft.trim()).length} / 8000`, await count())
check('antworten: Knopf „Jetzt senden“ im Fuß des Felds', (await send.count()) === 1)
await shoot(page, 'antworten-offen-vorbelegt.png')
// 2. Tippen: der Zähler zählt Zeichen (Code Points), der Entwurf in der Zeile bleibt unberührt
await area.fill('Hallo 😀')
check('antworten: Zähler zählt mit (ein Emoji ist ein Zeichen: „7 / 8000“)', (await count()) === '7 / 8000', await count())
check('antworten: das Antwortfeld ändert den Entwurf der Zeile nicht (getrennte Felder)', (await mail.getByLabel(`Entwurf der Antwort: ${T4.mail}`, { exact: true }).inputValue()) === draft)
// 3. Leerer Text: Fehler in der Oberfläche, kein Aufruf am Dienst
await area.fill('')
await send.click()
const err = mail.locator('.msg.err')
await err.waitFor()
check('antworten: leerer Text ⇒ „Bitte einen Text eingeben.“ (rot, role=alert), kein Aufruf am Dienst', (await textOf(err)) === NEED_TEXT && (await err.getAttribute('role')) === 'alert' && (await callsOf(page, 'reply')).length === 0, { text: await textOf(err), calls: (await callsOf(page, 'reply')).length })
await shoot(page, 'antworten-leer-fehler.png')
await area.fill(' \n \t ')
await send.click()
check('antworten: nur Leerraum ⇒ derselbe Satz, weiterhin kein Aufruf', (await textOf(err)) === NEED_TEXT && (await callsOf(page, 'reply')).length === 0)
// 4. Zu lang: 8001 Zeichen (Emoji zählen als eins) ⇒ Fehler, Zähler rot; genau 8000 Emoji sind erlaubt
await area.fill('😀'.repeat(8001))
check('antworten: 8001 Zeichen: Zähler „8001 / 8000“ ist rot (Klasse over)', (await count()) === '8001 / 8000' && (await panel.locator('.wk-pult-count').getAttribute('class')).includes('over'), await count())
await send.click()
await page.waitForFunction((t) => document.querySelector('li.wk-pult-row .msg.err') && document.querySelector('li.wk-pult-row .msg.err').textContent === t, TOO_LONG_8000)
check('antworten: 8001 Zeichen ⇒ „Der Text ist zu lang (höchstens 8000 Zeichen).“, kein Aufruf am Dienst', (await callsOf(page, 'reply')).length === 0)
await shoot(page, 'antworten-zu-lang.png')
await area.fill('😀'.repeat(8000))
check('antworten: genau 8000 Emoji (16000 UTF-16-Einheiten): Zähler „8000 / 8000“ ohne Rot', (await count()) === '8000 / 8000' && !(await panel.locator('.wk-pult-count').getAttribute('class')).includes('over'), await count())
// 5. 409 vom Dienst: der Satz steht in der Zeile, der Text bleibt, die Zeile bleibt, der Knopf ist wieder frei
const answer = ' Guten Tag Herr Beispiel,\n\nanbei die Vorschau, wie besprochen.\n\nBeste Grüße\nKenso \n'
await area.fill(answer)
await setPultFault(page, 'reply', { status: 409, message: CONFLICT })
await send.click()
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), CONFLICT)
check('antworten: 409 ⇒ der Satz des Dienstes steht in der Zeile (rot, role=alert)', (await textOf(noteOf(mail))) === CONFLICT && (await noteOf(mail).getAttribute('role')) === 'alert', await textOf(noteOf(mail)))
check('antworten: nach dem 409 bleiben Zeile, Text und Feld erhalten, „Jetzt senden“ ist wieder frei', (await mail.count()) === 1 && (await area.inputValue()) === answer && (await send.isEnabled()) && (await mail.getByRole('button', { name: 'Verwerfen' }).isEnabled()))
await shoot(page, 'antworten-409.png')
await setPultFault(page, 'reply', { status: 409, message: GONE })
await send.click()
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE)
check('antworten: zweiter 409 ersetzt die Meldung („Dieser Kontakt ist nicht mehr aktiv.“), nicht angehäuft', (await noteOf(mail).count()) === 1 && (await textOf(noteOf(mail))) === GONE)
await setPultFault(page, 'reply', { status: 413, message: TOO_BIG })
await send.click()
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), TOO_BIG)
check('antworten: 413 ⇒ „Anfrage zu groß.“ in der Zeile, Text bleibt', (await textOf(noteOf(mail))) === TOO_BIG && (await area.inputValue()) === answer)
// Verbindung bricht beim Senden ab: ob die Mail hinausging, ist offen (D14), die Meldung sagt das und schickt nicht zum zweiten Mal
await setPultFault(page, 'reply', { status: 0, message: 'Verbindung weg' })
await send.click()
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Kann gesendet sein – Pult neu laden.')
check('antworten: Verbindungsabbruch beim Senden ⇒ „Kann gesendet sein – Pult neu laden.“ (nicht „nicht gesendet“), Text bleibt', (await textOf(noteOf(mail))) === 'Kann gesendet sein – Pult neu laden.' && (await area.inputValue()) === answer, await textOf(noteOf(mail)))
// 6. Senden: während die Anfrage läuft sind alle Knöpfe der Zeile gesperrt, ein zweiter Klick schickt nichts, danach Erfolgsmeldung oben
await setPultFault(page, 'reply', null)
await setDelay(page, 1500)
const before = (await callsOf(page, 'reply')).length
await send.click()
const lockedNames = ['Ich kümmere mich', 'Als gesendet markieren', 'Als Mensch antworten', 'Verwerfen']
const locked = []
for (const name of lockedNames) locked.push(await mail.getByRole('button', { name, exact: true }).isDisabled())
check('antworten: während die Anfrage läuft sind „Jetzt senden“ und alle Knöpfe der Zeile gesperrt', (await send.isDisabled()) && locked.every(Boolean), locked)
await send.evaluate((el) => el.click()) // ein zweiter Klick auf den gesperrten Knopf darf nichts auslösen
await shoot(page, 'antworten-pending.png')
await finishedLine(page).first().waitFor()
const done = await textOf(finishedLine(page).first())
check('antworten: Erfolg ⇒ Meldung oben „…“ gesendet. (role=status), die Zeile ist weg', done === `„${T4.mail}“ gesendet.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && (await mail.count()) === 0, done)
const replyCalls = await callsOf(page, 'reply')
const last = replyCalls[replyCalls.length - 1]
check('antworten: Doppelklick während der Anfrage ergibt genau einen Aufruf; gesendet wird der getrimmte Text als { text }', replyCalls.length === before + 1 && last.id === 'p4-mail' && same(last.body, { text: answer.trim() }), { calls: replyCalls.length, before, last })
check('antworten: die Antworten der Fehlerfälle gingen alle mit demselben getrimmten Text hinaus', replyCalls.slice(0, before).every((c) => c.body && c.body.text === answer.trim()), replyCalls.map((c) => c.body && c.body.text && c.body.text.length))
await shoot(page, 'antworten-gesendet.png')
await setDelay(page, 0)
// 7. Eintrag „unsicher“: Antwortfeld ohne Vorbelegung, Text überlebt Zuklappen und Filterwechsel
check('antworten: „unsicher“-Zeile: Ich kümmere mich, Als Mensch antworten, Vorschau bauen, Erledigt, Verwerfen (kein Mail-Link, kein Entwurf)', same(await actionNames(uns), ['Ich kümmere mich', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen']) && (await uns.locator('textarea').count()) === 0, await actionNames(uns))
const unsOpener = uns.getByRole('button', { name: 'Als Mensch antworten', exact: true })
await unsOpener.click()
const unsPanel = uns.locator('.wk-pult-reply')
await unsPanel.waitFor()
const unsArea = unsPanel.locator('textarea')
check('antworten: „unsicher“: Antwortfeld ist leer (kein Entwurf), Zähler „0 / 8000“', (await unsArea.inputValue()) === '' && (await textOf(unsPanel.locator('.wk-pult-count'))) === '0 / 8000')
await unsArea.fill('Zwischenstand der Antwort')
await unsOpener.click()
check('antworten: zweiter Klick auf „Als Mensch antworten“ klappt das Feld zu (aria-expanded=false)', (await uns.locator('.wk-pult-reply').count()) === 0 && (await unsOpener.getAttribute('aria-expanded')) === 'false')
await unsOpener.click()
check('antworten: wieder aufgeklappt steht der Zwischenstand noch im Feld', (await uns.locator('.wk-pult-reply textarea').inputValue()) === 'Zwischenstand der Antwort')
await chooseFilter4(page, 'Veraltet')
await page.locator('.empty').waitFor()
await chooseFilter4(page, 'Wartet')
await waitTitles(page, [T4.frei, T4.falsch, T4.uns, T4.koll])
await unsOpener.click()
check('antworten: der Text überlebt einen Wechsel des Filters', (await uns.locator('.wk-pult-reply textarea').inputValue()) === 'Zwischenstand der Antwort')
await shoot(page, 'antworten-unsicher.png')
await setPultFault(page, 'reply', { status: 400, message: NEED_TEXT })
await uns.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click()
await uns.locator('.msg.err').waitFor()
check('antworten: 400 vom Dienst („Bitte einen Text eingeben.“) erscheint in der Zeile, die Zeile bleibt', (await textOf(uns.locator('.msg.err'))) === NEED_TEXT && (await uns.count()) === 1)
await setPultFault(page, 'reply', null)
check('antworten: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page))
await context.close()
}
// ---------- Szenario L: Vorschau freigeben (Desktop) ----------
async function vorschau(browser, base) {
const { context, page } = await newPultPage(browser, 'vorschau', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
await openP4(page, base)
const frei = row4(page, T4.frei)
const falsch = row4(page, T4.falsch)
await topOfPage(page)
await frei.scrollIntoViewIfNeeded()
await shoot(page, 'vorschau-wartet.png')
// 1. Zeile und Link
check('vorschau: Art-Badge „Vorschau freigeben“, Knöpfe Ich kümmere mich, Vorschau ansehen, Freigeben & senden, Verwerfen (kein Erledigt, kein Als Mensch antworten)',
(await textOf(frei.locator('.wk-pult-head .badge').first())) === 'Vorschau freigeben' && same(await actionNames(frei), ['Ich kümmere mich', 'Vorschau ansehen', 'Freigeben & senden', 'Verwerfen']), await actionNames(frei))
const link = frei.getByRole('link', { name: `Vorschau ansehen: ${T4.frei}`, exact: true })
check('vorschau: „Vorschau ansehen“ hat genau die https-Adresse der Vorschau', (await link.getAttribute('href')) === PREVIEW_URL, await link.getAttribute('href'))
check('vorschau: „Vorschau ansehen“ öffnet im neuen Tab (target=_blank, rel=noopener noreferrer)', (await link.getAttribute('target')) === '_blank' && (await link.getAttribute('rel')) === 'noopener noreferrer')
check('vorschau: das sichtbare Wort „Vorschau ansehen“ steht im Namen vorn', (await textOf(link)) === 'Vorschau ansehen' && (await link.getAttribute('aria-label')).startsWith('Vorschau ansehen'))
check('vorschau: kein Link „Anzeige öffnen“ in der Zeile (die Adresse ist die Vorschau, keine Anzeige)', (await frei.getByRole('link', { name: /Anzeige öffnen/ }).count()) === 0)
check('vorschau: Hinweis „Verwerfen“ sendet die Vorschau nicht.', (await textOf(frei.locator('.wk-pult-actions'))).includes('„Verwerfen“ sendet die Vorschau nicht.'))
// 2. Vorschau-Adresse mit http statt https: kein Link, Hinweis
const fakeLinks = await falsch.locator('a').evaluateAll((els) => els.map((el) => el.getAttribute('href')))
check('vorschau: Vorschau mit http-Adresse wird kein Link (Hinweis „Keine gültige Vorschau-Adresse hinterlegt.“)', !fakeLinks.some((href) => /entwurf\.project\.webklar\.com/.test(href || '')) && (await textOf(falsch.locator('.wk-pult-actions'))).includes(NO_PREVIEW_URL), fakeLinks)
const falschApprove = falsch.getByRole('button', { name: 'Freigeben & senden', exact: true })
metrics.freigabeOhneVorschauLink = { knopfDa: (await falschApprove.count()) === 1, knopfFrei: (await falschApprove.count()) === 1 && (await falschApprove.isEnabled()) }
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 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 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. Fragt „Vorschau bauen“ nach? Festgestellt wird nur (metrics.bauenFragtNach): ruft der erste Klick den Dienst auf, gibt es keine Rückfrage
// (anders als bei Freigeben und Verwerfen). Fragt der Knopf nach, bestätigt der zweite Klick, damit der Rest des Szenarios gleich läuft.
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
if (afterFirst === before) await build.click()
check('bauen: während der Bau angelegt wird sind alle Knöpfe der Zeile gesperrt', (await uns.getByRole('button', { name: 'Ich kümmere mich', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Als Mensch antworten', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Erledigt', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Verwerfen', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).isDisabled()))
await shoot(page, 'bauen-pending.png')
await finishedLine(page).first().waitFor()
await setDelay(page, 0)
check('bauen: Erfolg ⇒ Meldung oben „…“ wird gebaut. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T4.uns}“ wird gebaut.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && (await uns.count()) === 0, await textOf(finishedLine(page).first()))
const calls = await callsOf(page, 'start-build', 'p4-unsicher')
check('bauen: genau ein erfolgreicher Aufruf ohne Body (nach den zwei Fehlversuchen)', calls.length === before + 1 && calls.every((c) => c.body === undefined), calls)
await shoot(page, 'bauen-gestartet.png')
await context.close()
}
// ---------- Szenario O: Fokus der aufgeklappten Felder und nach den Aktionen (Desktop) ----------
async function fokus(browser, base) {
const { context, page } = await newPultPage(browser, 'fokus', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
await openP4(page, base)
const mail = row4(page, T4.mail)
const noBody = (info) => info.tag !== null && info.tag !== 'BODY'
// Esc schließt das Feld; gibt es keinen Esc-Weg, zählt ein Knopf „Abbrechen“. Ergebnis, ob eines von beiden das Feld schließt und wo dann der Fokus ist.
async function leaveByKeyboard(row, panelSelector, probe) {
const detached = (ms) => row.locator(panelSelector).waitFor({ state: 'detached', timeout: ms }).then(() => true, () => false)
await page.keyboard.press('Escape')
let how = 'esc'
let closed = await detached(700)
if (!closed) {
const cancel = row.locator(panelSelector).getByRole('button', { name: 'Abbrechen', exact: true })
if ((await cancel.count()) > 0) {
how = 'abbrechen'
await cancel.click()
closed = await detached(700)
} else {
how = 'weder Esc noch Abbrechen'
}
}
return { how, closed, focus: await focusInfo(page), onOpener: (await focusInfo(page)).probe === probe }
}
// 1. „Als Mensch antworten“
const replyOpener = mail.getByRole('button', { name: 'Als Mensch antworten', exact: true })
await replyOpener.evaluate((el) => el.setAttribute('data-probe', 'reply-opener'))
await replyOpener.click()
await mail.locator('.wk-pult-reply').waitFor()
const f1 = await focusInfo(page)
check('fokus: „Als Mensch antworten“ öffnen ⇒ der Fokus liegt im Textfeld des Antwortfelds', f1.tag === 'TEXTAREA' && f1.inReply, f1)
await shoot(page, 'fokus-antwort-offen.png')
const l1 = await leaveByKeyboard(mail, '.wk-pult-reply', 'reply-opener')
check('fokus: Esc oder „Abbrechen“ schließt das Antwortfeld und gibt den Fokus an „Als Mensch antworten“ zurück', l1.closed && l1.onOpener, l1)
if (!l1.closed) {
await replyOpener.click() // Rückweg über den Knopf selbst, damit der Rest des Szenarios weiterläuft
await mail.locator('.wk-pult-reply').waitFor({ state: 'detached' })
}
const f1b = await focusInfo(page)
check('fokus: zweiter Klick auf „Als Mensch antworten“ schließt das Feld, der Fokus bleibt auf dem Knopf (nicht auf <body>)', (await mail.locator('.wk-pult-reply').count()) === 0 && f1b.probe === 'reply-opener', f1b)
// 2. „Details“ (Dialog)
const details = mail.getByRole('button', { name: `Details zu ${T4.mail}`, exact: true })
await details.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
await details.click()
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
await sleep(500)
check('fokus: „Details“ öffnen ⇒ der Fokus liegt im Dialog', (await focusInfo(page)).inDialog, await focusInfo(page))
await page.keyboard.press('Escape')
await page.getByRole('dialog').waitFor({ state: 'detached' })
check('fokus: Esc schließt „Details“, der Fokus kehrt auf „Details“ zurück', (await focusInfo(page)).probe === 'details-opener', await focusInfo(page))
// 3. Fokus nach Fehler und nach Erfolg
await replyOpener.click()
const area = mail.locator('.wk-pult-reply textarea')
await area.waitFor()
await setPultFault(page, 'reply', { status: 409, message: CONFLICT })
await mail.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click()
await mail.locator('.msg.err').waitFor()
await sleep(200)
const fErr = await focusInfo(page)
check('fokus: nach einem Fehler (409) beim Senden steht der Fokus nicht auf <body> (Knopf oder Feld)', noBody(fErr), fErr)
await setPultFault(page, 'reply', null)
await mail.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click()
await finishedLine(page).first().waitFor()
await sleep(200)
const fReply = await focusInfo(page)
check('fokus: nach dem erfolgreichen Senden einer Antwort (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fReply), fReply)
const frei = row4(page, T4.frei)
const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ })
await approveBtn.click()
await approveBtn.click()
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent.includes(t), 'freigegeben')
await sleep(200)
const fApprove = await focusInfo(page)
check('fokus: nach „Freigeben & senden“ (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fApprove), fApprove)
const uns = row4(page, T4.uns)
await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).click()
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent.includes(t), 'wird gebaut')
await sleep(200)
const fBuild = await focusInfo(page)
check('fokus: nach „Vorschau bauen“ (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fBuild), fBuild)
// 4. „Antwort des Kunden einfügen“ (Reiter Erledigt)
await chooseFilter4(page, 'Erledigt')
await waitTitles(page, [T4.mail, T4.frei, T4.uns, T4.ang, T4.plat]) // Mail, Freigabe und unsicher sind durch die Aktionen davor hierher gewandert
const plat = row4(page, T4.plat)
const pasteOpener = plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true })
await pasteOpener.evaluate((el) => el.setAttribute('data-probe', 'paste-opener'))
await pasteOpener.click()
await plat.locator('.wk-pult-paste').waitFor()
const f4 = await focusInfo(page)
check('fokus: „Antwort des Kunden einfügen“ öffnen ⇒ der Fokus liegt im Textfeld', f4.tag === 'TEXTAREA' && f4.inPaste, f4)
await shoot(page, 'fokus-einfuegen-offen.png')
const l4 = await leaveByKeyboard(plat, '.wk-pult-paste', 'paste-opener')
check('fokus: Esc oder „Abbrechen“ schließt das Einfüge-Feld und gibt den Fokus an „Antwort des Kunden einfügen“ zurück', l4.closed && l4.onOpener, l4)
if (!l4.closed) {
await pasteOpener.click()
await plat.locator('.wk-pult-paste').waitFor({ state: 'detached' })
}
await pasteOpener.click()
await plat.locator('.wk-pult-paste textarea').fill('Danke, das passt.')
await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click()
await plat.locator('.msg.ok').waitFor()
await sleep(200)
const fPaste = await focusInfo(page)
check('fokus: nach dem erfolgreichen Einfügen (Feld klappt zu) steht der Fokus nicht auf <body>', noBody(fPaste), fPaste)
// Zum Vergleich (nur festgehalten, kein Prüfpunkt): „Ich kümmere mich“ aus P3 behält die Zeile, der Knopf ist beim Warten gesperrt
await chooseFilter4(page, 'Wartet')
await waitTitles(page, [T4.falsch, T4.koll])
await row4(page, T4.falsch).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row')).some((el) => el.textContent.includes(t) && el.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')), T4.falsch)
await sleep(200)
metrics.fokusNachIchKuemmereMich = (await focusInfo(page)).tag
await context.close()
}
// ---------- Szenario P: Handy 390 x 844 für alle Felder und Zustände ----------
async function handyP4(browser, base) {
const { context, page } = await newPultPage(browser, 'handy-p4', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
await openP4(page, base)
await sleep(300)
const mail = row4(page, T4.mail)
const frei = row4(page, T4.frei)
const falsch = row4(page, T4.falsch)
const uns = row4(page, T4.uns)
const noScroll = async (label, detail) => {
const w = await widthOf(page)
check(`handy-p4: ${label}: kein seitliches Scrollen (scrollWidth <= innerWidth)`, w.scroll <= w.inner, { ...w, ...(detail || {}) })
}
const ACTIONS = '.wk-pult-actions > a, .wk-pult-actions > button'
// 1. Liste
await noScroll('Liste Wartet')
await topOfPage(page)
await shoot(page, 'handy-p4-wartet.png')
await shoot(page, 'handy-p4-wartet-ganz.png', { fullPage: true })
const reach1 = await reachOf(page, ACTIONS)
check('handy-p4: alle Aktionsknöpfe und Links der Liste sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach1.length >= 15 && badReach(reach1).length === 0, { count: reach1.length, bad: badReach(reach1) })
// 2. „Als Mensch antworten“ mit sehr langem Wort, Fehlermeldung
await mail.scrollIntoViewIfNeeded()
await mail.getByRole('button', { name: 'Als Mensch antworten', exact: true }).click()
const panel = mail.locator('.wk-pult-reply')
await panel.waitFor()
await sleep(200)
await noScroll('Antwortfeld offen')
const reach2 = await reachOf(page, '.wk-pult-reply textarea, .wk-pult-reply .wk-pult-panel-foot .btn')
const area = panel.locator('textarea')
const areaBox = await area.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height) } })
check('handy-p4: Antwortfeld: Textfeld und „Jetzt senden“ im Fenster, nicht verdeckt, Knopf mindestens 40 px hoch, Feld mindestens 100 px hoch', reach2.length === 2 && 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, „Einfügen“ und „Antwort des Kunden einfügen“ sind erreichbar (im Fenster, nicht verdeckt, Knöpfe mindestens 40 px hoch)', reach6.length === 6 && 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()
}
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],
];
(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) })