Workers P2: Prüfstand mit Skript-API und Playwright-Abnahme

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 04:55:51 +00:00
1 parent 0b78379b9b
commit d67f8cc471
6 files changed
+896

No files matched your search

+381
View File
@@ -0,0 +1,381 @@
// 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 200 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 })
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()
}
(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)
} 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) })