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

+46
View File
@@ -0,0 +1,46 @@
// Baut den Prüfstand: node scripts/workers-harness/build.mjs <outDir>
// entry.jsx -> harness.js + harness.css (esbuild, Entwicklungsmodus, damit StrictMode wie in der Entwicklung zweimal einhängt), dazu index.html.
// outDir muss ein neuer oder leerer Ordner unter dem Temp-Ordner (TMPDIR, sonst /tmp) oder unter .superpowers/ im Repo sein.
// Die Schrift /fonts/* bleibt außen vor: der Server von shot.cjs liefert sie aus public/fonts.
import esbuild from 'esbuild'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const repo = path.resolve(here, '../..')
const arg = process.argv[2]
if (!arg) {
console.error('usage: node scripts/workers-harness/build.mjs <outDir>')
process.exit(2)
}
const out = path.resolve(arg)
const tmp = path.resolve(process.env.TMPDIR || os.tmpdir())
const allowed = [tmp, path.join(repo, '.superpowers')]
if (!allowed.some((base) => out.startsWith(base + path.sep))) {
console.error(`refusing: outDir must be below ${allowed.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)
}
fs.mkdirSync(out, { recursive: true })
const result = await esbuild.build({
entryPoints: [path.join(here, 'entry.jsx')],
outfile: path.join(out, 'harness.js'),
bundle: true,
format: 'iife',
platform: 'browser',
jsx: 'automatic',
external: ['/fonts/*'],
define: { 'process.env.NODE_ENV': '"development"' },
logLevel: 'warning',
metafile: true,
})
fs.copyFileSync(path.join(here, 'index.html'), path.join(out, 'index.html'))
const sizes = Object.entries(result.metafile.outputs).map(([file, info]) => `${path.basename(file)} ${info.bytes}`)
console.log(`built ${sizes.join(', ')} -> ${out}`)
+34
View File
@@ -0,0 +1,34 @@
// Einstiegspunkt des Prüfstands: das Hochhaus-Board der Hustler-Seite mit der Skript-API, ohne Anmeldung und ohne Appwrite.
// Drumherum steht dieselbe Hülle wie in der App (Menüleiste links, Seitenrand), damit die Bühne genauso breit wird wie im echten Betrieb.
// ?halted=1 startet mit gesetzter Bounce-Notbremse (H4). window.__wk gibt es nur hier: die Bühne und die Skript-API für shot.cjs.
import { StrictMode, createRef } from 'react'
import { createRoot } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import '../../src/styles/global.css'
import '../../src/styles/workers.css'
import PageHeader from '../../src/components/ui/PageHeader'
import WorkersBoard from '../../src/components/workers/WorkersBoard'
import { createScriptedApi } from './scripted-api.js'
const params = new URLSearchParams(window.location.search)
const scripted = createScriptedApi({ now: () => performance.now(), base: Date.now(), halted: params.get('halted') === '1' })
const stage = createRef()
window.__wk = { stage, api: scripted }
createRoot(document.getElementById('root')).render(
<StrictMode>
<MemoryRouter>
<div className="shell">
<div className="sidebar" aria-hidden="true" />
<div className="main">
<main id="inhalt">
<div className="page wk-page">
<PageHeader title="Hustler" subtitle="Die Nachtschicht deiner Claude-Armee" />
<WorkersBoard api={scripted} isAdmin stageRef={stage} />
</div>
</main>
</div>
</div>
</MemoryRouter>
</StrictMode>,
)
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/harness.css" />
<title>Hustler-Prüfstand</title>
</head>
<body>
<div id="root"></div>
<script src="/harness.js"></script>
</body>
</html>
+301
View File
@@ -0,0 +1,301 @@
// Skript-API des Prüfstands: dieselbe Form wie createWorkersApi (src/lib/workersApi.js), aber alles kommt aus einem festen Drehbuch.
// Kein Netz, kein Appwrite, keine echte Zeit: die Uhr `now` (Millisekunden) wird von außen gegeben, die Basiszeit `base` ebenso.
// Die Antworten verhalten sich wie der Dienst P1: events(after) liefert aufsteigend alle fälligen Einträge ab `after - 5 s` (Überlappung,
// die Entdopplung übernimmt der Feed), höchstens `limit` Stück, die ältesten zuerst; nichts aus der Zukunft.
import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js'
import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js'
const DEFAULT_BASE = Date.parse('2026-10-17T20:00:00.000Z')
const OVERLAP_MS = 5000
const EVENTS_MAX = 200
const HALT_REASON = 'Zwei harte Bounces in den letzten 10 Mails.'
// Drehbuch: [{ at: Sekunden nach dem Start, event }]. Deckt alle 26 Ereignisarten und 16 Worker ab; das erste Ereignis liegt nicht vor 2 s,
// damit die Bühne vorher ruhig steht. Die Zeiten sind so verteilt, dass ein Abruf (alle 5 s) höchstens fünf bis sechs Ereignisse bringt.
const step = (at, workerId, kind, text, extra = {}) => ({ at, event: { workerId, kind, text, ...extra } })
export const SCRIPT = Object.freeze([
step(2.0, 'otto', 'auftrag', 'Rita sucht Anfragen der Nacht', { to: 'rita' }),
step(2.6, 'otto', 'auftrag', 'Ali sucht Praxen ohne Website', { to: 'ali' }),
step(4.0, 'rita', 'gelesen', 'Netz-Radar: 12 Beiträge gelesen', { meta: { count: 12 } }),
step(5.0, 'rita', 'fund', 'Neue Anfrage gefunden'),
step(6.2, 'ali', 'gelesen', 'Verzeichnis: 7 Praxen gelesen', { meta: { count: 7 } }),
step(7.2, 'ali', 'fund', 'Praxis ohne Website gefunden'),
step(8.2, 'arne', 'doppelt', 'Eine Praxis war schon im Register'),
step(9.4, 'rita', 'uebergabe', 'Anfrage an Bea übergeben', { to: 'bea' }),
step(10.6, 'ali', 'uebergabe', 'Praxis an Bea übergeben', { to: 'bea' }),
step(12.0, 'bea', 'bewertet', 'Anfrage mit 87 Punkten bewertet', { meta: { score: 87 } }),
step(13.2, 'bea', 'top3', 'In die Top 3 aufgenommen'),
step(14.0, 'arne', 'botlead', 'Kontakt in die Bot-Lead-Liste abgelegt'),
step(15.0, 'lea', 'leerlauf', 'Wartet auf den nächsten Auftrag'),
step(16.0, 'hanna', 'fund', 'Betrieb ohne Website gefunden'),
step(17.0, 'bea', 'uebergabe', 'Top 3 an Clara übergeben', { to: 'clara' }),
step(18.4, 'clara', 'entwurf', 'Antwort auf eine Anfrage entworfen'),
step(19.4, 'clara', 'pult', 'Entwurf liegt im Pult'),
step(20.6, 'max', 'entwurf', 'Erste Mail entworfen'),
step(21.6, 'max', 'gesendet', 'Mail verschickt'),
step(23.0, 'nora', 'eingang', 'Antwort eingegangen'),
step(24.0, 'nora', 'sortiert', 'Antwort als „Ja“ eingeordnet', { meta: { classification: 'ja' } }),
step(25.4, 'bob', 'bau_start', 'Vorschau wird gebaut'),
step(27.0, 'bob', 'bau_fertig', 'Vorschau ist gebaut'),
step(28.4, 'paul', 'pruefung_zurueck', 'Prüfung: Runde 1 zurück an Bob', { to: 'bob', meta: { round: 1 } }),
step(30.0, 'bob', 'bau_fertig', 'Vorschau überarbeitet'),
step(31.2, 'paul', 'pruefung_ok', 'Prüfung bestanden'),
step(32.4, 'max', 'vorschau_link', 'Vorschau-Link verschickt', { link: 'https://example.org/vorschau' }),
step(33.6, 'theo', 'angebot', 'Kauf-Link erzeugt'),
step(34.8, 'system', 'deal', 'Bezahlt: Hosting-Abo', { meta: { offerId: 'offer-1', customerId: 'customer-1', productKey: 'hosting', amountCents: 2000, kundenart: 'privat' } }),
step(36.0, 'system', 'gesperrt', 'Kontakt auf die Sperrliste gesetzt'),
step(37.2, 'otto', 'budget_warnung', 'Tagesbudget zu 80 % erreicht'),
step(38.4, 'otto', 'budget_stopp', 'Tagesbudget erreicht'),
step(39.6, 'sven', 'fehler', 'Quelle antwortet nicht'),
step(40.8, 'system', 'pause', 'Pause beendet', { meta: { paused: false } }),
// 12 Ziffern plus „ gelesen“ ergeben genau die längste erlaubte Sprechblase (20 Zeichen)
step(42.0, 'nora', 'gelesen', 'Posteingang gelesen', { meta: { count: 123456789012 } }),
step(43.4, 'bianca', 'bau_start', 'Zweite Vorschau wird gebaut'),
step(44.6, 'ben', 'leerlauf', 'Wartet auf einen Bauplan'),
step(45.8, 'sven', 'leerlauf', 'Quelle wieder erreichbar'),
])
// Zustand vor dem ersten Ereignis: gemischte Status; die drei nicht angeschlossenen Such-Worker (kim, pia, finn) sind grau
const NOT_CONNECTED = Object.freeze(['kim', 'pia', 'finn'])
const START = Object.freeze({
otto: ['arbeitet', 'Plant die Nacht'],
rita: ['arbeitet', 'Sucht frische Anfragen'],
kim: ['grau', ''],
pia: ['grau', ''],
finn: ['grau', ''],
ali: ['wartet', 'Wartet auf einen Auftrag'],
hanna: ['leerlauf', ''],
sven: ['leerlauf', ''],
lea: ['arbeitet', 'Liest Läden und Gastro'],
bea: ['wartet', 'Wartet auf Kandidaten'],
max: ['leerlauf', ''],
clara: ['arbeitet', 'Schreibt Antworten'],
nora: ['arbeitet', 'Sortiert den Posteingang'],
theo: ['leerlauf', ''],
bob: ['arbeitet', 'Baut eine Vorschau'],
bianca: ['wartet', 'Wartet auf einen Bauplan'],
ben: ['leerlauf', ''],
paul: ['leerlauf', ''],
arne: ['leerlauf', ''],
})
// Tageszähler je Worker (Schlüssel wie in counterLines)
const SCOUT_COUNTERS = Object.freeze({ gelesen: 24, gefunden: 3, passend: 2 })
const COUNTERS = Object.freeze({
rita: SCOUT_COUNTERS,
kim: Object.freeze({}),
pia: Object.freeze({}),
finn: Object.freeze({}),
ali: SCOUT_COUNTERS,
hanna: SCOUT_COUNTERS,
sven: SCOUT_COUNTERS,
lea: SCOUT_COUNTERS,
bea: Object.freeze({ bewertet: 9 }),
max: Object.freeze({ entwuerfe: 4, gesendet: 3 }),
clara: Object.freeze({ entwuerfe: 2 }),
theo: Object.freeze({ entwuerfe: 1 }),
nora: Object.freeze({ gelesen: 5 }),
bob: Object.freeze({ gebaut: 1 }),
bianca: Object.freeze({ gebaut: 1 }),
ben: Object.freeze({ gebaut: 0 }),
paul: Object.freeze({ geprueft: 2 }),
})
// Kennzahlen wie in den Test-Fixtures
const KPIS = Object.freeze({
newAbosMonth: 3,
mrrNewEur: 60,
dealsMonth: 3,
openThreads: 7,
pultOpen: 2,
botLeads: 112,
apiTodayUsd: 5.1,
apiMonthUsd: 50,
dailyBudgetUsd: 6,
monthlyBudgetUsd: 150,
})
// Spur: zwölf Einträge (neueste zuerst), darunter ein unsicherer Link (nur Text), ein sicherer externer und ein interner Link
const TRACE = Object.freeze([
['gelesen', 'Quelle geprüft', null],
['fund', 'Fund notiert', 'https://example.org/fund'],
['fehler', 'Link mit Skript abgelehnt', 'javascript:alert(1)'],
['uebergabe', 'Zur Übersicht der Hustler', '/hustler?tab=hochhaus'],
['gelesen', 'Beiträge der letzten Stunde gelesen', null],
['fund', 'Zweiten Kandidaten gefunden', null],
['leerlauf', 'Kurze Pause', null],
['gelesen', 'Neue Quelle gelesen', null],
['uebergabe', 'Dokument weitergereicht', null],
['fund', 'Kandidat notiert', null],
['gelesen', 'Postfach geleert', null],
['auftrag', 'Auftrag von Otto angenommen', null],
])
const clone = (v) => JSON.parse(JSON.stringify(v))
const isoOf = (ms) => new Date(ms).toISOString()
// Standardwerte der Einstellungen (Übersicht §4.1)
function defaultSettings(halted) {
return {
paused: false,
dailyBudgetUsd: 6,
monthlyBudgetUsd: 150,
warnPct: 80,
mailColdPerDay: 3,
mailReplyCapPerDay: 15,
sendWindowStart: '09:00',
sendWindowEnd: '16:00',
sendWeekdaysOnly: true,
runsPerNight: 1,
candidatesPerRun: 10,
activeRooms: [...SEARCH_ROOMS],
connectedRooms: SEARCH_ROOMS.filter((room) => !NOT_CONNECTED.includes(ROLES.find((r) => r.room === room).workerId)),
regions: ['Rheinland-Pfalz'],
previewGate: 'erste_n',
previewGateN: 10,
previewsSent: 0,
offerText: 'Wir bauen Ihnen kostenlos eine erste Vorschau Ihrer neuen Website. Gefällt sie Ihnen, betreuen wir sie für 20 € im Monat.',
offerProductKey: 'hosting',
pultStaleDays: 3,
retentionDays: 90,
followupDays: 7,
previewReminderDays: 5,
previewDeleteDays: 60,
reportEmail: 'bericht@example.org',
privacyInteressentenLive: false,
sendingHalted: halted,
sendingHaltedReason: halted ? HALT_REASON : '',
updatedAt: null,
updatedBy: 'system',
}
}
/**
* Skript-API für den Prüfstand. now() = Uhr in Millisekunden (nur die Differenz zum Aufruf von createScriptedApi zählt),
* base = Zeitpunkt (ms) des Starts für die Zeitstempel der Ereignisse, halted = Bounce-Notbremse ist von Anfang an gesetzt (H4).
* Rückgabe wie createWorkersApi; saved ist die Liste der Patches, die saveSettings bekam.
*/
export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false }) {
const start = now()
const elapsed = () => Math.max(0, now() - start)
let paused = false
const settings = defaultSettings(halted === true)
const saved = []
// Alle Ereignisse des Drehbuchs mit festen Kennungen und Zeitstempeln (aufsteigend); due() = bisher fällige
const all = SCRIPT.map((s, i) => ({
id: `harness-${String(i + 1).padStart(3, '0')}`,
ts: isoOf(base + Math.round(s.at * 1000)),
workerId: s.event.workerId,
kind: s.event.kind,
text: s.event.text,
link: s.event.link ?? null,
to: s.event.to ?? null,
prospectId: null,
costUsd: 0,
meta: s.event.meta ?? null,
}))
const dueEvents = () => all.filter((e) => Date.parse(e.ts) <= base + elapsed())
// Zustand: Startwerte, fortgeschrieben mit den fälligen Ereignissen (wie der Dienst)
function buildState() {
const startList = ROLES.map((r) => ({
workerId: r.workerId,
status: START[r.workerId][0],
task: START[r.workerId][1],
counters: { ...(COUNTERS[r.workerId] ?? {}) },
connected: !NOT_CONNECTED.includes(r.workerId),
}))
const due = dueEvents()
const table = reduceEvents(indexWorkers({ workers: startList, lastEventTs: isoOf(base) }), due)
const count = (kind) => due.filter((e) => e.kind === kind).length
return {
paused,
workers: startList.map((w) => ({ ...w, status: table[w.workerId].status, task: table[w.workerId].task })),
kpis: {
...KPIS,
newAbosMonth: KPIS.newAbosMonth + count('deal'),
mrrNewEur: KPIS.mrrNewEur + 20 * count('deal'),
dealsMonth: KPIS.dealsMonth + count('deal'),
pultOpen: KPIS.pultOpen + count('pult'),
},
sendingHalted: settings.sendingHalted === true,
sendingHaltedReason: typeof settings.sendingHaltedReason === 'string' ? settings.sendingHaltedReason : '',
lastEventTs: due.length ? due[due.length - 1].ts : isoOf(base),
}
}
const notFound = () => Object.assign(new Error('Diesen Hustler gibt es nicht.'), { status: 404 })
return {
saved,
async state() {
return clone(buildState())
},
async events(after, limit = EVENTS_MAX) {
const afterMs = after === null || after === undefined ? NaN : Date.parse(after)
const from = Number.isNaN(afterMs) ? -Infinity : afterMs - OVERLAP_MS
const n = Math.min(EVENTS_MAX, Math.max(1, Math.trunc(Number(limit)) || EVENTS_MAX))
return { events: clone(dueEvents().filter((e) => Date.parse(e.ts) >= from).slice(0, n)) }
},
async trace(workerId) {
const role = roleOf(workerId)
if (!role) throw notFound()
const nowMs = base + elapsed()
return clone({
worker: { ...role, modelId: MODEL_IDS[role.model] },
today: { counters: COUNTERS[workerId] ?? {}, tokens: { in: 18400, out: 2300 }, costUsd: 0.37 },
events: TRACE.map(([kind, text, link], i) => ({
id: `trace-${workerId}-${i + 1}`,
ts: isoOf(nowMs - (i * 2 + 1) * 60000),
workerId,
kind,
text,
link,
to: null,
prospectId: null,
costUsd: 0,
meta: null,
})),
})
},
async pause(next) {
paused = Boolean(next)
return { paused }
},
async settings() {
return clone({ ...settings, paused })
},
// Der Patch wird gemerkt und übernommen; sendingHalted: false hebt die Notbremse auf und leert den Grund (H4)
async saveSettings(patch) {
const change = patch !== null && typeof patch === 'object' ? clone(patch) : {}
saved.push(change)
Object.assign(settings, clone(change))
if (change.sendingHalted === false) settings.sendingHaltedReason = ''
return clone({ ...settings, paused })
},
async pult() {
return { items: [] }
},
async pultAction() {
return {}
},
async botleads() {
return { items: [], total: 0 }
},
async prospect() {
return {}
},
}
}
+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) })
+120
View File
@@ -0,0 +1,120 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Prüfstand der Hustler-Seite: die Skript-API (gleiche Form wie createWorkersApi) für die manuelle Playwright-Abnahme.
// Alles mit einer eigenen Uhr: keine echte Zeit, kein Netz.
const { createScriptedApi, SCRIPT } = await loadModule('scripts/workers-harness/scripted-api.js')
const { EVENT_KINDS } = await loadModule('src/lib/workersEvents.js')
const { SEARCH_ROOMS, ROLES, roleOf } = await loadModule('src/lib/workersRoster.js')
const ms = (iso) => Date.parse(iso)
test('Skript deckt alle 26 Ereignisarten und mindestens 15 Worker ab; erstes Ereignis nicht vor 2 s', () => {
assert.deepEqual(new Set(SCRIPT.map((s) => s.event.kind)), new Set(EVENT_KINDS))
assert.ok(new Set(SCRIPT.map((s) => s.event.workerId).filter((w) => w !== 'system')).size >= 15)
assert.ok(Math.min(...SCRIPT.map((s) => s.at)) >= 2)
})
test('scripted api: 19 Worker (kim, pia, finn nicht angeschlossen); events liefert wie der Dienst ab after - 5 s, nie aus der Zukunft; Pause folgt', async () => {
let t = 0
const api = createScriptedApi({ now: () => t })
const s = await api.state()
assert.equal(s.workers.length, 19)
assert.deepEqual(s.workers.filter((w) => !w.connected).map((w) => w.workerId).sort(), ['finn', 'kim', 'pia'])
// Grau-Regel (P1-E9): nur Such-Räume können nicht angeschlossen sein, die anderen elf sind es immer
assert.ok(s.workers.filter((w) => !w.connected).every((w) => SEARCH_ROOMS.includes(roleOf(w.workerId).room)))
assert.deepEqual(s.workers.map((w) => w.workerId), ROLES.map((r) => r.workerId))
assert.ok(new Set(s.workers.map((w) => w.status)).size >= 3) // gemischte Status
const base = ms(s.lastEventTs)
assert.ok(Number.isFinite(base))
assert.equal((await api.events(null)).events.length, 0) // nichts fällig bei 0 s
// PF-09: Antwort nach P1-Semantik (ts >= after - 5000, aufsteigend, nur Fälliges); die Entdopplung übernimmt der Feed (T05)
t = 10000
const first = (await api.events(s.lastEventTs)).events
assert.ok(first.length > 0)
assert.ok(first.every((e) => ms(e.ts) <= base + 10000)) // nichts aus der Zukunft
assert.ok(first.every((e, i) => i === 0 || ms(first[i - 1].ts) <= ms(e.ts))) // aufsteigend
const last = first.at(-1)
const again = (await api.events(last.ts)).events
assert.ok(again.some((e) => e.id === last.id)) // Überlappung: das letzte Ereignis kommt wieder
assert.ok(again.every((e) => ms(e.ts) >= ms(last.ts) - 5000 && ms(e.ts) <= base + 10000))
assert.equal(new Set(again.map((e) => e.id)).size, again.length) // innerhalb einer Antwort nie doppelt
assert.equal((await api.events(null, 2)).events.length, 2) // limit: die ältesten
assert.deepEqual((await api.events(null, 2)).events.map((e) => e.id), first.slice(0, 2).map((e) => e.id))
t = 30000 // später: Neues kommt dazu
const later = (await api.events(last.ts)).events
assert.ok(later.some((e) => !first.some((f) => f.id === e.id)))
assert.ok(later.every((e) => ms(e.ts) <= base + 30000))
await api.pause(true)
assert.equal((await api.state()).paused, true)
await api.pause(false)
assert.equal((await api.state()).paused, false)
assert.equal(s.sendingHalted, false) // Standard: nicht gestoppt
assert.equal(s.sendingHaltedReason, '')
// H4: gestoppt, Grund sichtbar, Freigabe über saveSettings
const h = createScriptedApi({ now: () => 0, halted: true })
const hs = await h.state()
assert.equal(hs.sendingHalted, true)
assert.match(hs.sendingHaltedReason, /Bounces/)
assert.equal((await h.settings()).sendingHalted, true)
const saved = await h.saveSettings({ sendingHalted: false })
assert.equal(saved.sendingHalted, false)
assert.equal(saved.sendingHaltedReason, '')
assert.deepEqual(h.saved, [{ sendingHalted: false }])
assert.equal((await h.state()).sendingHalted, false)
assert.equal((await h.settings()).sendingHalted, false)
assert.equal((await h.settings()).sendingHaltedReason, '')
})
test('scripted api: Spur mit 12 Ereignissen (unsicherer und sicherer Link), Tageswerte, Standard-Einstellungen, Zustand folgt den Ereignissen', async () => {
let t = 0
const api = createScriptedApi({ now: () => t })
const tr = await api.trace('kim')
assert.equal(tr.worker.workerId, 'kim')
assert.equal(tr.worker.name, 'Kim')
assert.equal(tr.worker.modelId, 'claude-haiku-5-5')
assert.equal(tr.events.length, 12)
assert.ok(tr.events.some((e) => e.link === 'javascript:alert(1)'))
assert.ok(tr.events.some((e) => e.link === 'https://example.org/fund'))
assert.ok(tr.events.every((e, i) => i === 0 || ms(tr.events[i - 1].ts) >= ms(e.ts))) // neueste zuerst
assert.ok(Number.isFinite(tr.today.tokens.in) && Number.isFinite(tr.today.tokens.out) && Number.isFinite(tr.today.costUsd))
assert.ok(Object.keys((await api.trace('rita')).today.counters).length > 0)
assert.deepEqual(tr.today.tokens, { in: 18400, out: 2300 })
assert.equal((await api.trace('bob')).worker.modelId, 'claude-opus-5-5')
assert.equal((await api.trace('bea')).worker.modelId, 'claude-sonnet-5-5')
const st = await api.settings() // Standardwerte aus Übersicht §4.1
assert.deepEqual(
[st.dailyBudgetUsd, st.monthlyBudgetUsd, st.warnPct, st.mailColdPerDay, st.mailReplyCapPerDay, st.sendWindowStart, st.sendWindowEnd, st.sendWeekdaysOnly],
[6, 150, 80, 3, 15, '09:00', '16:00', true])
assert.deepEqual([st.runsPerNight, st.candidatesPerRun, st.previewGate, st.previewGateN, st.retentionDays, st.offerProductKey], [1, 10, 'erste_n', 10, 90, 'hosting'])
assert.deepEqual(st.regions, ['Rheinland-Pfalz'])
assert.equal(st.privacyInteressentenLive, false)
assert.equal(st.sendingHalted, false)
assert.equal(st.sendingHaltedReason, '')
assert.ok(st.offerText.length >= 1 && st.offerText.length <= 400)
assert.deepEqual(st.connectedRooms.filter((r) => !SEARCH_ROOMS.includes(r)), []) // nur Such-Räume
assert.ok(!st.connectedRooms.includes('kleinanzeigen') && !st.connectedRooms.includes('portale') && !st.connectedRooms.includes('foren'))
const copy = await api.settings()
copy.dailyBudgetUsd = 99 // Antworten sind Kopien: der Speicher des Prüfstands bleibt heil
assert.equal((await api.settings()).dailyBudgetUsd, 6)
await api.saveSettings({ dailyBudgetUsd: 4 })
assert.equal((await api.settings()).dailyBudgetUsd, 4)
assert.deepEqual(api.saved, [{ dailyBudgetUsd: 4 }])
// Der Zustand schreibt die fälligen Ereignisse fort (wie der Dienst), lastEventTs rückt auf das jüngste fällige Ereignis
const s0 = await api.state()
t = 60000
const s1 = await api.state()
assert.ok(ms(s1.lastEventTs) > ms(s0.lastEventTs))
assert.equal(s1.workers.length, 19)
assert.deepEqual(s1.workers.filter((w) => !w.connected).map((w) => w.workerId).sort(), ['finn', 'kim', 'pia'])
assert.ok(s1.workers.filter((w) => !w.connected).every((w) => w.status === 'grau'))
assert.ok(s1.workers.find((w) => w.workerId === 'otto').task !== s0.workers.find((w) => w.workerId === 'otto').task)
for (const key of ['newAbosMonth', 'mrrNewEur', 'dealsMonth', 'openThreads', 'pultOpen', 'botLeads', 'apiTodayUsd', 'apiMonthUsd', 'dailyBudgetUsd', 'monthlyBudgetUsd']) assert.ok(Number.isFinite(s1.kpis[key]), key)
assert.deepEqual(await api.pult('offen'), { items: [] })
assert.deepEqual(await api.botleads({}), { items: [], total: 0 })
})