// Manuelle Abnahme der Hustler-Seite im echten Browser (Chromium, headless shell) gegen den Prüfstand. // Aufruf (immer unter Speicherdeckel, serielle Läufe): // systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 300 node --max-old-space-size=400 \ // scripts/workers-harness/shot.cjs // = Ausgabe von build.mjs, = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo. // Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1. // Szenarien: A Desktop, B Blasen, C Handy, D weniger Bewegung, E Notbremse, F Ausfall (P2); G Freigabe, H Interessenten-Panel, I Anhalten // mit Rückfrage, J Handy mit Freigabe und Panel (P3). Jedes Szenario läuft für sich: bricht eines ab, laufen die übrigen weiter und der // Abbruch steht als Verstoß in der Liste. HARNESS_ONLY=freigabe,interessent (Namen siehe `SCENARIOS` unten) lässt nur diese laufen (für // die Arbeit am Prüfstand; die Abnahme läuft immer vollständig). 'use strict' const { createRequire } = require('module') const path = require('path') const fs = require('fs') const os = require('os') const http = require('http') const req = createRequire('/root/.npm/_npx/db89d7302a373f10/node_modules/') const { chromium } = req('playwright-core') const repo = path.resolve(__dirname, '../..') const distArg = process.argv[2] const outArg = process.argv[3] if (!distArg || !outArg) { console.error('usage: node shot.cjs '); process.exit(2) } const dist = path.resolve(distArg) const out = path.resolve(outArg) const tmp = path.resolve(process.env.TMPDIR || os.tmpdir()) const okBase = [tmp, path.join(repo, '.superpowers')] if (!okBase.some((b) => out.startsWith(b + path.sep))) { console.error('refusing: outDir must be below ' + okBase.join(' or ')); process.exit(2) } if (fs.existsSync(out) && fs.readdirSync(out).length > 0) { console.error('refusing: outDir must be new or empty'); process.exit(2) } if (!fs.existsSync(path.join(dist, 'index.html'))) { console.error('refusing: no index.html in ' + dist); process.exit(2) } fs.mkdirSync(out, { recursive: true }) // Namen und Modelle aus der gemeinsamen Roster-Fixture (CommonJS kann den ESM-Quelltext nicht laden) const roster = JSON.parse(fs.readFileSync(path.join(repo, 'tests/fixtures/workers-roster.json'), 'utf8')).roles const MODEL = { opus: 'claude-opus-5-5', sonnet: 'claude-sonnet-5-5', haiku: 'claude-haiku-5-5' } const FONT_URL = '/fonts/PixelifySans-Variable.woff2' const CHROME = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell' const HALT_LINE = 'Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.' const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' } const fontLog = [] function serve() { const server = http.createServer((rq, rs) => { let rel try { rel = decodeURIComponent(new URL(rq.url, 'http://x').pathname) } catch { rs.writeHead(400).end(); return } const fonts = rel.startsWith('/fonts/') const root = fonts ? path.join(repo, 'public', 'fonts') : dist const file = path.resolve(root, '.' + (fonts ? rel.slice('/fonts'.length) : (rel === '/' ? '/index.html' : rel))) if (rq.method !== 'GET' || !file.startsWith(root + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { if (fonts) fontLog.push({ url: rel, status: 404 }) rs.writeHead(404).end('not found') return } if (fonts) fontLog.push({ url: rel, status: 200 }) rs.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }) fs.createReadStream(file).pipe(rs) }) return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server))) } const problems = [] const errors = [] const checks = {} const metrics = {} const shots = [] function check(name, ok, detail) { checks[name] = ok ? 'ok' : 'FEHLER' + (detail === undefined ? '' : ' ' + JSON.stringify(detail)) if (!ok) problems.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail))) } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) 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: ${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, 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)) // 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, 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) const doneMeta = await page.evaluate(() => [...document.querySelectorAll('.wk-pult-row')].map((row) => ({ badge: [...row.querySelectorAll('.wk-pult-head .badge')].map((b) => b.textContent.trim()).filter((t) => ['Gesendet', 'Verworfen', 'Erledigt'].includes(t)), meta: row.querySelector('.wk-pult-meta').textContent.trim(), }))) check('freigabe: „Erledigt“: jede Zeile nennt Ergebnis (Badge), Person und Zeitpunkt der Erledigung, nicht das Alter ab Anlage', doneMeta.length === 5 && doneMeta.every((r) => r.badge.length === 1 && new RegExp(`^${r.badge[0]} von (Kenso|Justin) · (gerade eben|vor \\d+ (Min\\.|Std\\.|Tag|Tagen))`).test(r.meta)), doneMeta) check('freigabe: „Erledigt“: Foto-Zeile „Gesendet von Kenso · vor 1 Tag“, Beschwerde „Erledigt von Justin · vor 3 Tagen“', (await textOf(rowOf(page, T_GES).locator('.wk-pult-meta'))).startsWith('Gesendet von Kenso · vor 1 Tag') && (await textOf(rowOf(page, T_ERL).locator('.wk-pult-meta'))).startsWith('Erledigt von Justin · vor 3 Tagen')) await shoot(page, 'desktop-freigabe-erledigt.png') // 10. Leerzustand „Wartet“ und Ausfall des Pults mit „Neu laden“ await page.evaluate(() => { window.__wk.api.pultEmpty = true }) await filterButtons.nth(0).click() await page.locator('.empty').waitFor() const emptyWait = await textOf(page.locator('.empty')) check('freigabe: Leerzustand „Wartet“: „Nichts wartet auf dich“ mit Hinweis', emptyWait.startsWith('Nichts wartet auf dich') && emptyWait.includes('Neue Antworten erscheinen hier, sobald ein Entwurf bereit ist.'), emptyWait) await shoot(page, 'desktop-freigabe-leer.png') await setPultFault(page, 'pult', { status: 503, message: PULT_DOWN }) await filterButtons.nth(1).click() await page.getByRole('button', { name: 'Neu laden', exact: true }).waitFor() const emptyDown = await textOf(page.locator('.empty')) check('freigabe: Pult nicht erreichbar: Hinweis mit dem Satz des Dienstes und „Neu laden“', emptyDown.includes('Das Pult ist gerade nicht erreichbar') && emptyDown.includes(PULT_DOWN), emptyDown) await shoot(page, 'desktop-freigabe-ausfall.png') await setPultFault(page, 'pult', null) await page.getByRole('button', { name: 'Neu laden', exact: true }).click() await page.waitForFunction(() => (document.querySelector('.empty') || {}).textContent?.startsWith('Nichts ist veraltet')) check('freigabe: „Neu laden“ nach dem Ausfall fragt erneut, der Leerzustand kehrt zurück', true) await page.evaluate(() => { window.__wk.api.pultEmpty = false }) await filterButtons.nth(0).click() await waitTitles(page, [T_KOLL, T_ALT]) check('freigabe: nach dem Ausfall und dem Leerzustand ist die Liste wieder da (Kollege und der eben übernommene veraltete Eintrag)', true) await context.close() } // ---------- Szenario H: Interessenten-Panel (Desktop) ---------- async function interessent(browser, base) { const { context, page } = await newPultPage(browser, 'interessent', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) await openPult(page, base) // 1. „Details“ öffnet das Panel als Dialog const opener = rowOf(page, T_MAIL).getByRole('button', { name: `Details zu ${T_MAIL}`, exact: true }) await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener')) await opener.click() const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true }) await dialog.waitFor() await dialog.locator('.wk-prospect').waitFor() await sleep(600) // der Dialog blendet ein check('interessent: „Details“ öffnet einen Dialog (role=dialog, aria-modal) mit dem Namen als Titel', (await dialog.getAttribute('aria-modal')) === 'true') // Im Entwicklungsmodus hängt StrictMode jeden Effekt zweimal ein: ein oder zwei Abfragen, aber immer nur dieser Interessent const asked = await page.evaluate(() => window.__wk.api.prospectCalls) check('interessent: das Panel fragt den Interessenten p-mail ab (und keinen anderen)', asked.length >= 1 && asked.every((id) => id === 'p-mail'), asked) check('interessent: der Fokus liegt beim Öffnen im Dialog', await dialog.evaluate((el) => el.contains(document.activeElement))) const scoreParts = await dialog.locator('.wk-prospect-score').evaluate((el) => Array.from(el.children).map((child) => child.textContent.trim())) check('interessent: Score als Badge „Score 84 · hoch“ mit Begründung', same(scoreParts, ['Score 84 · hoch', 'Klarer Auftrag, Budget genannt, Region passt.']), scoreParts) const facts = await dialog.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim())) check('interessent: Fakten Quelle, Gefunden, Ort, Status (ohne Telefon)', same(facts, ['Quelle', 'freelancermap', 'Gefunden', 'vor 3 Std.', 'Ort', '66640 Namborn', 'Status', 'Bewertet']), facts) const request = dialog.locator('.wk-prospect-section').first() const requestText = request.locator('.wk-pult-text') check('interessent: Anfragetext als Text mit erhaltenen Zeilenumbrüchen (pre-wrap)', (await requestText.textContent()).includes('Vereinsseite neu macht.\n\nBudget bis 1.500 Euro') && (await requestText.evaluate((el) => getComputedStyle(el).whiteSpace)) === 'pre-wrap') const open = request.getByRole('link', { name: 'Anfrage öffnen', exact: true }) check('interessent: „Anfrage öffnen“ ist ein sicherer Link (https, neuer Tab, noopener noreferrer)', (await open.getAttribute('href')) === SV_URL && (await open.getAttribute('target')) === '_blank' && (await open.getAttribute('rel')) === 'noopener noreferrer') const msgs = await dialog.locator('li.wk-prospect-msg').evaluateAll((els) => els.map((el) => ({ who: el.querySelector('strong').textContent, when: el.querySelector('.wk-prospect-msg-head span').textContent, body: el.querySelector('p').textContent }))) check('interessent: zwei Nachrichten: „Interessent“ (eingegangen) und „Justin“ (Mitarbeiter, Name aus der Liste), mit Alter', msgs.length === 2 && msgs[0].who === 'Interessent' && msgs[1].who === 'Justin' && msgs[0].when === 'vor 3 Std.' && msgs[1].when === 'vor 2 Std.' && msgs[0].body.startsWith('Guten Tag,\nkönnen Sie uns eine Vorschau zeigen?'), msgs) const previewLine = await textOf(dialog.locator('.wk-prospect-section').last().locator('p.muted')) check('interessent: Vorschau-Zeile „Vorschau: fertig · Runde 1 · erstellt vor 1 Std.“', previewLine === 'Vorschau: fertig · Runde 1 · erstellt vor 1 Std.', previewLine) const html = await page.evaluate(() => document.documentElement.outerHTML) const text = await page.evaluate(() => document.body.innerText) check('interessent: nirgends im Bild ein Telefon (weder Wort noch Nummer, weder in Text noch im HTML)', !PHONE_WORD.test(html) && !PHONE_NUMBER.test(html) && !PHONE_WORD.test(text) && !PHONE_NUMBER.test(text), { word: (html.match(PHONE_WORD) || [])[0], number: (html.match(PHONE_NUMBER) || [])[0] }) check('interessent: keine Mail-Rohdaten im Bild (messageId, inReplyTo, references)', !/messageId|inReplyTo|referencesJson/.test(html)) await shoot(page, 'desktop-interessent-panel.png') // 2. Esc schließt, der Fokus kehrt auf „Details“ zurück await page.keyboard.press('Escape') await page.getByRole('dialog').waitFor({ state: 'detached' }) check('interessent: Esc schließt das Panel', true) check('interessent: nach Esc steht der Fokus wieder auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) // 3. „Schließen“ im Fuß und das Kreuz schließen ebenfalls und geben den Fokus zurück await opener.click() await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor() await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).last().click() await page.getByRole('dialog').waitFor({ state: 'detached' }) check('interessent: „Schließen“ im Fuß schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) await opener.click() await page.getByRole('dialog', { name: 'SV Namborn', exact: true }).waitFor() await page.getByRole('dialog').getByRole('button', { name: 'Schließen', exact: true }).first().click() await page.getByRole('dialog').waitFor({ state: 'detached' }) check('interessent: das Kreuz oben schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) // 4. Interessent ohne Score, ohne Anfragetext, ohne Verlauf await rowOf(page, T_KOLL).getByRole('button', { name: `Details zu ${T_KOLL}`, exact: true }).click() const dialog2 = page.getByRole('dialog', { name: 'Meisterbetrieb Weber', exact: true }) await dialog2.waitFor() await dialog2.locator('.wk-prospect').waitFor() const body2 = await textOf(dialog2.locator('.wk-prospect')) check('interessent: ohne Score „Noch nicht bewertet“, ohne Anfragetext und Verlauf die Leerzeilen, keine Vorschau-Zeile', body2.includes('Noch nicht bewertet') && body2.includes('Kein Anfragetext gespeichert.') && body2.includes('Noch keine Nachrichten.') && !body2.includes('Vorschau'), body2) const facts2 = await dialog2.locator('dl.kv').evaluate((dl) => Array.from(dl.children).map((el) => el.textContent.trim())) check('interessent: Fakten Kleinanzeigen / 66849 Landstuhl / Gefunden', same(facts2, ['Quelle', 'Kleinanzeigen', 'Gefunden', 'vor 8 Std.', 'Ort', '66849 Landstuhl', 'Status', 'Gefunden']), facts2) await sleep(600) await shoot(page, 'desktop-interessent-leer.png') await page.keyboard.press('Escape') await page.getByRole('dialog').waitFor({ state: 'detached' }) // 5. Dienst meldet einen Fehler: Satz im Panel, „Neu laden“ holt den Interessenten await setPultFault(page, 'prospect', { status: 404, message: 'Interessent nicht gefunden.' }) await rowOf(page, T_PLAT).getByRole('button', { name: `Details zu ${T_PLAT}`, exact: true }).click() const dialog3 = page.getByRole('dialog', { name: 'Interessent', exact: true }) await dialog3.getByRole('alert').waitFor() check('interessent: Fehler vom Dienst: der Satz steht im Panel (role=alert) mit „Neu laden“', (await textOf(dialog3.getByRole('alert'))) === 'Interessent nicht gefunden.' && (await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).count()) === 1, await textOf(dialog3)) await sleep(600) await shoot(page, 'desktop-interessent-fehler.png') await setPultFault(page, 'prospect', null) await dialog3.getByRole('button', { name: 'Neu laden', exact: true }).click() const dialog4 = page.getByRole('dialog', { name: 'Friseursalon Haarmonie', exact: true }) await dialog4.locator('.wk-prospect').waitFor() check('interessent: „Neu laden“ holt den Interessenten (Score 72 · mittel)', (await textOf(dialog4.locator('.wk-prospect-score'))).startsWith('Score 72 · mittel'), await textOf(dialog4.locator('.wk-prospect-score'))) await page.keyboard.press('Escape') await page.getByRole('dialog').waitFor({ state: 'detached' }) await context.close() } // ---------- Szenario I: Alles anhalten mit Rückfrage (Desktop) ---------- async function anhalten(browser, base) { const { context, page } = await newPage(browser, 'anhalten', { viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) await ready(page, base + '/index.html') await sleep(400) await topOfPage(page) const status = () => textOf(page.locator('.wk-status')) const calls = () => page.evaluate(() => window.__wk.api.pauseCalls) const pauseBtn = page.locator('.wk-toolbar').getByRole('button', { name: 'Alles anhalten', exact: true }) // 1. Rückfrage statt sofortigem Anhalten await pauseBtn.evaluate((el) => el.setAttribute('data-probe', 'pause-button')) await pauseBtn.click() const dialog = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }) await dialog.waitFor() check('anhalten: „Alles anhalten“ öffnet den Dialog „Alles anhalten?“ mit dem Satz zu den Workern', (await textOf(dialog.locator('.dialog-body'))) === HALT_QUESTION, await textOf(dialog.locator('.dialog-body'))) check('anhalten: Dialog mit den Knöpfen „Abbrechen“ und „Anhalten“', same(await dialog.locator('.dialog-foot button').allTextContents(), ['Abbrechen', 'Anhalten'])) check('anhalten: vor der Bestätigung ist nichts angehalten (kein Aufruf, Status „Läuft“)', same(await calls(), []) && (await status()) === 'Läuft', { calls: await calls(), status: await status() }) await sleep(600) await shoot(page, 'desktop-anhalten-frage.png') // 2. Abbrechen tut nichts, der Fokus kehrt auf den Knopf zurück await dialog.getByRole('button', { name: 'Abbrechen', exact: true }).click() await page.getByRole('dialog').waitFor({ state: 'detached' }) check('anhalten: „Abbrechen“ schließt den Dialog, nichts wird angehalten', same(await calls(), []) && (await status()) === 'Läuft' && (await pauseBtn.textContent()) === 'Alles anhalten') check('anhalten: nach „Abbrechen“ steht der Fokus wieder auf „Alles anhalten“', (await activeProbe(page)) === 'pause-button', await activeTag(page)) // 3. Esc verhält sich wie Abbrechen await pauseBtn.click() await page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }).waitFor() await page.keyboard.press('Escape') await page.getByRole('dialog').waitFor({ state: 'detached' }) check('anhalten: Esc schließt den Dialog, nichts wird angehalten, Fokus zurück auf „Alles anhalten“', same(await calls(), []) && (await activeProbe(page)) === 'pause-button', { calls: await calls(), focus: await activeTag(page) }) // 4. Anhalten hält an await pauseBtn.click() const dialog2 = page.getByRole('dialog', { name: 'Alles anhalten?', exact: true }) await dialog2.waitFor() await dialog2.getByRole('button', { name: 'Anhalten', exact: true }).click() await page.getByRole('dialog').waitFor({ state: 'detached' }) await page.getByRole('button', { name: 'Fortsetzen', exact: true }).waitFor() await page.waitForFunction(() => document.querySelector('.wk-status') && document.querySelector('.wk-status').textContent.startsWith('Angehalten')) check('anhalten: „Anhalten“ pausiert genau einmal (Aufruf pause(true)), Status „Angehalten: …“, Knopf „Fortsetzen“', same(await calls(), [true]) && (await status()) === 'Angehalten: Es wird nichts gesendet und nichts gebaut.', { calls: await calls(), status: await status() }) await sleep(500) const focusTag = await activeTag(page) const focusText = await page.evaluate(() => (document.activeElement ? (document.activeElement.textContent || '').trim().slice(0, 40) : null)) check('anhalten: nach „Anhalten“ steht der Fokus nicht auf (Tastatur-Nutzer verlieren sonst ihre Stelle)', focusTag !== null && focusTag !== 'BODY', { tag: focusTag, text: focusText }) await topOfPage(page) await shoot(page, 'desktop-anhalten-angehalten.png') // 5. Fortsetzen geht ohne Rückfrage await page.getByRole('button', { name: 'Fortsetzen', exact: true }).click() await page.getByRole('button', { name: 'Alles anhalten', exact: true }).waitFor() check('anhalten: „Fortsetzen“ geht ohne Rückfrage (kein Dialog, pause(false), Status „Läuft“)', (await page.getByRole('dialog').count()) === 0 && same(await calls(), [true, false]) && (await status()) === 'Läuft', { calls: await calls(), status: await status() }) await context.close() } // ---------- Szenario J: Handy 390 x 844 für Freigabe und Panel ---------- async function handyFreigabe(browser, base) { const { context, page } = await newPultPage(browser, 'handy-freigabe', { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }) await openPult(page, base) await sleep(300) const width = () => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth })) const w0 = await width() metrics.phoneFreigabe = w0 check('handy-freigabe: Freigabe ohne seitliches Scrollen (scrollingElement.scrollWidth <= innerWidth)', w0.scroll <= w0.inner, w0) const bar = await page.evaluate(() => { const out = {} for (const [name, sel] of [['tabs', '.tabs'], ['filter', '.wk-pult-filters .seg']]) { const r = document.querySelector(sel).getBoundingClientRect() out[name] = { left: Math.round(r.left), right: Math.round(r.right) } } return out }) check('handy-freigabe: Reiterleiste und Filter liegen im Fenster', bar.tabs.left >= 0 && bar.tabs.right <= 390 && bar.filter.left >= 0 && bar.filter.right <= 390, bar) await topOfPage(page) await shoot(page, 'handy-freigabe.png') await shoot(page, 'handy-freigabe-ganz.png', { fullPage: true }) // Jede Schaltfläche und jeder Link der Aktionszeilen: im Fenster, nicht verdeckt, mindestens 40 px hoch (Tippfläche), volle Breite untereinander const reach = await page.evaluate(() => { const out = [] for (const el of document.querySelectorAll('.wk-pult-actions > *')) { el.scrollIntoView({ block: 'center' }) const r = el.getBoundingClientRect() const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) out.push({ text: el.textContent.trim().slice(0, 28), left: Math.round(r.left), right: Math.round(r.right), height: Math.round(r.height), covered: !(hit && (hit === el || el.contains(hit))) }) } return out }) const bad = reach.filter((r) => r.left < 0 || r.right > 390 || r.covered || r.height < 40) check('handy-freigabe: alle Aktionsknöpfe und Links sind erreichbar (im Fenster, nicht verdeckt, mindestens 40 px hoch)', reach.length >= 10 && bad.length === 0, { count: reach.length, bad }) const w1 = await width() check('handy-freigabe: nach dem Durchblättern der Aktionen weiterhin kein seitliches Scrollen', w1.scroll <= w1.inner, w1) // Die lange Adresse steckt in der Originalanfrage: aufgeklappt darf sie die Seite nicht verbreitern await rowOf(page, T_MAIL).locator('summary', { hasText: 'Originalanfrage' }).click() await sleep(150) const w1b = await width() check('handy-freigabe: aufgeklappte „Originalanfrage“ mit langer Adresse verbreitert die Seite nicht', w1b.scroll <= w1b.inner, w1b) await rowOf(page, T_MAIL).scrollIntoViewIfNeeded() await shoot(page, 'handy-freigabe-originalanfrage.png') // Panel am Handy: Bottom-Sheet im Fenster, lange Wörter brechen um, Schließen erreichbar const opener = rowOf(page, T_MAIL).getByRole('button', { name: `Details zu ${T_MAIL}`, exact: true }) await opener.evaluate((el) => el.setAttribute('data-probe', 'details-opener')) await opener.scrollIntoViewIfNeeded() await opener.click() const dialog = page.getByRole('dialog', { name: 'SV Namborn', exact: true }) await dialog.waitFor() await dialog.locator('.wk-prospect').waitFor() await sleep(700) const box = await dialog.evaluate((el) => { const r = el.getBoundingClientRect() const body = el.querySelector('.dialog-body') return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, bodyOverflowX: body.scrollWidth - body.clientWidth, bodyScrolls: body.scrollHeight > body.clientHeight } }) metrics.phonePanel = box check('handy-freigabe: Panel liegt im Fenster (links >= 0, rechts <= 390, unten <= Fensterhöhe)', box.left >= 0 && box.right <= 390 && box.top >= 0 && box.bottom <= box.inner, box) check('handy-freigabe: Panel ohne waagerechten Überlauf (lange Adressen brechen um)', box.bodyOverflowX <= 0, box) const w2 = await width() check('handy-freigabe: mit offenem Panel kein seitliches Scrollen', w2.scroll <= w2.inner, w2) await shoot(page, 'handy-interessent-panel.png') // ganz nach unten im Panel: Nachrichtenverlauf und Schließen-Knopf await dialog.locator('.dialog-body').evaluate((el) => { el.scrollTop = el.scrollHeight }) await sleep(200) await shoot(page, 'handy-interessent-panel-unten.png') const closeBtn = dialog.getByRole('button', { name: 'Schließen', exact: true }).last() const closeReach = await closeBtn.evaluate((el) => { const r = el.getBoundingClientRect() const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2) return { left: Math.round(r.left), right: Math.round(r.right), top: Math.round(r.top), bottom: Math.round(r.bottom), inner: window.innerHeight, covered: !(hit && (hit === el || el.contains(hit))) } }) check('handy-freigabe: „Schließen“ im Panel ist erreichbar (im Fenster, nicht verdeckt)', closeReach.left >= 0 && closeReach.right <= 390 && closeReach.top >= 0 && closeReach.bottom <= closeReach.inner && !closeReach.covered, closeReach) const lastMsg = await dialog.locator('li.wk-prospect-msg').last().evaluate((el) => { const r = el.getBoundingClientRect() const foot = el.closest('.dialog').querySelector('.dialog-foot').getBoundingClientRect() return { bottom: Math.round(r.bottom), footTop: Math.round(foot.top) } }) check('handy-freigabe: nach dem Blättern liegt die letzte Nachricht über dem Fuß (nichts abgeschnitten)', lastMsg.bottom <= lastMsg.footTop + 1, lastMsg) await closeBtn.click() await page.getByRole('dialog').waitFor({ state: 'detached' }) check('handy-freigabe: „Schließen“ schließt das Panel, Fokus zurück auf „Details“', (await activeProbe(page)) === 'details-opener', await activeTag(page)) await context.close() } 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) })