// Abnahme der Hustler-Pixelwelt im echten Browser (Chromium, headless shell) gegen den Prüfstand (entry.jsx, die echte Seite WorkersPage). // Aufruf (immer unter Speicherdeckel, seriell, nie zwei Browser gleichzeitig und nie parallel zur Test-Suite): // flock /run/lock/hustler-schwer.lock systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 timeout 600 \ // node --max-old-space-size=600 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. // Stufen-Sperre (src/lib/hustlerStufe.js): build.mjs baut standardmäßig mit VITE_HUSTLER_STUFE=6 (P4-Aktionen im Pult und Keller offen); alle Szenarien unten setzen das voraus. // 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 (Name für HARNESS_ONLY, Vorsilbe der Bilder): // A hochhaus (a-) Hochhaus am Desktop 1280 x 720 und 1920 x 1080: keine Fehler, nichts läuft über den Rand, 19 Figuren mit aria-label, Kim/Pia/Finn grau, Kopfzeile, Briefkasten // B regie (b-) Regie: innerhalb von 15 s erscheint eine Sprechblase, höchstens vier Effekte gleichzeitig // C handy (c-) Handy 390 x 844 (hochkant) und 844 x 390 (quer): Kopfzeile höchstens zwei Reihen, Wischen verschiebt, Doppeltipp zeigt das ganze Haus, Detail als Vollbild // D bewegung (d-) „Weniger Bewegung“: Schnee und Flackern stehen, kein Gleiten, nur Sprechblasen // E notbremse (e-) ?halted=1: Banner mit Grund, Einstellungen mit dem Notbremse-Block // F ausfall (f-) ?fault=down: Leerzustand „Der Hustler-Dienst ist noch nicht erreichbar“, nach „Neu laden“ das Haus, Verbindungsfehler-Banner // G detail (g-) Detailansicht: Dialog, Blättern mit ←/→, Esc mit Fokus zurück auf die Figur, lange Spur mit ◂ n / 19 ▸, ein Bild je Status // H etagen (h-) Etage zoomen (mit Gleiten), Tasten +/−/0, Etage „Freigabe“ → Pult und zurück, Keller-Etage → Bot-Leads und zurück // I pause (i-) Pause mit Pixel-Rückfrage, Animationen stehen (hk-steht), Fortsetzen ohne Rückfrage // J pult (j-) Pult mit den P3-Daten: Reiter, Umschlag/Brief, Ich kümmere mich, Kopieren & öffnen, Als gesendet markieren, Verwerfen mit Rückfrage, Interessent (WOMS-Aussehen), Fokus // K pult-p4 (k-) Pult mit den P4-Daten (?p4=1): als Mensch antworten, Vorschau freigeben, Antwort des Kunden einfügen, Vorschau bauen // L keller (l-) Keller: Reiter, Suche, Blättern, Akte (Heilberuf ohne Nummer, Quelle als Link), Einzelansicht (?bot=), leer (?botleads=leer), Arne // M handy-pult-keller (m-) Pult und Keller am Handy 390 x 844: volle Breite, Brief und Akte als Vollbild, nichts läuft über den Rand // 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=detail,pult // (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 }) const CHROME = '/root/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell' const FONT_FILES = ['/fonts/PixelifySans-Variable.woff2', '/fonts/PressStart2P-Regular.woff2'] const HALT_LINE = 'Versand gestoppt: Zwei harte Bounces in den letzten 10 Mails. Ein Admin kann ihn in den Einstellungen freigeben.' const PAUSED_LINE = 'Angehalten: Es wird nichts gesendet und nichts gebaut.' const HALT_QUESTION = 'Die Worker hören sofort auf, bis jemand fortsetzt.' const NO_SERVICE = 'Der Hustler-Dienst ist noch nicht erreichbar' 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 findings = [] // Befunde: Abweichungen, die an der App liegen (nicht am Prüfstand); sie lassen den Lauf nicht scheitern, stehen aber im Bericht const errors = [] const checks = {} const metrics = {} const shots = [] const perScenario = {} // Name -> { checks, failed } let current = '' // das laufende Szenario (für die Zählung) let lastPage = null // die zuletzt geöffnete Seite: bricht ein Szenario ab, wird von hier aus der Zustand im Bild festgehalten (abbruch-.png) let openContexts = [] // Kontexte des laufenden Szenarios (bei einem Abbruch schließt der Läufer sie) function check(name, ok, detail) { checks[name] = ok ? 'ok' : 'FEHLER' + (detail === undefined ? '' : ' ' + JSON.stringify(detail)) const entry = perScenario[current] || (perScenario[current] = { checks: 0, failed: 0 }) entry.checks += 1 if (!ok) { entry.failed += 1 problems.push(name + (detail === undefined ? '' : ' ' + JSON.stringify(detail))) } } // Wie check, aber für einen Befund an der App: bei einem Verstoß kein Fehlschlag des Laufs, sondern ein Eintrag unter „befunde“ (JSON-Zeile und Standardfehlerausgabe). // Wird der Befund in der App behoben, schlägt der Aufruf nie an; dann kann aus befund wieder check werden. function befund(name, ok, detail) { checks[name] = ok ? 'ok' : 'BEFUND' + (detail === undefined ? '' : ' ' + JSON.stringify(detail)) const entry = perScenario[current] || (perScenario[current] = { checks: 0, failed: 0 }) entry.checks += 1 if (!ok) { entry.befunde = (entry.befunde || 0) + 1 findings.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() const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 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 (pageerror, console.error, fehlgeschlagene und 4xx/5xx-Anfragen) async function newPage(browser, label, contextOptions) { const context = await browser.newContext({ deviceScaleFactor: 1, ...contextOptions }) openContexts.push(context) const page = await context.newPage() lastPage = page page.setDefaultTimeout(20000) page.on('pageerror', (e) => errors.push(label + ' pageerror: ' + String(e))) page.on('console', (m) => { if (m.type() === 'error') errors.push(label + ' console.error: ' + m.text()) }) page.on('requestfailed', (r) => errors.push(label + ' requestfailed: ' + r.url())) page.on('response', (r) => { if (r.status() >= 400) errors.push(label + ' http ' + r.status() + ': ' + r.url()) }) return { context, page } } // Seite mit ersetzter Zwischenablage und ersetztem window.open (beides vor dem Laden): halten fest, was die Oberfläche tut (window.__clip, __opened, __openMeta). // 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. async function newStubPage(browser, label, contextOptions) { const made = await newPage(browser, label, contextOptions) 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 } const settleFonts = (page) => page.evaluate(() => Promise.all([ document.fonts.load('600 12px "Pixelify Sans"'), document.fonts.load('12px "Press Start 2P"'), ]).then(() => document.fonts.ready)) // Seite laden und warten, bis das Haus mit allen 19 Figuren steht async function ready(page, url) { await page.goto(url) await page.waitForFunction(() => document.querySelectorAll('[data-hk-hustler]').length === 19) await settleFonts(page) } const apiOf = (page, key) => page.evaluate((k) => JSON.parse(JSON.stringify(window.__wk.api[k])), key) const setFault = (page, key, value) => page.evaluate(([k, v]) => { window.__wk.api.faults[k] = v }, [key, value]) const widthOf = (page) => page.evaluate(() => ({ scroll: document.scrollingElement.scrollWidth, inner: window.innerWidth, body: document.body.scrollWidth, root: (() => { const el = document.querySelector('.hk-hochhaus, .hk-pult, .hk-keller'); return el ? { scroll: el.scrollWidth, client: el.clientWidth } : null })(), })) const noSideScroll = (w) => w.scroll <= w.inner && w.body <= w.inner && (w.root === null || w.root.scroll <= w.root.client) const rectOf = (loc) => loc.evaluate((el) => { const b = el.getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height), r: Math.round(b.right), b: Math.round(b.bottom) } }) // Kamera der Hochhaus-Welt aus dem Stil der Bühne: transform: translate(x px, y px) scale(s) const cameraOf = (page) => page.evaluate(() => { const el = document.querySelector('.hk-buehne') const m = el ? /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/.exec(el.style.transform) : null return m ? { x: Number(m[1]), y: Number(m[2]), s: Number(m[3]) } : null }) const focusInfo = (page) => page.evaluate(() => { const el = document.activeElement if (!el) return { tag: null } return { tag: el.tagName, hustler: el.getAttribute('data-hk-hustler'), zeile: el.getAttribute('data-hk-zeile'), label: el.getAttribute('aria-label'), pause: el.hasAttribute('data-hk-pause'), role: el.getAttribute('role'), cls: String(el.className || '').slice(0, 60), text: (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 60), inDialog: Boolean(el.closest('[role="dialog"]')), } }) // ---------- Szenario A: Hochhaus am Desktop (1280 x 720 und 1920 x 1080) ---------- async function hochhaus(browser, base) { for (const [w, h] of [[1280, 720], [1920, 1080]]) { const tag = `${w}x${h}` const { context, page } = await newPage(browser, 'hochhaus-' + tag, { viewport: { width: w, height: h } }) await ready(page, base + '/index.html') await sleep(700) const m = await page.evaluate(() => { const box = (el) => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, r: b.right, b: b.bottom, w: b.width, h: b.height } } const sicht = box(document.querySelector('.hk-sicht')) const inside = (b) => b.x >= sicht.x - 2 && b.y >= sicht.y - 2 && b.r <= sicht.r + 2 && b.b <= sicht.b + 2 const figuren = [...document.querySelectorAll('[data-hk-hustler]')].map((el) => { const b = box(el) const sprite = el.firstElementChild return { id: el.getAttribute('data-hk-hustler'), tag: el.tagName, type: el.getAttribute('type'), label: el.getAttribute('aria-label') || '', w: Math.round(b.w), h: Math.round(b.h), inside: inside(b), sprite: sprite ? sprite.className : '' } }) const text = (sel) => [...document.querySelectorAll(sel)].map((el) => el.textContent.replace(/\s+/g, ' ').trim()) return { sicht, haus: inside(box(document.querySelector('.hk-haus-bild'))), kopf: { ...box(document.querySelector('.hk-kopf')), betraege: text('.hk-kopf-betrag'), zahlen: [...document.querySelectorAll('.hk-kopf-zahl')].map((z) => z.lastElementChild.textContent.trim()) }, figuren, grauRaeume: [...document.querySelectorAll('[class*="hk-raum-grau-"]')].map((el) => el.className.replace('hk-raum-grau-', '')).sort(), zettel: text('.hk-zettel'), briefkasten: text('.hk-briefkasten'), tresor: text('.hk-schildtext-tresor'), keller: text('.hk-schildtext-keller'), meldungen: document.querySelectorAll('.hk-meldung').length, dialoge: document.querySelectorAll('[role="dialog"]').length, fonts: { pixelify: document.fonts.check('600 12px "Pixelify Sans"'), pressStart: document.fonts.check('12px "Press Start 2P"') }, } }) const camera = await cameraOf(page) metrics['hochhaus ' + tag] = { sicht: { w: Math.round(m.sicht.w), h: Math.round(m.sicht.h) }, zoom: camera && Number(camera.s.toFixed(3)), kopf: { h: Math.round(m.kopf.h) } } const wide = await widthOf(page) check(`hochhaus ${tag}: kein waagrechtes Scrollen`, noSideScroll(wide), wide) check(`hochhaus ${tag}: das ganze Haus steht im Bild`, m.haus, m.sicht) check(`hochhaus ${tag}: 19 Figuren-Knöpfe (echte