Files
tickte-system/scripts/workers-harness/shot.cjs
T

382 lines
20 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 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) })