Echte Seite WorkersPage im Prüfstand (Austausch-Module für Anmeldung, Mitarbeiterliste und Sitzungs-JWT), Skript-API mit Pult, Aktionen und Interessent samt Schaltern für 409, 400, Ausfall und Leerzustand, vier neue Szenarien (Freigabe, Interessent, Anhalten, Handy), jedes Szenario läuft für sich, und der Szenario Desktop bestätigt jetzt die Rückfrage vor dem Anhalten. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
995 lines
68 KiB
JavaScript
995 lines
68 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). 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))
|
||
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()
|
||
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', 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, Verwerfen', same(await buttonNames(plat), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', '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))
|
||
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, Verwerfen)', same(await buttonNames(rowOf(page, T_ALT)), ['Kopieren & öffnen', 'Ich kümmere mich', 'Als gesendet markieren', '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])
|
||
check('freigabe: „Erledigt“ zeigt gesendet, verworfen, erledigt (fünf Zeilen), ohne Aktionsknöpfe', (await page.locator('.wk-pult-actions').count()) === 0)
|
||
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', 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', 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', 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', 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()
|
||
}
|
||
|
||
|
||
const SCENARIOS = [
|
||
['desktop', desktop], ['blasen', bubbles], ['handy', phone], ['reduziert', reduced], ['notbremse', halted], ['ausfall', outage],
|
||
['freigabe', freigabe], ['interessent', interessent], ['anhalten', anhalten], ['handy-freigabe', handyFreigabe],
|
||
];
|
||
|
||
(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)))
|
||
}
|
||
}
|
||
} 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) })
|