// 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. '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() 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) })