„Als Mensch antworten“ und „Antwort des Kunden einfügen“ ließen sich nur durch erneuten Klick auf den Auslöser schließen. Esc (aus dem ganzen Feld, mit preventDefault für die Handy-Leiste) und der neue Knopf „Abbrechen“ schließen das Feld, der Text bleibt in der Liste erhalten, der Fokus geht an den Auslöser zurück (data-opens am Knopf, gesucht in der Aktionsgruppe: Button reicht kein ref durch). Während einer Anfrage sind beide gesperrt. Neue Gruppe .wk-pult-panel-btns, auf dem Handy mit dem Hauptknopf oben. Test-Läufer: ref-Objekte an Tags und eine document-Attrappe für den Fokus. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1718 lines
125 KiB
JavaScript
1718 lines
125 KiB
JavaScript
// Manuelle Abnahme der Hustler-Seite im echten Browser (Chromium, headless shell) gegen den Prüfstand.
|
||
// Aufruf (immer unter Speicherdeckel, serielle Läufe):
|
||
// systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 300 node --max-old-space-size=400 \
|
||
// scripts/workers-harness/shot.cjs <distDir> <outDir>
|
||
// <distDir> = Ausgabe von build.mjs, <outDir> = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo.
|
||
// Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1.
|
||
// Szenarien: A Desktop, B Blasen, C Handy, D weniger Bewegung, E Notbremse, F Ausfall (P2); G Freigabe, H Interessenten-Panel, I Anhalten
|
||
// mit Rückfrage, J Handy mit Freigabe und Panel (P3); K Antworten als Mensch, L Vorschau freigeben, M Antwort des Kunden einfügen, N Vorschau
|
||
// bauen, O Fokus, P Handy für alle Felder (P4, eigener Datensatz ?p4=1, Bilder mit Vorsilbe antworten-, vorschau-, einfuegen-, bauen-, fokus-,
|
||
// handy-p4-). 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()) }
|
||
// Endprüfung W5: eine Vorschau ohne gültige Adresse lässt sich nicht freigeben (der Mensch könnte sie nicht ansehen); der Hinweis nennt den Ausweg, Verwerfen bleibt frei
|
||
check('vorschau: ohne gültige Vorschau-Adresse ist „Freigeben & senden“ gesperrt, der Hinweis nennt den Grund, „Verwerfen“ bleibt frei',
|
||
metrics.freigabeOhneVorschauLink.knopfDa && !metrics.freigabeOhneVorschauLink.knopfFrei && (await textOf(falsch.locator('.wk-pult-actions'))).includes('Freigabe nicht möglich')
|
||
&& (await falsch.getByRole('button', { name: 'Verwerfen', exact: true }).isEnabled()), metrics.freigabeOhneVorschauLink)
|
||
await falsch.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'vorschau-falsche-adresse.png')
|
||
|
||
// 3. Erster Klick fragt, nach 4 s verfällt die Frage
|
||
const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ })
|
||
await frei.scrollIntoViewIfNeeded()
|
||
await approveBtn.click()
|
||
check('vorschau: erster Klick fragt „Wirklich freigeben?“, noch keine Aktion am Dienst', (await textOf(approveBtn)) === 'Wirklich freigeben?' && (await callsOf(page, 'approve-preview')).length === 0, await textOf(approveBtn))
|
||
await shoot(page, 'vorschau-frage.png')
|
||
await sleep(4400)
|
||
check('vorschau: nach 4 s verfällt die Rückfrage (wieder „Freigeben & senden“), noch immer keine Aktion', (await textOf(approveBtn)) === 'Freigeben & senden' && (await callsOf(page, 'approve-preview')).length === 0, await textOf(approveBtn))
|
||
|
||
// 4. Fehler vom Dienst (409): Satz in der Zeile, Zeile bleibt, Frage ist wieder zurückgesetzt
|
||
await setPultFault(page, 'approve-preview', { status: 409, message: GONE })
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
await frei.locator('.msg.err').waitFor()
|
||
check('vorschau: 409 ⇒ „Dieser Kontakt ist nicht mehr aktiv.“ in der Zeile (rot, role=alert), Zeile bleibt', (await textOf(frei.locator('.msg.err'))) === GONE && (await frei.locator('.msg.err').getAttribute('role')) === 'alert' && (await frei.count()) === 1, await textOf(frei.locator('.msg.err')))
|
||
check('vorschau: nach dem Fehler fragt der Knopf wieder von vorn („Freigeben & senden“, frei)', (await textOf(approveBtn)) === 'Freigeben & senden' && (await approveBtn.isEnabled()))
|
||
await shoot(page, 'vorschau-409.png')
|
||
await setPultFault(page, 'approve-preview', { status: 409, message: CONFLICT })
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), CONFLICT)
|
||
check('vorschau: zweiter 409 („Wird schon von einem Kollegen bearbeitet.“) ersetzt die Meldung', (await frei.locator('.msg').count()) === 1 && (await textOf(frei.locator('.msg'))) === CONFLICT)
|
||
|
||
await setPultFault(page, 'approve-preview', { status: 0, message: 'Verbindung weg' })
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Kann gesendet sein – Pult neu laden.')
|
||
check('vorschau: Verbindungsabbruch bei der Freigabe ⇒ „Kann gesendet sein – Pult neu laden.“, die Zeile bleibt', (await textOf(frei.locator('.msg'))) === 'Kann gesendet sein – Pult neu laden.' && (await frei.count()) === 1)
|
||
|
||
// 5. Erfolg: zweiter Klick binnen 4 s sendet, währenddessen sind die Knöpfe gesperrt
|
||
await setPultFault(page, 'approve-preview', null)
|
||
await setDelay(page, 1500)
|
||
const before = (await callsOf(page, 'approve-preview')).length
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
const lockedNames = ['Ich kümmere mich', 'Freigeben & senden', 'Verwerfen']
|
||
const locked = []
|
||
for (const name of lockedNames) locked.push(await frei.getByRole('button', { name, exact: true }).isDisabled())
|
||
check('vorschau: während die Freigabe läuft sind „Freigeben & senden“, „Ich kümmere mich“ und „Verwerfen“ gesperrt', locked.every(Boolean), locked)
|
||
await approveBtn.evaluate((el) => el.click()) // weiterer Klick auf den gesperrten Knopf
|
||
await shoot(page, 'vorschau-pending.png')
|
||
await finishedLine(page).first().waitFor()
|
||
const done = await textOf(finishedLine(page).first())
|
||
check('vorschau: Erfolg ⇒ Meldung oben „…“ freigegeben. Max sendet den Link im nächsten Sendefenster. (role=status), Zeile weg', done === `„${T4.frei}“ freigegeben. Max sendet den Link im nächsten Sendefenster.` && (await frei.count()) === 0 && (await finishedLine(page).first().getAttribute('role')) === 'status', done)
|
||
const calls = await callsOf(page, 'approve-preview', 'p4-freigabe')
|
||
check('vorschau: genau ein erfolgreicher Aufruf ohne Body (drei Fehlversuche davor, der Doppelklick im Warten zählt nicht)', calls.length === before + 1 && calls.every((c) => c.body === undefined), calls)
|
||
await shoot(page, 'vorschau-freigegeben.png')
|
||
await setDelay(page, 0)
|
||
check('vorschau: die andere Freigabe (falsche Adresse) und die übrigen Zeilen bleiben unberührt', same(await rowTitles(page), [T4.mail, T4.falsch, T4.uns, T4.koll]), await rowTitles(page))
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario M: Antwort des Kunden einfügen (Desktop) ----------
|
||
|
||
async function einfuegen(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'einfuegen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openP4Done(page, base)
|
||
const plat = row4(page, T4.plat)
|
||
const ang = row4(page, T4.ang)
|
||
await topOfPage(page)
|
||
await shoot(page, 'einfuegen-erledigt.png')
|
||
|
||
// 1. Zwei Zeilen desselben Interessenten, beide gesendet, beide bieten das Einfügen
|
||
check('einfuegen: Erledigt zeigt zwei gesendete Plattform-Antworten desselben Interessenten (Standard-Platz und „angebot“)', same(await rowTitles(page), [T4.ang, T4.plat]), await rowTitles(page))
|
||
check('einfuegen: beide Zeilen sind „Gesendet von Kenso“ und bieten genau „Antwort des Kunden einfügen“', same(await actionNames(plat), ['Antwort des Kunden einfügen']) && same(await actionNames(ang), ['Antwort des Kunden einfügen']) && (await textOf(plat.locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso') && (await textOf(ang.locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso'), { plat: await actionNames(plat), ang: await actionNames(ang) })
|
||
const platOpen = plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true })
|
||
const angOpen = ang.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true })
|
||
|
||
// 2. Aufklappen: Hinweis, leeres Feld, Zähler, Knopf
|
||
check('einfuegen: vor dem Öffnen kein Feld, Knopf mit aria-expanded=false', (await plat.locator('.wk-pult-paste').count()) === 0 && (await platOpen.getAttribute('aria-expanded')) === 'false')
|
||
await platOpen.click()
|
||
const platPanel = plat.locator('.wk-pult-paste')
|
||
await platPanel.waitFor()
|
||
const platArea = platPanel.locator('textarea')
|
||
const platSend = platPanel.getByRole('button', { name: 'Einfügen', exact: true })
|
||
check('einfuegen: Hinweistext „Kopiere die Antwort aus der Plattform hierher. …“', (await textOf(platPanel.locator('.wk-hint'))) === PASTE_HINT, await textOf(platPanel.locator('.wk-hint')))
|
||
check('einfuegen: Textfeld ist leer, heißt „Antwort des Kunden: …“, Zähler „0 / 8000“, Knopf „Einfügen“', (await platArea.inputValue()) === '' && (await platArea.getAttribute('aria-label')) === `Antwort des Kunden: ${T4.plat}` && (await textOf(platPanel.locator('.wk-pult-count'))) === '0 / 8000' && (await platSend.count()) === 1 && (await platOpen.getAttribute('aria-expanded')) === 'true')
|
||
check('einfuegen: erste Zeile offen, zweite Zeile bleibt zu', (await ang.locator('.wk-pult-paste').count()) === 0)
|
||
|
||
// 3. Leerer Text: Fehler in der Oberfläche, kein Aufruf
|
||
await platSend.click()
|
||
await plat.locator('.msg.err').waitFor()
|
||
check('einfuegen: leerer Text ⇒ „Bitte einen Text eingeben.“ (rot, role=alert), kein Aufruf am Dienst', (await textOf(plat.locator('.msg.err'))) === NEED_TEXT && (await plat.locator('.msg.err').getAttribute('role')) === 'alert' && (await callsOf(page, 'paste')).length === 0, { text: await textOf(plat.locator('.msg.err')), calls: (await callsOf(page, 'paste')).length })
|
||
await shoot(page, 'einfuegen-leer-fehler.png')
|
||
|
||
// 4. Beide Zeilen unabhängig: eigene Felder, eigene Meldungen
|
||
await platArea.fill('Antwort Kunde A')
|
||
await angOpen.click()
|
||
const angPanel = ang.locator('.wk-pult-paste')
|
||
await angPanel.waitFor()
|
||
const angArea = angPanel.locator('textarea')
|
||
const angSend = angPanel.getByRole('button', { name: 'Einfügen', exact: true })
|
||
check('einfuegen: zweite Zeile öffnet ein eigenes, leeres Feld (kein Text der ersten)', (await angArea.inputValue()) === '' && (await platArea.inputValue()) === 'Antwort Kunde A')
|
||
await angArea.fill('Antwort Kunde B')
|
||
check('einfuegen: beide Felder gleichzeitig offen, jedes mit eigenem Text und eigenem Zähler', (await platArea.inputValue()) === 'Antwort Kunde A' && (await angArea.inputValue()) === 'Antwort Kunde B' && (await textOf(platPanel.locator('.wk-pult-count'))) === '15 / 8000' && (await textOf(angPanel.locator('.wk-pult-count'))) === '15 / 8000')
|
||
await shoot(page, 'einfuegen-zwei-offen.png')
|
||
check('einfuegen: die Fehlermeldung der ersten Zeile steht nur dort', (await ang.locator('.msg').count()) === 0)
|
||
|
||
// 5. Fehler vom Dienst: 413, 400 und 409 mit deutschem Satz; Text und Feld bleiben
|
||
await setPultFault(page, 'paste', { status: 413, message: TOO_BIG })
|
||
await platSend.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), TOO_BIG)
|
||
check('einfuegen: 413 ⇒ „Anfrage zu groß.“ (rot, role=alert) in der ersten Zeile; Feld und Text bleiben, Knopf frei', (await textOf(plat.locator('.msg.err'))) === TOO_BIG && (await platArea.inputValue()) === 'Antwort Kunde A' && (await platSend.isEnabled()), await textOf(plat.locator('.msg.err')))
|
||
await shoot(page, 'einfuegen-413.png')
|
||
await setPultFault(page, 'paste', { status: 400, message: NEED_TEXT })
|
||
await platSend.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), NEED_TEXT)
|
||
check('einfuegen: 400 vom Dienst ⇒ „Bitte einen Text eingeben.“, Meldung ersetzt die vorige', (await plat.locator('.msg').count()) === 1 && (await textOf(plat.locator('.msg'))) === NEED_TEXT)
|
||
await shoot(page, 'einfuegen-400.png')
|
||
await setPultFault(page, 'paste', { status: 409, message: GONE })
|
||
await platSend.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE)
|
||
check('einfuegen: 409 ⇒ deutscher Satz des Dienstes in der Zeile', (await textOf(plat.locator('.msg'))) === GONE)
|
||
check('einfuegen: die zweite Zeile zeigt von all dem nichts (keine Meldung, Text bleibt)', (await ang.locator('.msg').count()) === 0 && (await angArea.inputValue()) === 'Antwort Kunde B')
|
||
await setPultFault(page, 'paste', { status: 0, message: 'Verbindung weg' })
|
||
await platSend.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), 'Der Hustler-Dienst ist gerade nicht erreichbar.')
|
||
check('einfuegen: Verbindungsabbruch ⇒ „Der Hustler-Dienst ist gerade nicht erreichbar.“, Text bleibt im Feld', (await textOf(plat.locator('.msg'))) === 'Der Hustler-Dienst ist gerade nicht erreichbar.' && (await platArea.inputValue()) === 'Antwort Kunde A')
|
||
await setPultFault(page, 'paste', null)
|
||
|
||
// 6. Erfolg der ersten Zeile: gesperrt beim Warten, Meldung in der Zeile, Feld leer und zu, Zeile bleibt, zweite Zeile unberührt.
|
||
// Die Wartezeit liegt unter der Zeitgrenze des Prüfstands (1,5 s, entry.jsx): „paste“ hat anders als reply, approve-preview und start-build
|
||
// keine lange Frist (SLOW_PULT_ACTIONS), eine längere Wartezeit endete hier als „nicht erreichbar“.
|
||
await setDelay(page, 900)
|
||
await platSend.click()
|
||
check('einfuegen: während die Anfrage läuft sind „Einfügen“ und „Antwort des Kunden einfügen“ der Zeile gesperrt', (await platSend.isDisabled()) && (await platOpen.isDisabled()))
|
||
check('einfuegen: die zweite Zeile bleibt währenddessen bedienbar', (await angSend.isEnabled()) && (await angOpen.isEnabled()))
|
||
await platSend.evaluate((el) => el.click())
|
||
await shoot(page, 'einfuegen-pending.png')
|
||
await plat.locator('.msg.ok').waitFor()
|
||
await setDelay(page, 0)
|
||
check('einfuegen: Erfolg ⇒ „Eingefügt. Nora sortiert die Antwort ein.“ (grün, role=status) in der Zeile', (await textOf(plat.locator('.msg.ok'))) === PASTE_OK && (await plat.locator('.msg.ok').getAttribute('role')) === 'status', await textOf(plat.locator('.msg')))
|
||
check('einfuegen: danach ist das Feld zu, die Zeile bleibt in der Liste, die zweite Zeile ist unberührt (Feld offen, Text, keine Meldung)', (await plat.locator('.wk-pult-paste').count()) === 0 && (await plat.count()) === 1 && (await ang.locator('.wk-pult-paste textarea').inputValue()) === 'Antwort Kunde B' && (await ang.locator('.msg').count()) === 0)
|
||
const pasteCalls = await callsOf(page, 'paste')
|
||
const okCall = pasteCalls[pasteCalls.length - 1]
|
||
check('einfuegen: der Aufruf geht an den Eintrag der ersten Zeile mit { text: „Antwort Kunde A“ }; Doppelklick im Warten zählt nicht', okCall.id === 'p4-plat-gesendet' && same(okCall.body, { text: 'Antwort Kunde A' }) && pasteCalls.filter((c) => c.id === 'p4-plat-gesendet').length === 5 && pasteCalls.every((c) => c.id !== 'p4-plat-angebot'), pasteCalls)
|
||
await shoot(page, 'einfuegen-erfolg.png')
|
||
|
||
// 7. Dieselbe Antwort noch einmal: „doppelt“
|
||
await platOpen.click()
|
||
const again = plat.locator('.wk-pult-paste textarea')
|
||
await again.waitFor()
|
||
check('einfuegen: wieder geöffnet ist das Feld leer (nach dem Erfolg geleert)', (await again.inputValue()) === '')
|
||
await again.fill('Antwort Kunde A')
|
||
await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg')).some((el) => el.textContent === t), PASTE_TWICE)
|
||
check('einfuegen: dieselbe Antwort ein zweites Mal ⇒ „Diese Antwort war schon eingefügt.“', (await textOf(plat.locator('.msg'))) === PASTE_TWICE, await textOf(plat.locator('.msg')))
|
||
|
||
// 8. Zweite Zeile sendet für sich
|
||
await angSend.click()
|
||
await ang.locator('.msg.ok').waitFor()
|
||
const angCall = (await callsOf(page, 'paste', 'p4-plat-angebot')).pop()
|
||
check('einfuegen: zweite Zeile schickt ihren eigenen Text an ihren eigenen Eintrag, ihre Meldung steht nur dort', (await textOf(ang.locator('.msg.ok'))) === PASTE_OK && angCall && same(angCall.body, { text: 'Antwort Kunde B' }) && (await textOf(plat.locator('.msg'))) === PASTE_TWICE)
|
||
await shoot(page, 'einfuegen-beide-fertig.png')
|
||
check('einfuegen: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page))
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario N: Vorschau bauen (Desktop) ----------
|
||
|
||
async function bauen(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'bauen', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openP4(page, base)
|
||
const uns = row4(page, T4.uns)
|
||
const build = uns.getByRole('button', { name: /^(Vorschau bauen|Wirklich bauen\?)$/ })
|
||
await uns.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'bauen-zeile.png')
|
||
check('bauen: Zeile „unsicher“ bietet „Vorschau bauen“ (neben Ich kümmere mich, Als Mensch antworten, Erledigt, Verwerfen)', same(await actionNames(uns), ['Ich kümmere mich', 'Als Mensch antworten', 'Vorschau bauen', 'Erledigt', 'Verwerfen']), await actionNames(uns))
|
||
check('bauen: die Kollegen-Zeile und die Mail-Zeile bieten kein „Vorschau bauen“', (await row4(page, T4.koll).getByRole('button', { name: 'Vorschau bauen' }).count()) === 0 && (await row4(page, T4.mail).getByRole('button', { name: 'Vorschau bauen' }).count()) === 0)
|
||
|
||
// 1. Fehler vom Dienst
|
||
await setPultFault(page, 'start-build', { status: 409, message: 'Für diesen Interessenten gibt es schon eine Vorschau.' })
|
||
await build.click()
|
||
await build.click()
|
||
await uns.locator('.msg.err').waitFor()
|
||
check('bauen: 409 ⇒ „Für diesen Interessenten gibt es schon eine Vorschau.“ in der Zeile (rot, role=alert), Zeile bleibt', (await textOf(uns.locator('.msg.err'))) === 'Für diesen Interessenten gibt es schon eine Vorschau.' && (await uns.locator('.msg.err').getAttribute('role')) === 'alert' && (await uns.count()) === 1, await textOf(uns.locator('.msg.err')))
|
||
check('bauen: nach dem Fehler ist „Vorschau bauen“ wieder frei', await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).isEnabled())
|
||
await shoot(page, 'bauen-409.png')
|
||
await setPultFault(page, 'start-build', { status: 409, message: GONE })
|
||
await build.click()
|
||
await build.click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row .msg.err')).some((el) => el.textContent === t), GONE)
|
||
check('bauen: zweiter 409 („Dieser Kontakt ist nicht mehr aktiv.“) ersetzt die Meldung', (await uns.locator('.msg').count()) === 1 && (await textOf(uns.locator('.msg'))) === GONE)
|
||
await setPultFault(page, 'start-build', null)
|
||
|
||
// 2. „Vorschau bauen“ fragt wie Freigeben und Verwerfen zuerst nach (Endprüfung W2): der erste Klick ruft den Dienst nicht auf, die Frage verfällt nach 4 s,
|
||
// erst der zweite Klick startet den Bau. metrics.bauenFragtNach hält fest, ob der erste Klick folgenlos blieb.
|
||
await setDelay(page, 1500)
|
||
const before = (await callsOf(page, 'start-build')).length
|
||
await build.click()
|
||
await sleep(150)
|
||
const afterFirst = (await callsOf(page, 'start-build')).length
|
||
metrics.bauenFragtNach = afterFirst === before
|
||
metrics.bauenErsterKlickRuftDienst = afterFirst === before + 1
|
||
check('bauen: erster Klick fragt „Wirklich bauen?“ (mit Hinweis zu Opus-Lauf), noch keine Aktion am Dienst', (await textOf(build)) === 'Wirklich bauen?' && afterFirst === before && (await textOf(uns.locator('.wk-pult-actions'))).includes('Startet einen Opus-Lauf'), await textOf(build))
|
||
await shoot(page, 'bauen-frage.png')
|
||
await sleep(4400)
|
||
check('bauen: nach 4 s verfällt die Rückfrage (wieder „Vorschau bauen“, kein Hinweis), noch immer keine Aktion', (await textOf(build)) === 'Vorschau bauen' && (await callsOf(page, 'start-build')).length === before && !(await textOf(uns.locator('.wk-pult-actions'))).includes('Opus'), await textOf(build))
|
||
await build.click()
|
||
await build.click()
|
||
check('bauen: während der Bau angelegt wird sind alle Knöpfe der Zeile gesperrt', (await uns.getByRole('button', { name: 'Ich kümmere mich', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Als Mensch antworten', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Erledigt', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Verwerfen', exact: true }).isDisabled()) && (await uns.getByRole('button', { name: 'Vorschau bauen', exact: true }).isDisabled()))
|
||
await shoot(page, 'bauen-pending.png')
|
||
await finishedLine(page).first().waitFor()
|
||
await setDelay(page, 0)
|
||
check('bauen: Erfolg ⇒ Meldung oben „…“ wird gebaut. (role=status), die Zeile ist weg', (await textOf(finishedLine(page).first())) === `„${T4.uns}“ wird gebaut.` && (await finishedLine(page).first().getAttribute('role')) === 'status' && (await uns.count()) === 0, await textOf(finishedLine(page).first()))
|
||
const calls = await callsOf(page, 'start-build', 'p4-unsicher')
|
||
check('bauen: genau ein erfolgreicher Aufruf ohne Body (nach den zwei Fehlversuchen)', calls.length === before + 1 && calls.every((c) => c.body === undefined), calls)
|
||
await shoot(page, 'bauen-gestartet.png')
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario O: Fokus der aufgeklappten Felder und nach den Aktionen (Desktop) ----------
|
||
|
||
async function fokus(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'fokus', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 })
|
||
await openP4(page, base)
|
||
const mail = row4(page, T4.mail)
|
||
const noBody = (info) => info.tag !== null && info.tag !== 'BODY'
|
||
|
||
// Esc schließt das Feld; gibt es keinen Esc-Weg, zählt ein Knopf „Abbrechen“. Ergebnis, ob eines von beiden das Feld schließt und wo dann der Fokus ist.
|
||
async function leaveByKeyboard(row, panelSelector, probe) {
|
||
const detached = (ms) => row.locator(panelSelector).waitFor({ state: 'detached', timeout: ms }).then(() => true, () => false)
|
||
await page.keyboard.press('Escape')
|
||
let how = 'esc'
|
||
let closed = await detached(700)
|
||
if (!closed) {
|
||
const cancel = row.locator(panelSelector).getByRole('button', { name: 'Abbrechen', exact: true })
|
||
if ((await cancel.count()) > 0) {
|
||
how = 'abbrechen'
|
||
await cancel.click()
|
||
closed = await detached(700)
|
||
} else {
|
||
how = 'weder Esc noch Abbrechen'
|
||
}
|
||
}
|
||
return { how, closed, focus: await focusInfo(page), onOpener: (await focusInfo(page)).probe === probe }
|
||
}
|
||
|
||
// 1. „Als Mensch antworten“
|
||
const replyOpener = mail.getByRole('button', { name: 'Als Mensch antworten', exact: true })
|
||
await replyOpener.evaluate((el) => el.setAttribute('data-probe', 'reply-opener'))
|
||
await replyOpener.click()
|
||
await mail.locator('.wk-pult-reply').waitFor()
|
||
const f1 = await focusInfo(page)
|
||
check('fokus: „Als Mensch antworten“ öffnen ⇒ der Fokus liegt im Textfeld des Antwortfelds', f1.tag === 'TEXTAREA' && f1.inReply, f1)
|
||
await shoot(page, 'fokus-antwort-offen.png')
|
||
const l1 = await leaveByKeyboard(mail, '.wk-pult-reply', 'reply-opener')
|
||
check('fokus: Esc oder „Abbrechen“ schließt das Antwortfeld und gibt den Fokus an „Als Mensch antworten“ zurück', l1.closed && l1.onOpener, l1)
|
||
if (!l1.closed) {
|
||
await replyOpener.click() // Rückweg über den Knopf selbst, damit der Rest des Szenarios weiterläuft
|
||
await mail.locator('.wk-pult-reply').waitFor({ state: 'detached' })
|
||
}
|
||
const f1b = await focusInfo(page)
|
||
check('fokus: zweiter Klick auf „Als Mensch antworten“ schließt das Feld, der Fokus bleibt auf dem Knopf (nicht auf <body>)', (await mail.locator('.wk-pult-reply').count()) === 0 && f1b.probe === 'reply-opener', f1b)
|
||
|
||
// 2. „Details“ (Dialog)
|
||
const details = mail.getByRole('button', { name: `Details zu ${T4.mail}`, exact: true })
|
||
await details.evaluate((el) => el.setAttribute('data-probe', 'details-opener'))
|
||
await details.click()
|
||
await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor()
|
||
await sleep(500)
|
||
check('fokus: „Details“ öffnen ⇒ der Fokus liegt im Dialog', (await focusInfo(page)).inDialog, await focusInfo(page))
|
||
await page.keyboard.press('Escape')
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
check('fokus: Esc schließt „Details“, der Fokus kehrt auf „Details“ zurück', (await focusInfo(page)).probe === 'details-opener', await focusInfo(page))
|
||
|
||
// 3. Fokus nach Fehler und nach Erfolg
|
||
await replyOpener.click()
|
||
const area = mail.locator('.wk-pult-reply textarea')
|
||
await area.waitFor()
|
||
await setPultFault(page, 'reply', { status: 409, message: CONFLICT })
|
||
await mail.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click()
|
||
await mail.locator('.msg.err').waitFor()
|
||
await sleep(200)
|
||
const fErr = await focusInfo(page)
|
||
check('fokus: nach einem Fehler (409) beim Senden steht der Fokus nicht auf <body> (Knopf oder Feld)', noBody(fErr), fErr)
|
||
await setPultFault(page, 'reply', null)
|
||
await mail.locator('.wk-pult-reply').getByRole('button', { name: 'Jetzt senden', exact: true }).click()
|
||
await finishedLine(page).first().waitFor()
|
||
await sleep(200)
|
||
const fReply = await focusInfo(page)
|
||
check('fokus: nach dem erfolgreichen Senden einer Antwort (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fReply), fReply)
|
||
|
||
const frei = row4(page, T4.frei)
|
||
const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ })
|
||
await approveBtn.click()
|
||
await approveBtn.click()
|
||
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent.includes(t), 'freigegeben')
|
||
await sleep(200)
|
||
const fApprove = await focusInfo(page)
|
||
check('fokus: nach „Freigeben & senden“ (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fApprove), fApprove)
|
||
|
||
const uns = row4(page, T4.uns)
|
||
const buildBtn = uns.getByRole('button', { name: /^(Vorschau bauen|Wirklich bauen\?)$/ })
|
||
await buildBtn.click()
|
||
await buildBtn.click()
|
||
await page.waitForFunction((t) => document.querySelector('.wk-pult-filters ~ .msg') && document.querySelector('.wk-pult-filters ~ .msg').textContent.includes(t), 'wird gebaut')
|
||
await sleep(200)
|
||
const fBuild = await focusInfo(page)
|
||
check('fokus: nach „Vorschau bauen“ (Zeile verschwindet) steht der Fokus nicht auf <body>', noBody(fBuild), fBuild)
|
||
|
||
// 4. „Antwort des Kunden einfügen“ (Reiter Erledigt)
|
||
await chooseFilter4(page, 'Erledigt')
|
||
await waitTitles(page, [T4.mail, T4.frei, T4.uns, T4.ang, T4.plat]) // Mail, Freigabe und unsicher sind durch die Aktionen davor hierher gewandert
|
||
const plat = row4(page, T4.plat)
|
||
const pasteOpener = plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true })
|
||
await pasteOpener.evaluate((el) => el.setAttribute('data-probe', 'paste-opener'))
|
||
await pasteOpener.click()
|
||
await plat.locator('.wk-pult-paste').waitFor()
|
||
const f4 = await focusInfo(page)
|
||
check('fokus: „Antwort des Kunden einfügen“ öffnen ⇒ der Fokus liegt im Textfeld', f4.tag === 'TEXTAREA' && f4.inPaste, f4)
|
||
await shoot(page, 'fokus-einfuegen-offen.png')
|
||
const l4 = await leaveByKeyboard(plat, '.wk-pult-paste', 'paste-opener')
|
||
check('fokus: Esc oder „Abbrechen“ schließt das Einfüge-Feld und gibt den Fokus an „Antwort des Kunden einfügen“ zurück', l4.closed && l4.onOpener, l4)
|
||
if (!l4.closed) {
|
||
await pasteOpener.click()
|
||
await plat.locator('.wk-pult-paste').waitFor({ state: 'detached' })
|
||
}
|
||
await pasteOpener.click()
|
||
await plat.locator('.wk-pult-paste textarea').fill('Danke, das passt.')
|
||
await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click()
|
||
await plat.locator('.msg.ok').waitFor()
|
||
await sleep(200)
|
||
const fPaste = await focusInfo(page)
|
||
check('fokus: nach dem erfolgreichen Einfügen (Feld klappt zu) steht der Fokus nicht auf <body>', noBody(fPaste), fPaste)
|
||
|
||
// Zum Vergleich (nur festgehalten, kein Prüfpunkt): „Ich kümmere mich“ aus P3 behält die Zeile, der Knopf ist beim Warten gesperrt
|
||
await chooseFilter4(page, 'Wartet')
|
||
await waitTitles(page, [T4.falsch, T4.koll])
|
||
await row4(page, T4.falsch).getByRole('button', { name: 'Ich kümmere mich', exact: true }).click()
|
||
await page.waitForFunction((t) => Array.from(document.querySelectorAll('li.wk-pult-row')).some((el) => el.textContent.includes(t) && el.querySelector('.wk-pult-meta').textContent.includes('übernommen von Kenso')), T4.falsch)
|
||
await sleep(200)
|
||
metrics.fokusNachIchKuemmereMich = (await focusInfo(page)).tag
|
||
await context.close()
|
||
}
|
||
|
||
// ---------- Szenario P: Handy 390 x 844 für alle Felder und Zustände ----------
|
||
|
||
async function handyP4(browser, base) {
|
||
const { context, page } = await newPultPage(browser, 'handy-p4', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
|
||
await openP4(page, base)
|
||
await sleep(300)
|
||
const mail = row4(page, T4.mail)
|
||
const frei = row4(page, T4.frei)
|
||
const falsch = row4(page, T4.falsch)
|
||
const uns = row4(page, T4.uns)
|
||
const noScroll = async (label, detail) => {
|
||
const w = await widthOf(page)
|
||
check(`handy-p4: ${label}: kein seitliches Scrollen (scrollWidth <= innerWidth)`, w.scroll <= w.inner, { ...w, ...(detail || {}) })
|
||
}
|
||
const ACTIONS = '.wk-pult-actions > a, .wk-pult-actions > button'
|
||
|
||
// 1. Liste
|
||
await noScroll('Liste Wartet')
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-p4-wartet.png')
|
||
await shoot(page, 'handy-p4-wartet-ganz.png', { fullPage: true })
|
||
const reach1 = await reachOf(page, ACTIONS)
|
||
check('handy-p4: alle Aktionsknöpfe und Links der Liste sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach1.length >= 15 && badReach(reach1).length === 0, { count: reach1.length, bad: badReach(reach1) })
|
||
|
||
// 2. „Als Mensch antworten“ mit sehr langem Wort, Fehlermeldung
|
||
await mail.scrollIntoViewIfNeeded()
|
||
await mail.getByRole('button', { name: 'Als Mensch antworten', exact: true }).click()
|
||
const panel = mail.locator('.wk-pult-reply')
|
||
await panel.waitFor()
|
||
await sleep(200)
|
||
await noScroll('Antwortfeld offen')
|
||
const reach2 = await reachOf(page, '.wk-pult-reply textarea, .wk-pult-reply .wk-pult-panel-foot .btn')
|
||
const area = panel.locator('textarea')
|
||
const areaBox = await area.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height) } })
|
||
check('handy-p4: Antwortfeld: Textfeld, „Abbrechen“ und „Jetzt senden“ im Fenster, nicht verdeckt, Knöpfe mindestens 40 px hoch, Feld mindestens 100 px hoch', reach2.length === 3 && badReach(reach2).length === 0 && areaBox.left >= 0 && areaBox.right <= 390 && areaBox.height >= 100, { reach2, areaBox })
|
||
await panel.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-antwort-offen.png')
|
||
await area.fill('x'.repeat(400) + ' https://www.sv-namborn.example/' + 'lang/'.repeat(30))
|
||
await noScroll('Antwortfeld mit sehr langem Text')
|
||
await setPultFault(page, 'reply', { status: 409, message: 'Wird schon von einem Kollegen bearbeitet.' })
|
||
await panel.getByRole('button', { name: 'Jetzt senden', exact: true }).click()
|
||
await mail.locator('.msg.err').waitFor()
|
||
await setPultFault(page, 'reply', null)
|
||
await noScroll('Antwortfeld mit Fehlermeldung')
|
||
await mail.locator('.msg.err').scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-antwort-409.png')
|
||
|
||
// 3. Vorschau-Freigabe: Frage, Link, falsche Adresse
|
||
await frei.scrollIntoViewIfNeeded()
|
||
const approveBtn = frei.getByRole('button', { name: /^(Freigeben & senden|Wirklich freigeben\?)$/ })
|
||
await approveBtn.click()
|
||
check('handy-p4: Freigabe-Zeile fragt „Wirklich freigeben?“', (await textOf(approveBtn)) === 'Wirklich freigeben?')
|
||
await noScroll('Freigabe-Zeile mit Rückfrage')
|
||
await shoot(page, 'handy-p4-freigabe-frage.png')
|
||
await falsch.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-freigabe-falsche-adresse.png')
|
||
|
||
// 4. „unsicher“: Antwortfeld leer, „Vorschau bauen“
|
||
await uns.scrollIntoViewIfNeeded()
|
||
await uns.getByRole('button', { name: 'Als Mensch antworten', exact: true }).click()
|
||
await uns.locator('.wk-pult-reply').waitFor()
|
||
await noScroll('Zeile unsicher mit Antwortfeld')
|
||
const reach4 = await reachOf(page, 'li.wk-pult-row .wk-pult-reply .btn, li.wk-pult-row .wk-pult-actions > button')
|
||
check('handy-p4: alle Knöpfe von Antwortfeld und Aktionen sind erreichbar (nicht verdeckt, im Fenster, mindestens 40 px hoch)', reach4.length >= 10 && badReach(reach4).length === 0, { count: reach4.length, bad: badReach(reach4) })
|
||
await uns.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-unsicher.png')
|
||
|
||
// 5. „Details“ als Panel
|
||
const details = uns.getByRole('button', { name: `Details zu ${T4.uns}`, exact: true })
|
||
await details.scrollIntoViewIfNeeded()
|
||
await details.click()
|
||
const dialog = page.getByRole('dialog', { name: 'Gasthaus Sonne', exact: true })
|
||
await dialog.waitFor()
|
||
await dialog.locator('.wk-prospect').waitFor()
|
||
await sleep(700)
|
||
const box = await dialog.evaluate((el) => { const r = el.getBoundingClientRect(); return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight } })
|
||
check('handy-p4: „Details“-Panel liegt im Fenster (links >= 0, rechts <= 390, unten <= Fensterhöhe)', box.left >= 0 && box.right <= 390 && box.top >= 0 && box.bottom <= box.inner, box)
|
||
await noScroll('Details-Panel offen')
|
||
await shoot(page, 'handy-p4-details.png')
|
||
const closeBtn = dialog.getByRole('button', { name: 'Schließen', exact: true }).last()
|
||
const closeReach = await closeBtn.evaluate((el) => { const r = el.getBoundingClientRect(); const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), height: Math.round(r.height), inner: window.innerHeight, covered: !(hit && (hit === el || el.contains(hit))) } })
|
||
check('handy-p4: „Schließen“ im Details-Panel ist erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', closeReach.left >= 0 && closeReach.right <= 390 && closeReach.bottom <= closeReach.inner && !closeReach.covered && closeReach.height >= 40, closeReach)
|
||
await closeBtn.click()
|
||
await page.getByRole('dialog').waitFor({ state: 'detached' })
|
||
|
||
// 6. Erledigt: „Antwort des Kunden einfügen“ in beiden Zeilen, 413
|
||
await chooseFilter4(page, 'Erledigt')
|
||
await waitTitles(page, [T4.ang, T4.plat])
|
||
const plat = row4(page, T4.plat)
|
||
const ang = row4(page, T4.ang)
|
||
await noScroll('Liste Erledigt')
|
||
await topOfPage(page)
|
||
await shoot(page, 'handy-p4-erledigt.png')
|
||
await plat.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }).click()
|
||
await ang.getByRole('button', { name: 'Antwort des Kunden einfügen', exact: true }).click()
|
||
await plat.locator('.wk-pult-paste').waitFor()
|
||
await ang.locator('.wk-pult-paste').waitFor()
|
||
await plat.locator('.wk-pult-paste textarea').fill('Antwort Kunde A')
|
||
await setPultFault(page, 'paste', { status: 413, message: TOO_BIG })
|
||
await plat.locator('.wk-pult-paste').getByRole('button', { name: 'Einfügen', exact: true }).click()
|
||
await plat.locator('.msg.err').waitFor()
|
||
await setPultFault(page, 'paste', null)
|
||
await noScroll('beide Einfüge-Felder offen, 413 sichtbar')
|
||
const reach6 = await reachOf(page, '.wk-pult-paste textarea, .wk-pult-paste .wk-pult-panel-foot .btn, li.wk-pult-row .wk-pult-actions > button')
|
||
check('handy-p4: Einfüge-Felder, „Abbrechen“, „Einfügen“ und „Antwort des Kunden einfügen“ sind erreichbar (im Fenster, nicht verdeckt, Knöpfe mindestens 40 px hoch)', reach6.length === 8 && badReach(reach6).length === 0, { count: reach6.length, bad: badReach(reach6) })
|
||
await plat.scrollIntoViewIfNeeded()
|
||
await shoot(page, 'handy-p4-einfuegen-413.png')
|
||
await shoot(page, 'handy-p4-einfuegen-ganz.png', { fullPage: true })
|
||
check('handy-p4: kein Telefon im Bild (weder Wort noch Nummer)', await phoneFree(page))
|
||
await context.close()
|
||
}
|
||
|
||
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) })
|