Files
tickte-system/scripts/workers-harness/shot.cjs
T
ClaudeandClaude Sonnet 5.5 aceb558564 Workers P2: Prüfstand um Ausfall-Szenario erweitert (503, 401-Folge, hängende Verbindung, versteckter Tab)
Endprüfung P2. Die Zustände des Boards bei Ausfall des Dienstes und der Wechsel zwischen sichtbarem und verstecktem Tab
waren nur über Quelltext-Muster geprüft. Der Prüfstand (?fault=none|down|session|hang) schaltet jetzt den echten
API-Client hinter eine Fetch-Weiche (scripted-fetch.js, mit Node-Tests). Szenario F in shot.cjs prüft im Browser:
Hinweis mit „Neu laden“ bei 503 beim Öffnen und Rückkehr, Warnzeile „Verbindung gestört“ im Betrieb, versteckter Tab
(keine Abrufe, keine Bilder, beim Zurückkehren sofort beides), dauerhaftes 401 (höchstens ein frisches JWT, Meldung
„Anmeldung abgelaufen“, Erholung ohne neues Anmelden) und eine Antwort, die nie kommt (Zeitgrenze). Das Handy-Szenario
prüft zusätzlich Auswahl aus der Liste (Karte im Fenster, Fokus) und Fokus nach „Karte schließen“.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 05:41:03 +00:00

497 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Manuelle Abnahme der Hustler-Seite im echten Browser (Chromium, headless shell) gegen den Prüfstand.
// Aufruf (immer unter Speicherdeckel, serielle Läufe):
// systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 timeout 300 node --max-old-space-size=400 \
// scripts/workers-harness/shot.cjs <distDir> <outDir>
// <distDir> = Ausgabe von build.mjs, <outDir> = NEUER oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo.
// Ein kleiner Server auf 127.0.0.1 liefert distDir und /fonts/* aus public/fonts. Am Ende steht eine JSON-Zeile und `OK`; bei einem Verstoß Exit 1.
'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()
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()
}
(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 {
await desktop(browser, base)
await bubbles(browser, base)
await phone(browser, base)
await reduced(browser, base)
await halted(browser, base)
await outage(browser, base)
} catch (e) {
problems.push('Abbruch: ' + (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) })